open-standards/scripts/check-advisories.py
Transaction Science f19e84f40f
Some checks failed
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 5s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 16s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 18s
WAI conformance / does this change touch it (pull_request) Successful in 14s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 17s
WAI JPEG-AI whole decode (pinned inputs, required) / does this change touch it (pull_request) Successful in 7s
WAI energy receipts / does this change touch it (pull_request) Successful in 8s
WAI neural sink gates / does this change touch it (pull_request) Successful in 6s
wai-ops-gpu integer operators on GPUs / does this change touch it (pull_request) Successful in 6s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 6s
wai-quantum studio and desktop / does this change touch it (pull_request) Successful in 6s
wai-quantum WebGPU member / does this change touch it (pull_request) Successful in 6s
WAI mbt2018_mean integer decode (regenerated vectors) / does this change touch it (pull_request) Successful in 6s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 33s
wai-web wasm32 / does this change touch it (pull_request) Successful in 6s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 1m18s
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Successful in 1m57s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 33s
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 2m22s
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 2m49s
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Successful in 1m8s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 2m1s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Has been cancelled
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · macos (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Has been cancelled
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Has been cancelled
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 2m44s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Has been cancelled
WAI energy receipts / energy receipts · macos (pull_request) Has been cancelled
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Has been cancelled
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Has been cancelled
wai-ops-gpu integer operators on GPUs / builds · no adapter is not a pass (linux) (pull_request) Has been cancelled
wai-ops-gpu integer operators on GPUs / Metal GPU and browser WebGPU (macos) (pull_request) Has been cancelled
wai-pack / wai-pack · linux (pull_request) Has been cancelled
wai-pack / wai-pack · linux-arm64 (pull_request) Has been cancelled
wai-pack / wai-pack · macos (pull_request) Has been cancelled
wai-pack / wai-pack · independent verifier (Python) (pull_request) Has been cancelled
wai-pack / wai-pack · the floor through a media prober (pull_request) Has been cancelled
wai-pack / wai-pack · dependency licences (pull_request) Has been cancelled
wai-quantum studio and desktop / studio · browser (pull_request) Has been cancelled
wai-quantum studio and desktop / desktop app · macos (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux (pull_request) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Has been cancelled
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Has been cancelled
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness · MSRV (pull_request) Has been cancelled
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · Metal (macos) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · browser WebGPU (macos) (pull_request) Has been cancelled
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Has been cancelled
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Has been cancelled
wai-quantum WebGPU member / member · discrete GPU, Vulkan (jetson-hub) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · integrated GPU, Vulkan (framework-128gb) (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux (pull_request) Has been cancelled
wai-quantum WebGPU member / member · DX12 and Vulkan (cbio-windows) (pull_request) Has been cancelled
sec: zstd 0.14.3 for RUSTSEC-2026-0391, -0398, -0403 and -0410
Four advisories against the zstd crates, published October 2026, failed
the repository check on every pull request:
- RUSTSEC-2026-0391 (zstd < 0.14.3): data from an abandoned stream leaks
  into the next stream on a reused context;
- RUSTSEC-2026-0398 (zstd-safe < 8.2.0): Seekable::decompress sets a Vec
  length past its capacity;
- RUSTSEC-2026-0403 (zstd-safe < 8.2.0): uninitialised memory when a
  legacy frame's streaming decompression fails;
- RUSTSEC-2026-0410 (zstd-sys < 2.1.2): the wasm allocator shim corrupts
  memory on allocation failure.

Every direct use moves: wai (zstd 0.14.3, zstd-safe 8.2), jwp and
joule-moq-interop (zstd 0.14.3). Five lockfiles change exactly the three
zstd packages and nothing else: wai-rs, wai-rs/fuzz, joule-code-rs,
joule-moq-room and examples/transport-moqt.

The one copy left is wasmtime 49's, under wasmtime-internal-cache, which
compresses compiled modules for wasmtime's on-disk cache. sandbox-exec-wasm
builds its engine from Config::new() with fuel metering only and never
configures a cache, so zstd's code does not run in the sandbox. It is
allowed in scripts/check-advisories.py at those exact versions, so a
direct use regressing to them is not excused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:32:36 -04:00

324 lines
15 KiB
Python
Executable file

#!/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 <lock> --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** (PKCS#1 v1.5). There is no fixed release of `rsa` at all, so
# this one cannot be bumped away and the reason has to carry it. Both
# remaining copies were checked by call site, not by assumption:
#
# sandbox-rs: `sandbox-tee/src/amd_kds.rs` is the only direct user in the
# tree. It verifies AMD's KDS chain (ARK -> ASK -> VCEK, RSASSA-PSS /
# SHA-384) with `RsaPublicKey` + `pss::VerifyingKey` + `Verifier`.
# Public-key verification only; there is no decryption anywhere, and
# `RsaPrivateKey` appears solely inside `#[cfg(test)] mod tests`, which
# mints throwaway keys to build a synthetic chain.
# wai-rs: reaches it only through `c2pa`, which is an optional dependency
# behind the `c2pa_emit` feature and off by default, so a default build
# does not compile it. WAI's own signing is Ed25519 (`ed25519_dalek`),
# which uses neither `rsa` nor `crypto-bigint`.
#
# openpay's copy is gone: the dependency was declared and never used, so it
# was removed outright rather than excused.
"RUSTSEC-2023-0071": "rsa: no fixed release; verification only, no decryption, private keys test-only",
# ── Pinned by a dependency; needs an upstream release ──────────────────
# zstd's four October 2026 advisories (a stream's data leaking into the
# next on a reused context; Seekable::decompress past the end; uninitialised
# memory from a failed legacy-frame stream; the wasm allocator shim). Every
# direct use here moved to zstd 0.14.3 / zstd-safe 8.2.0 / zstd-sys 2.1.2.
# The one copy left is wasmtime 49's: wasmtime-internal-cache compresses
# compiled modules for wasmtime's on-disk cache, and sandbox-exec-wasm
# builds its engine from Config::new() with fuel metering only and never
# configures a cache, so none of zstd's code runs in the sandbox. Pinned to
# those exact versions: a direct use regressing to them is not excused.
"RUSTSEC-2026-0391": {
"why": "zstd 0.13.3 only in wasmtime 49's module cache, which the sandbox never configures",
"only": ("zstd 0.13.3",),
},
"RUSTSEC-2026-0398": {
"why": "zstd-safe 7.2.4 only under wasmtime 49's module cache, which the sandbox never configures",
"only": ("zstd-safe 7.2.4",),
},
"RUSTSEC-2026-0403": {
"why": "zstd-safe 7.2.4 only under wasmtime 49's module cache, which the sandbox never configures",
"only": ("zstd-safe 7.2.4",),
},
"RUSTSEC-2026-0410": {
"why": "zstd-sys 2.0.16 only under wasmtime 49's module cache, never configured, and native: the shim is wasm's",
"only": ("zstd-sys 2.0.16+zstd.1.5.7",),
},
# Secret-dependent branches in operations meant to be constant-time
# (subtraction, zero counting, division, Montgomery multiplication), so
# timing can leak intermediate arithmetic on a secret integer. Fixed in
# >=0.7.6, and smart-byte's 0.7.x copy is patched.
#
# Every remaining 0.5.5 copy arrives under `elliptic-curve` — it is the only
# dependent in all five lockfiles — which pins the 0.5 line, so reaching
# >=0.7.6 means moving the whole p256/elliptic-curve stack. That is an
# upgrade, not a bump, and this entry is that debt.
#
# What bounds it: a timing attack of this class recovers a key by observing
# many operations under the *same* secret, and none of our own signing runs
# through this crate. WAI, sandbox and jouleclaw sign with Ed25519
# (`ed25519_dalek` / curve25519-dalek, which does not use crypto-bigint);
# in wai-rs the elliptic-curve path arrives under `c2pa`, optional and off
# by default, where production callers supply their own signer and the
# in-tree `EphemeralSigner` holds a per-call key that reaches
# `ValidationState::Valid` and never `Trusted`. So the debt is real but it
# is not sitting under a long-lived key of ours.
#
# Version-pinned on purpose: a 0.7.x copy must never be excused by it.
"RUSTSEC-2026-0387": {
"why": "crypto-bigint 0.5.5 pinned by elliptic-curve 0.13; our signing is Ed25519; 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())