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` for JS, `pip-audit` for Python — each opted in by the
13//! 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(tool: &str, report: Report, releasing: bool, full: &str) -> Outcome {
59    match report {
60        Report::Clean => {
61            common::ok(&format!("{tool}: no known vulnerabilities"));
62            Outcome::Passed
63        }
64        Report::Advisories(ids) => {
65            common::warn(&format!(
66                "{tool}: advisories against the dependency tree (warnings — unmaintained/unsound): {}",
67                ids.join(", ")
68            ));
69            Outcome::Warned
70        }
71        Report::Vulnerabilities(what) => {
72            if releasing {
73                for line in full.lines() {
74                    crate::say!("{line}");
75                }
76                common::fail(&format!(
77                    "{tool}: known vulnerabilities in the dependency tree — a v* tag \
78                     does not ship with these: {}",
79                    what.join(", ")
80                ));
81                Outcome::Failed
82            } else {
83                common::warn(&format!(
84                    "{tool}: known vulnerabilities in the dependency tree ({}) — \
85                     this will BLOCK a v* tag push",
86                    what.join(", ")
87                ));
88                Outcome::Warned
89            }
90        }
91        Report::CouldNotCheck => {
92            common::warn(&format!(
93                "{tool} could not complete — the dependency tree was NOT checked. \
94                 This is not a clean result."
95            ));
96            Outcome::Unavailable
97        }
98    }
99}
100
101/// Is this word a RUSTSEC id? `RUSTSEC-` + 4 digits + `-` + 4 digits.
102fn is_advisory_id(w: &str) -> bool {
103    w.len() == 17
104        && w.starts_with("RUSTSEC-")
105        && w[8..12].bytes().all(|b| b.is_ascii_digit())
106        && w.as_bytes()[12] == b'-'
107        && w[13..17].bytes().all(|b| b.is_ascii_digit())
108}
109
110/// Pair each advisory with the crate it was raised against.
111///
112/// `cargo audit`'s terminal report is blocks — `Crate:` … `ID:` — and the
113/// id alone does not say which crate carries it, let alone which of YOUR
114/// crates depends on that. Answering "is this on the commit path or only in
115/// an opt-in tool?" meant running `cargo tree -i` by hand every time.
116///
117/// A `Crate:` binds to the next `ID:` and is then spent, so an id appearing
118/// outside a block — a summary line, a URL — reports no crate rather than
119/// inheriting the previous block's.
120fn ids_with_crates(out: &str) -> Vec<(String, Option<String>)> {
121    let mut pairs: Vec<(String, Option<String>)> = Vec::new();
122    let mut pending: Option<String> = None;
123    for line in out.lines() {
124        let t = line.trim();
125        if let Some(rest) = t.strip_prefix("Crate:") {
126            pending = rest.split_whitespace().next().map(str::to_string);
127            continue;
128        }
129        if let Some(rest) = t.strip_prefix("ID:") {
130            if let Some(id) = rest.split_whitespace().next().filter(|w| is_advisory_id(w)) {
131                pairs.push((id.to_string(), pending.take()));
132                continue;
133            }
134        }
135        // Ids outside a block still count — the older summary-line shapes
136        // and anything cargo audit prints loose.
137        for w in t.split_whitespace().filter(|w| is_advisory_id(w)) {
138            pairs.push((w.to_string(), None));
139        }
140    }
141    pairs
142}
143
144/// Which of THIS workspace's crates reach `crate`, read from `cargo tree -i`.
145///
146/// Cargo prints a local package with its path in parentheses and a registry
147/// package without one, which is the only discriminator needed and works in
148/// any repository — the hook cannot know a given workspace's member names.
149///
150/// An empty result is an answer, not a failure: `cargo tree` prints
151/// "nothing to print" for a crate that is in `Cargo.lock` but not in the
152/// build graph. `cargo audit` reads the lock file, so an advisory can name a
153/// crate nothing compiles — an optional dependency of a feature nobody
154/// enabled. Saying so is more useful than naming no crate at all.
155fn local_dependents(tree_out: &str) -> Vec<String> {
156    let mut names: Vec<String> = tree_out
157        .lines()
158        .filter(|l| l.contains(" (/"))
159        .filter_map(|l| {
160            l.split_whitespace()
161                .find(|w| !w.is_empty() && w.chars().next().is_some_and(|c| c.is_alphanumeric()))
162                .map(str::to_string)
163        })
164        .collect();
165    names.sort();
166    names.dedup();
167    names
168}
169
170/// One advisory, said in full: the id, the crate it is against, and which of
171/// this workspace's crates actually reach it.
172fn describe(id: &str, krate: Option<&str>, reaches: &[String]) -> String {
173    match (krate, reaches.is_empty()) {
174        (None, _) => id.to_string(),
175        (Some(k), true) => format!("{id} ({k}, not in the build graph)"),
176        (Some(k), false) => format!("{id} ({k} → {})", reaches.join(", ")),
177    }
178}
179
180/// `cargo audit`, ci.yaml's rules verbatim: the RUSTSEC ids decide, the
181/// exit code only says which class they are.
182fn read_cargo_audit(exit_ok: bool, out: &str) -> Report {
183    let mut ids: Vec<String> = out
184        .split_whitespace()
185        .filter(|w| {
186            w.len() == 17
187                && w.starts_with("RUSTSEC-")
188                && w[8..12].bytes().all(|b| b.is_ascii_digit())
189                && w.as_bytes()[12] == b'-'
190                && w[13..17].bytes().all(|b| b.is_ascii_digit())
191        })
192        .map(|w| w.to_string())
193        .collect();
194    ids.sort();
195    ids.dedup();
196    match (ids.is_empty(), exit_ok) {
197        (true, true) => Report::Clean,
198        (true, false) => Report::CouldNotCheck,
199        (false, true) => Report::Advisories(ids),
200        (false, false) => Report::Vulnerabilities(ids),
201    }
202}
203
204/// `npm audit`: the summary line decides. `found 0 vulnerabilities` is
205/// clean; `found N vulnerabilities` (npm appends the severity split) is
206/// the finding; no recognisable summary plus a refusal to exit clean is a
207/// tool that never answered.
208fn read_npm_audit(exit_ok: bool, out: &str) -> Report {
209    let summary = out
210        .lines()
211        .rev()
212        .map(str::trim)
213        .find(|l| l.starts_with("found ") && l.contains("vulnerabilit"));
214    match summary {
215        Some(l) if l.starts_with("found 0 ") => Report::Clean,
216        Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
217        None if exit_ok => Report::Clean,
218        None => Report::CouldNotCheck,
219    }
220}
221
222/// `govulncheck`: the GO- ids decide, the exit code classifies them — the
223/// same split cargo-audit taught. The tool exits non-zero only when the
224/// analysed CODE is affected; ids with a clean exit are the informational
225/// section (vulnerable modules whose functions are never called), which is
226/// advisory-grade. No ids plus a refusal to exit clean is a tool that never
227/// answered (no network, no vulnerability database).
228fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
229    let mut ids: Vec<String> = out
230        .split_whitespace()
231        .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
232        .filter(|w| {
233            w.len() >= 12
234                && w.starts_with("GO-")
235                && w[3..7].bytes().all(|b| b.is_ascii_digit())
236                && w.as_bytes()[7] == b'-'
237                && w[8..].bytes().all(|b| b.is_ascii_digit())
238        })
239        .map(|w| w.to_string())
240        .collect();
241    ids.sort();
242    ids.dedup();
243    match (ids.is_empty(), exit_ok) {
244        (true, true) => Report::Clean,
245        (true, false) => Report::CouldNotCheck,
246        (false, true) => Report::Advisories(ids),
247        (false, false) => Report::Vulnerabilities(ids),
248    }
249}
250
251/// `pip-audit`: its own closing sentence decides.
252fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
253    if out.contains("No known vulnerabilities found") {
254        return Report::Clean;
255    }
256    if let Some(line) = out
257        .lines()
258        .map(str::trim)
259        .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
260    {
261        return Report::Vulnerabilities(vec![line.to_string()]);
262    }
263    if exit_ok {
264        Report::Clean
265    } else {
266        Report::CouldNotCheck
267    }
268}
269
270/// The `site-packages` of the environment this project actually uses, if
271/// one is on disk.
272///
273/// `$VIRTUAL_ENV` first — an activated environment is the one whose imports
274/// are live — then the `.venv` uv and PEP 668 tooling create by convention.
275/// Layout differs by platform: `lib/python3.13/site-packages` everywhere
276/// except Windows, which uses `Lib/site-packages`, so the python-version
277/// directory is discovered rather than guessed.
278fn venv_site_packages(root: &str) -> Option<String> {
279    let candidates = std::env::var_os("VIRTUAL_ENV")
280        .map(std::path::PathBuf::from)
281        .into_iter()
282        .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
283    for venv in candidates {
284        let windows = venv.join("Lib").join("site-packages");
285        if windows.is_dir() {
286            return Some(windows.to_string_lossy().into_owned());
287        }
288        let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
289            continue;
290        };
291        for e in entries.flatten() {
292            if !e.file_name().to_string_lossy().starts_with("python") {
293                continue;
294            }
295            let sp = e.path().join("site-packages");
296            if sp.is_dir() {
297                return Some(sp.to_string_lossy().into_owned());
298            }
299        }
300    }
301    None
302}
303
304/// Run one audit tool from the repo root and read its answer.
305fn audited(argv: &[String]) -> Option<(bool, String)> {
306    let root = common::repo_root();
307    let mut cmd = std::process::Command::new(&argv[0]);
308    cmd.args(&argv[1..])
309        .current_dir(&root)
310        .stdin(std::process::Stdio::null());
311    common::strip_git_env(&mut cmd);
312    let (ran, out) = common::capture_within(&mut cmd)?;
313    match ran {
314        common::Ran::Status(s) => Some((s.success(), out)),
315        common::Ran::TimedOut(budget) => {
316            common::say_timed_out(&argv[0], budget);
317            None
318        }
319    }
320}
321
322pub fn rust(refs: &[PushRef]) -> Outcome {
323    if common::which("cargo-audit").is_none() {
324        common::warn(
325            "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
326             the audit did NOT run",
327        );
328        return Outcome::Unavailable;
329    }
330    let argv = vec![
331        common::program("cargo"),
332        "audit".into(),
333        "--color".into(),
334        "never".into(),
335    ];
336    let Some((exit_ok, out)) = audited(&argv) else {
337        return Outcome::Unavailable;
338    };
339    conclude(
340        "audit-rust",
341        attribute(read_cargo_audit(exit_ok, &out), &out, cargo_tree_inverse),
342        releasing(refs),
343        &out,
344    )
345}
346
347/// `cargo tree -i <crate>`, or None if it could not be run. Failure here is
348/// never fatal: attribution is an improvement to a message, and an advisory
349/// reported without it is still an advisory reported.
350fn cargo_tree_inverse(krate: &str) -> Option<String> {
351    let argv = vec![
352        common::program("cargo"),
353        "tree".into(),
354        "--invert".into(),
355        krate.into(),
356        "--edges".into(),
357        "normal".into(),
358        "--color".into(),
359        "never".into(),
360    ];
361    audited(&argv).map(|(_, out)| out)
362}
363
364/// Name the crate behind each advisory, and which of this workspace's crates
365/// reach it.
366///
367/// Deliberately spawns NOTHING on a clean report, which is every run that
368/// matters: the `cargo tree` calls happen once per distinct affected crate,
369/// only when there is already something to say. A hook on the push path does
370/// not pay for a message nobody will read.
371fn attribute(report: Report, out: &str, tree: impl Fn(&str) -> Option<String>) -> Report {
372    match report {
373        Report::Advisories(ids) => Report::Advisories(described(ids, out, tree)),
374        Report::Vulnerabilities(ids) => Report::Vulnerabilities(described(ids, out, tree)),
375        // Clean and CouldNotCheck carry no ids, so there is nothing to
376        // attribute and — the part that matters — nothing to spawn.
377        other => other,
378    }
379}
380
381/// Each id, rewritten with its crate and what reaches it where both are
382/// known. One `cargo tree` per distinct crate, not per advisory: `lru`
383/// carried two advisories in the run that prompted this.
384fn described(ids: Vec<String>, out: &str, tree: impl Fn(&str) -> Option<String>) -> Vec<String> {
385    let pairs = ids_with_crates(out);
386    let mut seen: Vec<(String, Vec<String>)> = Vec::new();
387    ids.iter()
388        .map(|id| {
389            let krate = pairs
390                .iter()
391                .find(|(pid, k)| pid == id && k.is_some())
392                .and_then(|(_, k)| k.clone());
393            let Some(k) = krate else {
394                return id.clone();
395            };
396            if let Some((_, reaches)) = seen.iter().find(|(name, _)| *name == k) {
397                return describe(id, Some(&k), reaches);
398            }
399            let reaches = tree(&k).map(|t| local_dependents(&t)).unwrap_or_default();
400            seen.push((k.clone(), reaches.clone()));
401            describe(id, Some(&k), &reaches)
402        })
403        .collect()
404}
405
406pub fn js(refs: &[PushRef]) -> Outcome {
407    let argv = vec![common::program("npm"), "audit".into()];
408    let Some((exit_ok, out)) = audited(&argv) else {
409        common::warn("audit-js: npm could not run — the audit did NOT run");
410        return Outcome::Unavailable;
411    };
412    conclude(
413        "audit-js",
414        read_npm_audit(exit_ok, &out),
415        releasing(refs),
416        &out,
417    )
418}
419
420pub fn go(refs: &[PushRef]) -> Outcome {
421    if common::which("govulncheck").is_none() {
422        common::warn(
423            "audit-go: govulncheck is not installed \
424             (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
425        );
426        return Outcome::Unavailable;
427    }
428    let argv = vec![common::program("govulncheck"), "./...".into()];
429    let Some((exit_ok, out)) = audited(&argv) else {
430        return Outcome::Unavailable;
431    };
432    conclude(
433        "audit-go",
434        read_govulncheck(exit_ok, &out),
435        releasing(refs),
436        &out,
437    )
438}
439
440pub fn python(refs: &[PushRef]) -> Outcome {
441    if common::which("pip-audit").is_none() {
442        common::warn(
443            "audit-python: pip-audit is not installed (pip install pip-audit) — \
444             the audit did NOT run",
445        );
446        return Outcome::Unavailable;
447    }
448    let root = common::repo_root();
449    let argv = if std::path::Path::new(&root)
450        .join("requirements.txt")
451        .exists()
452    {
453        vec![
454            common::program("pip-audit"),
455            "-r".into(),
456            "requirements.txt".into(),
457        ]
458    } else if let Some(site_packages) = venv_site_packages(&root) {
459        // A uv/PEP-621 project has no requirements.txt, and EXPORTING one
460        // does not work either: `uv export` emits the workspace's own
461        // members and any private-index dependency, and pip-audit resolves
462        // a requirements file in a throwaway venv that can reach neither —
463        // it dies on "No matching distribution found". Auditing the
464        // INSTALLED tree resolves nothing, and is the truer question
465        // anyway: these are the versions actually imported.
466        vec![
467            common::program("pip-audit"),
468            "--path".into(),
469            site_packages,
470            // Workspace members are installed editable and are not on
471            // PyPI; without this each one is a line of noise.
472            "--skip-editable".into(),
473        ]
474    } else {
475        common::warn(
476            "audit-python: no requirements.txt, and no virtualenv to audit \
477             (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
478        );
479        return Outcome::Unavailable;
480    };
481    let Some((exit_ok, out)) = audited(&argv) else {
482        return Outcome::Unavailable;
483    };
484    conclude(
485        "audit-python",
486        read_pip_audit(exit_ok, &out),
487        releasing(refs),
488        &out,
489    )
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    /// uv projects have no `requirements.txt`, so the venv is the only thing
497    /// left to audit — and before this, `audit-python` looked for nothing
498    /// else and reported "the audit did NOT run" forever. Six repositories
499    /// in one fleet were in exactly that state, one of them carrying 53
500    /// known vulnerabilities nobody had been told about.
501    #[test]
502    fn a_uv_project_is_audited_through_its_venv() {
503        let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
504        let _ = std::fs::remove_dir_all(&root);
505
506        // Nothing on disk: nothing to audit, and we say so rather than
507        // inventing a target.
508        std::fs::create_dir_all(&root).unwrap();
509        assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
510
511        // The posix layout, with the python version DISCOVERED — hard-coding
512        // `python3.13` would silently stop finding it after an upgrade.
513        // Joined segment by segment, NOT as one "a/b/c" literal: on Windows
514        // the literal keeps its forward slashes while the code under test
515        // returns backslashes, and the test fails on a difference that is
516        // only in the expectation.
517        let sp = root
518            .join(".venv")
519            .join("lib")
520            .join("python3.13")
521            .join("site-packages");
522        std::fs::create_dir_all(&sp).unwrap();
523        assert_eq!(
524            venv_site_packages(root.to_str().unwrap()),
525            Some(sp.to_string_lossy().into_owned())
526        );
527
528        // The Windows layout, which has no version directory at all.
529        let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
530        let _ = std::fs::remove_dir_all(&win);
531        let wsp = win.join(".venv").join("Lib").join("site-packages");
532        std::fs::create_dir_all(&wsp).unwrap();
533        assert_eq!(
534            venv_site_packages(win.to_str().unwrap()),
535            Some(wsp.to_string_lossy().into_owned())
536        );
537
538        let _ = std::fs::remove_dir_all(&root);
539        let _ = std::fs::remove_dir_all(&win);
540    }
541
542    fn tag(name: &str) -> PushRef {
543        PushRef {
544            local_ref: name.to_string(),
545            local_oid: "a".repeat(40),
546            remote_ref: name.to_string(),
547            remote_oid: "0".repeat(40),
548        }
549    }
550
551    /// `v` + digit gates; a branch, a bare-word tag, or a tag merely
552    /// starting with the letter v does not.
553    #[test]
554    fn a_release_is_a_v_number_tag() {
555        assert!(releasing(&[tag("refs/tags/v1.6.6")]));
556        assert!(releasing(&[tag("refs/tags/v2")]));
557        assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
558        assert!(!releasing(&[tag("refs/tags/release")]));
559        assert!(!releasing(&[tag("refs/heads/v1-styles")]));
560        assert!(!releasing(&[tag("refs/heads/main")]));
561        // A mixed push gates: the tag is in there.
562        assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
563    }
564
565    /// ci.yaml's lesson, pinned at the unit level: the ids decide, the exit
566    /// code only classifies them.
567    #[test]
568    fn cargo_audit_ids_decide_not_the_exit_code() {
569        assert_eq!(
570            read_cargo_audit(true, "ok, 312 crates checked"),
571            Report::Clean
572        );
573        assert_eq!(
574            read_cargo_audit(false, "error: couldn't fetch advisory database"),
575            Report::CouldNotCheck
576        );
577        let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
578        assert_eq!(
579            read_cargo_audit(true, warn),
580            Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
581        );
582        let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
583        assert_eq!(
584            read_cargo_audit(false, vuln),
585            Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
586        );
587        // A lookalike is not an id.
588        assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
589    }
590
591    /// The real report shape, trimmed from the run that prompted this: two
592    /// advisories against ONE crate, and one against another.
593    fn real_report() -> String {
594        [
595            "Crate:     paste",
596            "Version:   1.0.15",
597            "Warning:   unmaintained",
598            "ID:        RUSTSEC-2024-0436",
599            "URL:       https://rustsec.org/advisories/RUSTSEC-2024-0436",
600            "",
601            "Crate:     lru",
602            "Version:   0.12.5",
603            "Warning:   unsound",
604            "ID:        RUSTSEC-2026-0253",
605            "",
606            "Crate:     lru",
607            "Version:   0.12.5",
608            "ID:        RUSTSEC-2026-0002",
609            "",
610            "warning: 3 allowed warnings found",
611        ]
612        .join("\n")
613    }
614
615    #[test]
616    fn an_advisory_is_paired_with_its_crate() {
617        let pairs = ids_with_crates(&real_report());
618        assert_eq!(
619            pairs,
620            vec![
621                ("RUSTSEC-2024-0436".into(), Some("paste".into())),
622                ("RUSTSEC-2026-0253".into(), Some("lru".into())),
623                ("RUSTSEC-2026-0002".into(), Some("lru".into())),
624            ]
625        );
626    }
627
628    /// A `Crate:` binds to ONE id. An id printed loose reports no crate
629    /// rather than inheriting whichever block happened to precede it.
630    #[test]
631    fn a_loose_id_borrows_no_crate() {
632        let out = "Crate:     paste\nID:        RUSTSEC-2024-0436\nsee also RUSTSEC-2025-0001";
633        assert_eq!(
634            ids_with_crates(out),
635            vec![
636                ("RUSTSEC-2024-0436".into(), Some("paste".into())),
637                ("RUSTSEC-2025-0001".into(), None),
638            ]
639        );
640    }
641
642    /// Local crates carry a path; registry crates do not. That is the whole
643    /// discriminator, and it has to work without knowing the member names.
644    #[test]
645    fn only_local_crates_are_named_as_reached() {
646        let tree =
647            "lru v0.12.5\n└── ratatui v0.29.0\n    └── amont-fleet v1.32.0 (/w/crates/amont-fleet)";
648        assert_eq!(local_dependents(tree), vec!["amont-fleet".to_string()]);
649        assert!(local_dependents("lru v0.12.5\n└── ratatui v0.29.0").is_empty());
650    }
651
652    /// End to end on the real report, with the tree calls faked: each id
653    /// names its crate and the workspace crate that reaches it, and `lru`'s
654    /// two advisories cost ONE lookup.
655    #[test]
656    fn the_warning_names_the_crate_and_what_reaches_it() {
657        let calls = std::cell::RefCell::new(Vec::new());
658        let fake = |k: &str| {
659            calls.borrow_mut().push(k.to_string());
660            Some(format!(
661                "{k} v1\n└── ratatui v0.29.0\n    └── amont-fleet v1.32.0 (/w/crates/amont-fleet)"
662            ))
663        };
664        let out = real_report();
665        let got = attribute(read_cargo_audit(true, &out), &out, fake);
666        assert_eq!(
667            got,
668            Report::Advisories(vec![
669                "RUSTSEC-2024-0436 (paste → amont-fleet)".into(),
670                "RUSTSEC-2026-0002 (lru → amont-fleet)".into(),
671                "RUSTSEC-2026-0253 (lru → amont-fleet)".into(),
672            ])
673        );
674        assert_eq!(
675            calls.into_inner(),
676            vec!["paste", "lru"],
677            "one call per crate"
678        );
679    }
680
681    /// A lock-file-only crate — an optional dependency of a feature nobody
682    /// enabled — is reported as such. `cargo audit` reads Cargo.lock, so this
683    /// is a real and confusing case, and naming it is the point.
684    #[test]
685    fn a_crate_outside_the_build_graph_says_so() {
686        let out = "Crate:     wezterm-input-types\nID:        RUSTSEC-2025-0001";
687        let got = attribute(read_cargo_audit(true, out), out, |_| {
688            Some("warning: nothing to print.".into())
689        });
690        assert_eq!(
691            got,
692            Report::Advisories(vec![
693                "RUSTSEC-2025-0001 (wezterm-input-types, not in the build graph)".into()
694            ])
695        );
696    }
697
698    /// Attribution is a better message, never a gate. A clean report spawns
699    /// nothing, and a `cargo tree` that cannot run still reports the id.
700    #[test]
701    fn attribution_never_changes_the_verdict() {
702        let spawned = std::cell::Cell::new(false);
703        let clean = attribute(read_cargo_audit(true, "0 vulnerabilities"), "", |_| {
704            spawned.set(true);
705            None
706        });
707        assert_eq!(clean, Report::Clean);
708        assert!(!spawned.get(), "a clean report must spawn nothing");
709
710        let out = "Crate:     paste\nID:        RUSTSEC-2024-0436";
711        let blind = attribute(read_cargo_audit(false, out), out, |_| None);
712        assert_eq!(
713            blind,
714            Report::Vulnerabilities(vec![
715                "RUSTSEC-2024-0436 (paste, not in the build graph)".into()
716            ])
717        );
718    }
719
720    #[test]
721    fn npm_audit_summary_decides() {
722        assert_eq!(
723            read_npm_audit(true, "found 0 vulnerabilities\n"),
724            Report::Clean
725        );
726        assert_eq!(
727            read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
728            Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
729        );
730        assert_eq!(
731            read_npm_audit(true, "up to date, audited 100 packages\n"),
732            Report::Clean
733        );
734        assert_eq!(
735            read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
736            Report::CouldNotCheck
737        );
738    }
739
740    /// The cargo-audit split, spoken in Go: ids decide, the exit code says
741    /// whether the analysed code is actually affected.
742    #[test]
743    fn govulncheck_ids_decide_not_the_exit_code() {
744        assert_eq!(
745            read_govulncheck(true, "No vulnerabilities found.\n"),
746            Report::Clean
747        );
748        assert_eq!(
749            read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
750            Report::CouldNotCheck
751        );
752        // Informational: the module is vulnerable, the analysed code never
753        // calls it — exit 0, ids present.
754        assert_eq!(
755            read_govulncheck(
756                true,
757                "=== Informational ===\nVulnerability #1: GO-2023-1840\n  More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
758            ),
759            Report::Advisories(vec!["GO-2023-1840".into()])
760        );
761        assert_eq!(
762            read_govulncheck(
763                false,
764                "Vulnerability #1: GO-2022-0969\n  Your code calls it.\nGO-2022-0969 again\n"
765            ),
766            Report::Vulnerabilities(vec!["GO-2022-0969".into()])
767        );
768        // A lookalike is not an id.
769        assert_eq!(
770            read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
771            Report::Clean
772        );
773    }
774
775    #[test]
776    fn pip_audit_sentence_decides() {
777        assert_eq!(
778            read_pip_audit(true, "No known vulnerabilities found\n"),
779            Report::Clean
780        );
781        assert_eq!(
782            read_pip_audit(
783                false,
784                "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
785            ),
786            Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
787        );
788        assert_eq!(
789            read_pip_audit(false, "ERROR: could not resolve\n"),
790            Report::CouldNotCheck
791        );
792    }
793}