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/// `cargo audit`, ci.yaml's rules verbatim: the RUSTSEC ids decide, the
102/// exit code only says which class they are.
103fn read_cargo_audit(exit_ok: bool, out: &str) -> Report {
104    let mut ids: Vec<String> = out
105        .split_whitespace()
106        .filter(|w| {
107            w.len() == 17
108                && w.starts_with("RUSTSEC-")
109                && w[8..12].bytes().all(|b| b.is_ascii_digit())
110                && w.as_bytes()[12] == b'-'
111                && w[13..17].bytes().all(|b| b.is_ascii_digit())
112        })
113        .map(|w| w.to_string())
114        .collect();
115    ids.sort();
116    ids.dedup();
117    match (ids.is_empty(), exit_ok) {
118        (true, true) => Report::Clean,
119        (true, false) => Report::CouldNotCheck,
120        (false, true) => Report::Advisories(ids),
121        (false, false) => Report::Vulnerabilities(ids),
122    }
123}
124
125/// `npm audit`: the summary line decides. `found 0 vulnerabilities` is
126/// clean; `found N vulnerabilities` (npm appends the severity split) is
127/// the finding; no recognisable summary plus a refusal to exit clean is a
128/// tool that never answered.
129fn read_npm_audit(exit_ok: bool, out: &str) -> Report {
130    let summary = out
131        .lines()
132        .rev()
133        .map(str::trim)
134        .find(|l| l.starts_with("found ") && l.contains("vulnerabilit"));
135    match summary {
136        Some(l) if l.starts_with("found 0 ") => Report::Clean,
137        Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
138        None if exit_ok => Report::Clean,
139        None => Report::CouldNotCheck,
140    }
141}
142
143/// `govulncheck`: the GO- ids decide, the exit code classifies them — the
144/// same split cargo-audit taught. The tool exits non-zero only when the
145/// analysed CODE is affected; ids with a clean exit are the informational
146/// section (vulnerable modules whose functions are never called), which is
147/// advisory-grade. No ids plus a refusal to exit clean is a tool that never
148/// answered (no network, no vulnerability database).
149fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
150    let mut ids: Vec<String> = out
151        .split_whitespace()
152        .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
153        .filter(|w| {
154            w.len() >= 12
155                && w.starts_with("GO-")
156                && w[3..7].bytes().all(|b| b.is_ascii_digit())
157                && w.as_bytes()[7] == b'-'
158                && w[8..].bytes().all(|b| b.is_ascii_digit())
159        })
160        .map(|w| w.to_string())
161        .collect();
162    ids.sort();
163    ids.dedup();
164    match (ids.is_empty(), exit_ok) {
165        (true, true) => Report::Clean,
166        (true, false) => Report::CouldNotCheck,
167        (false, true) => Report::Advisories(ids),
168        (false, false) => Report::Vulnerabilities(ids),
169    }
170}
171
172/// `pip-audit`: its own closing sentence decides.
173fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
174    if out.contains("No known vulnerabilities found") {
175        return Report::Clean;
176    }
177    if let Some(line) = out
178        .lines()
179        .map(str::trim)
180        .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
181    {
182        return Report::Vulnerabilities(vec![line.to_string()]);
183    }
184    if exit_ok {
185        Report::Clean
186    } else {
187        Report::CouldNotCheck
188    }
189}
190
191/// The `site-packages` of the environment this project actually uses, if
192/// one is on disk.
193///
194/// `$VIRTUAL_ENV` first — an activated environment is the one whose imports
195/// are live — then the `.venv` uv and PEP 668 tooling create by convention.
196/// Layout differs by platform: `lib/python3.13/site-packages` everywhere
197/// except Windows, which uses `Lib/site-packages`, so the python-version
198/// directory is discovered rather than guessed.
199fn venv_site_packages(root: &str) -> Option<String> {
200    let candidates = std::env::var_os("VIRTUAL_ENV")
201        .map(std::path::PathBuf::from)
202        .into_iter()
203        .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
204    for venv in candidates {
205        let windows = venv.join("Lib").join("site-packages");
206        if windows.is_dir() {
207            return Some(windows.to_string_lossy().into_owned());
208        }
209        let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
210            continue;
211        };
212        for e in entries.flatten() {
213            if !e.file_name().to_string_lossy().starts_with("python") {
214                continue;
215            }
216            let sp = e.path().join("site-packages");
217            if sp.is_dir() {
218                return Some(sp.to_string_lossy().into_owned());
219            }
220        }
221    }
222    None
223}
224
225/// Run one audit tool from the repo root and read its answer.
226fn audited(argv: &[String]) -> Option<(bool, String)> {
227    let root = common::repo_root();
228    let mut cmd = std::process::Command::new(&argv[0]);
229    cmd.args(&argv[1..])
230        .current_dir(&root)
231        .stdin(std::process::Stdio::null());
232    common::strip_git_env(&mut cmd);
233    let (ran, out) = common::capture_within(&mut cmd)?;
234    match ran {
235        common::Ran::Status(s) => Some((s.success(), out)),
236        common::Ran::TimedOut(budget) => {
237            common::say_timed_out(&argv[0], budget);
238            None
239        }
240    }
241}
242
243pub fn rust(refs: &[PushRef]) -> Outcome {
244    if common::which("cargo-audit").is_none() {
245        common::warn(
246            "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
247             the audit did NOT run",
248        );
249        return Outcome::Unavailable;
250    }
251    let argv = vec![
252        common::program("cargo"),
253        "audit".into(),
254        "--color".into(),
255        "never".into(),
256    ];
257    let Some((exit_ok, out)) = audited(&argv) else {
258        return Outcome::Unavailable;
259    };
260    conclude(
261        "audit-rust",
262        read_cargo_audit(exit_ok, &out),
263        releasing(refs),
264        &out,
265    )
266}
267
268pub fn js(refs: &[PushRef]) -> Outcome {
269    let argv = vec![common::program("npm"), "audit".into()];
270    let Some((exit_ok, out)) = audited(&argv) else {
271        common::warn("audit-js: npm could not run — the audit did NOT run");
272        return Outcome::Unavailable;
273    };
274    conclude(
275        "audit-js",
276        read_npm_audit(exit_ok, &out),
277        releasing(refs),
278        &out,
279    )
280}
281
282pub fn go(refs: &[PushRef]) -> Outcome {
283    if common::which("govulncheck").is_none() {
284        common::warn(
285            "audit-go: govulncheck is not installed \
286             (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
287        );
288        return Outcome::Unavailable;
289    }
290    let argv = vec![common::program("govulncheck"), "./...".into()];
291    let Some((exit_ok, out)) = audited(&argv) else {
292        return Outcome::Unavailable;
293    };
294    conclude(
295        "audit-go",
296        read_govulncheck(exit_ok, &out),
297        releasing(refs),
298        &out,
299    )
300}
301
302pub fn python(refs: &[PushRef]) -> Outcome {
303    if common::which("pip-audit").is_none() {
304        common::warn(
305            "audit-python: pip-audit is not installed (pip install pip-audit) — \
306             the audit did NOT run",
307        );
308        return Outcome::Unavailable;
309    }
310    let root = common::repo_root();
311    let argv = if std::path::Path::new(&root)
312        .join("requirements.txt")
313        .exists()
314    {
315        vec![
316            common::program("pip-audit"),
317            "-r".into(),
318            "requirements.txt".into(),
319        ]
320    } else if let Some(site_packages) = venv_site_packages(&root) {
321        // A uv/PEP-621 project has no requirements.txt, and EXPORTING one
322        // does not work either: `uv export` emits the workspace's own
323        // members and any private-index dependency, and pip-audit resolves
324        // a requirements file in a throwaway venv that can reach neither —
325        // it dies on "No matching distribution found". Auditing the
326        // INSTALLED tree resolves nothing, and is the truer question
327        // anyway: these are the versions actually imported.
328        vec![
329            common::program("pip-audit"),
330            "--path".into(),
331            site_packages,
332            // Workspace members are installed editable and are not on
333            // PyPI; without this each one is a line of noise.
334            "--skip-editable".into(),
335        ]
336    } else {
337        common::warn(
338            "audit-python: no requirements.txt, and no virtualenv to audit \
339             (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
340        );
341        return Outcome::Unavailable;
342    };
343    let Some((exit_ok, out)) = audited(&argv) else {
344        return Outcome::Unavailable;
345    };
346    conclude(
347        "audit-python",
348        read_pip_audit(exit_ok, &out),
349        releasing(refs),
350        &out,
351    )
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    /// uv projects have no `requirements.txt`, so the venv is the only thing
359    /// left to audit — and before this, `audit-python` looked for nothing
360    /// else and reported "the audit did NOT run" forever. Six repositories
361    /// in one fleet were in exactly that state, one of them carrying 53
362    /// known vulnerabilities nobody had been told about.
363    #[test]
364    fn a_uv_project_is_audited_through_its_venv() {
365        let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
366        let _ = std::fs::remove_dir_all(&root);
367
368        // Nothing on disk: nothing to audit, and we say so rather than
369        // inventing a target.
370        std::fs::create_dir_all(&root).unwrap();
371        assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
372
373        // The posix layout, with the python version DISCOVERED — hard-coding
374        // `python3.13` would silently stop finding it after an upgrade.
375        // Joined segment by segment, NOT as one "a/b/c" literal: on Windows
376        // the literal keeps its forward slashes while the code under test
377        // returns backslashes, and the test fails on a difference that is
378        // only in the expectation.
379        let sp = root
380            .join(".venv")
381            .join("lib")
382            .join("python3.13")
383            .join("site-packages");
384        std::fs::create_dir_all(&sp).unwrap();
385        assert_eq!(
386            venv_site_packages(root.to_str().unwrap()),
387            Some(sp.to_string_lossy().into_owned())
388        );
389
390        // The Windows layout, which has no version directory at all.
391        let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
392        let _ = std::fs::remove_dir_all(&win);
393        let wsp = win.join(".venv").join("Lib").join("site-packages");
394        std::fs::create_dir_all(&wsp).unwrap();
395        assert_eq!(
396            venv_site_packages(win.to_str().unwrap()),
397            Some(wsp.to_string_lossy().into_owned())
398        );
399
400        let _ = std::fs::remove_dir_all(&root);
401        let _ = std::fs::remove_dir_all(&win);
402    }
403
404    fn tag(name: &str) -> PushRef {
405        PushRef {
406            local_ref: name.to_string(),
407            local_oid: "a".repeat(40),
408            remote_ref: name.to_string(),
409            remote_oid: "0".repeat(40),
410        }
411    }
412
413    /// `v` + digit gates; a branch, a bare-word tag, or a tag merely
414    /// starting with the letter v does not.
415    #[test]
416    fn a_release_is_a_v_number_tag() {
417        assert!(releasing(&[tag("refs/tags/v1.6.6")]));
418        assert!(releasing(&[tag("refs/tags/v2")]));
419        assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
420        assert!(!releasing(&[tag("refs/tags/release")]));
421        assert!(!releasing(&[tag("refs/heads/v1-styles")]));
422        assert!(!releasing(&[tag("refs/heads/main")]));
423        // A mixed push gates: the tag is in there.
424        assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
425    }
426
427    /// ci.yaml's lesson, pinned at the unit level: the ids decide, the exit
428    /// code only classifies them.
429    #[test]
430    fn cargo_audit_ids_decide_not_the_exit_code() {
431        assert_eq!(
432            read_cargo_audit(true, "ok, 312 crates checked"),
433            Report::Clean
434        );
435        assert_eq!(
436            read_cargo_audit(false, "error: couldn't fetch advisory database"),
437            Report::CouldNotCheck
438        );
439        let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
440        assert_eq!(
441            read_cargo_audit(true, warn),
442            Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
443        );
444        let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
445        assert_eq!(
446            read_cargo_audit(false, vuln),
447            Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
448        );
449        // A lookalike is not an id.
450        assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
451    }
452
453    #[test]
454    fn npm_audit_summary_decides() {
455        assert_eq!(
456            read_npm_audit(true, "found 0 vulnerabilities\n"),
457            Report::Clean
458        );
459        assert_eq!(
460            read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
461            Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
462        );
463        assert_eq!(
464            read_npm_audit(true, "up to date, audited 100 packages\n"),
465            Report::Clean
466        );
467        assert_eq!(
468            read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
469            Report::CouldNotCheck
470        );
471    }
472
473    /// The cargo-audit split, spoken in Go: ids decide, the exit code says
474    /// whether the analysed code is actually affected.
475    #[test]
476    fn govulncheck_ids_decide_not_the_exit_code() {
477        assert_eq!(
478            read_govulncheck(true, "No vulnerabilities found.\n"),
479            Report::Clean
480        );
481        assert_eq!(
482            read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
483            Report::CouldNotCheck
484        );
485        // Informational: the module is vulnerable, the analysed code never
486        // calls it — exit 0, ids present.
487        assert_eq!(
488            read_govulncheck(
489                true,
490                "=== Informational ===\nVulnerability #1: GO-2023-1840\n  More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
491            ),
492            Report::Advisories(vec!["GO-2023-1840".into()])
493        );
494        assert_eq!(
495            read_govulncheck(
496                false,
497                "Vulnerability #1: GO-2022-0969\n  Your code calls it.\nGO-2022-0969 again\n"
498            ),
499            Report::Vulnerabilities(vec!["GO-2022-0969".into()])
500        );
501        // A lookalike is not an id.
502        assert_eq!(
503            read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
504            Report::Clean
505        );
506    }
507
508    #[test]
509    fn pip_audit_sentence_decides() {
510        assert_eq!(
511            read_pip_audit(true, "No known vulnerabilities found\n"),
512            Report::Clean
513        );
514        assert_eq!(
515            read_pip_audit(
516                false,
517                "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
518            ),
519            Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
520        );
521        assert_eq!(
522            read_pip_audit(false, "ERROR: could not resolve\n"),
523            Report::CouldNotCheck
524        );
525    }
526}