#!/usr/bin/env python3 # # check-advisories.py — no workspace carries a security advisory nobody has read. # # This tree holds many workspaces, and a vulnerable crate usually arrives # transitively, in a lockfile nobody edited, for a reason nobody chose. Without # a gate the backlog only grows, and it grew: a sweep in October 2026 found 142 # vulnerability instances across 47 lockfiles, among them a WASM runtime with a # filesystem sandbox escape in the one component whose job is confinement. # Nothing had failed, because nothing was looking. # # For every tracked Cargo.lock this runs # # cargo audit --file --json # # and fails on any advisory that is not in ALLOW below. It reads the advisory # database and the lockfile; it builds nothing. # # ALLOW is the whole point. An advisory may be left in place — upstream has no # fixed release, or the vulnerable call is unreachable here — but only in # writing, with the reason next to the id. Two rules keep that honest: # # * an advisory that is not listed fails the run, so a new one has to be # looked at rather than accumulated; # * a listed advisory that no longer appears anywhere ALSO fails the run, so # an entry cannot outlive the exposure it excused. An allow list that is # never pruned stops describing the tree and starts hiding it. # # Warnings (unmaintained, unsound, yanked) are counted and printed but do not # fail the run: they are advice about maintenance, not a reachable defect, and # gating on them would bury the vulnerabilities in noise. They are printed so # the number stays visible rather than unknown. # # check-advisories.py every tracked Cargo.lock, less SKIP # check-advisories.py DIR ... exactly these directories # check-advisories.py --from-json F... audit JSON already captured (self-test) # # Exit 0: every advisory found is allowed, and every allowed advisory was found. # Exit 1: otherwise — including when a lockfile could not be audited at all, # which is reported under its own heading so an error is never read as "clean". import json import os import subprocess import sys # Advisories left in place on purpose. Each needs the reason, and each must # still be reported by at least one lockfile or this check fails. # # An entry is either # # "RUSTSEC-...": "why it can be left" # # which excuses the advisory at ANY version — correct when the reason does not # depend on the version, such as a call we never make — or # # "RUSTSEC-...": {"why": "...", "only": ("crate 1.2.3", ...)} # # which excuses it ONLY at those exact crate/version pairs. Use the second form # whenever the reason is "this particular line cannot move yet", because the # first form is version-blind: it would go on excusing the advisory if a # patched line later regressed to an unpatched release. Every pair named in # `only` must also still appear, or the entry is stale and the run fails. ALLOW = { # ── Unreachable here ──────────────────────────────────────────────────── # All three are faults in `extract()`: SUID/SGID bits preserved from the # archive, symlinks whose targets escape the destination, and permissions # applied through those symlinks. Nothing in this tree extracts an archive # — there is no `ZipArchive` or `ZipStreamReader` call site. zip arrives # under candle-core and the wasm toolchain. "RUSTSEC-2026-0367": "zip: extract-only; nothing here extracts an archive", "RUSTSEC-2026-0368": "zip: extract-only; nothing here extracts an archive", "RUSTSEC-2026-0369": "zip: extract-only; nothing here extracts an archive", "RUSTSEC-2025-0168": "zip: extract-only path canonicalization; nothing here extracts", # The Marvin attack recovers an RSA key from the timing of private-key # decryption. There is no fixed release of `rsa` at all. It reaches # sandbox-rs and wai-rs transitively and neither holds an RSA private key # nor decrypts: openpay's unused direct dependency was removed outright. "RUSTSEC-2023-0071": "rsa: no fixed release; no private-key decryption here", # ── Pinned by a dependency; needs an upstream release ────────────────── # Secret-dependent branches in operations meant to be constant-time # (subtraction, zero counting, division, Montgomery multiplication), so # timing can leak intermediate arithmetic on secret integers. Fixed in # >=0.7.6, and smart-byte's 0.7.x copy is patched. The 0.5.5 line is pinned # by elliptic-curve 0.13, so reaching it means moving the whole # p256/elliptic-curve stack — a real upgrade, not a bump, and this entry is # that debt, recorded on paths that sign and derive keys. Version-pinned on # purpose: a 0.7.x copy must never be excused by it. "RUSTSEC-2026-0387": { "why": "crypto-bigint 0.5.5 is pinned by elliptic-curve 0.13; 0.7.x is patched", "only": ("crypto-bigint 0.5.5",), }, # hpke-rs-libcrux pins `=0.7.0`, which pins libcrux-kem 0.0.9. Both are # panics on short or malformed hybrid keys — denial of service, not memory # corruption. Fixed by an openmls/hpke-rs release, not from here. "RUSTSEC-2026-0330": "libcrux-kem: pinned by hpke-rs-libcrux =0.7.0; upstream", "RUSTSEC-2026-0331": "libcrux-kem: pinned by hpke-rs-libcrux =0.7.0; upstream", # quick-xml is 0.41 everywhere the version is ours to choose, which includes # every call that actually parses: op-iso20022's codec binds the workspace # 0.41. Two older copies remain in the graph, neither on a parse path. # openpay resolves 0.37.5 through open-payments-iso20022 1.0.10, which # declares quick-xml but never calls it — the crate ships serde-annotated # ISO 20022 types (3 files, ~2.2k lines, no `quick_xml` reference), and # the parsing is ours. Compiled, never executed. # smart-byte resolves 0.39.4 through plist -> netdev -> netwatch -> iroh, # which parses macOS plists, not counterparty XML. # -0195 additionally needs `NsReader`, which appears nowhere in this tree. "RUSTSEC-2026-0194": "quick-xml 0.37.5/0.39.4: not on a parse path; our parsing is on 0.41", "RUSTSEC-2026-0195": "quick-xml 0.37.5/0.39.4: NsReader unused, and neither copy parses", # h2 0.3.27 under jouleclaw and 0.4.14 under smart-byte, both transitive; # the fix is a major move for the dependent. "RUSTSEC-2026-0258": "h2: transitive; fixed version is a major move for the dependent", # hickory-proto 0.25.2 under smart-byte via iroh. -0118 has no fixed # release; -0119 needs 0.26, which iroh does not take yet. "RUSTSEC-2026-0118": "hickory-proto: no fixed release; transitive under iroh", "RUSTSEC-2026-0119": "hickory-proto: 0.26 not taken by iroh yet", # pyo3 0.28.3 under eoc-rs. Both need 0.29, a breaking move for the binding. "RUSTSEC-2026-0176": "pyo3: 0.29 is a breaking move for the binding", "RUSTSEC-2026-0177": "pyo3: 0.29 is a breaking move for the binding", } # Lockfiles cargo does not read: a vendored crate's own lock, kept with the # vendored source. Each must still exist, or the entry is stale. SKIP = ( "jouledesk/jouledesk-rs/vendor/libwebrtc", "joule-code/joule-code-rs/vendor/wtransport", ) def discover(): root = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True ).stdout.strip() if not root: sys.exit("check-advisories: not in a git checkout.") os.chdir(root) for d in SKIP: if not os.path.isfile(os.path.join(d, "Cargo.lock")): sys.exit( f"check-advisories: the skip list names {d}, which holds no " "Cargo.lock. Remove the entry." ) out = subprocess.run(["git", "ls-files"], capture_output=True, text=True).stdout locks = [ p for p in out.splitlines() if p == "Cargo.lock" or p.endswith("/Cargo.lock") if os.path.dirname(p) not in SKIP ] return sorted(locks) def audit(lock): """Return (parsed_json, error_text). Exactly one is None.""" try: p = subprocess.run( ["cargo", "audit", "--file", lock, "--json", "--color", "never"], capture_output=True, text=True, timeout=600, ) except FileNotFoundError: return None, "cargo is not on PATH" except subprocess.TimeoutExpired: return None, "cargo audit timed out" # cargo audit exits non-zero when it finds something, so the exit status is # not the signal; absence of parseable JSON is. try: return json.loads(p.stdout), None except json.JSONDecodeError: tail = (p.stderr or p.stdout or "").strip().splitlines() return None, "; ".join(tail[-3:]) or "no JSON on stdout" def main(): args = sys.argv[1:] from_json = False if args and args[0] == "--from-json": from_json, args = True, args[1:] if from_json: sources = [(f, None) for f in args] elif args: sources = [(os.path.join(d, "Cargo.lock"), None) for d in args] else: sources = [(l, None) for l in discover()] if not sources: sys.exit("check-advisories: found no Cargo.lock to check.") found = {} # id -> [(crate, version, lock)] unchecked = [] # (lock, why) warn_count = 0 for lock, _ in sources: if from_json: try: with open(lock) as fh: data, err = json.load(fh), None except Exception as e: # noqa: BLE001 data, err = None, str(e) else: data, err = audit(lock) if data is None: unchecked.append((lock, err)) continue for v in (data.get("vulnerabilities") or {}).get("list") or []: adv, pkg = v.get("advisory") or {}, v.get("package") or {} found.setdefault(adv.get("id", "?"), []).append( (pkg.get("name", "?"), pkg.get("version", "?"), lock) ) for kind, items in (data.get("warnings") or {}).items(): warn_count += len(items or []) def covered(adv, crate, ver): entry = ALLOW.get(adv) if entry is None: return False if isinstance(entry, str): return True return f"{crate} {ver}" in entry["only"] unexpected = {} for adv, occurrences in found.items(): loose = [o for o in occurrences if not covered(adv, o[0], o[1])] if loose: unexpected[adv] = loose stale = [k for k in ALLOW if k not in found] # A version-pinned entry is also stale when a pair it names is gone: the # pin stops describing the tree the moment that line moves. for adv, entry in ALLOW.items(): if adv in found and isinstance(entry, dict): seen = {f"{c} {v}" for c, v, _ in found[adv]} for pair in entry["only"]: if pair not in seen: stale.append(f"{adv} (no longer at {pair})") if unexpected: print("Advisories that are not in the allow list:\n") for adv in sorted(unexpected): for crate, ver, lock in unexpected[adv]: print(f" {adv} {crate} {ver}") print(f" {lock}") print( "\nFix each, or add it to ALLOW in scripts/check-advisories.py with the\n" "reason it can be left — unreachable, dev-only, or pinned upstream." ) if stale: print("\nAllow-list entries that no longer match anything:\n") for adv in sorted(stale): entry = ALLOW.get(adv.split(" (")[0]) why = entry if isinstance(entry, str) else (entry or {}).get("why", "") print(f" {adv} — {why}") print( "\nThe exposure is gone. Remove the entry, so the list keeps describing\n" "the tree instead of hiding it." ) if unchecked: print("\nCould not be audited (this is not a verdict on the lockfile):\n") for lock, why in unchecked: print(f" {lock}: {why}") if unexpected or stale or unchecked: return 1 print( f"check-advisories: {len(sources)} lockfiles, " f"{len(found)} allowed advisories, {warn_count} warnings (not gated)." ) return 0 if __name__ == "__main__": sys.exit(main())