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/// Run one audit tool from the repo root and read its answer.
192fn audited(argv: &[String]) -> Option<(bool, String)> {
193    let root = common::repo_root();
194    let mut cmd = std::process::Command::new(&argv[0]);
195    cmd.args(&argv[1..])
196        .current_dir(&root)
197        .stdin(std::process::Stdio::null());
198    common::strip_git_env(&mut cmd);
199    let (ran, out) = common::capture_within(&mut cmd)?;
200    match ran {
201        common::Ran::Status(s) => Some((s.success(), out)),
202        common::Ran::TimedOut(budget) => {
203            common::say_timed_out(&argv[0], budget);
204            None
205        }
206    }
207}
208
209pub fn rust(refs: &[PushRef]) -> Outcome {
210    if common::which("cargo-audit").is_none() {
211        common::warn(
212            "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
213             the audit did NOT run",
214        );
215        return Outcome::Unavailable;
216    }
217    let argv = vec![
218        common::program("cargo"),
219        "audit".into(),
220        "--color".into(),
221        "never".into(),
222    ];
223    let Some((exit_ok, out)) = audited(&argv) else {
224        return Outcome::Unavailable;
225    };
226    conclude(
227        "audit-rust",
228        read_cargo_audit(exit_ok, &out),
229        releasing(refs),
230        &out,
231    )
232}
233
234pub fn js(refs: &[PushRef]) -> Outcome {
235    let argv = vec![common::program("npm"), "audit".into()];
236    let Some((exit_ok, out)) = audited(&argv) else {
237        common::warn("audit-js: npm could not run — the audit did NOT run");
238        return Outcome::Unavailable;
239    };
240    conclude(
241        "audit-js",
242        read_npm_audit(exit_ok, &out),
243        releasing(refs),
244        &out,
245    )
246}
247
248pub fn go(refs: &[PushRef]) -> Outcome {
249    if common::which("govulncheck").is_none() {
250        common::warn(
251            "audit-go: govulncheck is not installed \
252             (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
253        );
254        return Outcome::Unavailable;
255    }
256    let argv = vec![common::program("govulncheck"), "./...".into()];
257    let Some((exit_ok, out)) = audited(&argv) else {
258        return Outcome::Unavailable;
259    };
260    conclude(
261        "audit-go",
262        read_govulncheck(exit_ok, &out),
263        releasing(refs),
264        &out,
265    )
266}
267
268pub fn python(refs: &[PushRef]) -> Outcome {
269    if common::which("pip-audit").is_none() {
270        common::warn(
271            "audit-python: pip-audit is not installed (pip install pip-audit) — \
272             the audit did NOT run",
273        );
274        return Outcome::Unavailable;
275    }
276    let argv = vec![
277        common::program("pip-audit"),
278        "-r".into(),
279        "requirements.txt".into(),
280    ];
281    let Some((exit_ok, out)) = audited(&argv) else {
282        return Outcome::Unavailable;
283    };
284    conclude(
285        "audit-python",
286        read_pip_audit(exit_ok, &out),
287        releasing(refs),
288        &out,
289    )
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn tag(name: &str) -> PushRef {
297        PushRef {
298            local_ref: name.to_string(),
299            local_oid: "a".repeat(40),
300            remote_ref: name.to_string(),
301            remote_oid: "0".repeat(40),
302        }
303    }
304
305    /// `v` + digit gates; a branch, a bare-word tag, or a tag merely
306    /// starting with the letter v does not.
307    #[test]
308    fn a_release_is_a_v_number_tag() {
309        assert!(releasing(&[tag("refs/tags/v1.6.6")]));
310        assert!(releasing(&[tag("refs/tags/v2")]));
311        assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
312        assert!(!releasing(&[tag("refs/tags/release")]));
313        assert!(!releasing(&[tag("refs/heads/v1-styles")]));
314        assert!(!releasing(&[tag("refs/heads/main")]));
315        // A mixed push gates: the tag is in there.
316        assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
317    }
318
319    /// ci.yaml's lesson, pinned at the unit level: the ids decide, the exit
320    /// code only classifies them.
321    #[test]
322    fn cargo_audit_ids_decide_not_the_exit_code() {
323        assert_eq!(
324            read_cargo_audit(true, "ok, 312 crates checked"),
325            Report::Clean
326        );
327        assert_eq!(
328            read_cargo_audit(false, "error: couldn't fetch advisory database"),
329            Report::CouldNotCheck
330        );
331        let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
332        assert_eq!(
333            read_cargo_audit(true, warn),
334            Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
335        );
336        let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
337        assert_eq!(
338            read_cargo_audit(false, vuln),
339            Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
340        );
341        // A lookalike is not an id.
342        assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
343    }
344
345    #[test]
346    fn npm_audit_summary_decides() {
347        assert_eq!(
348            read_npm_audit(true, "found 0 vulnerabilities\n"),
349            Report::Clean
350        );
351        assert_eq!(
352            read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
353            Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
354        );
355        assert_eq!(
356            read_npm_audit(true, "up to date, audited 100 packages\n"),
357            Report::Clean
358        );
359        assert_eq!(
360            read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
361            Report::CouldNotCheck
362        );
363    }
364
365    /// The cargo-audit split, spoken in Go: ids decide, the exit code says
366    /// whether the analysed code is actually affected.
367    #[test]
368    fn govulncheck_ids_decide_not_the_exit_code() {
369        assert_eq!(
370            read_govulncheck(true, "No vulnerabilities found.\n"),
371            Report::Clean
372        );
373        assert_eq!(
374            read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
375            Report::CouldNotCheck
376        );
377        // Informational: the module is vulnerable, the analysed code never
378        // calls it — exit 0, ids present.
379        assert_eq!(
380            read_govulncheck(
381                true,
382                "=== Informational ===\nVulnerability #1: GO-2023-1840\n  More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
383            ),
384            Report::Advisories(vec!["GO-2023-1840".into()])
385        );
386        assert_eq!(
387            read_govulncheck(
388                false,
389                "Vulnerability #1: GO-2022-0969\n  Your code calls it.\nGO-2022-0969 again\n"
390            ),
391            Report::Vulnerabilities(vec!["GO-2022-0969".into()])
392        );
393        // A lookalike is not an id.
394        assert_eq!(
395            read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
396            Report::Clean
397        );
398    }
399
400    #[test]
401    fn pip_audit_sentence_decides() {
402        assert_eq!(
403            read_pip_audit(true, "No known vulnerabilities found\n"),
404            Report::Clean
405        );
406        assert_eq!(
407            read_pip_audit(
408                false,
409                "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
410            ),
411            Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
412        );
413        assert_eq!(
414            read_pip_audit(false, "ERROR: could not resolve\n"),
415            Report::CouldNotCheck
416        );
417    }
418}