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    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. A clean tree is `found 0
211/// vulnerabilities` on every npm. A finding is `found N vulnerabilities`
212/// on npm 6 and `N vulnerabilities (a moderate, b high)` — the verb dropped,
213/// `1 vulnerability` in the singular — on npm 7 and later, which is every
214/// npm shipped since 2020. No recognisable summary plus a refusal to exit
215/// clean is a tool that never answered.
216fn read_npm_audit(exit_ok: bool, out: &str) -> Report {
217    let summary = out.lines().rev().map(str::trim).find(|l| {
218        l.contains("vulnerabilit")
219            && (l.starts_with("found ")
220                || l.split_whitespace()
221                    .next()
222                    .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())))
223    });
224    match summary {
225        Some(l) if l.starts_with("found 0 ") || l.starts_with("0 ") => Report::Clean,
226        Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
227        None if exit_ok => Report::Clean,
228        None => Report::CouldNotCheck,
229    }
230}
231
232/// `pnpm audit`: the summary line decides. A clean tree is `No known
233/// vulnerabilities found`; a finding is `N vulnerabilities found` followed by
234/// `Severity: a low | b moderate | …`, which is carried along so the warning
235/// says how bad. No recognisable summary plus a refusal to exit clean is a
236/// tool that never answered (`ERR_PNPM_AUDIT_BAD_RESPONSE`, no network).
237fn read_pnpm_audit(exit_ok: bool, out: &str) -> Report {
238    let lines: Vec<&str> = out.lines().map(str::trim).collect();
239    if lines
240        .iter()
241        .any(|l| l.starts_with("No known vulnerabilities found"))
242    {
243        return Report::Clean;
244    }
245    let found = lines.iter().enumerate().rev().find(|(_, l)| {
246        let mut w = l.split_whitespace();
247        w.next()
248            .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
249            && w.next().is_some_and(|v| v.starts_with("vulnerabilit"))
250            && w.next() == Some("found")
251    });
252    match found {
253        Some((_, l)) if l.starts_with("0 ") => Report::Clean,
254        Some((i, l)) => {
255            let severity = lines
256                .get(i + 1)
257                .and_then(|n| n.strip_prefix("Severity:"))
258                .map(str::trim);
259            Report::Vulnerabilities(vec![match severity {
260                Some(sev) => format!("{l} ({sev})"),
261                None => l.to_string(),
262            }])
263        }
264        None if exit_ok => Report::Clean,
265        None => Report::CouldNotCheck,
266    }
267}
268
269/// `govulncheck`: the GO- ids decide, the exit code classifies them — the
270/// same split cargo-audit taught. The tool exits non-zero only when the
271/// analysed CODE is affected; ids with a clean exit are the informational
272/// section (vulnerable modules whose functions are never called), which is
273/// advisory-grade. No ids plus a refusal to exit clean is a tool that never
274/// answered (no network, no vulnerability database).
275fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
276    let mut ids: Vec<String> = out
277        .split_whitespace()
278        .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
279        .filter(|w| {
280            w.len() >= 12
281                && w.starts_with("GO-")
282                && w[3..7].bytes().all(|b| b.is_ascii_digit())
283                && w.as_bytes()[7] == b'-'
284                && w[8..].bytes().all(|b| b.is_ascii_digit())
285        })
286        .map(|w| w.to_string())
287        .collect();
288    ids.sort();
289    ids.dedup();
290    match (ids.is_empty(), exit_ok) {
291        (true, true) => Report::Clean,
292        (true, false) => Report::CouldNotCheck,
293        (false, true) => Report::Advisories(ids),
294        (false, false) => Report::Vulnerabilities(ids),
295    }
296}
297
298/// `pip-audit`: its own closing sentence decides.
299fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
300    if out.contains("No known vulnerabilities found") {
301        return Report::Clean;
302    }
303    if let Some(line) = out
304        .lines()
305        .map(str::trim)
306        .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
307    {
308        return Report::Vulnerabilities(vec![line.to_string()]);
309    }
310    if exit_ok {
311        Report::Clean
312    } else {
313        Report::CouldNotCheck
314    }
315}
316
317/// The `site-packages` of the environment this project actually uses, if
318/// one is on disk.
319///
320/// `$VIRTUAL_ENV` first — an activated environment is the one whose imports
321/// are live — then the `.venv` uv and PEP 668 tooling create by convention.
322/// Layout differs by platform: `lib/python3.13/site-packages` everywhere
323/// except Windows, which uses `Lib/site-packages`, so the python-version
324/// directory is discovered rather than guessed.
325fn venv_site_packages(root: &str) -> Option<String> {
326    let candidates = std::env::var_os("VIRTUAL_ENV")
327        .map(std::path::PathBuf::from)
328        .into_iter()
329        .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
330    for venv in candidates {
331        let windows = venv.join("Lib").join("site-packages");
332        if windows.is_dir() {
333            return Some(windows.to_string_lossy().into_owned());
334        }
335        let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
336            continue;
337        };
338        for e in entries.flatten() {
339            if !e.file_name().to_string_lossy().starts_with("python") {
340                continue;
341            }
342            let sp = e.path().join("site-packages");
343            if sp.is_dir() {
344                return Some(sp.to_string_lossy().into_owned());
345            }
346        }
347    }
348    None
349}
350
351/// Run one audit tool from the repo root and read its answer.
352fn audited(settings: &crate::config::Settings, argv: &[String]) -> Option<(bool, String)> {
353    audited_in(settings, argv, std::path::Path::new(&common::repo_root()))
354}
355
356/// Run one audit tool from `dir` and read its answer.
357fn audited_in(
358    settings: &crate::config::Settings,
359    argv: &[String],
360    dir: &std::path::Path,
361) -> Option<(bool, String)> {
362    let mut cmd = std::process::Command::new(&argv[0]);
363    cmd.args(&argv[1..])
364        .current_dir(dir)
365        .stdin(std::process::Stdio::null());
366    common::strip_git_env(&mut cmd);
367    let (ran, out) = common::capture_within(settings, &mut cmd)?;
368    match ran {
369        common::Ran::Status(s) => Some((s.success(), out)),
370        common::Ran::TimedOut(budget) => {
371            common::say_timed_out(&argv[0], budget);
372            None
373        }
374    }
375}
376
377pub fn rust(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
378    if !has_lockfile("Cargo.lock") {
379        return Outcome::Inert;
380    }
381    if common::which("cargo-audit").is_none() {
382        common::warn(
383            "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
384             the audit did NOT run",
385        );
386        return Outcome::Unavailable;
387    }
388    let argv = vec![
389        common::program("cargo"),
390        "audit".into(),
391        "--color".into(),
392        "never".into(),
393    ];
394    let Some((exit_ok, out)) = audited(settings, &argv) else {
395        return Outcome::Unavailable;
396    };
397    conclude(
398        settings,
399        "audit-rust",
400        attribute(read_cargo_audit(exit_ok, &out), &out, |krate| {
401            cargo_tree_inverse(settings, krate)
402        }),
403        releasing(refs),
404        &out,
405    )
406}
407
408/// `cargo tree -i <crate>`, or None if it could not be run. Failure here is
409/// never fatal: attribution is an improvement to a message, and an advisory
410/// reported without it is still an advisory reported.
411fn cargo_tree_inverse(settings: &crate::config::Settings, krate: &str) -> Option<String> {
412    let argv = vec![
413        common::program("cargo"),
414        "tree".into(),
415        "--invert".into(),
416        krate.into(),
417        "--edges".into(),
418        "normal".into(),
419        "--color".into(),
420        "never".into(),
421    ];
422    audited(settings, &argv).map(|(_, out)| out)
423}
424
425/// Name the crate behind each advisory, and which of this workspace's crates
426/// reach it.
427///
428/// Deliberately spawns NOTHING on a clean report, which is every run that
429/// matters: the `cargo tree` calls happen once per distinct affected crate,
430/// only when there is already something to say. A hook on the push path does
431/// not pay for a message nobody will read.
432fn attribute(report: Report, out: &str, tree: impl Fn(&str) -> Option<String>) -> Report {
433    match report {
434        Report::Advisories(ids) => Report::Advisories(described(ids, out, tree)),
435        Report::Vulnerabilities(ids) => Report::Vulnerabilities(described(ids, out, tree)),
436        // Clean and CouldNotCheck carry no ids, so there is nothing to
437        // attribute and — the part that matters — nothing to spawn.
438        other => other,
439    }
440}
441
442/// Each id, rewritten with its crate and what reaches it where both are
443/// known. One `cargo tree` per distinct crate, not per advisory: `lru`
444/// carried two advisories in the run that prompted this.
445fn described(ids: Vec<String>, out: &str, tree: impl Fn(&str) -> Option<String>) -> Vec<String> {
446    let pairs = ids_with_crates(out);
447    let mut seen: Vec<(String, Vec<String>)> = Vec::new();
448    ids.iter()
449        .map(|id| {
450            let krate = pairs
451                .iter()
452                .find(|(pid, k)| pid == id && k.is_some())
453                .and_then(|(_, k)| k.clone());
454            let Some(k) = krate else {
455                return id.clone();
456            };
457            if let Some((_, reaches)) = seen.iter().find(|(name, _)| *name == k) {
458                return describe(id, Some(&k), reaches);
459            }
460            let reaches = tree(&k).map(|t| local_dependents(&t)).unwrap_or_default();
461            seen.push((k.clone(), reaches.clone()));
462            describe(id, Some(&k), &reaches)
463        })
464        .collect()
465}
466
467/// Does the repository carry `lockfile` anywhere in its index? An audit
468/// without a resolved tree audits a guess — and one asked about a
469/// repository in another language has nothing to audit at all. That is
470/// `Inert`, not "could not run": the dispatcher asks the same question
471/// from the registry's scope, and this is the answer for a check invoked
472/// by name.
473fn has_lockfile(lockfile: &str) -> bool {
474    crate::tracked_paths()
475        .iter()
476        .any(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
477}
478
479/// The directories holding a tracked `lockfile`, repo-relative ("" for the
480/// root), sorted. npm resolves a project from its own directory, so a
481/// repository whose packages live in subdirectories (`web/`, `mcp/`) has no
482/// lockfile at the root at all.
483fn lockfile_dirs(lockfile: &str) -> Vec<String> {
484    let mut dirs: Vec<String> = crate::tracked_paths()
485        .iter()
486        .filter(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
487        .map(|p| {
488            p.rsplit_once('/')
489                .map(|(d, _)| d.to_string())
490                .unwrap_or_default()
491        })
492        .collect();
493    dirs.sort();
494    dirs.dedup();
495    dirs
496}
497
498/// One JS package manager `audit-js` knows how to ask: the lockfile that
499/// opts a directory in, the tool that reads it, and how to read its answer.
500struct JsAuditor {
501    lockfile: &'static str,
502    tool: &'static str,
503    read: fn(bool, &str) -> Report,
504    /// Extra arguments for a project in `dir`.
505    args: fn(&str) -> Vec<String>,
506}
507
508/// pnpm answers for the WORKSPACE it finds above a directory, not for the
509/// directory: a standalone project with its own pnpm-lock.yaml inside a
510/// workspace (a spike, an example) was reported with the workspace root's
511/// findings, all of them, under its own name. `--ignore-workspace` makes it
512/// read the lockfile it stands next to — except at a workspace's own root,
513/// where the workspace IS the project and every member must be audited.
514fn pnpm_args(dir: &str) -> Vec<String> {
515    let manifest = if dir.is_empty() {
516        "pnpm-workspace.yaml".to_string()
517    } else {
518        format!("{dir}/pnpm-workspace.yaml")
519    };
520    if crate::tracked_paths().contains(&manifest) {
521        vec![]
522    } else {
523        vec!["--ignore-workspace".into()]
524    }
525}
526
527const JS_AUDITORS: [JsAuditor; 2] = [
528    JsAuditor {
529        lockfile: "package-lock.json",
530        tool: "npm",
531        read: read_npm_audit,
532        args: |_| vec![],
533    },
534    JsAuditor {
535        lockfile: "pnpm-lock.yaml",
536        tool: "pnpm",
537        read: read_pnpm_audit,
538        args: pnpm_args,
539    },
540];
541
542/// `npm audit` in every directory that tracks a `package-lock.json`, and
543/// `pnpm audit` in every directory that tracks a `pnpm-lock.yaml`.
544///
545/// It used to run once at the repository root, where a repository whose
546/// packages live in subdirectories has no lockfile: npm answered ENOLOCK,
547/// the check said "could not complete", and a tree with 17 known
548/// vulnerabilities (9 high) went unaudited release after release. Each
549/// project is audited on its own; any finding counts, labelled with its
550/// directory, and a project the tool could not answer for keeps the whole
551/// result from reading clean.
552///
553/// And it only ever knew npm: a pnpm workspace has no `package-lock.json`,
554/// so its whole tree — 28 vulnerable versions, two critical, in one
555/// repository — was never audited and nothing said so. pnpm audits its own
556/// lockfile, from the lockfile alone, the same way.
557pub fn js(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
558    let projects: Vec<(&JsAuditor, String)> = JS_AUDITORS
559        .iter()
560        .flat_map(|a| lockfile_dirs(a.lockfile).into_iter().map(move |d| (a, d)))
561        .collect();
562    if projects.is_empty() {
563        return Outcome::Inert;
564    }
565    let root = std::path::PathBuf::from(common::repo_root());
566    let mut found = Vec::new();
567    let mut unchecked = Vec::new();
568    let mut full = String::new();
569    for (auditor, dir) in &projects {
570        let label = if dir.is_empty() {
571            ".".to_string()
572        } else {
573            dir.clone()
574        };
575        let mut argv = vec![common::program(auditor.tool), "audit".into()];
576        argv.extend((auditor.args)(dir));
577        let Some((exit_ok, out)) = audited_in(settings, &argv, &root.join(dir)) else {
578            common::warn(&format!(
579                "audit-js: {} could not run in {label} — the audit did NOT run",
580                auditor.tool
581            ));
582            return Outcome::Unavailable;
583        };
584        match (auditor.read)(exit_ok, &out) {
585            Report::Vulnerabilities(what) => {
586                found.extend(what.into_iter().map(|w| format!("{label}: {w}")));
587                full.push_str(&format!("── {} audit in {label}\n{out}\n", auditor.tool));
588            }
589            Report::CouldNotCheck => unchecked.push(format!("{label} ({})", auditor.tool)),
590            Report::Clean | Report::Advisories(_) => {}
591        }
592    }
593    if !unchecked.is_empty() {
594        common::warn(&format!(
595            "audit-js: the audit could not answer in {} — those projects were NOT checked",
596            unchecked.join(", ")
597        ));
598    }
599    let report = if !found.is_empty() {
600        Report::Vulnerabilities(found)
601    } else if !unchecked.is_empty() {
602        Report::CouldNotCheck
603    } else {
604        Report::Clean
605    };
606    conclude(settings, "audit-js", report, releasing(refs), &full)
607}
608
609pub fn go(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
610    if !has_lockfile("go.sum") {
611        return Outcome::Inert;
612    }
613    if common::which("govulncheck").is_none() {
614        common::warn(
615            "audit-go: govulncheck is not installed \
616             (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
617        );
618        return Outcome::Unavailable;
619    }
620    let argv = vec![common::program("govulncheck"), "./...".into()];
621    let Some((exit_ok, out)) = audited(settings, &argv) else {
622        return Outcome::Unavailable;
623    };
624    conclude(
625        settings,
626        "audit-go",
627        read_govulncheck(exit_ok, &out),
628        releasing(refs),
629        &out,
630    )
631}
632
633pub fn python(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
634    if !has_lockfile("requirements.txt") && !has_lockfile("pyproject.toml") {
635        return Outcome::Inert;
636    }
637    if common::which("pip-audit").is_none() {
638        common::warn(
639            "audit-python: pip-audit is not installed (pip install pip-audit) — \
640             the audit did NOT run",
641        );
642        return Outcome::Unavailable;
643    }
644    let root = common::repo_root();
645    let argv = if std::path::Path::new(&root)
646        .join("requirements.txt")
647        .exists()
648    {
649        vec![
650            common::program("pip-audit"),
651            "-r".into(),
652            "requirements.txt".into(),
653        ]
654    } else if let Some(site_packages) = venv_site_packages(&root) {
655        // A uv/PEP-621 project has no requirements.txt, and EXPORTING one
656        // does not work either: `uv export` emits the workspace's own
657        // members and any private-index dependency, and pip-audit resolves
658        // a requirements file in a throwaway venv that can reach neither —
659        // it dies on "No matching distribution found". Auditing the
660        // INSTALLED tree resolves nothing, and is the truer question
661        // anyway: these are the versions actually imported.
662        vec![
663            common::program("pip-audit"),
664            "--path".into(),
665            site_packages,
666            // Workspace members are installed editable and are not on
667            // PyPI; without this each one is a line of noise.
668            "--skip-editable".into(),
669        ]
670    } else {
671        common::warn(
672            "audit-python: no requirements.txt, and no virtualenv to audit \
673             (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
674        );
675        return Outcome::Unavailable;
676    };
677    let Some((exit_ok, out)) = audited(settings, &argv) else {
678        return Outcome::Unavailable;
679    };
680    conclude(
681        settings,
682        "audit-python",
683        read_pip_audit(exit_ok, &out),
684        releasing(refs),
685        &out,
686    )
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    /// uv projects have no `requirements.txt`, so the venv is the only thing
694    /// left to audit — and before this, `audit-python` looked for nothing
695    /// else and reported "the audit did NOT run" forever. Six repositories
696    /// in one fleet were in exactly that state, one of them carrying 53
697    /// known vulnerabilities nobody had been told about.
698    #[test]
699    fn a_uv_project_is_audited_through_its_venv() {
700        let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
701        let _ = std::fs::remove_dir_all(&root);
702
703        // Nothing on disk: nothing to audit, and we say so rather than
704        // inventing a target.
705        std::fs::create_dir_all(&root).unwrap();
706        assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
707
708        // The posix layout, with the python version DISCOVERED — hard-coding
709        // `python3.13` would silently stop finding it after an upgrade.
710        // Joined segment by segment, NOT as one "a/b/c" literal: on Windows
711        // the literal keeps its forward slashes while the code under test
712        // returns backslashes, and the test fails on a difference that is
713        // only in the expectation.
714        let sp = root
715            .join(".venv")
716            .join("lib")
717            .join("python3.13")
718            .join("site-packages");
719        std::fs::create_dir_all(&sp).unwrap();
720        assert_eq!(
721            venv_site_packages(root.to_str().unwrap()),
722            Some(sp.to_string_lossy().into_owned())
723        );
724
725        // The Windows layout, which has no version directory at all.
726        let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
727        let _ = std::fs::remove_dir_all(&win);
728        let wsp = win.join(".venv").join("Lib").join("site-packages");
729        std::fs::create_dir_all(&wsp).unwrap();
730        assert_eq!(
731            venv_site_packages(win.to_str().unwrap()),
732            Some(wsp.to_string_lossy().into_owned())
733        );
734
735        let _ = std::fs::remove_dir_all(&root);
736        let _ = std::fs::remove_dir_all(&win);
737    }
738
739    fn tag(name: &str) -> PushRef {
740        PushRef {
741            local_ref: name.to_string(),
742            local_oid: "a".repeat(40),
743            remote_ref: name.to_string(),
744            remote_oid: "0".repeat(40),
745        }
746    }
747
748    /// `v` + digit gates; a branch, a bare-word tag, or a tag merely
749    /// starting with the letter v does not.
750    #[test]
751    fn a_release_is_a_v_number_tag() {
752        assert!(releasing(&[tag("refs/tags/v1.6.6")]));
753        assert!(releasing(&[tag("refs/tags/v2")]));
754        assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
755        assert!(!releasing(&[tag("refs/tags/release")]));
756        assert!(!releasing(&[tag("refs/heads/v1-styles")]));
757        assert!(!releasing(&[tag("refs/heads/main")]));
758        // A mixed push gates: the tag is in there.
759        assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
760    }
761
762    /// ci.yaml's lesson, pinned at the unit level: the ids decide, the exit
763    /// code only classifies them.
764    #[test]
765    fn cargo_audit_ids_decide_not_the_exit_code() {
766        assert_eq!(
767            read_cargo_audit(true, "ok, 312 crates checked"),
768            Report::Clean
769        );
770        assert_eq!(
771            read_cargo_audit(false, "error: couldn't fetch advisory database"),
772            Report::CouldNotCheck
773        );
774        let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
775        assert_eq!(
776            read_cargo_audit(true, warn),
777            Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
778        );
779        let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
780        assert_eq!(
781            read_cargo_audit(false, vuln),
782            Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
783        );
784        // A lookalike is not an id.
785        assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
786    }
787
788    /// The real report shape, trimmed from the run that prompted this: two
789    /// advisories against ONE crate, and one against another.
790    fn real_report() -> String {
791        [
792            "Crate:     paste",
793            "Version:   1.0.15",
794            "Warning:   unmaintained",
795            "ID:        RUSTSEC-2024-0436",
796            "URL:       https://rustsec.org/advisories/RUSTSEC-2024-0436",
797            "",
798            "Crate:     lru",
799            "Version:   0.12.5",
800            "Warning:   unsound",
801            "ID:        RUSTSEC-2026-0253",
802            "",
803            "Crate:     lru",
804            "Version:   0.12.5",
805            "ID:        RUSTSEC-2026-0002",
806            "",
807            "warning: 3 allowed warnings found",
808        ]
809        .join("\n")
810    }
811
812    #[test]
813    fn an_advisory_is_paired_with_its_crate() {
814        let pairs = ids_with_crates(&real_report());
815        assert_eq!(
816            pairs,
817            vec![
818                ("RUSTSEC-2024-0436".into(), Some("paste".into())),
819                ("RUSTSEC-2026-0253".into(), Some("lru".into())),
820                ("RUSTSEC-2026-0002".into(), Some("lru".into())),
821            ]
822        );
823    }
824
825    /// A `Crate:` binds to ONE id. An id printed loose reports no crate
826    /// rather than inheriting whichever block happened to precede it.
827    #[test]
828    fn a_loose_id_borrows_no_crate() {
829        let out = "Crate:     paste\nID:        RUSTSEC-2024-0436\nsee also RUSTSEC-2025-0001";
830        assert_eq!(
831            ids_with_crates(out),
832            vec![
833                ("RUSTSEC-2024-0436".into(), Some("paste".into())),
834                ("RUSTSEC-2025-0001".into(), None),
835            ]
836        );
837    }
838
839    /// Local crates carry a path; registry crates do not. That is the whole
840    /// discriminator, and it has to work without knowing the member names.
841    #[test]
842    fn only_local_crates_are_named_as_reached() {
843        let tree =
844            "lru v0.12.5\n└── ratatui v0.29.0\n    └── amont-fleet v1.32.0 (/w/crates/amont-fleet)";
845        assert_eq!(local_dependents(tree), vec!["amont-fleet".to_string()]);
846        assert!(local_dependents("lru v0.12.5\n└── ratatui v0.29.0").is_empty());
847    }
848
849    /// End to end on the real report, with the tree calls faked: each id
850    /// names its crate and the workspace crate that reaches it, and `lru`'s
851    /// two advisories cost ONE lookup.
852    #[test]
853    fn the_warning_names_the_crate_and_what_reaches_it() {
854        let calls = std::cell::RefCell::new(Vec::new());
855        let fake = |k: &str| {
856            calls.borrow_mut().push(k.to_string());
857            Some(format!(
858                "{k} v1\n└── ratatui v0.29.0\n    └── amont-fleet v1.32.0 (/w/crates/amont-fleet)"
859            ))
860        };
861        let out = real_report();
862        let got = attribute(read_cargo_audit(true, &out), &out, fake);
863        assert_eq!(
864            got,
865            Report::Advisories(vec![
866                "RUSTSEC-2024-0436 (paste → amont-fleet)".into(),
867                "RUSTSEC-2026-0002 (lru → amont-fleet)".into(),
868                "RUSTSEC-2026-0253 (lru → amont-fleet)".into(),
869            ])
870        );
871        assert_eq!(
872            calls.into_inner(),
873            vec!["paste", "lru"],
874            "one call per crate"
875        );
876    }
877
878    /// A lock-file-only crate — an optional dependency of a feature nobody
879    /// enabled — is reported as such. `cargo audit` reads Cargo.lock, so this
880    /// is a real and confusing case, and naming it is the point.
881    #[test]
882    fn a_crate_outside_the_build_graph_says_so() {
883        let out = "Crate:     wezterm-input-types\nID:        RUSTSEC-2025-0001";
884        let got = attribute(read_cargo_audit(true, out), out, |_| {
885            Some("warning: nothing to print.".into())
886        });
887        assert_eq!(
888            got,
889            Report::Advisories(vec![
890                "RUSTSEC-2025-0001 (wezterm-input-types, not in the build graph)".into()
891            ])
892        );
893    }
894
895    /// Attribution is a better message, never a gate. A clean report spawns
896    /// nothing, and a `cargo tree` that cannot run still reports the id.
897    #[test]
898    fn attribution_never_changes_the_verdict() {
899        let spawned = std::cell::Cell::new(false);
900        let clean = attribute(read_cargo_audit(true, "0 vulnerabilities"), "", |_| {
901            spawned.set(true);
902            None
903        });
904        assert_eq!(clean, Report::Clean);
905        assert!(!spawned.get(), "a clean report must spawn nothing");
906
907        let out = "Crate:     paste\nID:        RUSTSEC-2024-0436";
908        let blind = attribute(read_cargo_audit(false, out), out, |_| None);
909        assert_eq!(
910            blind,
911            Report::Vulnerabilities(vec![
912                "RUSTSEC-2024-0436 (paste, not in the build graph)".into()
913            ])
914        );
915    }
916
917    #[test]
918    fn npm_audit_summary_decides() {
919        assert_eq!(
920            read_npm_audit(true, "found 0 vulnerabilities\n"),
921            Report::Clean
922        );
923        assert_eq!(
924            read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
925            Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
926        );
927        // npm 7+ dropped the verb: this is what every current npm prints,
928        // and what read as "never answered" until the parser learned it.
929        assert_eq!(
930            read_npm_audit(
931                false,
932                "# npm audit report\n\nvite  6.0.0 - 6.1.5\nSeverity: high\n\n\
933                 17 vulnerabilities (8 moderate, 9 high)\n\nTo address all issues, run:\n  npm audit fix\n"
934            ),
935            Report::Vulnerabilities(vec!["17 vulnerabilities (8 moderate, 9 high)".into()])
936        );
937        assert_eq!(
938            read_npm_audit(false, "1 vulnerability (1 high)\n"),
939            Report::Vulnerabilities(vec!["1 vulnerability (1 high)".into()])
940        );
941        assert_eq!(
942            read_npm_audit(true, "up to date, audited 100 packages\n"),
943            Report::Clean
944        );
945        assert_eq!(
946            read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
947            Report::CouldNotCheck
948        );
949    }
950
951    #[test]
952    fn pnpm_audit_summary_decides() {
953        assert_eq!(
954            read_pnpm_audit(true, "No known vulnerabilities found\n"),
955            Report::Clean
956        );
957        // What pnpm prints after its table: the count, then the severities,
958        // which the finding carries so the warning says how bad.
959        assert_eq!(
960            read_pnpm_audit(
961                false,
962                "│ More info │ https://github.com/advisories/GHSA-395f │\n\
963                 └───────────┴──────────────────────────────────────────┘\n\
964                 107 vulnerabilities found\n\
965                 Severity: 6 low | 45 moderate | 54 high | 2 critical\n"
966            ),
967            Report::Vulnerabilities(vec![
968                "107 vulnerabilities found (6 low | 45 moderate | 54 high | 2 critical)".into()
969            ])
970        );
971        assert_eq!(
972            read_pnpm_audit(false, "1 vulnerabilities found\n"),
973            Report::Vulnerabilities(vec!["1 vulnerabilities found".into()])
974        );
975        assert_eq!(
976            read_pnpm_audit(
977                false,
978                " ERR_PNPM_AUDIT_BAD_RESPONSE  The audit endpoint responded with 503\n"
979            ),
980            Report::CouldNotCheck
981        );
982    }
983
984    /// The cargo-audit split, spoken in Go: ids decide, the exit code says
985    /// whether the analysed code is actually affected.
986    #[test]
987    fn govulncheck_ids_decide_not_the_exit_code() {
988        assert_eq!(
989            read_govulncheck(true, "No vulnerabilities found.\n"),
990            Report::Clean
991        );
992        assert_eq!(
993            read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
994            Report::CouldNotCheck
995        );
996        // Informational: the module is vulnerable, the analysed code never
997        // calls it — exit 0, ids present.
998        assert_eq!(
999            read_govulncheck(
1000                true,
1001                "=== Informational ===\nVulnerability #1: GO-2023-1840\n  More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
1002            ),
1003            Report::Advisories(vec!["GO-2023-1840".into()])
1004        );
1005        assert_eq!(
1006            read_govulncheck(
1007                false,
1008                "Vulnerability #1: GO-2022-0969\n  Your code calls it.\nGO-2022-0969 again\n"
1009            ),
1010            Report::Vulnerabilities(vec!["GO-2022-0969".into()])
1011        );
1012        // A lookalike is not an id.
1013        assert_eq!(
1014            read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
1015            Report::Clean
1016        );
1017    }
1018
1019    #[test]
1020    fn pip_audit_sentence_decides() {
1021        assert_eq!(
1022            read_pip_audit(true, "No known vulnerabilities found\n"),
1023            Report::Clean
1024        );
1025        assert_eq!(
1026            read_pip_audit(
1027                false,
1028                "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
1029            ),
1030            Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
1031        );
1032        assert_eq!(
1033            read_pip_audit(false, "ERROR: could not resolve\n"),
1034            Report::CouldNotCheck
1035        );
1036    }
1037}