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