From 9c304ed9a3dd2d236524283ca630b36c598bbfce Mon Sep 17 00:00:00 2001 From: Adrien Murillo <66782347+murillo-consulting@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:37:50 +0200 Subject: [PATCH 1/2] fix: enforce provider transport boundaries and validation failures --- .github/workflows/audit-cron.yml | 6 +- .github/workflows/ci.yml | 142 ++++++++++++------------- .github/workflows/cla.yml | 2 +- .github/workflows/cleanup-branches.yml | 4 +- .github/workflows/codeql.yml | 12 +-- .github/workflows/docs-lint.yml | 18 ++-- .github/workflows/nightly.yml | 12 +-- .github/workflows/release-plz.yml | 12 +-- .github/workflows/semgrep.yml | 4 +- .github/workflows/shellcheck.yml | 4 +- README.md | 5 + docs/product-review.md | 79 ++++++++++++++ src/commands/validate.rs | 62 +++++++++-- src/providers/mod.rs | 72 ++++++++++++- 14 files changed, 314 insertions(+), 120 deletions(-) create mode 100644 docs/product-review.md diff --git a/.github/workflows/audit-cron.yml b/.github/workflows/audit-cron.yml index 2e4cc11..df97672 100644 --- a/.github/workflows/audit-cron.yml +++ b/.github/workflows/audit-cron.yml @@ -27,10 +27,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 - - uses: rustsec/audit-check@v2.0.0 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5470f78..22d11e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,11 +76,11 @@ jobs: ci: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')) && 'true' || steps.filter.outputs.ci }} docs: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')) && 'true' || steps.filter.outputs.docs }} steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 - - uses: dorny/paths-filter@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter with: filters: | @@ -107,10 +107,10 @@ jobs: version: ${{ steps.ver.outputs.version }} tag: ${{ steps.ver.outputs.tag }} steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Read version from Cargo.toml id: ver run: | @@ -143,23 +143,23 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: clippy, rustfmt - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: prek save-if: ${{ github.ref == 'refs/heads/main' }} # The hooks are `language = "system"`, so their tools must be on PATH. # install-action serves prebuilt binaries, so this costs seconds. - - uses: taiki-e/install-action@v2 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 # v2 with: tool: cargo-deny,cargo-machete,cargo-audit - - uses: j178/prek-action@v2 + - uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2 with: # --stage pre-push selects every hook: the pre-push-only ones plus the # unstaged (all-stage) defaults. @@ -198,11 +198,11 @@ jobs: # platform-specific coverage prek cannot give from one runner. os: [macos-latest, windows-latest] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: clippy-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} @@ -222,10 +222,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - name: Install gitleaks @@ -261,20 +261,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: llvm-tools-preview - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: coverage save-if: ${{ github.ref == 'refs/heads/main' }} - - uses: taiki-e/install-action@cargo-llvm-cov + - uses: taiki-e/install-action@44c65a4290d667d8b91a0285e52f878dde7ef106 # cargo-llvm-cov - run: cargo llvm-cov --lcov --output-path lcov.info - - uses: codecov/codecov-action@v6 + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 with: files: lcov.info fail_ci_if_error: ${{ secrets.CODECOV_TOKEN != '' }} @@ -314,16 +314,16 @@ jobs: partition: 2 hack_args: "--group-features dlp,oauth,tap,compliance,policies --group-features mcp,harness,otel,acme,tls" steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: hack-${{ matrix.group }} save-if: ${{ github.ref == 'refs/heads/main' }} - - uses: taiki-e/install-action@cargo-hack + - uses: taiki-e/install-action@ea4d119ec0835f6decf1a150b78f4178c4fa413c # cargo-hack - run: cargo hack check --feature-powerset --depth 2 --no-dev-deps --partition ${{ matrix.partition }}/2 ${{ matrix.hack_args }} # ========================================================================= @@ -347,16 +347,16 @@ jobs: matrix: shard: [1, 2, 3, 4] steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: test-ubuntu-latest save-if: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 }} - - uses: taiki-e/install-action@nextest + - uses: taiki-e/install-action@c6702eacb5b86c67cc1f5b92f3c9e322e1a687b7 # nextest - run: cargo nextest run --profile ci --partition count:${{ matrix.shard }}/4 - run: cargo test --doc if: matrix.shard == 1 @@ -398,13 +398,13 @@ jobs: matrix: os: [macos-latest, windows-latest] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: test-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - - uses: taiki-e/install-action@nextest + - uses: taiki-e/install-action@c6702eacb5b86c67cc1f5b92f3c9e322e1a687b7 # nextest - name: Run tests run: | if [ "${{ matrix.os }}" = "windows-latest" ]; then @@ -490,12 +490,12 @@ jobs: file: src/routing/classify/classify.rs mutants_shard: "5/6" steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: mutants-${{ matrix.shard }} save-if: ${{ github.ref == 'refs/heads/main' }} @@ -513,7 +513,7 @@ jobs: -- --lib - name: Upload mutation testing results if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mutants-results-shard-${{ matrix.shard }}-${{ github.sha }} path: mutants.out/ @@ -537,15 +537,15 @@ jobs: contents: read pull-requests: write # post sticky comment with sampling summary steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: # Need the merge-base to compute `BASE...HEAD` diff. fetch-depth: 0 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: mutants-pr save-if: false # never write the cache from PR runs @@ -567,7 +567,7 @@ jobs: exit 0 - name: Upload mutation testing results if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mutants-pr-results-${{ github.sha }} path: mutants.out/ @@ -697,13 +697,13 @@ jobs: use-cross: false steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: targets: ${{ matrix.target }} @@ -735,7 +735,7 @@ jobs: shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256" echo "ARCHIVE=${ARCHIVE}" >> "$GITHUB_ENV" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ matrix.target }}-${{ github.sha }} path: | @@ -760,16 +760,16 @@ jobs: packages: write id-token: write # cosign keyless signing steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: x86_64-unknown-linux-musl-${{ github.sha }} path: artifacts/amd64 - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: aarch64-unknown-linux-musl-${{ github.sha }} path: artifacts/arm64 @@ -830,14 +830,14 @@ jobs: fi - name: Generate SBOM - uses: anchore/sbom-action@v0 + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 with: image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.version }} artifact-name: sbom-gate.spdx.json output-file: sbom-gate.spdx.json - name: Install cosign - uses: sigstore/cosign-installer@v4.1.1 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Sign container image run: | @@ -846,7 +846,7 @@ jobs: DIGEST=$(crane digest "${IMAGE}:${VERSION}") cosign sign --yes "${IMAGE}@${DIGEST}" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: sbom-${{ github.sha }} path: sbom-gate.spdx.json @@ -862,7 +862,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - name: Update formula @@ -946,10 +946,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Setup E2E pod uses: ./.github/actions/setup-e2e-pod @@ -987,10 +987,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Setup E2E pod uses: ./.github/actions/setup-e2e-pod @@ -1021,12 +1021,12 @@ jobs: permissions: contents: write # create GitHub Release steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts merge-multiple: true @@ -1102,11 +1102,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 - - uses: rhysd/actionlint@v1.7.11 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11 with: args: -color @@ -1190,12 +1190,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - name: Collect job results and durations - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const jobs = [ @@ -1239,7 +1239,7 @@ jobs: await core.summary.write(); - name: Export pipeline duration for trend tracking (D9) - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const fs = require('fs'); @@ -1260,7 +1260,7 @@ jobs: } fs.writeFileSync('/tmp/ci-benchmark.json', JSON.stringify(data, null, 2)); - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ci-benchmark-${{ github.sha }} path: /tmp/ci-benchmark.json diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index b46372b..f0f7e9b 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -15,7 +15,7 @@ jobs: cla-check: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const ALLOWLIST = [ diff --git a/.github/workflows/cleanup-branches.yml b/.github/workflows/cleanup-branches.yml index 3b8ef3c..a8da7a1 100644 --- a/.github/workflows/cleanup-branches.yml +++ b/.github/workflows/cleanup-branches.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Delete merged branch - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const pr = context.payload.pull_request; @@ -51,7 +51,7 @@ jobs: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 365ad02..78190d9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,19 +45,19 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - name: Rust cache - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: codeql save-if: ${{ github.ref == 'refs/heads/main' }} - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 with: languages: rust @@ -66,7 +66,7 @@ jobs: - name: Perform CodeQL Analysis id: analyze - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 with: category: "/language:rust" output: codeql-results @@ -75,7 +75,7 @@ jobs: - name: Store CodeQL results if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: codeql-results-${{ github.sha }} path: ${{ steps.analyze.outputs.sarif-output }} diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml index 6a36df3..45739fb 100644 --- a/.github/workflows/docs-lint.yml +++ b/.github/workflows/docs-lint.yml @@ -37,12 +37,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Run markdownlint-cli2 - uses: DavidAnson/markdownlint-cli2-action@v20 + uses: DavidAnson/markdownlint-cli2-action@992badcdf24e3b8eb7e87ff9287fe931bcb00c6e # v20 with: globs: | docs/**/*.md @@ -58,10 +58,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 2 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Scan for hardcoded versions run: | set -euo pipefail @@ -86,18 +86,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Restore lychee cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: .lycheecache key: cache-lychee-${{ github.sha }} restore-keys: cache-lychee- - name: Run lychee - uses: lycheeverse/lychee-action@v2 + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 with: # --offline would skip network checks; we keep live checking # but cache results to avoid hammering external hosts. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f1b9051..b59f5fb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -30,17 +30,17 @@ jobs: toolchain: [beta, nightly] os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: main - - uses: dtolnay/rust-toolchain@master + - uses: dtolnay/rust-toolchain@d1031067263f94b142dd6c0ce24c5eb9d02d52a0 # master with: toolchain: ${{ matrix.toolchain }} components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: nightly-${{ matrix.toolchain }}-${{ matrix.os }} save-if: true @@ -88,11 +88,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - name: Open or update tracking issue - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const label = 'nightly-toolchain'; diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 719fa1a..49b8848 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -32,14 +32,14 @@ jobs: group: release-plz-pr cancel-in-progress: false steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 token: ${{ secrets.RELEASE_PLZ_TOKEN || github.token }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - - uses: release-plz/action@v0.5 + - uses: release-plz/action@b5543c19b03be9bd48852d20ca89f478b7723260 # v0.5 with: command: release-pr env: @@ -57,15 +57,15 @@ jobs: group: release-plz-tag cancel-in-progress: false steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 ref: main token: ${{ secrets.RELEASE_PLZ_TOKEN || github.token }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - - uses: release-plz/action@v0.5 + - uses: release-plz/action@b5543c19b03be9bd48852d20ca89f478b7723260 # v0.5 with: command: release env: diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 02cc62f..6191fb0 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -27,7 +27,7 @@ jobs: image: semgrep/semgrep:1.144.0 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Run Semgrep run: >- @@ -43,7 +43,7 @@ jobs: - name: Store Semgrep results if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: semgrep-results-${{ github.sha }} path: semgrep-results.sarif diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 0236f0d..a029897 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -36,10 +36,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2 with: egress-policy: audit - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Run shellcheck run: | set -euo pipefail diff --git a/README.md b/README.md index 787bd88..7c55e50 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,11 @@ after the child exits. ## Configuration +Read the [gateway review and adoption guide](docs/product-review.md) for provider +redirect behavior, validation exit codes, offline regression checks and the +prioritized roadmap. Configure final provider URLs: Gate does not automatically +follow provider redirects. + Start with the interactive wizard: ```bash diff --git a/docs/product-review.md b/docs/product-review.md new file mode 100644 index 0000000..a68730b --- /dev/null +++ b/docs/product-review.md @@ -0,0 +1,79 @@ +# Gateway review and adoption guide + +Reviewed on 2026-09-05. Gate's useful position is a local Rust gateway for AI +tools with configurable provider routing, DLP and operational evidence. +Feature presence does not establish a certification, DLP detection rate or +performance advantage. + +## Market references + +| Reference | Observed practice | Direction for Gate | +| --- | --- | --- | +| [LiteLLM fallback documentation](https://docs.litellm.ai/docs/proxy/reliability) | Explicit fallback groups and provider failure testing | Keep provider failover in the configured routing chain and test with synthetic upstream failures. | +| [LiteLLM documentation](https://docs.litellm.ai/) | Gateway budgets, guardrails and observability | Describe defaults and limits for each control. Avoid treating feature switches as evidence that a policy was enforced. | +| [Reqwest redirect policy](https://docs.rs/reqwest/latest/reqwest/redirect/index.html) | Redirect behavior is an explicit HTTP-client policy | Disable automatic redirects for provider calls so configured routing remains the egress boundary. | + +## Changes and operator impact + +### Provider redirects + +Provider HTTP clients do not automatically follow 301, 302, 303, 307 or 308 +responses. Prompts and provider-specific headers must not be replayed at a +destination selected by a redirect. Configure the final provider endpoint +directly. This applies to both streaming and non-streaming calls using the +shared provider client. + +If HTTP-client initialization fails, Gate stops instead of silently replacing +the requested TLS and redirect policy with a default client. Investigate the +startup error and certificate configuration before restarting. This does not +add an outbound firewall or validate every possible DNS resolution. + +### Scriptable validation + +`gate validate` now exits unsuccessfully when provider initialization fails or +a router model has no healthy provider. A working fallback still counts as a +usable model, matching the printed health summary. Partial provider failures +remain visible in that summary. This command makes real provider calls and can +consume tokens; it is not an offline configuration-only check. + +```bash +# Explicit live validation before adopting a configuration: +gate validate && echo "Every checked router model has a healthy provider" +``` + +### Supply chain + +Third-party GitHub Actions are pinned to verified commit IDs while preserving +their existing selected versions. Dependabot can propose updates for review. +Commit pinning does not certify the contents or provenance of an action. + +## Offline regression checks + +```bash +cargo fmt --all -- --check +cargo test --locked --lib +cargo clippy --locked --all-targets -- -D warnings +``` + +Redirect tests use two local synthetic HTTP servers and check that no redirected +prompt reaches the second server. Direct requests still succeed. Validation +tests cover empty results, broken models, mixed model health and a working +fallback. No production provider credentials are required by these tests. + +## Prioritized next milestones + +| Priority | Work | Acceptance criterion | +| --- | --- | --- | +| P1 | Budget enforcement under concurrency | Parallel requests cannot overrun a configured reservation ceiling; restart and failed-upstream accounting are tested. | +| P1 | Streaming privacy corpus | Secrets split across chunks and tool-call fields are tested with documented precision/recall limits. | +| P1 | Audit continuity | Detect tampering, missing records and key rotation using synthetic fixtures and an independently verified export. | +| P2 | Provider compatibility matrix | Versioned fixtures exercise errors, streaming cancellation, tool calls and fallback for each supported protocol. | +| P2 | Reproducible performance evidence | Publish p50/p95/p99, throughput, memory and hardware with direct-provider baselines and identical payloads. | + +## Rollback + +There is no configuration or spend-journal migration. Revert the change commit +to restore prior behavior. Endpoint URLs that depended on redirects should be +corrected rather than relying on rollback, which restores the egress risk. +Automation that previously ignored validation failures must handle nonzero +exit status after this change. diff --git a/src/commands/validate.rs b/src/commands/validate.rs index bac3c22..6e52c4d 100644 --- a/src/commands/validate.rs +++ b/src/commands/validate.rs @@ -1,18 +1,13 @@ use crate::{cli, preset}; +use anyhow::Context; /// Validates the configuration by testing each model with real API calls. pub async fn cmd_validate(config: &cli::AppConfig) -> anyhow::Result<()> { println!("🔍 Validating configuration..."); println!(); - let (registry, _token_store) = match preset::build_registry(config) { - Ok(r) => r, - Err(e) => { - eprintln!("❌ Failed to initialize providers: {}", e); - eprintln!(" Fix your config and try again."); - return Ok(()); - } - }; + let (registry, _token_store) = preset::build_registry(config) + .context("Failed to initialize providers; fix your configuration and try again")?; println!( " Testing {} model(s) with real API calls...", @@ -22,5 +17,56 @@ pub async fn cmd_validate(config: &cli::AppConfig) -> anyhow::Result<()> { let results = preset::validate_config(config, ®istry).await; preset::print_validation_results(&results); + require_healthy_models(&results) +} + +fn require_healthy_models(results: &[preset::ModelValidation]) -> anyhow::Result<()> { + anyhow::ensure!(!results.is_empty(), "No router models were validated"); + let broken: Vec<_> = results + .iter() + .filter(|result| !result.any_ok()) + .map(|result| result.model_name.as_str()) + .collect(); + anyhow::ensure!( + broken.is_empty(), + "Models with no healthy provider: {}", + broken.join(", ") + ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn model(health: &[bool]) -> preset::ModelValidation { + preset::ModelValidation { + model_name: "test-model".into(), + role: "default".into(), + mappings: health + .iter() + .map(|ok| preset::MappingResult { + priority: 1, + provider: "test".into(), + actual_model: "test".into(), + ok: *ok, + detail: String::new(), + }) + .collect(), + } + } + + #[test] + fn validation_fails_for_empty_or_unusable_models() { + assert!(require_healthy_models(&[]).is_err()); + assert!(require_healthy_models(&[model(&[])]).is_err()); + assert!(require_healthy_models(&[model(&[false])]).is_err()); + assert!(require_healthy_models(&[model(&[true]), model(&[false])]).is_err()); + } + + #[test] + fn validation_accepts_a_working_fallback() { + assert!(require_healthy_models(&[model(&[true])]).is_ok()); + assert!(require_healthy_models(&[model(&[false, true])]).is_ok()); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 78d4e2d..cb62264 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -124,6 +124,9 @@ pub fn build_provider_client( ca: Option, ) -> Client { let mut builder = Client::builder() + // A redirect must not move prompts or provider-specific API keys to a + // destination outside the configured routing and policy boundary. + .redirect(reqwest::redirect::Policy::none()) .tcp_nodelay(true) .connect_timeout(connect_timeout) .pool_max_idle_per_host(20) @@ -137,10 +140,9 @@ pub fn build_provider_client( builder = builder.add_root_certificate(cert); } - builder.build().unwrap_or_else(|e| { - tracing::warn!("Provider client build failed, using defaults: {}", e); - Client::new() - }) + builder.build().expect( + "Provider HTTP client initialization failed; refusing to discard TLS or redirect policy", + ) } /// Logs a warning if a provider base URL uses plaintext HTTP for a non-localhost endpoint. @@ -371,3 +373,65 @@ pub mod mocks { } } } + +#[cfg(test)] +mod transport_security_tests { + use super::*; + + #[tokio::test] + async fn redirects_never_forward_prompts_or_provider_keys() { + let mut destination = mockito::Server::new_async().await; + let never_called = destination + .mock("POST", "/capture") + .expect(0) + .create_async() + .await; + let mut provider = mockito::Server::new_async().await; + let client = build_provider_client(Duration::from_secs(2), None, None); + + for status in [301, 302, 303, 307, 308] { + let redirect = provider + .mock("POST", "/v1/messages") + .with_status(status) + .with_header("location", &format!("{}/capture", destination.url())) + .expect(1) + .create_async() + .await; + let response = client + .post(format!("{}/v1/messages", provider.url())) + .header("x-api-key", "synthetic-test-key") + .json(&serde_json::json!({"prompt": "synthetic confidential input"})) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), status as u16); + assert_eq!(response.url().host_str(), Some("127.0.0.1")); + assert_eq!(response.url().path(), "/v1/messages"); + redirect.assert_async().await; + } + never_called.assert_async().await; + } + + #[tokio::test] + async fn direct_provider_requests_still_work() { + let mut provider = mockito::Server::new_async().await; + let request = provider + .mock("POST", "/v1/messages") + .match_header("x-api-key", "synthetic-test-key") + .with_status(200) + .with_body("ok") + .expect(1) + .create_async() + .await; + let client = build_provider_client(Duration::from_secs(2), None, None); + let response = client + .post(format!("{}/v1/messages", provider.url())) + .header("x-api-key", "synthetic-test-key") + .body("synthetic input") + .send() + .await + .unwrap(); + assert_eq!(response.text().await.unwrap(), "ok"); + request.assert_async().await; + } +} From b60080d7140b8a97895f6e33736b3b440a58e6ef Mon Sep 17 00:00:00 2001 From: Adrien Murillo <66782347+murillo-consulting@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:51:01 +0200 Subject: [PATCH 2/2] fix: patch h2 HTTP2 security advisory --- Cargo.lock | 4 ++-- docs/product-review.md | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3fd8afd..85b04f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1920,9 +1920,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", diff --git a/docs/product-review.md b/docs/product-review.md index a68730b..9a27036 100644 --- a/docs/product-review.md +++ b/docs/product-review.md @@ -41,6 +41,12 @@ consume tokens; it is not an offline configuration-only check. gate validate && echo "Every checked router model has a healthy provider" ``` +### Dependency security + +The HTTP/2 dependency is updated to address RUSTSEC-2026-0258 (unbounded empty +DATA-frame queuing). The change is limited to that package in the lockfile. +The existing dependency-advisory CI gate remains enabled. + ### Supply chain Third-party GitHub Actions are pinned to verified commit IDs while preserving