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/// `pip-audit`: its own closing sentence decides.
144fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
145    if out.contains("No known vulnerabilities found") {
146        return Report::Clean;
147    }
148    if let Some(line) = out
149        .lines()
150        .map(str::trim)
151        .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
152    {
153        return Report::Vulnerabilities(vec![line.to_string()]);
154    }
155    if exit_ok {
156        Report::Clean
157    } else {
158        Report::CouldNotCheck
159    }
160}
161
162/// Run one audit tool from the repo root and read its answer.
163fn audited(argv: &[String]) -> Option<(bool, String)> {
164    let root = common::repo_root();
165    let mut cmd = std::process::Command::new(&argv[0]);
166    cmd.args(&argv[1..])
167        .current_dir(&root)
168        .stdin(std::process::Stdio::null());
169    common::strip_git_env(&mut cmd);
170    let (ran, out) = common::capture_within(&mut cmd)?;
171    match ran {
172        common::Ran::Status(s) => Some((s.success(), out)),
173        common::Ran::TimedOut(budget) => {
174            common::say_timed_out(&argv[0], budget);
175            None
176        }
177    }
178}
179
180pub fn rust(refs: &[PushRef]) -> Outcome {
181    if common::which("cargo-audit").is_none() {
182        common::warn(
183            "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
184             the audit did NOT run",
185        );
186        return Outcome::Unavailable;
187    }
188    let argv = vec![
189        common::program("cargo"),
190        "audit".into(),
191        "--color".into(),
192        "never".into(),
193    ];
194    let Some((exit_ok, out)) = audited(&argv) else {
195        return Outcome::Unavailable;
196    };
197    conclude(
198        "audit-rust",
199        read_cargo_audit(exit_ok, &out),
200        releasing(refs),
201        &out,
202    )
203}
204
205pub fn js(refs: &[PushRef]) -> Outcome {
206    let argv = vec![common::program("npm"), "audit".into()];
207    let Some((exit_ok, out)) = audited(&argv) else {
208        common::warn("audit-js: npm could not run — the audit did NOT run");
209        return Outcome::Unavailable;
210    };
211    conclude(
212        "audit-js",
213        read_npm_audit(exit_ok, &out),
214        releasing(refs),
215        &out,
216    )
217}
218
219pub fn python(refs: &[PushRef]) -> Outcome {
220    if common::which("pip-audit").is_none() {
221        common::warn(
222            "audit-python: pip-audit is not installed (pip install pip-audit) — \
223             the audit did NOT run",
224        );
225        return Outcome::Unavailable;
226    }
227    let argv = vec![
228        common::program("pip-audit"),
229        "-r".into(),
230        "requirements.txt".into(),
231    ];
232    let Some((exit_ok, out)) = audited(&argv) else {
233        return Outcome::Unavailable;
234    };
235    conclude(
236        "audit-python",
237        read_pip_audit(exit_ok, &out),
238        releasing(refs),
239        &out,
240    )
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn tag(name: &str) -> PushRef {
248        PushRef {
249            local_ref: name.to_string(),
250            local_oid: "a".repeat(40),
251            remote_ref: name.to_string(),
252            remote_oid: "0".repeat(40),
253        }
254    }
255
256    /// `v` + digit gates; a branch, a bare-word tag, or a tag merely
257    /// starting with the letter v does not.
258    #[test]
259    fn a_release_is_a_v_number_tag() {
260        assert!(releasing(&[tag("refs/tags/v1.6.6")]));
261        assert!(releasing(&[tag("refs/tags/v2")]));
262        assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
263        assert!(!releasing(&[tag("refs/tags/release")]));
264        assert!(!releasing(&[tag("refs/heads/v1-styles")]));
265        assert!(!releasing(&[tag("refs/heads/main")]));
266        // A mixed push gates: the tag is in there.
267        assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
268    }
269
270    /// ci.yaml's lesson, pinned at the unit level: the ids decide, the exit
271    /// code only classifies them.
272    #[test]
273    fn cargo_audit_ids_decide_not_the_exit_code() {
274        assert_eq!(
275            read_cargo_audit(true, "ok, 312 crates checked"),
276            Report::Clean
277        );
278        assert_eq!(
279            read_cargo_audit(false, "error: couldn't fetch advisory database"),
280            Report::CouldNotCheck
281        );
282        let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
283        assert_eq!(
284            read_cargo_audit(true, warn),
285            Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
286        );
287        let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
288        assert_eq!(
289            read_cargo_audit(false, vuln),
290            Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
291        );
292        // A lookalike is not an id.
293        assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
294    }
295
296    #[test]
297    fn npm_audit_summary_decides() {
298        assert_eq!(
299            read_npm_audit(true, "found 0 vulnerabilities\n"),
300            Report::Clean
301        );
302        assert_eq!(
303            read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
304            Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
305        );
306        assert_eq!(
307            read_npm_audit(true, "up to date, audited 100 packages\n"),
308            Report::Clean
309        );
310        assert_eq!(
311            read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
312            Report::CouldNotCheck
313        );
314    }
315
316    #[test]
317    fn pip_audit_sentence_decides() {
318        assert_eq!(
319            read_pip_audit(true, "No known vulnerabilities found\n"),
320            Report::Clean
321        );
322        assert_eq!(
323            read_pip_audit(
324                false,
325                "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
326            ),
327            Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
328        );
329        assert_eq!(
330            read_pip_audit(false, "ERROR: could not resolve\n"),
331            Report::CouldNotCheck
332        );
333    }
334}