From 4220f8dd5a16f15bb1fb5e14264a479b253d14f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:06:05 +0000 Subject: [PATCH 1/3] fix(ci): build the bridge-gate scanner at the Static Analysis Gate pin bridge-gate.yml cloned panic-attack with --depth 1 and built whatever was on its main at run time. The same commit could pass or fail by the time of the run, and a change in panic-attack ran in this CI without review here (CWE-829). Build at the commit that static-analysis-gate.yml pins in PANIC_ATTACK_REF. This job reads that value with yq, so the pin has one owner and the two workflows cannot use different scanners. A missing or floating value builds nothing and exits 2. The build is the one the Static Analysis Gate uses: cargo install --git --rev --locked, a check that .crates.toml names that revision, and a build receipt with the binary digest. No uses: reference changes, so actions.lock is unchanged. Refs #481 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0136P7ZmAsYpFA9gxfQLF3F6 Signed-off-by: Claude --- .github/workflows/bridge-gate.yml | 32 ++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/bridge-gate.yml b/.github/workflows/bridge-gate.yml index eda0d039..18c6f98d 100644 --- a/.github/workflows/bridge-gate.yml +++ b/.github/workflows/bridge-gate.yml @@ -17,9 +17,9 @@ # Notes for review: # - B2 (major -> review) is left to the existing human-review path; it can be added # later via dependabot/fetch-metadata. -# - panic-attack is built from source (public clone). Production hardening: a released -# binary, or -- to match estate convention -- a hyperpolymath/standards reusable -# workflow. Offered as a follow-up. +# - panic-attack is built at the commit that static-analysis-gate.yml pins in +# PANIC_ATTACK_REF. That file owns the pin: this job reads it, so the two +# workflows cannot use different scanners and no moving ref is built (#481). # - github context is passed via env: only (no ${{ }} in run lines) per estate rules. name: bridge-gate on: @@ -44,11 +44,29 @@ jobs: exit 1 fi - - name: Build panic-attack (Patch-Bridge) + - name: Build panic-attack at the Static Analysis Gate pin (Patch-Bridge) run: | - git clone --depth 1 https://github.com/hyperpolymath/panic-attack /tmp/pa - cargo build --release --features http --manifest-path /tmp/pa/Cargo.toml - echo "/tmp/pa/target/release" >> "$GITHUB_PATH" + set -euo pipefail + # One pin for both workflows. A missing or floating value builds nothing. + ref=$(yq -r '.env.PANIC_ATTACK_REF' .github/workflows/static-analysis-gate.yml) + if ! [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::static-analysis-gate.yml has no full-SHA PANIC_ATTACK_REF (got '${ref}'); no scanner was built." + exit 2 + fi + echo "PANIC_ATTACK_REF=$ref" >> "$GITHUB_ENV" + tool_root="$RUNNER_TEMP/panic-toolchain" + # Same build as the Static Analysis Gate: exact revision, locked + # dependencies, and proof that the installed binary names that revision. + timeout 600 cargo install --git https://github.com/hyperpolymath/panic-attack \ + --rev "$ref" --locked --features http --root "$tool_root" panic-attack + grep -Fq "#$ref" "$tool_root/.crates.toml" + "$tool_root/bin/panic-attack" --version + jq -cn --arg sha "$ref" \ + --arg digest "$(sha256sum "$tool_root/bin/panic-attack" | cut -d' ' -f1)" \ + --arg rust "$(rustc --version)" --arg cargo "$(cargo --version)" \ + '{source_sha:$sha,binary_sha256:$digest,features:["http"],rustc:$rust,cargo:$cargo}' \ + > "$tool_root/source.json" + echo "$tool_root/bin" >> "$GITHUB_PATH" - name: Bridge triage (queries OSV) -> BridgeReport run: panic-attack bridge triage . --output bridge-report.json From 67ea81a123387fae826b8918be0d28c85e72b295 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:07:56 +0000 Subject: [PATCH 2/3] fix(ci): make the B1 verdict require a queried feed and match the report Two defects in the bridge-gate B1 step. It trusted any report. Patch Bridge schema 0.3.0 adds vulnerability_data: a report with "offline-unavailable" or "no-dependencies" has an empty CVE list because no feed was read. B1 read that as zero CVEs and passed. It could never fail. The step selected classification "Unmitigable" and reachability status "Reachable". The scanner at the pinned commit writes "unmitigable" and "reachable" (serde lowercase and kebab-case), so the filter matched nothing and B1 passed for every report. Move the verdict into scripts/ci/static-analysis-evidence.sh as bridge-b1, next to the adapter that the Static Analysis Gate uses: - the triage step now runs the adapter, which checks the binary against its build receipt and qualifies only a complete 0.3.0 report whose vulnerability_data is "queried"; - bridge-b1 exits 2 when the receipt is not a qualified Patch Bridge receipt, and also when a CVE has a reachability status outside the four known values, so a renamed value cannot pass in silence; - it exits 1 for a reachable unmitigable CVE, with the real values. Controls in scripts/ci/test-static-analysis-evidence.sh, which the Static Analysis Gate runs on every pull request: 36 before, 59 now. The 36 earlier controls pass unchanged against the new script. Three mutants fail the controls: the old capitalised values, no refusal of an unknown status, and no qualified-receipt requirement. bridge-gate.yml runs only on dependabot/ and renovate/ branches, so this pull request does not execute the job itself. Refs #481 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0136P7ZmAsYpFA9gxfQLF3F6 Signed-off-by: Claude --- .github/workflows/bridge-gate.yml | 26 +++++++---- scripts/ci/static-analysis-evidence.sh | 36 ++++++++++++++- scripts/ci/test-static-analysis-evidence.sh | 51 +++++++++++++++++++++ 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/.github/workflows/bridge-gate.yml b/.github/workflows/bridge-gate.yml index 18c6f98d..234b0e3a 100644 --- a/.github/workflows/bridge-gate.yml +++ b/.github/workflows/bridge-gate.yml @@ -68,15 +68,21 @@ jobs: > "$tool_root/source.json" echo "$tool_root/bin" >> "$GITHUB_PATH" - - name: Bridge triage (queries OSV) -> BridgeReport - run: panic-attack bridge triage . --output bridge-report.json + # The adapter that the Static Analysis Gate uses. It checks the binary + # against its build receipt and writes a receipt only for a complete + # schema 0.3.0 report whose vulnerability_data is "queried". + - name: Bridge triage (queries OSV) -> qualified BridgeReport + env: + SCAN_EXPECTED_SHA: ${{ github.sha }} + run: | + bash scripts/ci/static-analysis-evidence.sh scan patch-bridge "$PANIC_ATTACK_REF" \ + "$RUNNER_TEMP/panic-toolchain/bin/panic-attack" "$RUNNER_TEMP/panic-toolchain/source.json" \ + "$RUNNER_TEMP/bridge-report" + # Exit 1: a reachable unmitigable CVE. Exit 2: no qualified report, so + # there is no CVE result to judge. Controls for both are in + # scripts/ci/test-static-analysis-evidence.sh. - name: Apply B1 verdict -- reachable unmitigable CVE -> flag - run: | - blockers=$(jq '[.cves[]? | select(.classification=="Unmitigable" and .reachability.status=="Reachable")] | length' bridge-report.json) - if [ "$blockers" -gt 0 ]; then - echo "::error::B1: $blockers reachable unmitigable CVE(s) -> safety=flag (auto-merge blocked)" - jq -r '.cves[]? | select(.classification=="Unmitigable" and .reachability.status=="Reachable") | " - \(.vulnerability.cve // .vulnerability.id): \(.rationale)"' bridge-report.json - exit 1 - fi - echo "B1 PASS: no reachable unmitigable CVEs -> arm-eligible (pool-gated)." + env: + SCAN_EXPECTED_SHA: ${{ github.sha }} + run: bash scripts/ci/static-analysis-evidence.sh bridge-b1 "$RUNNER_TEMP/bridge-report/receipt.json" diff --git a/scripts/ci/static-analysis-evidence.sh b/scripts/ci/static-analysis-evidence.sh index 49f43fff..215ca501 100755 --- a/scripts/ci/static-analysis-evidence.sh +++ b/scripts/ci/static-analysis-evidence.sh @@ -246,9 +246,43 @@ deposit() { + [$hyp[0][] | . + {scanner:"hypatia"}]),patch_bridge:$bridge[0]}' > "$dir/unified-findings.json" } +# Merge-orchestration B1 (bridge-gate.yml): a reachable unmitigable CVE blocks a +# dependency bump. Exit 1 is that verdict. Exit 2 means there is no CVE result +# to judge: the receipt is not a qualified Patch Bridge receipt (which requires +# vulnerability_data == "queried"), or a CVE carries a reachability status this +# gate does not know. An unknown value must never count as "not reachable". +bridge_b1() { + local receipt=$1 report blockers + local reachable_unmitigable='.cves[] | select(.classification == "unmitigable" and .reachability.status == "reachable")' + one_json "$receipt" || return 2 + report=$(mktemp) + if [[ "$(jq -r '.tool' "$receipt")" != patch-bridge ]] || + ! qualified_receipt patch-bridge "${PANIC_ATTACK_REF:?}" "$receipt" "$report"; then + rm -f -- "$report" + echo '::error::B1: no qualified Patch Bridge report. The vulnerability feed must be queried; this is not a CVE result.' >&2 + return 2 + fi + if ! jq -e 'all(.cves[]; .reachability.status as $s | + ["phantom-declared","phantom-transitive","unreachable","reachable"] | index($s) != null)' "$report" >/dev/null 2>&1; then + rm -f -- "$report" + echo '::error::B1: a CVE has no known reachability status; the report does not match the pinned contract.' >&2 + return 2 + fi + blockers=$(jq "[$reachable_unmitigable] | length" "$report") + if [[ "$blockers" -gt 0 ]]; then + echo "::error::B1: $blockers reachable unmitigable CVE(s) -> safety=flag (auto-merge blocked)" + jq -r "$reachable_unmitigable"' | " - \(.vulnerability.cve // .vulnerability.id // "unidentified"): \(.rationale // "no rationale")"' "$report" + rm -f -- "$report" + return 1 + fi + rm -f -- "$report" + echo 'B1 PASS: the feed was queried and no unmitigable CVE is reachable -> arm-eligible (pool-gated).' +} + case "${1:-}" in scan) shift; scan "$@" ;; gate) shift; gate "$@" ;; deposit) shift; deposit "$@" ;; - *) echo 'usage: static-analysis-evidence.sh scan TOOL REF BIN SOURCE-RECEIPT OUTPUT-DIR | gate RECEIPT | deposit ARTIFACT-DIR' >&2; exit 2 ;; + bridge-b1) shift; bridge_b1 "$@" ;; + *) echo 'usage: static-analysis-evidence.sh scan TOOL REF BIN SOURCE-RECEIPT OUTPUT-DIR | gate RECEIPT | deposit ARTIFACT-DIR | bridge-b1 RECEIPT' >&2; exit 2 ;; esac diff --git a/scripts/ci/test-static-analysis-evidence.sh b/scripts/ci/test-static-analysis-evidence.sh index 07b5f20b..3ecfd94f 100755 --- a/scripts/ci/test-static-analysis-evidence.sh +++ b/scripts/ci/test-static-analysis-evidence.sh @@ -162,6 +162,57 @@ expect_exit 2 bridge printf '%s\n' '{"schema_version":"0.2.0","total_dependencies":2,"vulnerable_dependencies":1,"cves":[{"classification":"unmitigable"}],"mitigated":0,"unmitigable":1,"concatenative":0,"informational":0}' > "$MOCK_REPORT" expect_exit 2 bridge +# B1 (bridge-gate.yml): exit 1 only for a reachable unmitigable CVE in a +# qualified report. No qualified report, or an unknown reachability value, is +# exit 2: it is never a pass. +b1() { bash "$adapter" bridge-b1 "$tmp/bridge/receipt.json"; } +bridge_report() { # + jq -cn --arg data "$1" --arg class "$2" --arg reach "$3" ' + {schema_version:"0.3.0",vulnerability_data:$data,total_dependencies:2,vulnerable_dependencies:1, + cves:[{classification:$class,reachability:{status:$reach},vulnerability:{id:"FIXTURE-0001"},rationale:"planted"}], + mitigated:(if $class == "mitigable" then 1 else 0 end),unmitigable:(if $class == "unmitigable" then 1 else 0 end), + concatenative:0,informational:(if $class == "informational" then 1 else 0 end)}' > "$MOCK_REPORT" +} +bridge_report queried unmitigable reachable +expect_exit 0 bridge +expect_exit 1 b1 +grep -Fq 'FIXTURE-0001: planted' "$tmp/control.log" +# Paired: the same CVE does not block when it is not reachable or is mitigable. +for reach in unreachable phantom-declared phantom-transitive; do + bridge_report queried unmitigable "$reach" + expect_exit 0 bridge + expect_exit 0 b1 +done +bridge_report queried mitigable reachable +expect_exit 0 bridge +expect_exit 0 b1 +printf '%s\n' '{"schema_version":"0.3.0","vulnerability_data":"queried","total_dependencies":2,"vulnerable_dependencies":0,"cves":[],"mitigated":0,"unmitigable":0,"concatenative":0,"informational":0}' > "$MOCK_REPORT" +expect_exit 0 bridge +expect_exit 0 b1 +# The spelling the workflow compared before #481 never matched the report. +# An unknown status is refused, so a renamed value cannot pass in silence. +bridge_report queried unmitigable Reachable +expect_exit 0 bridge +expect_exit 2 b1 +# A report whose feed was not queried leaves no qualified receipt to judge. +for data in offline-unavailable no-dependencies; do + bridge_report "$data" unmitigable reachable + expect_exit 2 bridge + expect_exit 2 b1 +done +# The verdict checks the report itself, not only that a receipt exists: a +# receipt whose report says the feed was not queried, or says nothing about +# the feed, is refused. Without that check this zero-CVE report would pass. +printf '%s\n' '{"schema_version":"0.3.0","vulnerability_data":"queried","total_dependencies":2,"vulnerable_dependencies":0,"cves":[],"mitigated":0,"unmitigable":0,"concatenative":0,"informational":0}' > "$MOCK_REPORT" +expect_exit 0 bridge +jq '.report.vulnerability_data = "offline-unavailable"' "$tmp/bridge/receipt.json" > "$tmp/unqueried-receipt.json" +expect_exit 2 bash "$adapter" bridge-b1 "$tmp/unqueried-receipt.json" +jq 'del(.report.vulnerability_data)' "$tmp/bridge/receipt.json" > "$tmp/unqueried-receipt.json" +expect_exit 2 bash "$adapter" bridge-b1 "$tmp/unqueried-receipt.json" +# Another scanner's receipt and a missing receipt are not CVE results. +expect_exit 2 bash "$adapter" bridge-b1 "$tmp/qualified-hypatia.json" +expect_exit 2 bash "$adapter" bridge-b1 "$tmp/no-such-receipt.json" + # Deposit preserves actual critical findings even though their gate failed. # Only mutually matching completed scan receipts may create the legacy artifact. mkdir -p "$tmp/findings/"{panic-attack-findings,hypatia-findings,bridge-report} From 91e023541c4fa52b9147c05b4bf13c18b100de81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:20:44 +0000 Subject: [PATCH 3/3] test(ci): name the arguments of the bridge report fixture helper SonarCloud reported shell:S7679 three times on bridge_report in scripts/ci/test-static-analysis-evidence.sh: the helper used $1, $2 and $3 directly. Assign them to local names. No behaviour change: the 59 controls pass. Refs #481 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0136P7ZmAsYpFA9gxfQLF3F6 Signed-off-by: Claude --- scripts/ci/test-static-analysis-evidence.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/test-static-analysis-evidence.sh b/scripts/ci/test-static-analysis-evidence.sh index 3ecfd94f..119cdfd4 100755 --- a/scripts/ci/test-static-analysis-evidence.sh +++ b/scripts/ci/test-static-analysis-evidence.sh @@ -167,7 +167,8 @@ expect_exit 2 bridge # exit 2: it is never a pass. b1() { bash "$adapter" bridge-b1 "$tmp/bridge/receipt.json"; } bridge_report() { # - jq -cn --arg data "$1" --arg class "$2" --arg reach "$3" ' + local data=$1 class=$2 reach=$3 + jq -cn --arg data "$data" --arg class "$class" --arg reach "$reach" ' {schema_version:"0.3.0",vulnerability_data:$data,total_dependencies:2,vulnerable_dependencies:1, cves:[{classification:$class,reachability:{status:$reach},vulnerability:{id:"FIXTURE-0001"},rationale:"planted"}], mitigated:(if $class == "mitigable" then 1 else 0 end),unmitigable:(if $class == "unmitigable" then 1 else 0 end),