From 610e46b1e32e2a7e043dbb5951bcf35cf23a075d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 07:17:27 -0700 Subject: [PATCH 1/3] fix(ci): bump rustls and expire the h2 audit exception I bumped rustls to 0.23.46 and chacha20 to 0.10.2 so RUSTSEC-2026-0346 through RUSTSEC-2026-0349 clear and the yanked chacha20 release is gone. I added .cargo/audit.toml for the lone RUSTSEC-2026-0258 exception until 2027-01-15 and taught ci-preflight to print audit JSON, enforce that expiry, and keep scheduled fail-closed behavior for every other advisory (#349). --- .cargo/audit.toml | 9 +++ AGENTS.md | 6 +- Cargo.lock | 8 +-- deny.toml | 3 +- docs/CI.md | 4 +- scripts/ci-preflight-test.sh | 2 + scripts/ci-preflight.sh | 117 +++++++++++++++++++++++++++++++---- 7 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 .cargo/audit.toml diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 00000000..997bb2ee --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,9 @@ +# RUSTSEC-2026-0258 (h2 0.3.27) has no fixed 0.3 release. +# The libsql remote connector only offers HTTP/1.1 (src/remote_https.rs, +# enforced by https_client_config_advertises_http11_alpn), so the h2 0.3 +# code path never runs. Upstream fix: tursodatabase/libsql#2224. +# Tracked in #349. This ignore expires 2027-01-15; ci-preflight fails on +# that date until the exception is removed or re-reviewed. + +[advisories] +ignore = ["RUSTSEC-2026-0258"] diff --git a/AGENTS.md b/AGENTS.md index c4ebbb77..0550cf3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,8 +249,10 @@ workspace test runs only the in-process smoke), the API build without default fe and ordinary main pushes ignore reported CVE advisories but still fail when `cargo audit` cannot run). `npm audit --prefix tools/eslint --audit-level=high` always fails the `frontend` group on a high or critical advisory and on an -advisory-endpoint failure. There is no fail-open switch and no ignore list; -an unfixable transitive advisory is handled by pinning or dropping that package. GitHub-hosted Dependabot Updates dynamic workflows +advisory-endpoint failure. There is no fail-open switch for cargo-audit; the only +cargo-audit ignore is `RUSTSEC-2026-0258` in `.cargo/audit.toml`, expiring +2027-01-15 (see #349). Any other unfixable transitive advisory is handled by +pinning or dropping that package. GitHub-hosted Dependabot Updates dynamic workflows are not CI; do not add `.github/workflows` for them. Keep **Dependabot on self-hosted runners** disabled. This repository has no GitHub Actions self-hosted runners; otherwise jobs queue on a `dependabot` label and GitHub cancels them after 24 hours. Do not register leftover Proxmox GHA runners or label diff --git a/Cargo.lock b/Cargo.lock index 9e7d5708..1572385f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,9 +322,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -2136,9 +2136,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "once_cell", diff --git a/deny.toml b/deny.toml index 292a46c4..ae93fb96 100644 --- a/deny.toml +++ b/deny.toml @@ -1,5 +1,6 @@ # Supply-chain policy for cargo-deny (licenses, bans, sources). -# Advisories are not part of this file. GitHub Actions runs cargo-audit: +# Advisories are not part of this file; the single cargo-audit exception is +# `.cargo/audit.toml`. GitHub Actions runs cargo-audit: # pull requests and ordinary main pushes do not fail the supply-chain check on a new # CVE advisories on the fail-open path; cargo-audit operational failures still fail the job. # Scheduled builds fail closed so the advisory is visible. diff --git a/docs/CI.md b/docs/CI.md index c45495e5..2ffb69b8 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -65,7 +65,9 @@ Both npm audits fail on high/critical findings or endpoint errors, including `npm audit --prefix tools/eslint --audit-level=high`. Cargo advisory reports remain non-blocking for PRs/ordinary main pushes; operational audit failures always fail. Scheduled runs set `JMODGUARD_AUDIT_FAIL_CLOSED=1`, run all gates, and withhold -Nightly publication when an advisory is outstanding. +Nightly publication when an advisory is outstanding. `RUSTSEC-2026-0258` is the +one ignored cargo-audit advisory until 2027-01-15; preflight fails on that date +while the ignore remains. Scheduled runs still fail closed on every other advisory. ## Publication and trust diff --git a/scripts/ci-preflight-test.sh b/scripts/ci-preflight-test.sh index 1f7c661e..1f5908ac 100755 --- a/scripts/ci-preflight-test.sh +++ b/scripts/ci-preflight-test.sh @@ -170,3 +170,5 @@ if [ -f "$install_log" ]; then fi echo 'ci-preflight test: ensure_cargo_tool selection verified' + +bash "$root/scripts/ci-preflight.sh" --audit-expiry-self-check diff --git a/scripts/ci-preflight.sh b/scripts/ci-preflight.sh index 660cdda6..4864fad6 100755 --- a/scripts/ci-preflight.sh +++ b/scripts/ci-preflight.sh @@ -4,6 +4,24 @@ # Dependabot Updates dynamic workflows are not CI. set -euo pipefail +# UTC calendar dates are YYYY-MM-DD. cargo-audit has no expiry field. +audit_ignore_is_expired() { + local today=$1 expiry=$2 + ( + LC_ALL=C + [[ "$today" == "$expiry" || "$today" > "$expiry" ]] + ) +} + +if [ "${1:-}" = "--audit-expiry-self-check" ]; then + fail() { echo "ci-preflight: audit expiry self-check: $*" >&2; exit 1; } + audit_ignore_is_expired 2027-01-14 2027-01-15 && fail "2027-01-14 must stay valid" + audit_ignore_is_expired 2027-01-15 2027-01-15 || fail "2027-01-15 must expire" + audit_ignore_is_expired 2027-01-16 2027-01-15 || fail "2027-01-16 must expire" + echo "ci-preflight: audit expiry self-check passed" + exit 0 +fi + root="$(git rev-parse --show-toplevel)" cd "$root" @@ -259,7 +277,7 @@ EOF # failures (for example advisory DB fetch/load). Classify from JSON stdout and # stderr instead of trusting exit 1 alone. run_cargo_audit_gate() { - local audit_out audit_err audit_rc advisory_count + local audit_out audit_err audit_rc advisory_count node_rc ignore_0258 audit_out="$(mktemp)" audit_err="$(mktemp)" set +e @@ -270,34 +288,77 @@ run_cargo_audit_gate() { cat "$audit_err" >&2 fi - if [ "$audit_rc" -eq 0 ]; then - rm -f "$audit_out" "$audit_err" - return 0 - fi - if [ "$audit_rc" -eq 2 ]; then echo "ci-preflight: cargo audit failed to run (exit ${audit_rc})" >&2 rm -f "$audit_out" "$audit_err" return 1 fi - if [ "$audit_rc" -ne 1 ]; then + if [ "$audit_rc" -ne 0 ] && [ "$audit_rc" -ne 1 ]; then echo "ci-preflight: cargo audit failed to run (exit ${audit_rc})" >&2 rm -f "$audit_out" "$audit_err" return 1 fi - if grep -qE '^error:' "$audit_err" || [ ! -s "$audit_out" ]; then - echo 'ci-preflight: cargo audit failed to run (operational error)' >&2 - rm -f "$audit_out" "$audit_err" - return 1 + if [ "$audit_rc" -eq 1 ]; then + if grep -qE '^error:' "$audit_err" || [ ! -s "$audit_out" ]; then + echo 'ci-preflight: cargo audit failed to run (operational error)' >&2 + rm -f "$audit_out" "$audit_err" + return 1 + fi fi + set +e advisory_count="$( AUDIT_OUT="$audit_out" node --input-type=commonjs - <<'JS' const fs = require("fs"); -const report = JSON.parse(fs.readFileSync(process.env.AUDIT_OUT, "utf8")); +let report; +try { + report = JSON.parse(fs.readFileSync(process.env.AUDIT_OUT, "utf8")); +} catch { + process.exit(1); +} + const vulnerabilities = report.vulnerabilities || {}; +for (const entry of vulnerabilities.list || []) { + const id = entry.advisory?.id; + const pkg = entry.package; + if (!id || !pkg?.name || !pkg?.version) { + process.exit(1); + } + console.error(`ci-preflight: advisory ${id} ${pkg.name} ${pkg.version}`); +} + +for (const id of report.settings?.ignore || []) { + if (id === "RUSTSEC-2026-0258") { + console.error( + "ci-preflight: advisory RUSTSEC-2026-0258 ignored (expires 2027-01-15; see #349)" + ); + } else { + console.error(`ci-preflight: advisory ${id} ignored`); + } +} + +const warnings = report.warnings || {}; +for (const kind of ["yanked", "unmaintained"]) { + for (const entry of warnings[kind] || []) { + const pkg = entry.package; + if (!pkg?.name || !pkg?.version) { + process.exit(1); + } + const adv = entry.advisory?.id; + if (adv) { + console.error( + `ci-preflight: warning ${kind} ${pkg.name} ${pkg.version} ${adv}` + ); + } else { + console.error( + `ci-preflight: warning ${kind} ${pkg.name} ${pkg.version}` + ); + } + } +} + let count = vulnerabilities.count; if (count == null) { count = (vulnerabilities.list || []).length; @@ -308,9 +369,39 @@ if (vulnerabilities.found || count) { process.stdout.write("-1"); } JS - )" || advisory_count=-1 + )" + node_rc=$? + set -e + + if [ "$node_rc" -ne 0 ] && [ "$audit_rc" -eq 0 ]; then + echo 'ci-preflight: cargo audit failed to run (operational error)' >&2 + rm -f "$audit_out" "$audit_err" + return 1 + fi + + if [ "$node_rc" -ne 0 ]; then + advisory_count=-1 + fi + + ignore_0258=0 + if AUDIT_OUT="$audit_out" node --input-type=commonjs -e ' +const report = JSON.parse(require("fs").readFileSync(process.env.AUDIT_OUT, "utf8")); +process.exit((report.settings?.ignore || []).includes("RUSTSEC-2026-0258") ? 0 : 1); +' 2>/dev/null; then + ignore_0258=1 + fi + + if [ "$ignore_0258" -eq 1 ] && audit_ignore_is_expired "$(date -u +%Y-%m-%d)" 2027-01-15; then + echo 'ci-preflight: cargo audit exception RUSTSEC-2026-0258 expired on 2027-01-15; see #349; re-review the exception' >&2 + rm -f "$audit_out" "$audit_err" + return 1 + fi if [ "$advisory_count" -lt 0 ]; then + if [ "$audit_rc" -eq 0 ]; then + rm -f "$audit_out" "$audit_err" + return 0 + fi echo 'ci-preflight: cargo audit failed to run (unexpected exit 1 without advisories)' >&2 rm -f "$audit_out" "$audit_err" return 1 From 9b3e09d6f4c197bf9f4efcfc9f8c0e0e43824413 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 08:40:22 -0700 Subject: [PATCH 2/3] fix(ci): reject incomplete audit reports and expire every ignore --- .cargo/audit-expiries | 3 + .cargo/audit.toml | 5 +- AGENTS.md | 7 +- docs/CI.md | 7 +- scripts/ci-preflight.sh | 475 +++++++++++++++++++++++++++++++--------- 5 files changed, 382 insertions(+), 115 deletions(-) create mode 100644 .cargo/audit-expiries diff --git a/.cargo/audit-expiries b/.cargo/audit-expiries new file mode 100644 index 00000000..69db1638 --- /dev/null +++ b/.cargo/audit-expiries @@ -0,0 +1,3 @@ +# UTC expiry for each cargo-audit ignore. ci-preflight fails on this date. +# .cargo/audit.toml cannot store the date; cargo-audit rejects unknown fields. +RUSTSEC-2026-0258 2027-01-15 diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 997bb2ee..65248594 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -2,8 +2,9 @@ # The libsql remote connector only offers HTTP/1.1 (src/remote_https.rs, # enforced by https_client_config_advertises_http11_alpn), so the h2 0.3 # code path never runs. Upstream fix: tursodatabase/libsql#2224. -# Tracked in #349. This ignore expires 2027-01-15; ci-preflight fails on -# that date until the exception is removed or re-reviewed. +# Tracked in #349. Expiry UTC date is in `.cargo/audit-expiries` (2027-01-15); +# ci-preflight fails on that date until the exception is removed or re-reviewed. +# Every ignored advisory needs an expiry line there; cargo-audit cannot store dates. [advisories] ignore = ["RUSTSEC-2026-0258"] diff --git a/AGENTS.md b/AGENTS.md index 0550cf3e..a7db5e86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,9 +250,10 @@ and ordinary main pushes ignore reported CVE advisories but still fail when `cargo audit` cannot run). `npm audit --prefix tools/eslint --audit-level=high` always fails the `frontend` group on a high or critical advisory and on an advisory-endpoint failure. There is no fail-open switch for cargo-audit; the only -cargo-audit ignore is `RUSTSEC-2026-0258` in `.cargo/audit.toml`, expiring -2027-01-15 (see #349). Any other unfixable transitive advisory is handled by -pinning or dropping that package. GitHub-hosted Dependabot Updates dynamic workflows +cargo-audit ignore is `RUSTSEC-2026-0258` in `.cargo/audit.toml` (see #349). +Its UTC expiry is in `.cargo/audit-expiries` (2027-01-15); every ignored +advisory needs an expiry line there. Any other unfixable transitive advisory is +handled by pinning or dropping that package. GitHub-hosted Dependabot Updates dynamic workflows are not CI; do not add `.github/workflows` for them. Keep **Dependabot on self-hosted runners** disabled. This repository has no GitHub Actions self-hosted runners; otherwise jobs queue on a `dependabot` label and GitHub cancels them after 24 hours. Do not register leftover Proxmox GHA runners or label diff --git a/docs/CI.md b/docs/CI.md index 2ffb69b8..a2ad34ae 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -66,8 +66,11 @@ Both npm audits fail on high/critical findings or endpoint errors, including non-blocking for PRs/ordinary main pushes; operational audit failures always fail. Scheduled runs set `JMODGUARD_AUDIT_FAIL_CLOSED=1`, run all gates, and withhold Nightly publication when an advisory is outstanding. `RUSTSEC-2026-0258` is the -one ignored cargo-audit advisory until 2027-01-15; preflight fails on that date -while the ignore remains. Scheduled runs still fail closed on every other advisory. +one ignored cargo-audit advisory (see #349); its UTC expiry is in +`.cargo/audit-expiries` (2027-01-15). Every ignored advisory needs an expiry +there; any other unfixable advisory is handled by pinning or dropping that +package. Preflight fails on the expiry date while the ignore remains. Scheduled +runs still fail closed on every other advisory. ## Publication and trust diff --git a/scripts/ci-preflight.sh b/scripts/ci-preflight.sh index 4864fad6..cca4ccfd 100755 --- a/scripts/ci-preflight.sh +++ b/scripts/ci-preflight.sh @@ -4,7 +4,7 @@ # Dependabot Updates dynamic workflows are not CI. set -euo pipefail -# UTC calendar dates are YYYY-MM-DD. cargo-audit has no expiry field. +# UTC calendar dates are YYYY-MM-DD. Expiry dates live in `.cargo/audit-expiries`. audit_ignore_is_expired() { local today=$1 expiry=$2 ( @@ -13,11 +13,361 @@ audit_ignore_is_expired() { ) } +audit_date_is_ymd() { + local value=$1 normalized + [[ "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || return 1 + normalized="$(TZ=UTC date -u -d "${value}T00:00:00Z" +%Y-%m-%d 2>/dev/null)" || return 1 + [[ "$normalized" == "$value" ]] +} + +audit_accept_today() { + local status=$1 raw=$2 + [[ "$status" == 0 ]] || return 1 + audit_date_is_ymd "$raw" || return 1 + printf '%s\n' "$raw" + return 0 +} + +audit_read_expiries() { + local file=$1 + local line id date seen="" + [[ -f "$file" ]] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [ -n "$line" ] || continue + if [[ ! "$line" =~ ^([A-Z][A-Z0-9]*(-[0-9A-Z]+)+)[[:space:]]([0-9]{4}-[0-9]{2}-[0-9]{2})$ ]]; then + return 1 + fi + id="${BASH_REMATCH[1]}" + date="${BASH_REMATCH[3]}" + audit_date_is_ymd "$date" || return 1 + case " $seen " in + *" $id "*) return 1 ;; + esac + seen="${seen} ${id}" + printf '%s %s\n' "$id" "$date" + done <"$file" +} + +audit_classify_report() { + local report=$1 ignores_out=$2 + REPORT="$report" IGNORES_OUT="$ignores_out" node --input-type=commonjs - <<'JS' +const fs = require("fs"); + +const idRe = /^[A-Z][A-Z0-9]*(-[0-9A-Z]+)+$/; + +let report; +try { + report = JSON.parse(fs.readFileSync(process.env.REPORT, "utf8")); +} catch { + process.exit(1); +} + +if (!report || Array.isArray(report) || typeof report !== "object") { + process.exit(1); +} + +const vulnerabilities = report.vulnerabilities; +if ( + vulnerabilities == null || + Array.isArray(vulnerabilities) || + typeof vulnerabilities !== "object" +) { + process.exit(1); +} + +if (typeof vulnerabilities.found !== "boolean") { + process.exit(1); +} + +const count = vulnerabilities.count; +if ( + !Number.isInteger(count) || + count < 0 +) { + process.exit(1); +} + +if (!Array.isArray(vulnerabilities.list)) { + process.exit(1); +} + +for (const entry of vulnerabilities.list) { + const advId = entry.advisory?.id; + const pkg = entry.package; + if (!advId || !pkg?.name || !pkg?.version) { + process.exit(1); + } + console.error(`ci-preflight: advisory ${advId} ${pkg.name} ${pkg.version}`); +} + +const ignoreSetting = report.settings?.ignore; +const ignores = ignoreSetting == null ? [] : ignoreSetting; +if (!Array.isArray(ignores)) { + process.exit(1); +} + +const seen = new Set(); +for (const id of ignores) { + if (typeof id !== "string" || !idRe.test(id)) { + process.exit(1); + } + if (seen.has(id)) { + process.exit(1); + } + seen.add(id); +} + +fs.writeFileSync(process.env.IGNORES_OUT, ignores.join("\n") + (ignores.length ? "\n" : ""), "utf8"); + +const warnings = report.warnings || {}; +for (const kind of ["yanked", "unmaintained"]) { + for (const entry of warnings[kind] || []) { + const pkg = entry.package; + if (!pkg?.name || !pkg?.version) { + process.exit(1); + } + const adv = entry.advisory?.id; + if (adv) { + console.error( + `ci-preflight: warning ${kind} ${pkg.name} ${pkg.version} ${adv}` + ); + } else { + console.error( + `ci-preflight: warning ${kind} ${pkg.name} ${pkg.version}` + ); + } + } +} + +if (!vulnerabilities.found && count === 0) { + process.stdout.write("-1"); +} else { + process.stdout.write(String(count)); +} +JS +} + +decide_audit_report() { + local report=$1 expiries=$2 audit_rc=$3 today_rc=$4 today_raw=$5 fail_closed=$6 + local ignores_tmp count classify_rc + local -A expiry_map=() + local id date line ignore_count=0 expiry_count=0 restore_errexit=0 + ignores_tmp="$(mktemp)" + case "$-" in + *e*) restore_errexit=1 ;; + esac + set +e + dar_fin() { + if [ "$restore_errexit" -eq 1 ]; then + set -e + fi + } + count="$(audit_classify_report "$report" "$ignores_tmp")" + classify_rc=$? + if [ "$classify_rc" -ne 0 ]; then + rm -f "$ignores_tmp" + echo 'ci-preflight: cargo audit failed to run (operational error)' >&2 + dar_fin + return 1 + fi + + while IFS= read -r line; do + [ -n "$line" ] || continue + id="${line%% *}" + date="${line#* }" + expiry_map["$id"]="$date" + expiry_count=$((expiry_count + 1)) + done < <(audit_read_expiries "$expiries") + local read_rc=$? + if [ "$read_rc" -ne 0 ]; then + rm -f "$ignores_tmp" + echo 'ci-preflight: cargo audit expiry file is invalid' >&2 + dar_fin + return 1 + fi + + while IFS= read -r id || [ -n "$id" ]; do + [ -n "$id" ] || continue + ignore_count=$((ignore_count + 1)) + date="${expiry_map[$id]:-}" + if [ -z "$date" ]; then + rm -f "$ignores_tmp" + echo "ci-preflight: cargo audit ignore ${id} has no expiry in .cargo/audit-expiries" >&2 + dar_fin + return 1 + fi + done <"$ignores_tmp" + + for id in "${!expiry_map[@]}"; do + if ! grep -Fxq "$id" "$ignores_tmp" 2>/dev/null; then + rm -f "$ignores_tmp" + echo "ci-preflight: cargo audit expiry ${id} is not ignored" >&2 + dar_fin + return 1 + fi + done + + if [ "$ignore_count" -gt 0 ] || [ "$expiry_count" -gt 0 ]; then + if [ "$today_rc" -ne 0 ] || ! audit_date_is_ymd "$today_raw"; then + rm -f "$ignores_tmp" + echo 'ci-preflight: cargo audit exception expiry date is unavailable' >&2 + dar_fin + return 1 + fi + while IFS= read -r id || [ -n "$id" ]; do + [ -n "$id" ] || continue + date="${expiry_map[$id]}" + if [ "$id" = RUSTSEC-2026-0258 ]; then + echo "ci-preflight: advisory ${id} ignored (expires ${date}; see #349)" >&2 + if audit_ignore_is_expired "$today_raw" "$date"; then + echo "ci-preflight: cargo audit exception ${id} expired on ${date}; see #349; re-review the exception" >&2 + rm -f "$ignores_tmp" + dar_fin + return 1 + fi + else + echo "ci-preflight: advisory ${id} ignored (expires ${date})" >&2 + if audit_ignore_is_expired "$today_raw" "$date"; then + echo "ci-preflight: cargo audit exception ${id} expired on ${date}; re-review the exception" >&2 + rm -f "$ignores_tmp" + dar_fin + return 1 + fi + fi + done <"$ignores_tmp" + fi + + rm -f "$ignores_tmp" + + if [ "$count" = -1 ]; then + if [ "$audit_rc" -eq 0 ]; then + dar_fin + return 0 + fi + echo 'ci-preflight: cargo audit failed to run (unexpected exit 1 without advisories)' >&2 + dar_fin + return 1 + fi + + if [ "$fail_closed" = 1 ]; then + echo "ci-preflight: cargo audit failed closed (scheduled CI; ${count} advisories)" >&2 + dar_fin + return 1 + fi + + echo "ci-preflight: cargo audit reported ${count} advisories; pull requests and ordinary main pushes do not fail the supply-chain check" >&2 + dar_fin + return 0 +} + if [ "${1:-}" = "--audit-expiry-self-check" ]; then fail() { echo "ci-preflight: audit expiry self-check: $*" >&2; exit 1; } - audit_ignore_is_expired 2027-01-14 2027-01-15 && fail "2027-01-14 must stay valid" - audit_ignore_is_expired 2027-01-15 2027-01-15 || fail "2027-01-15 must expire" - audit_ignore_is_expired 2027-01-16 2027-01-15 || fail "2027-01-16 must expire" + self_root="$(cd "$(dirname "$0")/.." && pwd)" + real_expiries="${self_root}/.cargo/audit-expiries" + + audit_ignore_is_expired 2024-05-01 2024-05-02 && fail "2024-05-01 must stay valid before 2024-05-02" + audit_ignore_is_expired 2024-05-02 2024-05-02 || fail "2024-05-02 must expire on 2024-05-02" + audit_ignore_is_expired 2024-05-03 2024-05-02 || fail "2024-05-03 must expire after 2024-05-02" + + audit_accept_today 1 2024-05-02 >/dev/null 2>&1 && fail "audit_accept_today must fail for status 1" + audit_accept_today 0 not-a-date >/dev/null 2>&1 && fail "audit_accept_today must fail for not-a-date" + audit_accept_today 0 2027-13-40 >/dev/null 2>&1 && fail "audit_accept_today must fail for 2027-13-40" + out="$(audit_accept_today 0 2024-05-02)" || fail "audit_accept_today must accept 2024-05-02" + [ "$out" = 2024-05-02 ] || fail "audit_accept_today must echo 2024-05-02" + + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + write_report() { + printf '%s' "$1" >"$tmp/report.json" + } + + decide_expect() { + local expect_rc=$1 + local report_json=$2 expiries_file=$3 audit_rc=$4 today_rc=$5 today_raw=$6 fail_closed=$7 + local rc + write_report "$report_json" + : >"$tmp/decide.err" + set +e + decide_audit_report "$tmp/report.json" "$expiries_file" "$audit_rc" "$today_rc" "$today_raw" "$fail_closed" 2>"$tmp/decide.err" + rc=$? + set -e + if [ "$rc" -ne "$expect_rc" ]; then + cat "$tmp/decide.err" >&2 + fail "decide_audit_report expected rc ${expect_rc}, got ${rc}" + fi + } + + printf '\n' >"$tmp/empty-expiries" + + decide_expect 1 '{' "$tmp/empty-expiries" 0 0 2024-05-02 0 + grep -Fq 'operational error' "$tmp/decide.err" || fail 'malformed JSON must print operational error' + + decide_expect 1 '{}' "$tmp/empty-expiries" 0 0 2024-05-02 0 + grep -Fq 'operational error' "$tmp/decide.err" || fail 'missing vulnerabilities must print operational error' + + decide_expect 1 '{"vulnerabilities":null}' "$tmp/empty-expiries" 0 0 2024-05-02 0 + grep -Fq 'operational error' "$tmp/decide.err" || fail 'null vulnerabilities must print operational error' + + decide_expect 1 '{"vulnerabilities":[]}' "$tmp/empty-expiries" 0 0 2024-05-02 0 + grep -Fq 'operational error' "$tmp/decide.err" || fail 'array vulnerabilities must print operational error' + + decide_expect 0 '{"vulnerabilities":{"found":false,"count":0,"list":[]}}' "$tmp/empty-expiries" 0 0 2024-05-02 0 + decide_expect 0 '{"vulnerabilities":{"found":false,"count":0,"list":[]}}' "$tmp/empty-expiries" 0 0 2024-05-02 1 + + one_adv='{"vulnerabilities":{"found":true,"count":1,"list":[{"advisory":{"id":"RUSTSEC-2020-0001"},"package":{"name":"crate","version":"1.0.0"}}]},"settings":{"ignore":[]}}' + decide_expect 0 "$one_adv" "$tmp/empty-expiries" 0 0 2024-05-02 0 + grep -Fq 'pull requests and ordinary main pushes do not fail' "$tmp/decide.err" || fail 'one advisory fail-open must print PR sentence' + decide_expect 1 "$one_adv" "$tmp/empty-expiries" 0 0 2024-05-02 1 + grep -Fq 'failed closed' "$tmp/decide.err" || fail 'one advisory fail-closed must print failed closed' + + decide_expect 1 '{"vulnerabilities":{"found":false,"count":0,"list":[]}}' "$tmp/empty-expiries" 1 0 2024-05-02 0 + grep -Fq 'unexpected exit 1 without advisories' "$tmp/decide.err" || fail 'exit 1 empty report must print unexpected exit 1' + + printf 'RUSTSEC-2026-0001 2099-01-01\n' >"$tmp/partial-expiries" + two_ignore='{"vulnerabilities":{"found":false,"count":0,"list":[]},"settings":{"ignore":["RUSTSEC-2026-0001","RUSTSEC-2026-0002"]}}' + decide_expect 1 "$two_ignore" "$tmp/partial-expiries" 0 0 2024-05-02 0 + grep -Fq 'RUSTSEC-2026-0002' "$tmp/decide.err" || fail 'missing expiry must name RUSTSEC-2026-0002' + grep -Fq 'has no expiry' "$tmp/decide.err" || fail 'missing expiry must print has no expiry' + + printf 'RUSTSEC-2026-9999 2099-01-01\n' >"$tmp/orphan-expiry" + no_ignore='{"vulnerabilities":{"found":false,"count":0,"list":[]},"settings":{"ignore":[]}}' + decide_expect 1 "$no_ignore" "$tmp/orphan-expiry" 0 0 2024-05-02 0 + grep -Fq 'is not ignored' "$tmp/decide.err" || fail 'orphan expiry must print is not ignored' + + printf 'RUSTSEC-2026-0001 2099-06-01\nRUSTSEC-2026-0002 2099-12-01\n' >"$tmp/two-future" + two_ignore_ok='{"vulnerabilities":{"found":false,"count":0,"list":[]},"settings":{"ignore":["RUSTSEC-2026-0001","RUSTSEC-2026-0002"]}}' + decide_expect 0 "$two_ignore_ok" "$tmp/two-future" 0 0 2024-05-02 0 + grep -Fq 'RUSTSEC-2026-0001 ignored (expires 2099-06-01)' "$tmp/decide.err" || fail 'two future: first ignore line' + grep -Fq 'RUSTSEC-2026-0002 ignored (expires 2099-12-01)' "$tmp/decide.err" || fail 'two future: second ignore line' + + printf 'RUSTSEC-2026-0001 2024-05-01\nRUSTSEC-2026-0002 2099-12-01\n' >"$tmp/today-expire" + decide_expect 1 "$two_ignore_ok" "$tmp/today-expire" 0 0 2024-05-02 0 + grep -Fq 'expired on' "$tmp/decide.err" || fail 'today equal earlier expiry must print expired on' + grep -Fq 'RUSTSEC-2026-0001' "$tmp/decide.err" || fail 'today equal earlier expiry must name id' + grep -Fq '2024-05-01' "$tmp/decide.err" || fail 'today equal earlier expiry must name date' + + printf 'RUSTSEC-2026-0001 2099-01-01\n' >"$tmp/one-future" + one_ignore='{"vulnerabilities":{"found":false,"count":0,"list":[]},"settings":{"ignore":["RUSTSEC-2026-0001"]}}' + decide_expect 1 "$one_ignore" "$tmp/one-future" 0 1 '' 0 + grep -Fq 'expiry date is unavailable' "$tmp/decide.err" || fail 'today_rc 1 must print expiry date is unavailable' + decide_expect 1 "$one_ignore" "$tmp/one-future" 0 0 'yesterday' 0 + grep -Fq 'expiry date is unavailable' "$tmp/decide.err" || fail 'non-calendar today must print expiry date is unavailable' + + decide_expect 0 '{"vulnerabilities":{"found":false,"count":0,"list":[]}}' "$tmp/empty-expiries" 0 1 '' 0 + + rustsec_expiry="$(awk '/^RUSTSEC-2026-0258 / {print $2; exit}' "$real_expiries")" + [ -n "$rustsec_expiry" ] || fail 'real audit-expiries must list RUSTSEC-2026-0258' + day_before="$(TZ=UTC date -u -d "${rustsec_expiry}T00:00:00Z - 1 day" +%Y-%m-%d)" + rustsec_ok='{"vulnerabilities":{"found":false,"count":0,"list":[]},"settings":{"ignore":["RUSTSEC-2026-0258"]}}' + decide_expect 0 "$rustsec_ok" "$real_expiries" 0 0 "$day_before" 0 + grep -Fq "ignored (expires ${rustsec_expiry}; see #349)" "$tmp/decide.err" || fail 'documented date: day before must print ignored with see #349' + decide_expect 1 "$rustsec_ok" "$real_expiries" 0 0 "$rustsec_expiry" 0 + grep -Fq "expired on ${rustsec_expiry}; see #349; re-review the exception" "$tmp/decide.err" || fail 'documented date: expiry day must print expired with see #349' + echo "ci-preflight: audit expiry self-check passed" exit 0 fi @@ -277,7 +627,7 @@ EOF # failures (for example advisory DB fetch/load). Classify from JSON stdout and # stderr instead of trusting exit 1 alone. run_cargo_audit_gate() { - local audit_out audit_err audit_rc advisory_count node_rc ignore_0258 + local audit_out audit_err audit_rc today_raw today_rc fail_closed decide_rc audit_out="$(mktemp)" audit_err="$(mktemp)" set +e @@ -308,114 +658,23 @@ run_cargo_audit_gate() { fi fi - set +e - advisory_count="$( - AUDIT_OUT="$audit_out" node --input-type=commonjs - <<'JS' -const fs = require("fs"); -let report; -try { - report = JSON.parse(fs.readFileSync(process.env.AUDIT_OUT, "utf8")); -} catch { - process.exit(1); -} - -const vulnerabilities = report.vulnerabilities || {}; -for (const entry of vulnerabilities.list || []) { - const id = entry.advisory?.id; - const pkg = entry.package; - if (!id || !pkg?.name || !pkg?.version) { - process.exit(1); - } - console.error(`ci-preflight: advisory ${id} ${pkg.name} ${pkg.version}`); -} - -for (const id of report.settings?.ignore || []) { - if (id === "RUSTSEC-2026-0258") { - console.error( - "ci-preflight: advisory RUSTSEC-2026-0258 ignored (expires 2027-01-15; see #349)" - ); - } else { - console.error(`ci-preflight: advisory ${id} ignored`); - } -} - -const warnings = report.warnings || {}; -for (const kind of ["yanked", "unmaintained"]) { - for (const entry of warnings[kind] || []) { - const pkg = entry.package; - if (!pkg?.name || !pkg?.version) { - process.exit(1); - } - const adv = entry.advisory?.id; - if (adv) { - console.error( - `ci-preflight: warning ${kind} ${pkg.name} ${pkg.version} ${adv}` - ); - } else { - console.error( - `ci-preflight: warning ${kind} ${pkg.name} ${pkg.version}` - ); - } - } -} - -let count = vulnerabilities.count; -if (count == null) { - count = (vulnerabilities.list || []).length; -} -if (vulnerabilities.found || count) { - process.stdout.write(String(Number(count))); -} else { - process.stdout.write("-1"); -} -JS - )" - node_rc=$? - set -e - - if [ "$node_rc" -ne 0 ] && [ "$audit_rc" -eq 0 ]; then - echo 'ci-preflight: cargo audit failed to run (operational error)' >&2 - rm -f "$audit_out" "$audit_err" - return 1 - fi - - if [ "$node_rc" -ne 0 ]; then - advisory_count=-1 - fi - - ignore_0258=0 - if AUDIT_OUT="$audit_out" node --input-type=commonjs -e ' -const report = JSON.parse(require("fs").readFileSync(process.env.AUDIT_OUT, "utf8")); -process.exit((report.settings?.ignore || []).includes("RUSTSEC-2026-0258") ? 0 : 1); -' 2>/dev/null; then - ignore_0258=1 - fi - - if [ "$ignore_0258" -eq 1 ] && audit_ignore_is_expired "$(date -u +%Y-%m-%d)" 2027-01-15; then - echo 'ci-preflight: cargo audit exception RUSTSEC-2026-0258 expired on 2027-01-15; see #349; re-review the exception' >&2 - rm -f "$audit_out" "$audit_err" - return 1 - fi - - if [ "$advisory_count" -lt 0 ]; then - if [ "$audit_rc" -eq 0 ]; then - rm -f "$audit_out" "$audit_err" - return 0 - fi - echo 'ci-preflight: cargo audit failed to run (unexpected exit 1 without advisories)' >&2 - rm -f "$audit_out" "$audit_err" - return 1 + today_raw="" + today_rc=1 + if today_raw="$(date -u +%Y-%m-%d 2>/dev/null)"; then + today_rc=0 fi - if [ "${JMODGUARD_AUDIT_FAIL_CLOSED:-0}" = "1" ]; then - echo "ci-preflight: cargo audit failed closed (scheduled CI; ${advisory_count} advisories)" >&2 - rm -f "$audit_out" "$audit_err" - return 1 + fail_closed=0 + if [ "${JMODGUARD_AUDIT_FAIL_CLOSED:-}" = "1" ]; then + fail_closed=1 fi - echo "ci-preflight: cargo audit reported ${advisory_count} advisories; pull requests and ordinary main pushes do not fail the supply-chain check" >&2 + set +e + decide_audit_report "$audit_out" "$root/.cargo/audit-expiries" "$audit_rc" "$today_rc" "$today_raw" "$fail_closed" + decide_rc=$? + set -e rm -f "$audit_out" "$audit_err" - return 0 + return "$decide_rc" } whitespace_check() { From 121f6bd1569f8d4ffee9eab05ec9ffc64686ce10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 08:56:39 -0700 Subject: [PATCH 3/3] fix(ci): reject a partially parsed audit expiry file The expiry parser's failure was hidden by process substitution, so a duplicate id, a bad line, or an invalid date after a valid prefix still let the gate succeed. Parsing now has to succeed before any deadline is used, and the self-check covers those files in both fail-closed modes. --- scripts/ci-preflight.sh | 88 ++++++++++++++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 19 deletions(-) diff --git a/scripts/ci-preflight.sh b/scripts/ci-preflight.sh index cca4ccfd..a8bc702f 100755 --- a/scripts/ci-preflight.sh +++ b/scripts/ci-preflight.sh @@ -152,10 +152,11 @@ JS decide_audit_report() { local report=$1 expiries=$2 audit_rc=$3 today_rc=$4 today_raw=$5 fail_closed=$6 - local ignores_tmp count classify_rc + local ignores_tmp expiries_tmp count classify_rc local -A expiry_map=() - local id date line ignore_count=0 expiry_count=0 restore_errexit=0 + local id date line ignore_count=0 expiry_count=0 expired=0 restore_errexit=0 ignores_tmp="$(mktemp)" + expiries_tmp="$(mktemp)" case "$-" in *e*) restore_errexit=1 ;; esac @@ -168,26 +169,28 @@ decide_audit_report() { count="$(audit_classify_report "$report" "$ignores_tmp")" classify_rc=$? if [ "$classify_rc" -ne 0 ]; then - rm -f "$ignores_tmp" + rm -f "$ignores_tmp" "$expiries_tmp" echo 'ci-preflight: cargo audit failed to run (operational error)' >&2 dar_fin return 1 fi - while IFS= read -r line; do + # The parser prints valid lines before it returns 1. A process substitution + # would keep that prefix and hide the failure, so keep the exit status. + if ! audit_read_expiries "$expiries" >"$expiries_tmp"; then + rm -f "$ignores_tmp" "$expiries_tmp" + echo 'ci-preflight: cargo audit expiry file is invalid' >&2 + dar_fin + return 1 + fi + while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue id="${line%% *}" date="${line#* }" expiry_map["$id"]="$date" expiry_count=$((expiry_count + 1)) - done < <(audit_read_expiries "$expiries") - local read_rc=$? - if [ "$read_rc" -ne 0 ]; then - rm -f "$ignores_tmp" - echo 'ci-preflight: cargo audit expiry file is invalid' >&2 - dar_fin - return 1 - fi + done <"$expiries_tmp" + rm -f "$expiries_tmp" while IFS= read -r id || [ -n "$id" ]; do [ -n "$id" ] || continue @@ -224,20 +227,21 @@ decide_audit_report() { echo "ci-preflight: advisory ${id} ignored (expires ${date}; see #349)" >&2 if audit_ignore_is_expired "$today_raw" "$date"; then echo "ci-preflight: cargo audit exception ${id} expired on ${date}; see #349; re-review the exception" >&2 - rm -f "$ignores_tmp" - dar_fin - return 1 + expired=1 fi else echo "ci-preflight: advisory ${id} ignored (expires ${date})" >&2 if audit_ignore_is_expired "$today_raw" "$date"; then echo "ci-preflight: cargo audit exception ${id} expired on ${date}; re-review the exception" >&2 - rm -f "$ignores_tmp" - dar_fin - return 1 + expired=1 fi fi done <"$ignores_tmp" + if [ "$expired" -eq 1 ]; then + rm -f "$ignores_tmp" + dar_fin + return 1 + fi fi rm -f "$ignores_tmp" @@ -345,11 +349,34 @@ if [ "${1:-}" = "--audit-expiry-self-check" ]; then grep -Fq 'RUSTSEC-2026-0002 ignored (expires 2099-12-01)' "$tmp/decide.err" || fail 'two future: second ignore line' printf 'RUSTSEC-2026-0001 2024-05-01\nRUSTSEC-2026-0002 2099-12-01\n' >"$tmp/today-expire" - decide_expect 1 "$two_ignore_ok" "$tmp/today-expire" 0 0 2024-05-02 0 + decide_expect 1 "$two_ignore_ok" "$tmp/today-expire" 0 0 2024-05-01 0 grep -Fq 'expired on' "$tmp/decide.err" || fail 'today equal earlier expiry must print expired on' grep -Fq 'RUSTSEC-2026-0001' "$tmp/decide.err" || fail 'today equal earlier expiry must name id' grep -Fq '2024-05-01' "$tmp/decide.err" || fail 'today equal earlier expiry must name date' + printf 'RUSTSEC-2026-0001 2024-05-01\nRUSTSEC-2026-0002 2024-05-02\n' >"$tmp/both-expired" + decide_expect 1 "$two_ignore_ok" "$tmp/both-expired" 0 0 2024-05-02 0 + grep -Fq 'exception RUSTSEC-2026-0001 expired on 2024-05-01' "$tmp/decide.err" || fail 'both expired must name the first id' + grep -Fq 'exception RUSTSEC-2026-0002 expired on 2024-05-02' "$tmp/decide.err" || fail 'both expired must name the second id' + + # Pullfrog repro: a valid prefix must not survive a later parser failure. + rustsec_only='{"vulnerabilities":{"found":false,"count":0,"list":[]},"settings":{"ignore":["RUSTSEC-2026-0258"]}}' + printf '%s\n' 'RUSTSEC-2026-0258 2027-01-15' 'RUSTSEC-2026-0258 2026-01-15' >"$tmp/dup-expiries" + decide_expect 1 "$rustsec_only" "$tmp/dup-expiries" 0 0 2026-10-17 0 + grep -Fq 'expiry file is invalid' "$tmp/decide.err" || fail 'duplicate expiry id must print expiry file is invalid when fail-open' + decide_expect 1 "$rustsec_only" "$tmp/dup-expiries" 0 0 2026-10-17 1 + grep -Fq 'expiry file is invalid' "$tmp/decide.err" || fail 'duplicate expiry id must print expiry file is invalid when fail-closed' + printf '%s\n' 'RUSTSEC-2026-0258 2027-01-15' 'INVALID-LINE' >"$tmp/malformed-expiries" + decide_expect 1 "$rustsec_only" "$tmp/malformed-expiries" 0 0 2026-10-17 0 + grep -Fq 'expiry file is invalid' "$tmp/decide.err" || fail 'malformed expiry line must print expiry file is invalid when fail-open' + decide_expect 1 "$rustsec_only" "$tmp/malformed-expiries" 0 0 2026-10-17 1 + grep -Fq 'expiry file is invalid' "$tmp/decide.err" || fail 'malformed expiry line must print expiry file is invalid when fail-closed' + printf '%s\n' 'RUSTSEC-2026-0258 2027-01-15' 'RUSTSEC-2026-0002 2026-13-40' >"$tmp/bad-date-expiries" + decide_expect 1 "$rustsec_only" "$tmp/bad-date-expiries" 0 0 2026-10-17 0 + grep -Fq 'expiry file is invalid' "$tmp/decide.err" || fail 'invalid expiry date must print expiry file is invalid when fail-open' + decide_expect 1 "$rustsec_only" "$tmp/bad-date-expiries" 0 0 2026-10-17 1 + grep -Fq 'expiry file is invalid' "$tmp/decide.err" || fail 'invalid expiry date must print expiry file is invalid when fail-closed' + printf 'RUSTSEC-2026-0001 2099-01-01\n' >"$tmp/one-future" one_ignore='{"vulnerabilities":{"found":false,"count":0,"list":[]},"settings":{"ignore":["RUSTSEC-2026-0001"]}}' decide_expect 1 "$one_ignore" "$tmp/one-future" 0 1 '' 0 @@ -361,6 +388,11 @@ if [ "${1:-}" = "--audit-expiry-self-check" ]; then rustsec_expiry="$(awk '/^RUSTSEC-2026-0258 / {print $2; exit}' "$real_expiries")" [ -n "$rustsec_expiry" ] || fail 'real audit-expiries must list RUSTSEC-2026-0258' + data_lines="$(grep -cE '^RUSTSEC-' "$real_expiries" || true)" + [ "$data_lines" = 1 ] || fail 'audit-expiries must contain exactly one advisory line' + grep -F "$rustsec_expiry" "$self_root/AGENTS.md" >/dev/null || fail 'AGENTS.md must document the enforced expiry' + grep -F "$rustsec_expiry" "$self_root/docs/CI.md" >/dev/null || fail 'docs/CI.md must document the enforced expiry' + grep -F "$rustsec_expiry" "$self_root/.cargo/audit.toml" >/dev/null || fail 'audit.toml must document the enforced expiry' day_before="$(TZ=UTC date -u -d "${rustsec_expiry}T00:00:00Z - 1 day" +%Y-%m-%d)" rustsec_ok='{"vulnerabilities":{"found":false,"count":0,"list":[]},"settings":{"ignore":["RUSTSEC-2026-0258"]}}' decide_expect 0 "$rustsec_ok" "$real_expiries" 0 0 "$day_before" 0 @@ -368,6 +400,24 @@ if [ "${1:-}" = "--audit-expiry-self-check" ]; then decide_expect 1 "$rustsec_ok" "$real_expiries" 0 0 "$rustsec_expiry" 0 grep -Fq "expired on ${rustsec_expiry}; see #349; re-review the exception" "$tmp/decide.err" || fail 'documented date: expiry day must print expired with see #349' + write_report '{"vulnerabilities":{"found":false,"count":0,"list":[]}}' + set -e + decide_audit_report "$tmp/report.json" "$tmp/empty-expiries" 0 0 2024-05-02 0 + case $- in + *e*) ;; + *) fail 'decide_audit_report disabled errexit on success' ;; + esac + set +e + decide_audit_report "$tmp/report.json" "$tmp/missing-expiries" 0 0 2024-05-02 0 2>"$tmp/decide.err" + errexit_rc=$? + set -e + [ "$errexit_rc" -ne 0 ] || fail 'missing expiry file must fail' + grep -Fq 'expiry file is invalid' "$tmp/decide.err" || fail 'missing expiry file must print expiry file is invalid' + case $- in + *e*) ;; + *) fail 'decide_audit_report left errexit disabled after failure' ;; + esac + echo "ci-preflight: audit expiry self-check passed" exit 0 fi