xtask/src/cve.rs
//! The **0-known-CVE gate**: `cargo-deny` advisories + bans + sources over
//! gunnar's RESOLVED `Cargo.lock`, keyed off the repository's own `deny.toml`.
//!
//! # Why this lives here as well as in the deploy script
//!
//! The deploy pipeline already had a `door cve` (`<ops>/deploy.sh`): it runs the same
//! `cargo deny` checks on the farm's `nordisk/gunnar` before a reinstall.
//! That door is real, and it is kept. But a door that only fires inside one
//! machine's deploy script proves "0 known CVE" *per deploy*, not *per release*
//! — a gunnar tag cut without going through that script was never asserted to
//! carry a vulnerability-free dependency tree.
//!
//! So the gate is also a step of gunnar's own CI (`cargo xtask ci` → the `cve`
//! step) and is exercised, permanently and in both directions, by
//! `xtask/tests/cve.rs`:
//!
//! * **red**, against a committed fixture (`tests/fixtures/cve_vulnerable`)
//! whose `Cargo.lock` names a crate version carrying a real RustSec advisory
//! (`rsa 0.10.0-rc.18`, RUSTSEC-2023-0071). The gate must go red on it and
//! must name that advisory — a gate that returned "dirty" for everything
//! would pass a bare `assert!(!clean)` and prove nothing (measure, never assert).
//! * **green**, against gunnar's own resolved graph with the repo's `deny.toml`.
//! A gate that rejects every tree is the identity-value trap; it has to accept
//! the thing it exists to accept.
//!
//! # One tool, one policy (one implementation, not two)
//!
//! There is exactly one gate implementation: `cargo-deny` reading gunnar's
//! `deny.toml`. This module and the deploy `door cve` are two *invocation
//! sites* of that one tool + config, not two copies of the rules. Every
//! `[advisories] ignore` decision — each with a dated reachability rationale —
//! lives once, in `deny.toml`, and both sites obey it. Change the policy there
//! and both the release gate and the deploy door move together.
//!
//! # The database the verdict is about
//!
//! "0 KNOWN CVE" is a claim about a LIST, and the list is the RustSec advisory
//! database cargo-deny keeps under `$CARGO_HOME/advisory-dbs`. The gate passed
//! `--offline` unconditionally until 2026-09-11, so a year-old checkout — or
//! none at all, which is what a fresh CI runner has — produced `advisories ok`
//! in milliseconds, and that output is byte-identical to a fully-fetched run of
//! a genuinely clean tree. [`MAX_ADVISORY_DB_AGE_DAYS`] is the answer: offline
//! while the list is current, a fetch when it is not.
//!
//! # Nothing here is a build dependency
//!
//! Like the rest of `xtask`, this invokes an external tool through `Command`
//! and adds no crate to the graph it guards.
use std::path::{Path, PathBuf};
use std::process::Command;
/// The two RustSec IDs are matched textually out of `cargo-deny`'s report.
///
/// A RustSec identifier is `RUSTSEC-YYYY-NNNN`. cargo-deny prints it as a
/// `├ ID: RUSTSEC-2023-0071` line for every advisory that fired. Reporting
/// *which* advisory fired is what lets the red-first test assert the gate is
/// red for the right reason.
fn parse_rustsec_ids(output: &str) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
// Only inside an `error[…]` block, and only on its `├ ID:` line: a
// warning (a stale ignore cargo-deny no longer meets) and an advisory's
// prose (links to other advisories) name nothing that fired.
let mut in_error = false;
for line in output.lines() {
let head = line.trim_start();
if head.starts_with("error[") {
in_error = true;
} else if head.starts_with("warning[")
|| head.starts_with("note[")
|| head.starts_with("help[")
{
in_error = false;
}
if !in_error || !head.trim_start_matches(['├', '│', ' ']).starts_with("ID:") {
continue;
}
if let Some(pos) = line.find("RUSTSEC-") {
let rest = &line[pos..];
let id: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
// RUSTSEC-YYYY-NNNN is 4 dash-separated parts; guard against a
// stray "RUSTSEC-" in prose.
if id.matches('-').count() == 2 && !ids.contains(&id) {
ids.push(id);
}
}
}
ids
}
/// One `cargo deny check <name>` invocation and its verdict.
#[derive(Debug)]
pub struct Check {
pub name: &'static str,
/// The process exit code. `None` means it was killed by a signal (or never
/// produced a code), which is **not** a pass — a gate that cannot report is
/// not a gate that passed.
pub exit: Option<i32>,
/// stdout and stderr, concatenated. cargo-deny writes its findings to
/// stderr; both are kept so the report is whole.
pub output: String,
}
impl Check {
/// A check passes only on a clean exit code of 0. Anything else — a red
/// gate (1), a config error, a signal — is not a pass.
pub fn passed(&self) -> bool {
self.exit == Some(0)
}
}
/// The verdict of the whole gate: every sub-check, and what they found.
#[derive(Debug)]
pub struct CveReport {
pub advisories: Check,
pub bans: Check,
/// `cargo deny check sources` — where the crates came FROM.
///
/// `deny.toml` has declared `unknown-registry = "deny"` and a one-entry
/// `allow-registry` since it was written, and until 2026-09-11 nothing ran
/// the check that enforces it: the gate ran `advisories` and `bans` and
/// stopped. A policy nobody runs is a comment. It matters here more than
/// the usual amount, because gunnar's own header for the vendor directory
/// argues at length about where code is allowed to come from — and the one
/// mechanical check of that argument was not wired to anything.
pub sources: Check,
/// How old the advisory database was when the gate read it, or `None` if
/// there was none. Recorded because a verdict about known vulnerabilities
/// is only as good as the list of known vulnerabilities it was checked
/// against, and the log has to say which list that was.
pub db_age: Option<std::time::Duration>,
/// Whether the run really stayed offline. A REQUEST for `--offline` that
/// the staleness rule overrode must not read as one that was honoured.
pub offline: bool,
}
impl CveReport {
/// The gate is clean only if EVERY sub-check passed. This is the exit
/// code the CI step and the deploy door both key off.
pub fn is_clean(&self) -> bool {
self.advisories.passed() && self.bans.passed() && self.sources.passed()
}
/// Every RustSec advisory id the advisories check reported, in order.
/// Empty on a clean tree.
pub fn rustsec_ids(&self) -> Vec<String> {
parse_rustsec_ids(&self.advisories.output)
}
/// A human-readable verdict for the CI log and the deploy `$OUT`.
pub fn render(&self) -> String {
let mut s = String::new();
let verdict = |c: &Check| match c.exit {
Some(0) => "ok".to_string(),
Some(n) => format!("FAILED (exit {n})"),
None => "FAILED (killed, no exit code)".to_string(),
};
s.push_str(&format!(" advisories: {}\n", verdict(&self.advisories)));
s.push_str(&format!(" bans: {}\n", verdict(&self.bans)));
s.push_str(&format!(" sources: {}\n", verdict(&self.sources)));
let ids = self.rustsec_ids();
if !ids.is_empty() {
s.push_str(&format!(" advisories firing: {}\n", ids.join(", ")));
}
// The provenance of the verdict, printed whether it is good news or
// not: "advisories ok" against a database nobody can date is the empty
// green this gate exists to refuse.
s.push_str(&format!(
" advisory db: {}, {}\n",
match self.db_age {
Some(age) => format!("{} day(s) old", age.as_secs() / (24 * 60 * 60)),
None => "NOT PRESENT".to_string(),
},
if self.offline {
"read offline"
} else {
"fetched for this run"
}
));
s
}
}
/// Is `cargo-deny` installed and runnable?
///
/// The distinction matters: a missing tool must never be mistaken for a clean
/// tree. `cargo deny --version` exits 0 and prints `cargo-deny <ver>` when the
/// subcommand is present, and cargo prints `no such command: 'deny'` (non-zero)
/// when it is not.
pub fn tool_available() -> bool {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
Command::new(cargo)
.args(["deny", "--version"])
.output()
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("cargo-deny"))
.unwrap_or(false)
}
/// Pure: **the line a CVE test prints instead of its verdict where `cargo-deny` is missing**
/// (`None` where it is there and the test runs).
///
/// The tests are the gate's red-first PROOF, not the gate: the gate is `cargo xtask ci`'s `cve`
/// step, which fails without the tool ("a gate that cannot run is not a gate that passed"). A
/// test that panicked there instead made every `cargo test -p xtask` on a box without the tool
/// (the toolchain image) red for a reason that is not the code's; this says by name that it did
/// not run, and why, and the `cve` step still refuses the run.
pub fn not_run(test: &str, available: bool) -> Option<String> {
(!available).then(|| {
format!(
"NOT RUN {test}: cargo-deny is not installed (cargo install cargo-deny). The CVE gate \
itself is `cargo xtask ci`'s `cve` step, which fails without it."
)
})
}
/// Run one `cargo deny check <what>` in `dir`.
///
/// * `config` overrides the `deny.toml` cargo-deny would otherwise find in
/// `dir` — used by the red-first fixture, which supplies an empty-`ignore`
/// config so nothing is excused.
/// * `offline` passes `--offline`, so cargo-deny resolves the graph from the
/// local registry cache and uses the already-cloned advisory database rather
/// than fetching. Tests use it to stay hermetic on a warm cache.
///
/// `--no-default-features` matches the deploy door: the gate reads the resolved
/// `Cargo.lock`, and default-feature selection would only narrow the graph the
/// lock already fixed.
fn run_check(
dir: &Path,
what: &'static str,
config: Option<&Path>,
offline: bool,
) -> Result<Check, String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let mut cmd = Command::new(cargo);
cmd.arg("deny").arg("--no-default-features");
if offline {
cmd.arg("--offline");
}
// **`--config` is cargo-deny's own option and goes BEFORE the subcommand.**
// It was appended after `check <what>` until 2026-09-11, where cargo-deny
// 0.20.2 answers `error: unexpected argument '--config' found` and exits 2
// — MEASURED. That is not a red gate, it is a gate that did not run;
// `Check::passed` calls it a failure, correctly, but nothing in the
// repository ever passed a config, so the arm was dead and the mistake was
// invisible. The `cve_vulnerable` fixture reaches its own `deny.toml`
// through `current_dir`, which is why the red-first proof never noticed.
if let Some(c) = config {
cmd.arg("--config").arg(c);
}
cmd.arg("check").arg(what);
cmd.current_dir(dir);
let out = cmd
.output()
.map_err(|e| format!("could not spawn `cargo deny check {what}` in {dir:?}: {e}"))?;
let mut output = String::from_utf8_lossy(&out.stdout).into_owned();
output.push_str(&String::from_utf8_lossy(&out.stderr));
Ok(Check {
name: what,
exit: out.status.code(),
output,
})
}
/// **How old the local advisory database may be and still be believed.**
///
/// The number is a judgement and is written down rather than implied. RustSec
/// publishes on the order of a few advisories a week, so a fortnight is the
/// point at which "nothing fired" stops being evidence about today's tree and
/// starts being evidence about the tree of two weeks ago. Shorter would make
/// the gate fetch on most runs and lose the hermetic property that made it
/// `--offline` in the first place; longer is the failure this constant exists
/// to prevent.
pub const MAX_ADVISORY_DB_AGE_DAYS: u64 = 14;
/// Where cargo-deny keeps its clones of the RustSec advisory database.
///
/// `$CARGO_HOME/advisory-dbs/<db>-<hash>`, one directory per configured source.
pub fn advisory_db_dir() -> PathBuf {
std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".cargo"))
})
.unwrap_or_else(|| PathBuf::from(".cargo"))
.join("advisory-dbs")
}
/// How long ago the advisory database was last **fetched**, or `None` if there
/// is no database at all.
///
/// `.git/FETCH_HEAD` and not the checkout's own mtime: a directory's mtime
/// moves when anything under it is touched, and cargo-deny writes `FETCH_HEAD`
/// on every fetch and at no other time, which is exactly the event this is
/// asking about. `.git/HEAD` is the fall-back for a database that was cloned
/// and has not been fetched since.
///
/// The newest answer across every configured database wins: they are all read,
/// so the gate's knowledge is as fresh as its freshest source.
pub fn advisory_db_age() -> Option<std::time::Duration> {
let now = std::time::SystemTime::now();
let mut newest: Option<std::time::Duration> = None;
for entry in std::fs::read_dir(advisory_db_dir()).ok()?.flatten() {
let git = entry.path().join(".git");
for marker in ["FETCH_HEAD", "HEAD"] {
let Ok(meta) = std::fs::metadata(git.join(marker)) else {
continue;
};
let Ok(at) = meta.modified() else { continue };
let Ok(age) = now.duration_since(at) else {
continue;
};
if newest.map(|best| age < best).unwrap_or(true) {
newest = Some(age);
}
break;
}
}
newest
}
/// **May an `--offline` run of this gate be believed?**
///
/// Pure, and separated from [`advisory_db_age`] for that reason: the decision
/// is the thing worth testing, and it cannot be tested against a real clock and
/// a real `$CARGO_HOME`.
///
/// `None` — no database at all — is `false`, not `true`. That is the whole
/// shape of this finding: an absent or ancient database makes `cargo deny check
/// advisories --offline` report a clean tree in a few milliseconds, and a clean
/// report from a gate that consulted nothing is byte-identical to a clean
/// report from a gate that consulted everything.
pub fn may_stay_offline(age: Option<std::time::Duration>) -> bool {
let limit = std::time::Duration::from_secs(MAX_ADVISORY_DB_AGE_DAYS * 24 * 60 * 60);
matches!(age, Some(age) if age <= limit)
}
/// The gate: advisories + bans + sources, in `dir`, optionally with a config
/// override and offline. Every sub-check always runs — a red on advisories must
/// not hide the state of bans, and neither must hide where the crates came from.
///
/// # `offline` is a request, not a promise
///
/// It was an unconditional `--offline` until 2026-09-11, and that made the
/// gate's answer a function of how recently somebody had happened to run it. A
/// year-old checkout of the advisory database — or none at all, which is what a
/// fresh CI runner has — produces `advisories ok` just as fast as a current one
/// does, and the two outputs are identical. The whole claim of the gate is
/// "0 KNOWN CVE", and what is known is precisely the contents of that database.
///
/// So `offline` is honoured only while the database is fresh enough to carry
/// the claim ([`MAX_ADVISORY_DB_AGE_DAYS`]). When it is not, the gate drops
/// `--offline` and lets cargo-deny fetch, which both refreshes the database and
/// makes the run answer about today. On a box that then cannot reach the
/// network the gate fails loudly, which is the right outcome — a gate that
/// could not run is not a gate that passed.
pub fn run(dir: &Path, config: Option<&Path>, offline: bool) -> Result<CveReport, String> {
if !tool_available() {
return Err(
"cargo-deny is not installed (cargo install cargo-deny); the CVE gate \
could not run, which is NOT a pass"
.to_string(),
);
}
let db_age = advisory_db_age();
let offline = offline && may_stay_offline(db_age);
let advisories = run_check(dir, "advisories", config, offline)?;
let bans = run_check(dir, "bans", config, offline)?;
let sources = run_check(dir, "sources", config, offline)?;
Ok(CveReport {
advisories,
bans,
sources,
db_age,
offline,
})
}
/// The workspace root of gunnar, from this crate's location. Mirrors
/// `guard::workspace_root`: `xtask` sits at `<root>/xtask`.
pub fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask has a parent")
.to_path_buf()
}
/// The green path a release is asserted on: gunnar's own tree, its own
/// `deny.toml`.
///
/// Offline **while the advisory database is fresh**, so a warm cache keeps the
/// run hermetic and a stale or absent one makes it fetch instead. See [`run`]:
/// the hermetic property was worth having and was never worth the claim it was
/// quietly weakening.
pub fn run_gunnar() -> Result<CveReport, String> {
run(&workspace_root(), None, true)
}
#[cfg(test)]
mod parse_tests {
use super::*;
use std::time::Duration;
#[test]
fn a_missing_tool_is_said_by_the_tests_name_and_a_present_one_runs() {
let line =
not_run("red_a_vulnerable_dependency_is_refused", false).expect("a NOT RUN line");
assert!(
line.starts_with(
"NOT RUN red_a_vulnerable_dependency_is_refused: cargo-deny is not installed"
),
"{line}"
);
assert!(
line.contains("`cve` step"),
"it names where the gate still bites: {line}"
);
assert_eq!(not_run("green_gunnars_own_tree_passes", true), None);
}
const DAY: Duration = Duration::from_secs(24 * 60 * 60);
/// **A gate may only stay offline while the list it is checking against is
/// current.**
///
/// The failure this encodes is not that the check goes wrong; it is that it
/// goes RIGHT, quickly, against nothing. `cargo deny check advisories
/// --offline` on a box with no advisory database at all prints
/// `advisories ok` and exits 0, and that output is byte-identical to the one
/// from a fully-fetched run of a clean tree. `None` is therefore the case
/// this function exists for, and it is the one a `Duration`-shaped
/// comparison written without thinking gets backwards.
#[test]
fn an_absent_or_stale_advisory_database_cannot_be_read_offline() {
assert!(
!may_stay_offline(None),
"no advisory database at all was treated as a fresh one; a clean report from a \
gate that consulted nothing is the empty green this whole module is about"
);
assert!(
!may_stay_offline(Some(DAY * (MAX_ADVISORY_DB_AGE_DAYS as u32 + 1))),
"a database one day past the limit was still believed"
);
assert!(
may_stay_offline(Some(DAY)),
"a database fetched yesterday was refused, so the gate now fetches on every \
run and the hermetic property is gone"
);
assert!(
may_stay_offline(Some(DAY * MAX_ADVISORY_DB_AGE_DAYS as u32)),
"the limit itself must be inside the window it names"
);
}
/// The identity-value guard for the test above.
///
/// `MAX_ADVISORY_DB_AGE_DAYS = 0` would satisfy two of those assertions and
/// `u64::MAX` the other two, and either would make the rule vacuous in one
/// direction. This pins it to a bound that is a real fortnight-ish window —
/// the value can move, but not to a value that means "always" or "never".
#[test]
fn the_staleness_limit_is_a_bound_and_not_a_switch() {
assert!(
(1..=90).contains(&MAX_ADVISORY_DB_AGE_DAYS),
"{MAX_ADVISORY_DB_AGE_DAYS} days is not a freshness rule, it is one of the two \
ways of having no rule"
);
}
/// **The locator has to find a database that is really there**, or
/// `may_stay_offline` is answering `None` for everyone and the gate fetches
/// on every run — which is safe, and is also not what was built.
///
/// Skipped, explicitly and loudly, when this machine has no database: that
/// is a fact about the box and not about the code, and the test says which
/// it is rather than passing quietly. `xtask/tests/cve.rs` requires
/// cargo-deny to be installed, and a cargo-deny that has run has a database.
#[test]
fn the_advisory_database_this_box_has_is_the_one_the_locator_finds() {
let dir = advisory_db_dir();
if !dir.is_dir() {
eprintln!(
"no advisory database at {} — nothing to locate on this box, so this test \
asserts nothing here",
dir.display()
);
return;
}
let age = advisory_db_age();
assert!(
age.is_some(),
"{} exists but the locator dated nothing in it, so every gate on this box now \
fetches and the freshness rule is decoration",
dir.display()
);
}
#[test]
fn pulls_the_id_off_a_deny_report_line() {
let sample = "\
error[vulnerability]: Marvin Attack: potential key recovery
├ ID: RUSTSEC-2023-0071
├ Advisory: https://rustsec.org/advisories/RUSTSEC-2023-0071
advisories FAILED";
// The prose "RUSTSEC-" in the URL is the same id, so de-dup collapses
// it: one advisory, reported once.
assert_eq!(parse_rustsec_ids(sample), vec!["RUSTSEC-2023-0071"]);
}
/// ★ **Only an advisory that FIRED is named** (round 4 triage, 2026-10-06:
/// the gate reported five — RUSTSEC-2026-0173 fired; RUSTSEC-2024-0370 was a
/// link in its prose, and 0222/0269/0316 were deny.toml ignores cargo-deny
/// warned it no longer met). An id counts on an `├ ID:` line of an `error[…]`
/// block; a warning's lines and the prose name nothing.
#[test]
fn only_an_advisory_that_fired_is_named_not_the_prose_or_a_stale_ignore() {
let sample = "\
error[unmaintained]: proc-macro-error2 is unmaintained
├ ID: RUSTSEC-2026-0173
├ Advisory: https://rustsec.org/advisories/RUSTSEC-2026-0173
├ `proc-macro-error2` was originally created as a maintained fork (see [RUSTSEC-2024-0370](https://rustsec.org/advisories/RUSTSEC-2024-0370))
├ Solution: No safe upgrade is available!
warning[advisory-not-detected]: advisory was not encountered
┌─ /x/deny.toml:56:13
56 │ { id = \"RUSTSEC-2026-0222\", reason = \"wasmtime 45.0.3\" },
│ ━━━━━━━━━━━━━━━━━ no crate matched advisory criteria
advisories FAILED";
assert_eq!(parse_rustsec_ids(sample), vec!["RUSTSEC-2026-0173"]);
}
#[test]
fn a_clean_report_names_nothing() {
assert!(parse_rustsec_ids("advisories ok\n").is_empty());
}
}