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 new file mode 100644 index 00000000..65248594 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,10 @@ +# 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. 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 c4ebbb77..a7db5e86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,8 +249,11 @@ 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` (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/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..a2ad34ae 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -65,7 +65,12 @@ 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 (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-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..a8bc702f 100755 --- a/scripts/ci-preflight.sh +++ b/scripts/ci-preflight.sh @@ -4,6 +4,424 @@ # Dependabot Updates dynamic workflows are not CI. set -euo pipefail +# UTC calendar dates are YYYY-MM-DD. Expiry dates live in `.cargo/audit-expiries`. +audit_ignore_is_expired() { + local today=$1 expiry=$2 + ( + LC_ALL=C + [[ "$today" == "$expiry" || "$today" > "$expiry" ]] + ) +} + +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 expiries_tmp count classify_rc + local -A expiry_map=() + 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 + 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" "$expiries_tmp" + echo 'ci-preflight: cargo audit failed to run (operational error)' >&2 + dar_fin + return 1 + fi + + # 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 <"$expiries_tmp" + rm -f "$expiries_tmp" + + 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 + 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 + 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" + + 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; } + 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-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 + 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' + 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 + 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' + + 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 + root="$(git rev-parse --show-toplevel)" cd "$root" @@ -259,7 +677,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 today_raw today_rc fail_closed decide_rc audit_out="$(mktemp)" audit_err="$(mktemp)" set +e @@ -270,61 +688,43 @@ 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 - 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")); -const vulnerabilities = report.vulnerabilities || {}; -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 - )" || advisory_count=-1 - - if [ "$advisory_count" -lt 0 ]; then - 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() {