Skip to main content

amont_runtime/hooks/
audit.rs

1//! Dependency-vulnerability audits, with the severity the push deserves.
2//!
3//! The same policy the release workflow enforces in CI, brought to the
4//! machine where the push starts: an advisory against the dependency tree
5//! is INFORMATION on a branch push — named, never blocking, retried for
6//! free tomorrow — and a REFUSAL on a push that carries a `v*` tag, because
7//! a tag is a release leaving the building and immutable registries do not
8//! take anything back. The hook advises early; CI (for repositories that
9//! have it) enforces finally.
10//!
11//! One check per ecosystem amont already speaks — `cargo audit` for Rust,
12//! `npm audit` / `pnpm audit` for JS, `pip-audit` for Python — each opted
13//! in by the lockfile its tool actually audits. No lockfile, no check: an audit
14//! without a resolved tree audits a guess.
15//!
16//! Three verdicts, learned the hard way in ci.yaml's advisory job and kept
17//! here: the tools' OUTPUT decides, not the exit code alone, because every
18//! one of them conflates "found vulnerabilities" with "could not fetch the
19//! advisory database" in its exit status. And "could not check" is spoken
20//! loudly but never blocks — [`crate::check::Outcome::Unavailable`]'s
21//! contract: a hook may be offline, and a push gate that fails on a captive
22//! portal teaches `--no-verify`. The release workflow, which is never
23//! offline, is where an unchecked tree refuses to ship.
24
25use crate::check::Outcome;
26use crate::pushrefs::PushRef;
27
28use super::common;
29
30/// What an audit's output said, before the push's stakes are applied.
31#[derive(Debug, PartialEq, Eq)]
32enum Report {
33    Clean,
34    /// Warning-class advisories (unmaintained/unsound) — named, never
35    /// blocking anywhere: a gate nothing can pass is a gate people delete.
36    Advisories(Vec<String>),
37    /// Real vulnerabilities. Blocking iff the push carries a `v*` tag.
38    Vulnerabilities(Vec<String>),
39    /// The tool ran but could not answer (no network, no database).
40    CouldNotCheck,
41}
42
43/// Does this push carry a release? `v` + digit, so `v1.6.6` and `v2` gate
44/// while a tag that merely starts with a letter v (`vendor-drop`) does not.
45/// Deletes push no code and carry nothing.
46fn releasing(refs: &[PushRef]) -> bool {
47    refs.iter().any(|r| {
48        r.remote_ref
49            .strip_prefix("refs/tags/")
50            .and_then(|t| t.strip_prefix('v'))
51            .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
52    })
53}
54
55/// Apply the push's stakes to the tool's report. `full` is the captured
56/// output, reprinted only when the verdict blocks — that is the moment the
57/// reader needs the table, and the only moment worth the scrollback.
58fn conclude(
59    settings: &crate::config::Settings,
60    tool: &str,
61    report: Report,
62    releasing: bool,
63    full: &str,
64) -> Outcome {
65    conclude_waivable(settings, tool, report, releasing, full, true)
66}
67
68/// [`conclude`], where `waivable` is false when something in the report
69/// could not be attributed (a project the audit could not answer for, a
70/// finding without a recognised id): a waiver must never vouch for what
71/// nobody saw.
72fn conclude_waivable(
73    settings: &crate::config::Settings,
74    tool: &str,
75    report: Report,
76    releasing: bool,
77    full: &str,
78    waivable: bool,
79) -> Outcome {
80    match report {
81        Report::Clean => {
82            common::ok(settings, &format!("{tool}: no known vulnerabilities"));
83            Outcome::Passed
84        }
85        Report::Advisories(ids) => {
86            common::warn(&format!(
87                "{tool}: advisories against the dependency tree (warnings — unmaintained/unsound): {}",
88                ids.join(", ")
89            ));
90            Outcome::Warned
91        }
92        Report::Vulnerabilities(what) => {
93            let waivers = Waivers::load(&common::repo_root(), today());
94            // The ids the verdict names first; the tool's report when the
95            // verdict is a summary line (npm, pnpm).
96            let mut ids = advisory_ids(&what.join(" "));
97            if ids.is_empty() {
98                ids = advisory_ids(full);
99            }
100            let unwaived: Vec<&String> = ids.iter().filter(|id| !waivers.covers(id)).collect();
101            let all_waived = waivable && !ids.is_empty() && unwaived.is_empty();
102            if releasing {
103                if all_waived {
104                    common::warn(&format!(
105                        "{tool}: known vulnerabilities shipped under a reviewed waiver \
106                         ({WAIVERS_FILE}) — not blocking this release: {}",
107                        ids.iter()
108                            .map(|id| waivers.describe(id))
109                            .collect::<Vec<_>>()
110                            .join("; ")
111                    ));
112                    return Outcome::Warned;
113                }
114                for line in full.lines() {
115                    crate::say!("{line}");
116                }
117                for note in waivers.problems() {
118                    common::warn(&format!("{tool}: {note}"));
119                }
120                common::fail(&format!(
121                    "{tool}: known vulnerabilities in the dependency tree — a v* tag \
122                     does not ship with these: {}{}",
123                    what.join(", "),
124                    if ids.is_empty() {
125                        " (no advisory id to match a waiver against)".to_string()
126                    } else {
127                        format!(
128                            " — not waived: {}",
129                            unwaived
130                                .iter()
131                                .map(|s| s.as_str())
132                                .collect::<Vec<_>>()
133                                .join(", ")
134                        )
135                    }
136                ));
137                Outcome::Failed
138            } else if all_waived {
139                common::warn(&format!(
140                    "{tool}: known vulnerabilities ({}) — covered by a reviewed waiver \
141                     ({WAIVERS_FILE}), so a v* tag push will pass while it holds: {}",
142                    what.join(", "),
143                    ids.iter()
144                        .map(|id| waivers.describe(id))
145                        .collect::<Vec<_>>()
146                        .join("; ")
147                ));
148                Outcome::Warned
149            } else {
150                common::warn(&format!(
151                    "{tool}: known vulnerabilities in the dependency tree ({}) — \
152                     this will BLOCK a v* tag push",
153                    what.join(", ")
154                ));
155                Outcome::Warned
156            }
157        }
158        Report::CouldNotCheck => {
159            common::warn(&format!(
160                "{tool} could not complete — the dependency tree was NOT checked. \
161                 This is not a clean result."
162            ));
163            Outcome::Unavailable
164        }
165    }
166}
167
168/// Where a repository records the advisories a release may ship anyway.
169const WAIVERS_FILE: &str = ".amont-audit-waivers";
170
171/// How far ahead a waiver may run. A waiver is a decision to revisit, not
172/// an exemption: one dated further out is void, so nothing is waived for
173/// good by accident.
174const MAX_WAIVER_DAYS: i64 = 90;
175
176/// One line of the waiver file: `<advisory id> <expires YYYY-MM-DD> <reason>`.
177#[derive(Debug)]
178struct Waiver {
179    id: String,
180    expires: String,
181    reason: String,
182    valid: bool,
183}
184
185/// The repository's reviewed waivers, judged against today.
186///
187/// A waiver lets a RELEASE ship a known vulnerability nobody can fix yet —
188/// an advisory with no patched version, on a path the project cannot
189/// replace. It is committed (so it is reviewed like code), names its
190/// reason, and expires: past its date, or dated more than
191/// [`MAX_WAIVER_DAYS`] ahead, it waives nothing and the tag is refused
192/// again. Branch pushes never consult it — they never block.
193#[derive(Debug, Default)]
194struct Waivers {
195    entries: Vec<Waiver>,
196    problems: Vec<String>,
197}
198
199impl Waivers {
200    fn load(root: &str, today: i64) -> Self {
201        match std::fs::read_to_string(std::path::Path::new(root).join(WAIVERS_FILE)) {
202            Ok(text) => Self::parse(&text, today),
203            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
204            Err(e) => Self {
205                entries: Vec::new(),
206                problems: vec![format!(
207                    "{WAIVERS_FILE} could not be read ({e}) — it waives nothing"
208                )],
209            },
210        }
211    }
212
213    fn parse(text: &str, today: i64) -> Self {
214        let mut w = Self::default();
215        for line in text.lines().map(str::trim) {
216            if line.is_empty() || line.starts_with('#') {
217                continue;
218            }
219            let mut parts = line.splitn(3, char::is_whitespace);
220            let id = parts.next().unwrap_or_default().to_string();
221            let expires = parts.next().unwrap_or_default().trim().to_string();
222            let reason = parts.next().unwrap_or_default().trim().to_string();
223            let valid = match days_from_date(&expires) {
224                None => {
225                    w.problems.push(format!(
226                        "waiver for {id} has no readable expiry date (YYYY-MM-DD) — it waives nothing"
227                    ));
228                    false
229                }
230                Some(_) if reason.is_empty() => {
231                    w.problems.push(format!(
232                        "waiver for {id} gives no reason — it waives nothing"
233                    ));
234                    false
235                }
236                Some(d) if d < today => {
237                    w.problems
238                        .push(format!("waiver for {id} expired on {expires}"));
239                    false
240                }
241                Some(d) if d - today > MAX_WAIVER_DAYS => {
242                    w.problems.push(format!(
243                        "waiver for {id} runs to {expires}, more than {MAX_WAIVER_DAYS} days \
244                         ahead — it waives nothing; date it sooner and revisit"
245                    ));
246                    false
247                }
248                Some(_) => true,
249            };
250            w.entries.push(Waiver {
251                id,
252                expires,
253                reason,
254                valid,
255            });
256        }
257        w
258    }
259
260    fn covers(&self, id: &str) -> bool {
261        self.entries.iter().any(|w| w.valid && w.id == id)
262    }
263
264    fn describe(&self, id: &str) -> String {
265        match self.entries.iter().find(|w| w.valid && w.id == id) {
266            Some(w) => format!("{id} until {} ({})", w.expires, w.reason),
267            None => id.to_string(),
268        }
269    }
270
271    fn problems(&self) -> &[String] {
272        &self.problems
273    }
274}
275
276/// Days since 1970-01-01 for a `YYYY-MM-DD` date (proleptic Gregorian).
277fn days_from_date(date: &str) -> Option<i64> {
278    let mut it = date.split('-');
279    let (y, m, d): (i64, i64, i64) = (
280        it.next()?.parse().ok()?,
281        it.next()?.parse().ok()?,
282        it.next()?.parse().ok()?,
283    );
284    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
285    let month_days = [
286        31,
287        if leap { 29 } else { 28 },
288        31,
289        30,
290        31,
291        30,
292        31,
293        31,
294        30,
295        31,
296        30,
297        31,
298    ];
299    if it.next().is_some() || !(1..=12).contains(&m) || d < 1 || d > month_days[(m - 1) as usize] {
300        return None;
301    }
302    // Howard Hinnant's days_from_civil.
303    let y = if m <= 2 { y - 1 } else { y };
304    let era = if y >= 0 { y } else { y - 399 } / 400;
305    let yoe = y - era * 400;
306    let mp = (m + 9) % 12;
307    let doy = (153 * mp + 2) / 5 + d - 1;
308    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
309    Some(era * 146_097 + doe - 719_468)
310}
311
312/// Today, in days since the epoch (UTC).
313fn today() -> i64 {
314    std::time::SystemTime::now()
315        .duration_since(std::time::UNIX_EPOCH)
316        .map(|d| (d.as_secs() / 86_400) as i64)
317        .unwrap_or(0)
318}
319
320/// Every advisory id in `text`: GitHub (`GHSA-xxxx-xxxx-xxxx`), RustSec,
321/// Go, PyPA and CVE ids, wherever they sit (a URL, a table cell).
322fn advisory_ids(text: &str) -> Vec<String> {
323    let mut ids: Vec<String> = text
324        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
325        .filter(|w| {
326            let parts: Vec<&str> = w.split('-').collect();
327            match parts.as_slice() {
328                ["GHSA", a, b, c] => [a, b, c]
329                    .iter()
330                    .all(|p| p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphanumeric())),
331                ["RUSTSEC" | "GO" | "PYSEC" | "CVE" | "OSV", year, num] => {
332                    year.len() == 4
333                        && year.bytes().all(|b| b.is_ascii_digit())
334                        && !num.is_empty()
335                        && num.bytes().all(|b| b.is_ascii_digit())
336                }
337                _ => false,
338            }
339        })
340        .map(str::to_string)
341        .collect();
342    ids.sort();
343    ids.dedup();
344    ids
345}
346
347/// Is this word a RUSTSEC id? `RUSTSEC-` + 4 digits + `-` + 4 digits.
348fn is_advisory_id(w: &str) -> bool {
349    w.len() == 17
350        && w.starts_with("RUSTSEC-")
351        && w[8..12].bytes().all(|b| b.is_ascii_digit())
352        && w.as_bytes()[12] == b'-'
353        && w[13..17].bytes().all(|b| b.is_ascii_digit())
354}
355
356/// Pair each advisory with the crate it was raised against.
357///
358/// `cargo audit`'s terminal report is blocks — `Crate:` … `ID:` — and the
359/// id alone does not say which crate carries it, let alone which of YOUR
360/// crates depends on that. Answering "is this on the commit path or only in
361/// an opt-in tool?" meant running `cargo tree -i` by hand every time.
362///
363/// A `Crate:` binds to the next `ID:` and is then spent, so an id appearing
364/// outside a block — a summary line, a URL — reports no crate rather than
365/// inheriting the previous block's.
366fn ids_with_crates(out: &str) -> Vec<(String, Option<String>)> {
367    let mut pairs: Vec<(String, Option<String>)> = Vec::new();
368    let mut pending: Option<String> = None;
369    for line in out.lines() {
370        let t = line.trim();
371        if let Some(rest) = t.strip_prefix("Crate:") {
372            pending = rest.split_whitespace().next().map(str::to_string);
373            continue;
374        }
375        if let Some(rest) = t.strip_prefix("ID:") {
376            if let Some(id) = rest.split_whitespace().next().filter(|w| is_advisory_id(w)) {
377                pairs.push((id.to_string(), pending.take()));
378                continue;
379            }
380        }
381        // Ids outside a block still count — the older summary-line shapes
382        // and anything cargo audit prints loose.
383        for w in t.split_whitespace().filter(|w| is_advisory_id(w)) {
384            pairs.push((w.to_string(), None));
385        }
386    }
387    pairs
388}
389
390/// Which of THIS workspace's crates reach `crate`, read from `cargo tree -i`.
391///
392/// Cargo prints a local package with its path in parentheses and a registry
393/// package without one, which is the only discriminator needed and works in
394/// any repository — the hook cannot know a given workspace's member names.
395///
396/// An empty result is an answer, not a failure: `cargo tree` prints
397/// "nothing to print" for a crate that is in `Cargo.lock` but not in the
398/// build graph. `cargo audit` reads the lock file, so an advisory can name a
399/// crate nothing compiles — an optional dependency of a feature nobody
400/// enabled. Saying so is more useful than naming no crate at all.
401fn local_dependents(tree_out: &str) -> Vec<String> {
402    let mut names: Vec<String> = tree_out
403        .lines()
404        .filter(|l| l.contains(" (/"))
405        .filter_map(|l| {
406            l.split_whitespace()
407                .find(|w| !w.is_empty() && w.chars().next().is_some_and(|c| c.is_alphanumeric()))
408                .map(str::to_string)
409        })
410        .collect();
411    names.sort();
412    names.dedup();
413    names
414}
415
416/// One advisory, said in full: the id, the crate it is against, and which of
417/// this workspace's crates actually reach it.
418fn describe(id: &str, krate: Option<&str>, reaches: &[String]) -> String {
419    match (krate, reaches.is_empty()) {
420        (None, _) => id.to_string(),
421        (Some(k), true) => format!("{id} ({k}, not in the build graph)"),
422        (Some(k), false) => format!("{id} ({k} → {})", reaches.join(", ")),
423    }
424}
425
426/// `cargo audit`, ci.yaml's rules verbatim: the RUSTSEC ids decide, the
427/// exit code only says which class they are.
428fn read_cargo_audit(exit_ok: bool, out: &str) -> Report {
429    let mut ids: Vec<String> = out
430        .split_whitespace()
431        .filter(|w| {
432            w.len() == 17
433                && w.starts_with("RUSTSEC-")
434                && w[8..12].bytes().all(|b| b.is_ascii_digit())
435                && w.as_bytes()[12] == b'-'
436                && w[13..17].bytes().all(|b| b.is_ascii_digit())
437        })
438        .map(|w| w.to_string())
439        .collect();
440    ids.sort();
441    ids.dedup();
442    match (ids.is_empty(), exit_ok) {
443        (true, true) => Report::Clean,
444        (true, false) => Report::CouldNotCheck,
445        (false, true) => Report::Advisories(ids),
446        (false, false) => Report::Vulnerabilities(ids),
447    }
448}
449
450/// `npm audit`: the summary line decides. A clean tree is `found 0
451/// vulnerabilities` on every npm. A finding is `found N vulnerabilities`
452/// on npm 6 and `N vulnerabilities (a moderate, b high)` — the verb dropped,
453/// `1 vulnerability` in the singular — on npm 7 and later, which is every
454/// npm shipped since 2020. No recognisable summary plus a refusal to exit
455/// clean is a tool that never answered.
456fn read_npm_audit(exit_ok: bool, out: &str) -> Report {
457    let summary = out.lines().rev().map(str::trim).find(|l| {
458        l.contains("vulnerabilit")
459            && (l.starts_with("found ")
460                || l.split_whitespace()
461                    .next()
462                    .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())))
463    });
464    match summary {
465        Some(l) if l.starts_with("found 0 ") || l.starts_with("0 ") => Report::Clean,
466        Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
467        None if exit_ok => Report::Clean,
468        None => Report::CouldNotCheck,
469    }
470}
471
472/// `pnpm audit`: the summary line decides. A clean tree is `No known
473/// vulnerabilities found`; a finding is `N vulnerabilities found` followed by
474/// `Severity: a low | b moderate | …`, which is carried along so the warning
475/// says how bad. No recognisable summary plus a refusal to exit clean is a
476/// tool that never answered (`ERR_PNPM_AUDIT_BAD_RESPONSE`, no network).
477fn read_pnpm_audit(exit_ok: bool, out: &str) -> Report {
478    let lines: Vec<&str> = out.lines().map(str::trim).collect();
479    if lines
480        .iter()
481        .any(|l| l.starts_with("No known vulnerabilities found"))
482    {
483        return Report::Clean;
484    }
485    let found = lines.iter().enumerate().rev().find(|(_, l)| {
486        let mut w = l.split_whitespace();
487        w.next()
488            .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
489            && w.next().is_some_and(|v| v.starts_with("vulnerabilit"))
490            && w.next() == Some("found")
491    });
492    match found {
493        Some((_, l)) if l.starts_with("0 ") => Report::Clean,
494        Some((i, l)) => {
495            let severity = lines
496                .get(i + 1)
497                .and_then(|n| n.strip_prefix("Severity:"))
498                .map(str::trim);
499            Report::Vulnerabilities(vec![match severity {
500                Some(sev) => format!("{l} ({sev})"),
501                None => l.to_string(),
502            }])
503        }
504        None if exit_ok => Report::Clean,
505        None => Report::CouldNotCheck,
506    }
507}
508
509/// `govulncheck`: the GO- ids decide, the exit code classifies them — the
510/// same split cargo-audit taught. The tool exits non-zero only when the
511/// analysed CODE is affected; ids with a clean exit are the informational
512/// section (vulnerable modules whose functions are never called), which is
513/// advisory-grade. No ids plus a refusal to exit clean is a tool that never
514/// answered (no network, no vulnerability database).
515fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
516    let mut ids: Vec<String> = out
517        .split_whitespace()
518        .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
519        .filter(|w| {
520            w.len() >= 12
521                && w.starts_with("GO-")
522                && w[3..7].bytes().all(|b| b.is_ascii_digit())
523                && w.as_bytes()[7] == b'-'
524                && w[8..].bytes().all(|b| b.is_ascii_digit())
525        })
526        .map(|w| w.to_string())
527        .collect();
528    ids.sort();
529    ids.dedup();
530    match (ids.is_empty(), exit_ok) {
531        (true, true) => Report::Clean,
532        (true, false) => Report::CouldNotCheck,
533        (false, true) => Report::Advisories(ids),
534        (false, false) => Report::Vulnerabilities(ids),
535    }
536}
537
538/// `pip-audit`: its own closing sentence decides.
539fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
540    if out.contains("No known vulnerabilities found") {
541        return Report::Clean;
542    }
543    if let Some(line) = out
544        .lines()
545        .map(str::trim)
546        .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
547    {
548        return Report::Vulnerabilities(vec![line.to_string()]);
549    }
550    if exit_ok {
551        Report::Clean
552    } else {
553        Report::CouldNotCheck
554    }
555}
556
557/// The `site-packages` of the environment this project actually uses, if
558/// one is on disk.
559///
560/// `$VIRTUAL_ENV` first — an activated environment is the one whose imports
561/// are live — then the `.venv` uv and PEP 668 tooling create by convention.
562/// Layout differs by platform: `lib/python3.13/site-packages` everywhere
563/// except Windows, which uses `Lib/site-packages`, so the python-version
564/// directory is discovered rather than guessed.
565fn venv_site_packages(root: &str) -> Option<String> {
566    let candidates = std::env::var_os("VIRTUAL_ENV")
567        .map(std::path::PathBuf::from)
568        .into_iter()
569        .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
570    for venv in candidates {
571        let windows = venv.join("Lib").join("site-packages");
572        if windows.is_dir() {
573            return Some(windows.to_string_lossy().into_owned());
574        }
575        let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
576            continue;
577        };
578        for e in entries.flatten() {
579            if !e.file_name().to_string_lossy().starts_with("python") {
580                continue;
581            }
582            let sp = e.path().join("site-packages");
583            if sp.is_dir() {
584                return Some(sp.to_string_lossy().into_owned());
585            }
586        }
587    }
588    None
589}
590
591/// Run one audit tool from the repo root and read its answer.
592fn audited(settings: &crate::config::Settings, argv: &[String]) -> Option<(bool, String)> {
593    audited_in(settings, argv, std::path::Path::new(&common::repo_root()))
594}
595
596/// Run one audit tool from `dir` and read its answer.
597fn audited_in(
598    settings: &crate::config::Settings,
599    argv: &[String],
600    dir: &std::path::Path,
601) -> Option<(bool, String)> {
602    let mut cmd = std::process::Command::new(&argv[0]);
603    cmd.args(&argv[1..])
604        .current_dir(dir)
605        .stdin(std::process::Stdio::null());
606    common::strip_git_env(&mut cmd);
607    let (ran, out) = common::capture_within(settings, &mut cmd)?;
608    match ran {
609        common::Ran::Status(s) => Some((s.success(), out)),
610        common::Ran::TimedOut(budget) => {
611            common::say_timed_out(&argv[0], budget);
612            None
613        }
614    }
615}
616
617pub fn rust(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
618    if !has_lockfile("Cargo.lock") {
619        return Outcome::Inert;
620    }
621    if common::which("cargo-audit").is_none() {
622        common::warn(
623            "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
624             the audit did NOT run",
625        );
626        return Outcome::Unavailable;
627    }
628    let argv = vec![
629        common::program("cargo"),
630        "audit".into(),
631        "--color".into(),
632        "never".into(),
633    ];
634    let Some((exit_ok, out)) = audited(settings, &argv) else {
635        return Outcome::Unavailable;
636    };
637    let release = releasing(refs);
638    let mut report = read_cargo_audit(exit_ok, &out);
639    if release {
640        if let Report::Vulnerabilities(ids) = report {
641            let (shipped, dev_only) =
642                split_shipped_crates(ids, &out, |krate| cargo_tree_shipped(settings, krate));
643            warn_dev_only("audit-rust", &dev_only);
644            report = if shipped.is_empty() {
645                Report::Clean
646            } else {
647                Report::Vulnerabilities(shipped)
648            };
649        }
650    }
651    conclude(
652        settings,
653        "audit-rust",
654        attribute(report, &out, |krate| cargo_tree_inverse(settings, krate)),
655        release,
656        &out,
657    )
658}
659
660/// `cargo tree -i <crate>` over the edges a RELEASE ships: normal and build
661/// dependencies (a dependency's build script runs on every machine that
662/// compiles it), for every target and every feature, so nothing a consumer
663/// could switch on is mistaken for dev-only. None if it could not run.
664fn cargo_tree_shipped(settings: &crate::config::Settings, spec: &str) -> Option<String> {
665    let argv = vec![
666        common::program("cargo"),
667        "tree".into(),
668        "--invert".into(),
669        spec.into(),
670        "--edges".into(),
671        "normal,build".into(),
672        "--target".into(),
673        "all".into(),
674        "--all-features".into(),
675        "--color".into(),
676        "never".into(),
677    ];
678    // Unlike the message-only inverse tree, a failure here must not read as
679    // "nothing reaches it": an error prints no local crate either.
680    audited(settings, &argv).and_then(|(ok, out)| ok.then_some(out))
681}
682
683/// Does a shipped-edges inverse tree say the crate is dev-only? Only on
684/// positive evidence: no local crate reaches it AND cargo said there is
685/// nothing to print. Anything else — an unreadable tree — ships.
686fn tree_says_dev_only(tree: &str) -> bool {
687    local_dependents(tree).is_empty() && tree.contains("nothing to print")
688}
689
690/// Split a release's advisory ids into those that ship and those only the
691/// dev tree carries. An id whose crate is unknown, or whose tree could not
692/// be read, ships: an unknown is not a pass.
693fn split_shipped_crates(
694    ids: Vec<String>,
695    out: &str,
696    shipped_tree: impl Fn(&str) -> Option<String>,
697) -> (Vec<String>, Vec<String>) {
698    let specs = ids_with_specs(out);
699    let mut verdicts: Vec<(String, bool)> = Vec::new();
700    let (mut shipped, mut dev_only) = (Vec::new(), Vec::new());
701    for id in ids {
702        let spec = specs
703            .iter()
704            .find(|(pid, spec)| *pid == id && spec.is_some())
705            .and_then(|(_, spec)| spec.clone());
706        let ships = match spec {
707            None => true,
708            // Cached by name@version: two locked versions of one crate are
709            // two answers, and one being dev-only says nothing of the other.
710            Some(spec) => match verdicts.iter().find(|(s, _)| *s == spec) {
711                Some((_, v)) => *v,
712                None => {
713                    let v = shipped_tree(&spec).map_or(true, |t| !tree_says_dev_only(&t));
714                    verdicts.push((spec, v));
715                    v
716                }
717            },
718        };
719        if ships {
720            shipped.push(id);
721        } else {
722            dev_only.push(id);
723        }
724    }
725    (shipped, dev_only)
726}
727
728/// Each advisory id with the package its OWN block names: `crate@version`
729/// when cargo audit printed a `Version:` in that block, the bare crate name
730/// otherwise, None for an id outside any block.
731fn ids_with_specs(out: &str) -> Vec<(String, Option<String>)> {
732    let mut pairs = Vec::new();
733    let (mut krate, mut version): (Option<String>, Option<String>) = (None, None);
734    for line in out.lines().map(str::trim) {
735        if let Some(rest) = line.strip_prefix("Crate:") {
736            krate = rest.split_whitespace().next().map(str::to_string);
737            version = None;
738        } else if let Some(rest) = line.strip_prefix("Version:") {
739            version = rest.split_whitespace().next().map(str::to_string);
740        } else if let Some(rest) = line.strip_prefix("ID:") {
741            if let Some(id) = rest.split_whitespace().next().filter(|w| is_advisory_id(w)) {
742                let spec = krate.take().map(|k| match version.take() {
743                    Some(v) => format!("{k}@{v}"),
744                    None => k,
745                });
746                pairs.push((id.to_string(), spec));
747            }
748        }
749    }
750    pairs
751}
752
753/// Name what a release does not ship and therefore does not refuse.
754fn warn_dev_only(tool: &str, dev_only: &[String]) {
755    if !dev_only.is_empty() {
756        common::warn(&format!(
757            "{tool}: only in development dependencies, which the release does not ship — not blocking: {}",
758            dev_only.join("; ")
759        ));
760    }
761}
762
763/// `cargo tree -i <crate>`, or None if it could not be run. Failure here is
764/// never fatal: attribution is an improvement to a message, and an advisory
765/// reported without it is still an advisory reported.
766fn cargo_tree_inverse(settings: &crate::config::Settings, krate: &str) -> Option<String> {
767    let argv = vec![
768        common::program("cargo"),
769        "tree".into(),
770        "--invert".into(),
771        krate.into(),
772        "--edges".into(),
773        "normal".into(),
774        "--color".into(),
775        "never".into(),
776    ];
777    audited(settings, &argv).map(|(_, out)| out)
778}
779
780/// Name the crate behind each advisory, and which of this workspace's crates
781/// reach it.
782///
783/// Deliberately spawns NOTHING on a clean report, which is every run that
784/// matters: the `cargo tree` calls happen once per distinct affected crate,
785/// only when there is already something to say. A hook on the push path does
786/// not pay for a message nobody will read.
787fn attribute(report: Report, out: &str, tree: impl Fn(&str) -> Option<String>) -> Report {
788    match report {
789        Report::Advisories(ids) => Report::Advisories(described(ids, out, tree)),
790        Report::Vulnerabilities(ids) => Report::Vulnerabilities(described(ids, out, tree)),
791        // Clean and CouldNotCheck carry no ids, so there is nothing to
792        // attribute and — the part that matters — nothing to spawn.
793        other => other,
794    }
795}
796
797/// Each id, rewritten with its crate and what reaches it where both are
798/// known. One `cargo tree` per distinct crate, not per advisory: `lru`
799/// carried two advisories in the run that prompted this.
800fn described(ids: Vec<String>, out: &str, tree: impl Fn(&str) -> Option<String>) -> Vec<String> {
801    let pairs = ids_with_crates(out);
802    let mut seen: Vec<(String, Vec<String>)> = Vec::new();
803    ids.iter()
804        .map(|id| {
805            let krate = pairs
806                .iter()
807                .find(|(pid, k)| pid == id && k.is_some())
808                .and_then(|(_, k)| k.clone());
809            let Some(k) = krate else {
810                return id.clone();
811            };
812            if let Some((_, reaches)) = seen.iter().find(|(name, _)| *name == k) {
813                return describe(id, Some(&k), reaches);
814            }
815            let reaches = tree(&k).map(|t| local_dependents(&t)).unwrap_or_default();
816            seen.push((k.clone(), reaches.clone()));
817            describe(id, Some(&k), &reaches)
818        })
819        .collect()
820}
821
822/// Does the repository carry `lockfile` anywhere in its index? An audit
823/// without a resolved tree audits a guess — and one asked about a
824/// repository in another language has nothing to audit at all. That is
825/// `Inert`, not "could not run": the dispatcher asks the same question
826/// from the registry's scope, and this is the answer for a check invoked
827/// by name.
828fn has_lockfile(lockfile: &str) -> bool {
829    crate::tracked_paths()
830        .iter()
831        .any(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
832}
833
834/// The directories holding a tracked `lockfile`, repo-relative ("" for the
835/// root), sorted. npm resolves a project from its own directory, so a
836/// repository whose packages live in subdirectories (`web/`, `mcp/`) has no
837/// lockfile at the root at all.
838fn lockfile_dirs(lockfile: &str) -> Vec<String> {
839    let mut dirs: Vec<String> = crate::tracked_paths()
840        .iter()
841        .filter(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
842        .map(|p| {
843            p.rsplit_once('/')
844                .map(|(d, _)| d.to_string())
845                .unwrap_or_default()
846        })
847        .collect();
848    dirs.sort();
849    dirs.dedup();
850    dirs
851}
852
853/// One JS package manager `audit-js` knows how to ask: the lockfile that
854/// opts a directory in, the tool that reads it, and how to read its answer.
855struct JsAuditor {
856    lockfile: &'static str,
857    tool: &'static str,
858    read: fn(bool, &str) -> Report,
859    /// Extra arguments for a project in `dir`.
860    args: fn(&str) -> Vec<String>,
861    /// The flag that limits the audit to what the project ships — its
862    /// production dependencies, without the dev tree.
863    prod_only: &'static str,
864}
865
866/// pnpm answers for the WORKSPACE it finds above a directory, not for the
867/// directory: a standalone project with its own pnpm-lock.yaml inside a
868/// workspace (a spike, an example) was reported with the workspace root's
869/// findings, all of them, under its own name. `--ignore-workspace` makes it
870/// read the lockfile it stands next to — except at a workspace's own root,
871/// where the workspace IS the project and every member must be audited.
872fn pnpm_args(dir: &str) -> Vec<String> {
873    let manifest = if dir.is_empty() {
874        "pnpm-workspace.yaml".to_string()
875    } else {
876        format!("{dir}/pnpm-workspace.yaml")
877    };
878    if crate::tracked_paths().contains(&manifest) {
879        vec![]
880    } else {
881        vec!["--ignore-workspace".into()]
882    }
883}
884
885const JS_AUDITORS: [JsAuditor; 2] = [
886    JsAuditor {
887        lockfile: "package-lock.json",
888        tool: "npm",
889        read: read_npm_audit,
890        args: |_| vec![],
891        prod_only: "--omit=dev",
892    },
893    JsAuditor {
894        lockfile: "pnpm-lock.yaml",
895        tool: "pnpm",
896        read: read_pnpm_audit,
897        args: pnpm_args,
898        prod_only: "--prod",
899    },
900];
901
902/// `npm audit` in every directory that tracks a `package-lock.json`, and
903/// `pnpm audit` in every directory that tracks a `pnpm-lock.yaml`.
904///
905/// It used to run once at the repository root, where a repository whose
906/// packages live in subdirectories has no lockfile: npm answered ENOLOCK,
907/// the check said "could not complete", and a tree with 17 known
908/// vulnerabilities (9 high) went unaudited release after release. Each
909/// project is audited on its own; any finding counts, labelled with its
910/// directory, and a project the tool could not answer for keeps the whole
911/// result from reading clean.
912///
913/// And it only ever knew npm: a pnpm workspace has no `package-lock.json`,
914/// so its whole tree — 28 vulnerable versions, two critical, in one
915/// repository — was never audited and nothing said so. pnpm audits its own
916/// lockfile, from the lockfile alone, the same way.
917///
918/// On a release, what blocks is what SHIPS: a finding is audited again with
919/// the production dependencies only (`--omit=dev` / `--prod`), and one that
920/// lives only in the dev tree — a build script's toolchain, a test runner —
921/// is named but does not refuse the tag. Nobody who installs the package
922/// installs it. A finding whose production re-audit cannot answer still
923/// blocks: an unknown is not a pass.
924pub fn js(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
925    let projects: Vec<(&JsAuditor, String)> = JS_AUDITORS
926        .iter()
927        .flat_map(|a| lockfile_dirs(a.lockfile).into_iter().map(move |d| (a, d)))
928        .collect();
929    if projects.is_empty() {
930        return Outcome::Inert;
931    }
932    let root = std::path::PathBuf::from(common::repo_root());
933    let release = releasing(refs);
934    let mut found = Vec::new();
935    let mut dev_only = Vec::new();
936    let mut unchecked = Vec::new();
937    let mut full = String::new();
938    for (auditor, dir) in &projects {
939        let label = if dir.is_empty() {
940            ".".to_string()
941        } else {
942            dir.clone()
943        };
944        let mut argv = vec![common::program(auditor.tool), "audit".into()];
945        argv.extend((auditor.args)(dir));
946        let Some((exit_ok, out)) = audited_in(settings, &argv, &root.join(dir)) else {
947            common::warn(&format!(
948                "audit-js: {} could not run in {label} — the audit did NOT run",
949                auditor.tool
950            ));
951            return Outcome::Unavailable;
952        };
953        match (auditor.read)(exit_ok, &out) {
954            Report::Vulnerabilities(what) if release => {
955                let mut prod = argv.clone();
956                prod.push(auditor.prod_only.into());
957                match audited_in(settings, &prod, &root.join(dir))
958                    .map(|(ok, pout)| ((auditor.read)(ok, &pout), pout))
959                {
960                    Some((Report::Clean | Report::Advisories(_), _)) => {
961                        dev_only.extend(what.into_iter().map(|w| format!("{label}: {w}")));
962                    }
963                    Some((Report::Vulnerabilities(shipped), pout)) => {
964                        found.extend(shipped.into_iter().map(|w| format!("{label}: {w}")));
965                        full.push_str(&format!(
966                            "── {} audit {} in {label}\n{pout}\n",
967                            auditor.tool, auditor.prod_only
968                        ));
969                    }
970                    Some((Report::CouldNotCheck, _)) | None => {
971                        found.extend(what.into_iter().map(|w| format!("{label}: {w}")));
972                        full.push_str(&format!("── {} audit in {label}\n{out}\n", auditor.tool));
973                    }
974                }
975            }
976            Report::Vulnerabilities(what) => {
977                found.extend(what.into_iter().map(|w| format!("{label}: {w}")));
978                full.push_str(&format!("── {} audit in {label}\n{out}\n", auditor.tool));
979            }
980            Report::CouldNotCheck => unchecked.push(format!("{label} ({})", auditor.tool)),
981            Report::Clean | Report::Advisories(_) => {}
982        }
983    }
984    if !unchecked.is_empty() {
985        common::warn(&format!(
986            "audit-js: the audit could not answer in {} — those projects were NOT checked",
987            unchecked.join(", ")
988        ));
989    }
990    warn_dev_only("audit-js", &dev_only);
991    let waivable = unchecked.is_empty();
992    let report = if !found.is_empty() {
993        Report::Vulnerabilities(found)
994    } else if !unchecked.is_empty() {
995        Report::CouldNotCheck
996    } else {
997        Report::Clean
998    };
999    conclude_waivable(settings, "audit-js", report, release, &full, waivable)
1000}
1001
1002/// Go needs no production filter: modules have no dev-dependency set, and
1003/// `govulncheck ./...` without `-test` reports only vulnerabilities the
1004/// module's non-test code can reach — what ships, already.
1005pub fn go(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
1006    if !has_lockfile("go.sum") {
1007        return Outcome::Inert;
1008    }
1009    if common::which("govulncheck").is_none() {
1010        common::warn(
1011            "audit-go: govulncheck is not installed \
1012             (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
1013        );
1014        return Outcome::Unavailable;
1015    }
1016    let argv = vec![common::program("govulncheck"), "./...".into()];
1017    let Some((exit_ok, out)) = audited(settings, &argv) else {
1018        return Outcome::Unavailable;
1019    };
1020    conclude(
1021        settings,
1022        "audit-go",
1023        read_govulncheck(exit_ok, &out),
1024        releasing(refs),
1025        &out,
1026    )
1027}
1028
1029pub fn python(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
1030    if !has_lockfile("requirements.txt") && !has_lockfile("pyproject.toml") {
1031        return Outcome::Inert;
1032    }
1033    if common::which("pip-audit").is_none() {
1034        common::warn(
1035            "audit-python: pip-audit is not installed (pip install pip-audit) — \
1036             the audit did NOT run",
1037        );
1038        return Outcome::Unavailable;
1039    }
1040    let root = common::repo_root();
1041    let argv = if std::path::Path::new(&root)
1042        .join("requirements.txt")
1043        .exists()
1044    {
1045        vec![
1046            common::program("pip-audit"),
1047            "-r".into(),
1048            "requirements.txt".into(),
1049        ]
1050    } else if let Some(site_packages) = venv_site_packages(&root) {
1051        // A uv/PEP-621 project has no requirements.txt, and EXPORTING one
1052        // does not work either: `uv export` emits the workspace's own
1053        // members and any private-index dependency, and pip-audit resolves
1054        // a requirements file in a throwaway venv that can reach neither —
1055        // it dies on "No matching distribution found". Auditing the
1056        // INSTALLED tree resolves nothing, and is the truer question
1057        // anyway: these are the versions actually imported.
1058        vec![
1059            common::program("pip-audit"),
1060            "--path".into(),
1061            site_packages,
1062            // Workspace members are installed editable and are not on
1063            // PyPI; without this each one is a line of noise.
1064            "--skip-editable".into(),
1065        ]
1066    } else {
1067        common::warn(
1068            "audit-python: no requirements.txt, and no virtualenv to audit \
1069             (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
1070        );
1071        return Outcome::Unavailable;
1072    };
1073    let Some((exit_ok, out)) = audited(settings, &argv) else {
1074        return Outcome::Unavailable;
1075    };
1076    let release = releasing(refs);
1077    let mut report = read_pip_audit(exit_ok, &out);
1078    // A requirements.txt is the production list by convention (dev pins
1079    // live beside it, in requirements-dev.txt). A virtualenv holds the dev
1080    // groups too, so on a release its findings are checked against what
1081    // `uv export --no-dev` says ships.
1082    let venv_mode = argv.iter().any(|a| a == "--path");
1083    // A row whose advisory id is not one a waiver can name (MAL-, a new
1084    // scheme) makes the whole report unwaivable, in every mode.
1085    let mut waivable = pip_rows_all_named(&out);
1086    if release && venv_mode && matches!(report, Report::Vulnerabilities(_)) {
1087        let rows = pip_vulnerable_rows(&out);
1088        if let (false, Some(prod)) = (rows.is_empty(), uv_production_names(settings)) {
1089            let (shipped, dev_only): (Vec<String>, Vec<String>) = rows
1090                .into_iter()
1091                .map(|(name, id)| (prod.contains(&name), format!("{name} {id}")))
1092                .fold((Vec::new(), Vec::new()), |(mut s, mut d), (ships, row)| {
1093                    if ships {
1094                        s.push(row);
1095                    } else {
1096                        d.push(row);
1097                    }
1098                    (s, d)
1099                });
1100            warn_dev_only("audit-python", &dev_only);
1101            waivable = waivable && shipped.iter().all(|row| !advisory_ids(row).is_empty());
1102            report = if shipped.is_empty() {
1103                Report::Clean
1104            } else {
1105                Report::Vulnerabilities(shipped)
1106            };
1107        }
1108    }
1109    conclude_waivable(settings, "audit-python", report, release, &out, waivable)
1110}
1111
1112/// PEP 503 normalised: `Foo_Bar.baz` and `foo-bar-baz` are one package.
1113fn normalise(name: &str) -> String {
1114    name.to_ascii_lowercase()
1115        .split(['-', '_', '.'])
1116        .filter(|p| !p.is_empty())
1117        .collect::<Vec<_>>()
1118        .join("-")
1119}
1120
1121/// The packages pip-audit's table names, one row per advisory:
1122/// `Name Version ID Fix Versions`. Only rows whose third column is an
1123/// advisory id count, so the header, the rule and the summary never do.
1124fn pip_vulnerable_rows(out: &str) -> Vec<(String, String)> {
1125    let mut rows: Vec<(String, String)> = out
1126        .lines()
1127        .filter_map(|l| {
1128            let cols: Vec<&str> = l.split_whitespace().collect();
1129            let id = cols.get(2)?;
1130            ["PYSEC-", "GHSA-", "CVE-", "OSV-"]
1131                .iter()
1132                .any(|p| id.starts_with(p))
1133                .then(|| (normalise(cols[0]), (*id).to_string()))
1134        })
1135        .collect();
1136    rows.sort();
1137    rows.dedup();
1138    rows
1139}
1140
1141/// Does every advisory row of pip-audit's table carry an id a waiver can
1142/// name? A row is `name version id …`: a version starting with a digit and
1143/// an id shaped `PREFIX-…`, whatever the prefix.
1144fn pip_rows_all_named(out: &str) -> bool {
1145    out.lines().all(|l| {
1146        let cols: Vec<&str> = l.split_whitespace().collect();
1147        let is_row = cols.len() >= 3
1148            && cols[1].starts_with(|c: char| c.is_ascii_digit())
1149            && cols[2].contains('-')
1150            && cols[2].starts_with(|c: char| c.is_ascii_uppercase());
1151        !is_row || !advisory_ids(cols[2]).is_empty()
1152    })
1153}
1154
1155/// What a uv project ships: `uv export --no-dev` of its lock, names only.
1156/// None when there is no `uv.lock`, no `uv`, or the export fails — the
1157/// caller then keeps every finding, because an unknown is not a pass.
1158fn uv_production_names(
1159    settings: &crate::config::Settings,
1160) -> Option<std::collections::HashSet<String>> {
1161    if !has_lockfile("uv.lock") || common::which("uv").is_none() {
1162        return None;
1163    }
1164    let argv = vec![
1165        common::program("uv"),
1166        "export".into(),
1167        "--frozen".into(),
1168        "--no-dev".into(),
1169        // Optional extras ship: a consumer who asks for one installs it.
1170        "--all-extras".into(),
1171        "--no-hashes".into(),
1172        "--no-emit-workspace".into(),
1173        "--format".into(),
1174        "requirements-txt".into(),
1175    ];
1176    let (ok, out) = audited(settings, &argv)?;
1177    if !ok {
1178        return None;
1179    }
1180    Some(
1181        out.lines()
1182            .map(str::trim)
1183            .filter(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with('-'))
1184            .filter_map(|l| {
1185                l.split(|c: char| "=<>!~;[ ".contains(c))
1186                    .next()
1187                    .map(normalise)
1188            })
1189            .collect(),
1190    )
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196
1197    /// uv projects have no `requirements.txt`, so the venv is the only thing
1198    /// left to audit — and before this, `audit-python` looked for nothing
1199    /// else and reported "the audit did NOT run" forever. Six repositories
1200    /// in one fleet were in exactly that state, one of them carrying 53
1201    /// known vulnerabilities nobody had been told about.
1202    #[test]
1203    fn a_uv_project_is_audited_through_its_venv() {
1204        let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
1205        let _ = std::fs::remove_dir_all(&root);
1206
1207        // Nothing on disk: nothing to audit, and we say so rather than
1208        // inventing a target.
1209        std::fs::create_dir_all(&root).unwrap();
1210        assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
1211
1212        // The posix layout, with the python version DISCOVERED — hard-coding
1213        // `python3.13` would silently stop finding it after an upgrade.
1214        // Joined segment by segment, NOT as one "a/b/c" literal: on Windows
1215        // the literal keeps its forward slashes while the code under test
1216        // returns backslashes, and the test fails on a difference that is
1217        // only in the expectation.
1218        let sp = root
1219            .join(".venv")
1220            .join("lib")
1221            .join("python3.13")
1222            .join("site-packages");
1223        std::fs::create_dir_all(&sp).unwrap();
1224        assert_eq!(
1225            venv_site_packages(root.to_str().unwrap()),
1226            Some(sp.to_string_lossy().into_owned())
1227        );
1228
1229        // The Windows layout, which has no version directory at all.
1230        let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
1231        let _ = std::fs::remove_dir_all(&win);
1232        let wsp = win.join(".venv").join("Lib").join("site-packages");
1233        std::fs::create_dir_all(&wsp).unwrap();
1234        assert_eq!(
1235            venv_site_packages(win.to_str().unwrap()),
1236            Some(wsp.to_string_lossy().into_owned())
1237        );
1238
1239        let _ = std::fs::remove_dir_all(&root);
1240        let _ = std::fs::remove_dir_all(&win);
1241    }
1242
1243    fn tag(name: &str) -> PushRef {
1244        PushRef {
1245            local_ref: name.to_string(),
1246            local_oid: "a".repeat(40),
1247            remote_ref: name.to_string(),
1248            remote_oid: "0".repeat(40),
1249        }
1250    }
1251
1252    /// `v` + digit gates; a branch, a bare-word tag, or a tag merely
1253    /// starting with the letter v does not.
1254    #[test]
1255    fn pip_audit_rows_name_their_packages_normalised() {
1256        let out = "Name       Version ID                  Fix Versions\n\
1257                   ---------- ------- ------------------- ------------\n\
1258                   Pytest_Cov 4.0.0   GHSA-aaaa-bbbb-cccc 4.1.0\n\
1259                   requests   2.31.0  PYSEC-2023-74       2.31.1\n\
1260                   requests   2.31.0  CVE-2024-35195      2.32.0\n\
1261                   Found 3 known vulnerabilities in 2 packages\n";
1262        let names: Vec<String> = pip_vulnerable_rows(out)
1263            .into_iter()
1264            .map(|(n, _)| n)
1265            .collect();
1266        assert_eq!(names, vec!["pytest-cov", "requests", "requests"]);
1267        assert_eq!(normalise("Foo_Bar.baz"), "foo-bar-baz");
1268    }
1269
1270    #[test]
1271    fn advisory_ids_are_found_wherever_they_sit() {
1272        let text = "More info https://github.com/advisories/GHSA-h3mg-xc3c-68pw\n\
1273                    ID: RUSTSEC-2025-0001, GO-2024-2687 PYSEC-2023-74 CVE-2024-35195 OSV-2024-12 GHSA-bad";
1274        assert_eq!(
1275            advisory_ids(text),
1276            vec![
1277                "CVE-2024-35195",
1278                "GHSA-h3mg-xc3c-68pw",
1279                "GO-2024-2687",
1280                "OSV-2024-12",
1281                "PYSEC-2023-74",
1282                "RUSTSEC-2025-0001"
1283            ]
1284        );
1285    }
1286
1287    #[test]
1288    fn a_waiver_is_reasoned_dated_and_bounded() {
1289        let today = days_from_date("2026-10-05").unwrap();
1290        let w = Waivers::parse(
1291            "# id expires reason\n\
1292             GHSA-aaaa-bbbb-cccc 2026-12-01 no patched version; build-time only\n\
1293             GHSA-dddd-eeee-ffff 2026-10-01 expired one\n\
1294             GHSA-gggg-hhhh-iiii 2027-02-01 too far ahead\n\
1295             GHSA-jjjj-kkkk-llll 2026-11-01\n\
1296             GHSA-mmmm-nnnn-oooo soon no date\n",
1297            today,
1298        );
1299        assert!(w.covers("GHSA-aaaa-bbbb-cccc"));
1300        assert!(!w.covers("GHSA-dddd-eeee-ffff"), "expired");
1301        assert!(!w.covers("GHSA-gggg-hhhh-iiii"), "beyond the horizon");
1302        assert!(!w.covers("GHSA-jjjj-kkkk-llll"), "no reason");
1303        assert!(!w.covers("GHSA-mmmm-nnnn-oooo"), "no date");
1304        assert_eq!(w.problems().len(), 4);
1305        assert_eq!(days_from_date("1970-01-01"), Some(0));
1306        assert_eq!(days_from_date("2026-02-30"), None);
1307        assert!(days_from_date("2028-02-29").is_some());
1308        assert_eq!(days_from_date("2026-13-01"), None);
1309    }
1310
1311    #[test]
1312    fn two_locked_versions_are_two_answers() {
1313        let out = "Crate: foo\nVersion: 0.1.0\nID: RUSTSEC-2025-0010\n\
1314                   Crate: foo\nVersion: 0.2.0\nID: RUSTSEC-2025-0011\n";
1315        let ids = vec![
1316            "RUSTSEC-2025-0010".to_string(),
1317            "RUSTSEC-2025-0011".to_string(),
1318        ];
1319        let (shipped, dev) = split_shipped_crates(ids, out, |spec| match spec {
1320            "foo@0.1.0" => Some("warning: nothing to print.\n".into()),
1321            "foo@0.2.0" => Some("foo v0.2.0\n└── me v0.1.0 (/repo)\n".into()),
1322            _ => None,
1323        });
1324        assert_eq!(shipped, vec!["RUSTSEC-2025-0011"]);
1325        assert_eq!(dev, vec!["RUSTSEC-2025-0010"]);
1326    }
1327
1328    #[test]
1329    fn a_pip_row_without_a_nameable_id_is_unwaivable() {
1330        let named = "requests 2.31.0 PYSEC-2023-74 2.31.1\nFound 1 known vulnerability\n";
1331        let unnamed = "evil 1.0.0 MAL-2024-1234\nrequests 2.31.0 PYSEC-2023-74 2.31.1\n";
1332        assert!(pip_rows_all_named(named));
1333        assert!(!pip_rows_all_named(unnamed));
1334    }
1335
1336    #[test]
1337    fn a_crate_ships_unless_its_tree_says_otherwise() {
1338        let out = "Crate: dev\nVersion: 0.2.0\nID: RUSTSEC-2025-0001\n\
1339                   Crate: app\nID: RUSTSEC-2025-0002\n\
1340                   Crate: odd\nID: RUSTSEC-2025-0004\nRUSTSEC-2025-0003\n";
1341        let ids = vec![
1342            "RUSTSEC-2025-0001".to_string(),
1343            "RUSTSEC-2025-0002".to_string(),
1344            "RUSTSEC-2025-0003".to_string(),
1345            "RUSTSEC-2025-0004".to_string(),
1346        ];
1347        let (shipped, dev) = split_shipped_crates(ids, out, |spec| match spec {
1348            // Asked by name@version when cargo audit gave the version.
1349            "dev@0.2.0" => Some("warning: nothing to print.\n".into()),
1350            "app" => Some("app v1.0.0\n└── me v0.1.0 (/repo)\n".into()),
1351            // A tree that says neither: not proof of dev-only.
1352            "odd" => Some("odd v1.0.0\n".into()),
1353            _ => None,
1354        });
1355        // 0003 has no crate, 0004's tree is unclear: unknowns ship.
1356        assert_eq!(
1357            shipped,
1358            vec![
1359                "RUSTSEC-2025-0002",
1360                "RUSTSEC-2025-0003",
1361                "RUSTSEC-2025-0004"
1362            ]
1363        );
1364        assert_eq!(dev, vec!["RUSTSEC-2025-0001"]);
1365    }
1366
1367    #[test]
1368    fn a_release_is_a_v_number_tag() {
1369        assert!(releasing(&[tag("refs/tags/v1.6.6")]));
1370        assert!(releasing(&[tag("refs/tags/v2")]));
1371        assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
1372        assert!(!releasing(&[tag("refs/tags/release")]));
1373        assert!(!releasing(&[tag("refs/heads/v1-styles")]));
1374        assert!(!releasing(&[tag("refs/heads/main")]));
1375        // A mixed push gates: the tag is in there.
1376        assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
1377    }
1378
1379    /// ci.yaml's lesson, pinned at the unit level: the ids decide, the exit
1380    /// code only classifies them.
1381    #[test]
1382    fn cargo_audit_ids_decide_not_the_exit_code() {
1383        assert_eq!(
1384            read_cargo_audit(true, "ok, 312 crates checked"),
1385            Report::Clean
1386        );
1387        assert_eq!(
1388            read_cargo_audit(false, "error: couldn't fetch advisory database"),
1389            Report::CouldNotCheck
1390        );
1391        let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
1392        assert_eq!(
1393            read_cargo_audit(true, warn),
1394            Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
1395        );
1396        let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
1397        assert_eq!(
1398            read_cargo_audit(false, vuln),
1399            Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
1400        );
1401        // A lookalike is not an id.
1402        assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
1403    }
1404
1405    /// The real report shape, trimmed from the run that prompted this: two
1406    /// advisories against ONE crate, and one against another.
1407    fn real_report() -> String {
1408        [
1409            "Crate:     paste",
1410            "Version:   1.0.15",
1411            "Warning:   unmaintained",
1412            "ID:        RUSTSEC-2024-0436",
1413            "URL:       https://rustsec.org/advisories/RUSTSEC-2024-0436",
1414            "",
1415            "Crate:     lru",
1416            "Version:   0.12.5",
1417            "Warning:   unsound",
1418            "ID:        RUSTSEC-2026-0253",
1419            "",
1420            "Crate:     lru",
1421            "Version:   0.12.5",
1422            "ID:        RUSTSEC-2026-0002",
1423            "",
1424            "warning: 3 allowed warnings found",
1425        ]
1426        .join("\n")
1427    }
1428
1429    #[test]
1430    fn an_advisory_is_paired_with_its_crate() {
1431        let pairs = ids_with_crates(&real_report());
1432        assert_eq!(
1433            pairs,
1434            vec![
1435                ("RUSTSEC-2024-0436".into(), Some("paste".into())),
1436                ("RUSTSEC-2026-0253".into(), Some("lru".into())),
1437                ("RUSTSEC-2026-0002".into(), Some("lru".into())),
1438            ]
1439        );
1440    }
1441
1442    /// A `Crate:` binds to ONE id. An id printed loose reports no crate
1443    /// rather than inheriting whichever block happened to precede it.
1444    #[test]
1445    fn a_loose_id_borrows_no_crate() {
1446        let out = "Crate:     paste\nID:        RUSTSEC-2024-0436\nsee also RUSTSEC-2025-0001";
1447        assert_eq!(
1448            ids_with_crates(out),
1449            vec![
1450                ("RUSTSEC-2024-0436".into(), Some("paste".into())),
1451                ("RUSTSEC-2025-0001".into(), None),
1452            ]
1453        );
1454    }
1455
1456    /// Local crates carry a path; registry crates do not. That is the whole
1457    /// discriminator, and it has to work without knowing the member names.
1458    #[test]
1459    fn only_local_crates_are_named_as_reached() {
1460        let tree =
1461            "lru v0.12.5\n└── ratatui v0.29.0\n    └── amont-fleet v1.32.0 (/w/crates/amont-fleet)";
1462        assert_eq!(local_dependents(tree), vec!["amont-fleet".to_string()]);
1463        assert!(local_dependents("lru v0.12.5\n└── ratatui v0.29.0").is_empty());
1464    }
1465
1466    /// End to end on the real report, with the tree calls faked: each id
1467    /// names its crate and the workspace crate that reaches it, and `lru`'s
1468    /// two advisories cost ONE lookup.
1469    #[test]
1470    fn the_warning_names_the_crate_and_what_reaches_it() {
1471        let calls = std::cell::RefCell::new(Vec::new());
1472        let fake = |k: &str| {
1473            calls.borrow_mut().push(k.to_string());
1474            Some(format!(
1475                "{k} v1\n└── ratatui v0.29.0\n    └── amont-fleet v1.32.0 (/w/crates/amont-fleet)"
1476            ))
1477        };
1478        let out = real_report();
1479        let got = attribute(read_cargo_audit(true, &out), &out, fake);
1480        assert_eq!(
1481            got,
1482            Report::Advisories(vec![
1483                "RUSTSEC-2024-0436 (paste → amont-fleet)".into(),
1484                "RUSTSEC-2026-0002 (lru → amont-fleet)".into(),
1485                "RUSTSEC-2026-0253 (lru → amont-fleet)".into(),
1486            ])
1487        );
1488        assert_eq!(
1489            calls.into_inner(),
1490            vec!["paste", "lru"],
1491            "one call per crate"
1492        );
1493    }
1494
1495    /// A lock-file-only crate — an optional dependency of a feature nobody
1496    /// enabled — is reported as such. `cargo audit` reads Cargo.lock, so this
1497    /// is a real and confusing case, and naming it is the point.
1498    #[test]
1499    fn a_crate_outside_the_build_graph_says_so() {
1500        let out = "Crate:     wezterm-input-types\nID:        RUSTSEC-2025-0001";
1501        let got = attribute(read_cargo_audit(true, out), out, |_| {
1502            Some("warning: nothing to print.".into())
1503        });
1504        assert_eq!(
1505            got,
1506            Report::Advisories(vec![
1507                "RUSTSEC-2025-0001 (wezterm-input-types, not in the build graph)".into()
1508            ])
1509        );
1510    }
1511
1512    /// Attribution is a better message, never a gate. A clean report spawns
1513    /// nothing, and a `cargo tree` that cannot run still reports the id.
1514    #[test]
1515    fn attribution_never_changes_the_verdict() {
1516        let spawned = std::cell::Cell::new(false);
1517        let clean = attribute(read_cargo_audit(true, "0 vulnerabilities"), "", |_| {
1518            spawned.set(true);
1519            None
1520        });
1521        assert_eq!(clean, Report::Clean);
1522        assert!(!spawned.get(), "a clean report must spawn nothing");
1523
1524        let out = "Crate:     paste\nID:        RUSTSEC-2024-0436";
1525        let blind = attribute(read_cargo_audit(false, out), out, |_| None);
1526        assert_eq!(
1527            blind,
1528            Report::Vulnerabilities(vec![
1529                "RUSTSEC-2024-0436 (paste, not in the build graph)".into()
1530            ])
1531        );
1532    }
1533
1534    #[test]
1535    fn npm_audit_summary_decides() {
1536        assert_eq!(
1537            read_npm_audit(true, "found 0 vulnerabilities\n"),
1538            Report::Clean
1539        );
1540        assert_eq!(
1541            read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
1542            Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
1543        );
1544        // npm 7+ dropped the verb: this is what every current npm prints,
1545        // and what read as "never answered" until the parser learned it.
1546        assert_eq!(
1547            read_npm_audit(
1548                false,
1549                "# npm audit report\n\nvite  6.0.0 - 6.1.5\nSeverity: high\n\n\
1550                 17 vulnerabilities (8 moderate, 9 high)\n\nTo address all issues, run:\n  npm audit fix\n"
1551            ),
1552            Report::Vulnerabilities(vec!["17 vulnerabilities (8 moderate, 9 high)".into()])
1553        );
1554        assert_eq!(
1555            read_npm_audit(false, "1 vulnerability (1 high)\n"),
1556            Report::Vulnerabilities(vec!["1 vulnerability (1 high)".into()])
1557        );
1558        assert_eq!(
1559            read_npm_audit(true, "up to date, audited 100 packages\n"),
1560            Report::Clean
1561        );
1562        assert_eq!(
1563            read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
1564            Report::CouldNotCheck
1565        );
1566    }
1567
1568    #[test]
1569    fn pnpm_audit_summary_decides() {
1570        assert_eq!(
1571            read_pnpm_audit(true, "No known vulnerabilities found\n"),
1572            Report::Clean
1573        );
1574        // What pnpm prints after its table: the count, then the severities,
1575        // which the finding carries so the warning says how bad.
1576        assert_eq!(
1577            read_pnpm_audit(
1578                false,
1579                "│ More info │ https://github.com/advisories/GHSA-395f │\n\
1580                 └───────────┴──────────────────────────────────────────┘\n\
1581                 107 vulnerabilities found\n\
1582                 Severity: 6 low | 45 moderate | 54 high | 2 critical\n"
1583            ),
1584            Report::Vulnerabilities(vec![
1585                "107 vulnerabilities found (6 low | 45 moderate | 54 high | 2 critical)".into()
1586            ])
1587        );
1588        assert_eq!(
1589            read_pnpm_audit(false, "1 vulnerabilities found\n"),
1590            Report::Vulnerabilities(vec!["1 vulnerabilities found".into()])
1591        );
1592        assert_eq!(
1593            read_pnpm_audit(
1594                false,
1595                " ERR_PNPM_AUDIT_BAD_RESPONSE  The audit endpoint responded with 503\n"
1596            ),
1597            Report::CouldNotCheck
1598        );
1599    }
1600
1601    /// The cargo-audit split, spoken in Go: ids decide, the exit code says
1602    /// whether the analysed code is actually affected.
1603    #[test]
1604    fn govulncheck_ids_decide_not_the_exit_code() {
1605        assert_eq!(
1606            read_govulncheck(true, "No vulnerabilities found.\n"),
1607            Report::Clean
1608        );
1609        assert_eq!(
1610            read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
1611            Report::CouldNotCheck
1612        );
1613        // Informational: the module is vulnerable, the analysed code never
1614        // calls it — exit 0, ids present.
1615        assert_eq!(
1616            read_govulncheck(
1617                true,
1618                "=== Informational ===\nVulnerability #1: GO-2023-1840\n  More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
1619            ),
1620            Report::Advisories(vec!["GO-2023-1840".into()])
1621        );
1622        assert_eq!(
1623            read_govulncheck(
1624                false,
1625                "Vulnerability #1: GO-2022-0969\n  Your code calls it.\nGO-2022-0969 again\n"
1626            ),
1627            Report::Vulnerabilities(vec!["GO-2022-0969".into()])
1628        );
1629        // A lookalike is not an id.
1630        assert_eq!(
1631            read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
1632            Report::Clean
1633        );
1634    }
1635
1636    #[test]
1637    fn pip_audit_sentence_decides() {
1638        assert_eq!(
1639            read_pip_audit(true, "No known vulnerabilities found\n"),
1640            Report::Clean
1641        );
1642        assert_eq!(
1643            read_pip_audit(
1644                false,
1645                "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
1646            ),
1647            Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
1648        );
1649        assert_eq!(
1650            read_pip_audit(false, "ERROR: could not resolve\n"),
1651            Report::CouldNotCheck
1652        );
1653    }
1654}