diff --git a/.github/workflows/bridge-gate.yml b/.github/workflows/bridge-gate.yml index eda0d039..234b0e3a 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,21 +44,45 @@ 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 + # 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..119cdfd4 100755 --- a/scripts/ci/test-static-analysis-evidence.sh +++ b/scripts/ci/test-static-analysis-evidence.sh @@ -162,6 +162,58 @@ 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() { # + 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), + 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}