Some checks failed
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 9s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 6s
jouleclaw fabrics / cuda (linux, RTX 4050 + Vulkan loader) (pull_request) Successful in 17s
jouleclaw fabrics / vulkan (linux, RTX 4050 + Vulkan loader) (pull_request) Successful in 17s
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Successful in 4m9s
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 5m46s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 39s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Failing after 10m26s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Failing after 10m44s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 9m33s
standards workspaces build + test / eoc-rs (eoc/eoc-rs, -p eoc-meter --features cuda, eoc-rs, linux) (pull_request) Successful in 3m13s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 3m37s
jouleclaw fabrics / metal (macOS, Apple GPU) (pull_request) Successful in 1m51s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 2m5s
standards workspaces build + test / smart-byte-rs (smart-byte/smart-byte-rs, smart-byte-rs, linux) (pull_request) Successful in 4m10s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 7m43s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 7m49s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 5m48s
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Has been cancelled
The advisory gate from #443 found something on its first real run, two days after landing: RUSTSEC-2026-0385 and -0387, both published 2026-10-23, neither of which existed when the sweep was done. That is the gate doing the job it was added for, so this is the answer rather than a new allowance. ctutils 0.4.2/0.4.3 -> 0.4.4 RUSTSEC-2026-0385, non-constant-time mask generation and `u128` comparisons. Reaches openpay's wallet crates through digest -> hmac/hkdf, so it sits under HMAC on payment paths. Fixed in four lockfiles. crypto-bigint 0.7.3 -> 0.7.6 RUSTSEC-2026-0387, secret-dependent branches in subtraction, zero counting, division and Montgomery multiplication — operations whose whole purpose is to be constant-time. The 0.5.5 line of crypto-bigint cannot follow: elliptic-curve 0.13 pins it, so reaching >=0.7.6 means moving the p256/elliptic-curve stack across openpay, sandbox, wai and jouleclaw. That is an upgrade, not a bump. It is allowed with that reason recorded, on paths that sign and derive keys, so it reads as debt. Writing that allowance exposed a weakness in the check itself: ALLOW was keyed by advisory id alone, so an entry excusing the stuck 0.5.5 line would have gone on excusing the advisory if a patched 0.7.x copy later regressed to an unpatched release — the same shape as sealing by name instead of by digest. An entry may now pin the exact crate/version pairs it covers: "RUSTSEC-...": {"why": "...", "only": ("crypto-bigint 0.5.5",)} Any other version of that crate fails, and a named pair that disappears makes the entry stale, so the pin cannot keep describing a tree that moved. The version-blind string form is kept for reasons that genuinely do not depend on the version, such as a call we never make, and the two forms are documented against each other. The self-test now builds its documents by importing the check's real ALLOW rather than scraping it with grep, so the two cannot drift, and covers the two new failure modes: a version-pinned allowance offered a version it does not name, and one whose named pair is gone. Also moves off every yanked dependency, each a patch bump: chacha20 0.10.0/0.10.1 -> 0.10.2 (a cipher, in six lockfiles), der 0.8.0 -> 0.8.2, spin 0.9.8 -> 0.9.9 and 0.10.0 -> 0.10.1, iroh-blobs 0.101.0 -> 0.101.1. A yank is the author withdrawing a release; none of these needed to stay on one. No yanked dependency remains anywhere in the tree. All 47 lockfiles current; the six affected workspaces check clean and `mls_keying` passes. Co-Authored-By: Claude Opus 5 <[email protected]>
272 lines
12 KiB
Python
Executable file
272 lines
12 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. 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())
|