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.
58/// `waivable` is false when something in the report
59/// could not be attributed (a project the audit could not answer for, a
60/// finding without a recognised id): a waiver must never vouch for what
61/// nobody saw.
62fn conclude_waivable(
63    settings: &crate::config::Settings,
64    tool: &str,
65    report: Report,
66    releasing: bool,
67    full: &str,
68    waivable: bool,
69) -> Outcome {
70    match report {
71        Report::Clean => {
72            common::ok(settings, &format!("{tool}: no known vulnerabilities"));
73            Outcome::Passed
74        }
75        Report::Advisories(ids) => {
76            common::warn(&format!(
77                "{tool}: advisories against the dependency tree (warnings — unmaintained/unsound): {}",
78                ids.join(", ")
79            ));
80            Outcome::Warned
81        }
82        Report::Vulnerabilities(what) => {
83            let waivers = Waivers::load(&common::repo_root(), today());
84            // The ids the verdict names first; the tool's report when the
85            // verdict is a summary line (npm, pnpm).
86            let mut ids = advisory_ids(&what.join(" "));
87            if ids.is_empty() {
88                ids = advisory_ids(full);
89            }
90            let unwaived: Vec<&String> = ids.iter().filter(|id| !waivers.covers(id)).collect();
91            let all_waived = waivable && !ids.is_empty() && unwaived.is_empty();
92            if releasing {
93                if all_waived {
94                    common::warn(&format!(
95                        "{tool}: known vulnerabilities shipped under a reviewed waiver \
96                         ({WAIVERS_FILE}) — not blocking this release: {}",
97                        ids.iter()
98                            .map(|id| waivers.describe(id))
99                            .collect::<Vec<_>>()
100                            .join("; ")
101                    ));
102                    return Outcome::Warned;
103                }
104                for line in full.lines() {
105                    crate::say!("{line}");
106                }
107                for note in waivers.problems() {
108                    common::warn(&format!("{tool}: {note}"));
109                }
110                common::fail(&format!(
111                    "{tool}: known vulnerabilities in the dependency tree — a v* tag \
112                     does not ship with these: {}{}",
113                    what.join(", "),
114                    if ids.is_empty() {
115                        " (no advisory id to match a waiver against)".to_string()
116                    } else {
117                        format!(
118                            " — not waived: {}",
119                            unwaived
120                                .iter()
121                                .map(|s| s.as_str())
122                                .collect::<Vec<_>>()
123                                .join(", ")
124                        )
125                    }
126                ));
127                Outcome::Failed
128            } else if all_waived {
129                common::warn(&format!(
130                    "{tool}: known vulnerabilities ({}) — covered by a reviewed waiver \
131                     ({WAIVERS_FILE}), so a v* tag push will pass while it holds: {}",
132                    what.join(", "),
133                    ids.iter()
134                        .map(|id| waivers.describe(id))
135                        .collect::<Vec<_>>()
136                        .join("; ")
137                ));
138                Outcome::Warned
139            } else {
140                common::warn(&format!(
141                    "{tool}: known vulnerabilities in the dependency tree ({}) — \
142                     this will BLOCK a v* tag push",
143                    what.join(", ")
144                ));
145                Outcome::Warned
146            }
147        }
148        Report::CouldNotCheck => {
149            common::warn(&format!(
150                "{tool} could not complete — the dependency tree was NOT checked. \
151                 This is not a clean result."
152            ));
153            Outcome::Unavailable
154        }
155    }
156}
157
158/// Where a repository records the advisories a release may ship anyway.
159const WAIVERS_FILE: &str = ".amont-audit-waivers";
160
161/// How far ahead a waiver may run. A waiver is a decision to revisit, not
162/// an exemption: one dated further out is void, so nothing is waived for
163/// good by accident.
164const MAX_WAIVER_DAYS: i64 = 90;
165
166/// One line of the waiver file: `<advisory id> <expires YYYY-MM-DD> <reason>`.
167#[derive(Debug)]
168struct Waiver {
169    id: String,
170    expires: String,
171    reason: String,
172    valid: bool,
173}
174
175/// The repository's reviewed waivers, judged against today.
176///
177/// A waiver lets a RELEASE ship a known vulnerability nobody can fix yet —
178/// an advisory with no patched version, on a path the project cannot
179/// replace. It is committed (so it is reviewed like code), names its
180/// reason, and expires: past its date, or dated more than
181/// [`MAX_WAIVER_DAYS`] ahead, it waives nothing and the tag is refused
182/// again. Branch pushes never consult it — they never block.
183#[derive(Debug, Default)]
184struct Waivers {
185    entries: Vec<Waiver>,
186    problems: Vec<String>,
187}
188
189impl Waivers {
190    fn load(root: &str, today: i64) -> Self {
191        match std::fs::read_to_string(std::path::Path::new(root).join(WAIVERS_FILE)) {
192            Ok(text) => Self::parse(&text, today),
193            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
194            Err(e) => Self {
195                entries: Vec::new(),
196                problems: vec![format!(
197                    "{WAIVERS_FILE} could not be read ({e}) — it waives nothing"
198                )],
199            },
200        }
201    }
202
203    fn parse(text: &str, today: i64) -> Self {
204        let mut w = Self::default();
205        for line in text.lines().map(str::trim) {
206            if line.is_empty() || line.starts_with('#') {
207                continue;
208            }
209            let mut parts = line.splitn(3, char::is_whitespace);
210            let id = parts.next().unwrap_or_default().to_string();
211            let expires = parts.next().unwrap_or_default().trim().to_string();
212            let reason = parts.next().unwrap_or_default().trim().to_string();
213            let valid = match days_from_date(&expires) {
214                None => {
215                    w.problems.push(format!(
216                        "waiver for {id} has no readable expiry date (YYYY-MM-DD) — it waives nothing"
217                    ));
218                    false
219                }
220                Some(_) if reason.is_empty() => {
221                    w.problems.push(format!(
222                        "waiver for {id} gives no reason — it waives nothing"
223                    ));
224                    false
225                }
226                Some(d) if d < today => {
227                    w.problems
228                        .push(format!("waiver for {id} expired on {expires}"));
229                    false
230                }
231                Some(d) if d - today > MAX_WAIVER_DAYS => {
232                    w.problems.push(format!(
233                        "waiver for {id} runs to {expires}, more than {MAX_WAIVER_DAYS} days \
234                         ahead — it waives nothing; date it sooner and revisit"
235                    ));
236                    false
237                }
238                Some(_) => true,
239            };
240            w.entries.push(Waiver {
241                id,
242                expires,
243                reason,
244                valid,
245            });
246        }
247        w
248    }
249
250    fn covers(&self, id: &str) -> bool {
251        self.entries.iter().any(|w| w.valid && w.id == id)
252    }
253
254    fn describe(&self, id: &str) -> String {
255        match self.entries.iter().find(|w| w.valid && w.id == id) {
256            Some(w) => format!("{id} until {} ({})", w.expires, w.reason),
257            None => id.to_string(),
258        }
259    }
260
261    fn problems(&self) -> &[String] {
262        &self.problems
263    }
264}
265
266/// Days since 1970-01-01 for a `YYYY-MM-DD` date (proleptic Gregorian).
267fn days_from_date(date: &str) -> Option<i64> {
268    let mut it = date.split('-');
269    let (y, m, d): (i64, i64, i64) = (
270        it.next()?.parse().ok()?,
271        it.next()?.parse().ok()?,
272        it.next()?.parse().ok()?,
273    );
274    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
275    let month_days = [
276        31,
277        if leap { 29 } else { 28 },
278        31,
279        30,
280        31,
281        30,
282        31,
283        31,
284        30,
285        31,
286        30,
287        31,
288    ];
289    if it.next().is_some() || !(1..=12).contains(&m) || d < 1 || d > month_days[(m - 1) as usize] {
290        return None;
291    }
292    // Howard Hinnant's days_from_civil.
293    let y = if m <= 2 { y - 1 } else { y };
294    let era = if y >= 0 { y } else { y - 399 } / 400;
295    let yoe = y - era * 400;
296    let mp = (m + 9) % 12;
297    let doy = (153 * mp + 2) / 5 + d - 1;
298    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
299    Some(era * 146_097 + doe - 719_468)
300}
301
302/// Today, in days since the epoch (UTC).
303fn today() -> i64 {
304    std::time::SystemTime::now()
305        .duration_since(std::time::UNIX_EPOCH)
306        .map(|d| (d.as_secs() / 86_400) as i64)
307        .unwrap_or(0)
308}
309
310/// Every advisory id in `text`: GitHub (`GHSA-xxxx-xxxx-xxxx`), RustSec,
311/// Go, PyPA and CVE ids, wherever they sit (a URL, a table cell).
312fn advisory_ids(text: &str) -> Vec<String> {
313    let mut ids: Vec<String> = text
314        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
315        .filter(|w| {
316            let parts: Vec<&str> = w.split('-').collect();
317            match parts.as_slice() {
318                ["GHSA", a, b, c] => [a, b, c]
319                    .iter()
320                    .all(|p| p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphanumeric())),
321                ["RUSTSEC" | "GO" | "PYSEC" | "CVE" | "OSV", year, num] => {
322                    year.len() == 4
323                        && year.bytes().all(|b| b.is_ascii_digit())
324                        && !num.is_empty()
325                        && num.bytes().all(|b| b.is_ascii_digit())
326                }
327                _ => false,
328            }
329        })
330        .map(str::to_string)
331        .collect();
332    ids.sort();
333    ids.dedup();
334    ids
335}
336
337/// Is this word a RUSTSEC id? `RUSTSEC-` + 4 digits + `-` + 4 digits.
338fn is_advisory_id(w: &str) -> bool {
339    w.len() == 17
340        && w.starts_with("RUSTSEC-")
341        && w[8..12].bytes().all(|b| b.is_ascii_digit())
342        && w.as_bytes()[12] == b'-'
343        && w[13..17].bytes().all(|b| b.is_ascii_digit())
344}
345
346/// Pair each advisory with the crate it was raised against.
347///
348/// `cargo audit`'s terminal report is blocks — `Crate:` … `ID:` — and the
349/// id alone does not say which crate carries it, let alone which of YOUR
350/// crates depends on that. Answering "is this on the commit path or only in
351/// an opt-in tool?" meant running `cargo tree -i` by hand every time.
352///
353/// A `Crate:` binds to the next `ID:` and is then spent, so an id appearing
354/// outside a block — a summary line, a URL — reports no crate rather than
355/// inheriting the previous block's.
356fn ids_with_crates(out: &str) -> Vec<(String, Option<String>)> {
357    let mut pairs: Vec<(String, Option<String>)> = Vec::new();
358    let mut pending: Option<String> = None;
359    for line in out.lines() {
360        let t = line.trim();
361        if let Some(rest) = t.strip_prefix("Crate:") {
362            pending = rest.split_whitespace().next().map(str::to_string);
363            continue;
364        }
365        if let Some(rest) = t.strip_prefix("ID:") {
366            if let Some(id) = rest.split_whitespace().next().filter(|w| is_advisory_id(w)) {
367                pairs.push((id.to_string(), pending.take()));
368                continue;
369            }
370        }
371        // Ids outside a block still count — the older summary-line shapes
372        // and anything cargo audit prints loose.
373        for w in t.split_whitespace().filter(|w| is_advisory_id(w)) {
374            pairs.push((w.to_string(), None));
375        }
376    }
377    pairs
378}
379
380/// Which of THIS workspace's crates reach `crate`, read from `cargo tree -i`.
381///
382/// Cargo prints a local package with its path in parentheses and a registry
383/// package without one, which is the only discriminator needed and works in
384/// any repository — the hook cannot know a given workspace's member names.
385///
386/// An empty result is an answer, not a failure: `cargo tree` prints
387/// "nothing to print" for a crate that is in `Cargo.lock` but not in the
388/// build graph. `cargo audit` reads the lock file, so an advisory can name a
389/// crate nothing compiles — an optional dependency of a feature nobody
390/// enabled. Saying so is more useful than naming no crate at all.
391fn local_dependents(tree_out: &str) -> Vec<String> {
392    let mut names: Vec<String> = tree_out
393        .lines()
394        .filter(|l| l.contains(" (/"))
395        .filter_map(|l| {
396            l.split_whitespace()
397                .find(|w| !w.is_empty() && w.chars().next().is_some_and(|c| c.is_alphanumeric()))
398                .map(str::to_string)
399        })
400        .collect();
401    names.sort();
402    names.dedup();
403    names
404}
405
406/// One advisory, said in full: the id, the crate it is against, and which of
407/// this workspace's crates actually reach it.
408fn describe(id: &str, krate: Option<&str>, reaches: &[String]) -> String {
409    match (krate, reaches.is_empty()) {
410        (None, _) => id.to_string(),
411        (Some(k), true) => format!("{id} ({k}, not in the build graph)"),
412        (Some(k), false) => format!("{id} ({k} → {})", reaches.join(", ")),
413    }
414}
415
416/// `cargo audit`, ci.yaml's rules verbatim: the RUSTSEC ids decide, the
417/// exit code only says which class they are.
418fn read_cargo_audit(exit_ok: bool, out: &str) -> Report {
419    let mut ids: Vec<String> = out
420        .split_whitespace()
421        .filter(|w| {
422            w.len() == 17
423                && w.starts_with("RUSTSEC-")
424                && w[8..12].bytes().all(|b| b.is_ascii_digit())
425                && w.as_bytes()[12] == b'-'
426                && w[13..17].bytes().all(|b| b.is_ascii_digit())
427        })
428        .map(|w| w.to_string())
429        .collect();
430    ids.sort();
431    ids.dedup();
432    match (ids.is_empty(), exit_ok) {
433        (true, true) => Report::Clean,
434        (true, false) => Report::CouldNotCheck,
435        (false, true) => Report::Advisories(ids),
436        (false, false) => Report::Vulnerabilities(ids),
437    }
438}
439
440/// `npm audit`: the summary line decides. A clean tree is `found 0
441/// vulnerabilities` on every npm. A finding is `found N vulnerabilities`
442/// on npm 6 and `N vulnerabilities (a moderate, b high)` — the verb dropped,
443/// `1 vulnerability` in the singular — on npm 7 and later, which is every
444/// npm shipped since 2020. No recognisable summary plus a refusal to exit
445/// clean is a tool that never answered.
446fn read_npm_audit(exit_ok: bool, out: &str) -> Report {
447    let summary = out.lines().rev().map(str::trim).find(|l| {
448        l.contains("vulnerabilit")
449            && (l.starts_with("found ")
450                || l.split_whitespace()
451                    .next()
452                    .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())))
453    });
454    match summary {
455        Some(l) if l.starts_with("found 0 ") || l.starts_with("0 ") => Report::Clean,
456        Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
457        None if exit_ok => Report::Clean,
458        None => Report::CouldNotCheck,
459    }
460}
461
462/// `pnpm audit`: the summary line decides. A clean tree is `No known
463/// vulnerabilities found`; a finding is `N vulnerabilities found` followed by
464/// `Severity: a low | b moderate | …`, which is carried along so the warning
465/// says how bad. No recognisable summary plus a refusal to exit clean is a
466/// tool that never answered (`ERR_PNPM_AUDIT_BAD_RESPONSE`, no network).
467fn read_pnpm_audit(exit_ok: bool, out: &str) -> Report {
468    let lines: Vec<&str> = out.lines().map(str::trim).collect();
469    if lines
470        .iter()
471        .any(|l| l.starts_with("No known vulnerabilities found"))
472    {
473        return Report::Clean;
474    }
475    let found = lines.iter().enumerate().rev().find(|(_, l)| {
476        let mut w = l.split_whitespace();
477        w.next()
478            .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
479            && w.next().is_some_and(|v| v.starts_with("vulnerabilit"))
480            && w.next() == Some("found")
481    });
482    match found {
483        Some((_, l)) if l.starts_with("0 ") => Report::Clean,
484        Some((i, l)) => {
485            let severity = lines
486                .get(i + 1)
487                .and_then(|n| n.strip_prefix("Severity:"))
488                .map(str::trim);
489            Report::Vulnerabilities(vec![match severity {
490                Some(sev) => format!("{l} ({sev})"),
491                None => l.to_string(),
492            }])
493        }
494        None if exit_ok => Report::Clean,
495        None => Report::CouldNotCheck,
496    }
497}
498
499/// `govulncheck`: the GO- ids decide, the exit code classifies them — the
500/// same split cargo-audit taught. The tool exits non-zero only when the
501/// analysed CODE is affected; ids with a clean exit are the informational
502/// section (vulnerable modules whose functions are never called), which is
503/// advisory-grade. No ids plus a refusal to exit clean is a tool that never
504/// answered (no network, no vulnerability database).
505fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
506    let mut ids: Vec<String> = out
507        .split_whitespace()
508        .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
509        .filter(|w| {
510            w.len() >= 12
511                && w.starts_with("GO-")
512                && w[3..7].bytes().all(|b| b.is_ascii_digit())
513                && w.as_bytes()[7] == b'-'
514                && w[8..].bytes().all(|b| b.is_ascii_digit())
515        })
516        .map(|w| w.to_string())
517        .collect();
518    ids.sort();
519    ids.dedup();
520    match (ids.is_empty(), exit_ok) {
521        (true, true) => Report::Clean,
522        (true, false) => Report::CouldNotCheck,
523        (false, true) => Report::Advisories(ids),
524        (false, false) => Report::Vulnerabilities(ids),
525    }
526}
527
528/// `pip-audit`: its own closing sentence decides.
529fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
530    if out.contains("No known vulnerabilities found") {
531        return Report::Clean;
532    }
533    if let Some(line) = out
534        .lines()
535        .map(str::trim)
536        .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
537    {
538        return Report::Vulnerabilities(vec![line.to_string()]);
539    }
540    if exit_ok {
541        Report::Clean
542    } else {
543        Report::CouldNotCheck
544    }
545}
546
547/// The `site-packages` of the environment this project actually uses, if
548/// one is on disk.
549///
550/// `$VIRTUAL_ENV` first — an activated environment is the one whose imports
551/// are live — then the `.venv` uv and PEP 668 tooling create by convention.
552/// Layout differs by platform: `lib/python3.13/site-packages` everywhere
553/// except Windows, which uses `Lib/site-packages`, so the python-version
554/// directory is discovered rather than guessed.
555fn venv_site_packages(root: &str) -> Option<String> {
556    let candidates = std::env::var_os("VIRTUAL_ENV")
557        .map(std::path::PathBuf::from)
558        .into_iter()
559        .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
560    for venv in candidates {
561        let windows = venv.join("Lib").join("site-packages");
562        if windows.is_dir() {
563            return Some(windows.to_string_lossy().into_owned());
564        }
565        let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
566            continue;
567        };
568        for e in entries.flatten() {
569            if !e.file_name().to_string_lossy().starts_with("python") {
570                continue;
571            }
572            let sp = e.path().join("site-packages");
573            if sp.is_dir() {
574                return Some(sp.to_string_lossy().into_owned());
575            }
576        }
577    }
578    None
579}
580
581/// Run one audit tool from the repo root and read its answer.
582fn audited(settings: &crate::config::Settings, argv: &[String]) -> Option<(bool, String)> {
583    audited_in(settings, argv, std::path::Path::new(&common::repo_root()))
584}
585
586/// Run one audit tool from `dir` and read its answer.
587fn audited_in(
588    settings: &crate::config::Settings,
589    argv: &[String],
590    dir: &std::path::Path,
591) -> Option<(bool, String)> {
592    let mut cmd = std::process::Command::new(&argv[0]);
593    cmd.args(&argv[1..])
594        .current_dir(dir)
595        .stdin(std::process::Stdio::null());
596    common::strip_git_env(&mut cmd);
597    let (ran, out) = common::capture_within(settings, &mut cmd)?;
598    match ran {
599        common::Ran::Status(s) => Some((s.success(), out)),
600        common::Ran::TimedOut(budget) => {
601            common::say_timed_out(&argv[0], budget);
602            None
603        }
604    }
605}
606
607/// The tool's own reason for not answering: its last line that says
608/// `error`, else its last non-empty line, clipped. "Could not complete" with
609/// nothing after it was the whole message once, over a tree with 19 known
610/// vulnerabilities (amont#305) — the next one must be readable from the hook
611/// output alone.
612fn failure_line(out: &str) -> Option<String> {
613    let lines: Vec<&str> = out
614        .lines()
615        .map(str::trim)
616        .filter(|l| !l.is_empty())
617        .collect();
618    let line = lines
619        .iter()
620        .rev()
621        .find(|l| l.to_ascii_lowercase().contains("error"))
622        .or(lines.last())?;
623    let mut s: String = line.chars().take(200).collect();
624    if line.chars().count() > 200 {
625        s.push('…');
626    }
627    Some(s)
628}
629
630/// What one audit found across every directory it ran in.
631struct Merged {
632    report: Report,
633    /// The tools' reports, each under a header naming its directory (the
634    /// bare report when the only directory is the root).
635    full: String,
636    waivable: bool,
637}
638
639/// Run one audit in every directory holding `lockfile` and merge the
640/// answers — the shape `js()` has had since a root-only run left a nested
641/// npm project unaudited. Rust, Go and Python kept the root-only runner, and
642/// a repository whose `Cargo.lock` lived in `apps/ui/src-tauri` was reported
643/// "could not complete" over 19 known vulnerabilities (amont#305).
644///
645/// `argv_in(dir)` builds the command for one directory, or None to skip it
646/// (said by the caller). `refine(dir, report, out)` runs in that directory
647/// after the read — a release's shipped-only split, attribution. A finding
648/// from a subdirectory is labelled with it; the root's are not, so a
649/// repository with its lockfile at the root reads exactly as before. A
650/// directory the tool could not answer for is named with the tool's own
651/// reason, and keeps the merged result from reading clean or being waived.
652fn audit_each_dir(
653    settings: &crate::config::Settings,
654    tool: &str,
655    lockfile: &str,
656    argv_in: impl Fn(&std::path::Path) -> Option<Vec<String>>,
657    read: fn(bool, &str) -> Report,
658    mut refine: impl FnMut(&std::path::Path, Report, &str) -> Report,
659) -> Option<Merged> {
660    let root = std::path::PathBuf::from(common::repo_root());
661    let dirs = lockfile_dirs(lockfile);
662    let only_root = dirs.len() == 1 && dirs[0].is_empty();
663    let (mut vulns, mut advisories, mut unchecked) = (Vec::new(), Vec::new(), Vec::new());
664    let mut full = String::new();
665    for dir in &dirs {
666        let path = root.join(dir);
667        let label = if dir.is_empty() { "." } else { dir.as_str() };
668        let label_ids = |ids: Vec<String>| -> Vec<String> {
669            if dir.is_empty() {
670                ids
671            } else {
672                ids.into_iter().map(|id| format!("{dir}: {id}")).collect()
673            }
674        };
675        let Some(argv) = argv_in(&path) else {
676            unchecked.push(format!("{label} (nothing to audit there)"));
677            continue;
678        };
679        // A timeout is said by `audited_in` itself; it is not an answer.
680        let (exit_ok, out) = audited_in(settings, &argv, &path)?;
681        match refine(&path, read(exit_ok, &out), &out) {
682            Report::Vulnerabilities(ids) => {
683                vulns.extend(label_ids(ids));
684                if only_root {
685                    full.push_str(&out);
686                } else {
687                    full.push_str(&format!("── {tool} in {label}\n{out}\n"));
688                }
689            }
690            Report::Advisories(ids) => advisories.extend(label_ids(ids)),
691            Report::CouldNotCheck => unchecked.push(match failure_line(&out) {
692                Some(why) => format!("{label} ({why})"),
693                None => label.to_string(),
694            }),
695            Report::Clean => {}
696        }
697    }
698    if !unchecked.is_empty() {
699        common::warn(&format!(
700            "{tool}: the audit could not answer in {} — NOT checked there",
701            unchecked.join(", ")
702        ));
703    }
704    let report = if !vulns.is_empty() {
705        Report::Vulnerabilities(vulns)
706    } else if !unchecked.is_empty() {
707        Report::CouldNotCheck
708    } else if !advisories.is_empty() {
709        Report::Advisories(advisories)
710    } else {
711        Report::Clean
712    };
713    Some(Merged {
714        report,
715        full,
716        waivable: unchecked.is_empty(),
717    })
718}
719
720pub fn rust(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
721    if !has_lockfile("Cargo.lock") {
722        return Outcome::Inert;
723    }
724    if common::which("cargo-audit").is_none() {
725        common::warn(
726            "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
727             the audit did NOT run",
728        );
729        return Outcome::Unavailable;
730    }
731    let argv = vec![
732        common::program("cargo"),
733        "audit".into(),
734        "--color".into(),
735        "never".into(),
736    ];
737    let release = releasing(refs);
738    let Some(merged) = audit_each_dir(
739        settings,
740        "audit-rust",
741        "Cargo.lock",
742        |_| Some(argv.clone()),
743        read_cargo_audit,
744        |dir, mut report, out| {
745            if release {
746                if let Report::Vulnerabilities(ids) = report {
747                    let (shipped, dev_only) = split_shipped_crates(ids, out, |krate| {
748                        cargo_tree_shipped(settings, dir, krate)
749                    });
750                    warn_dev_only("audit-rust", &dev_only);
751                    report = if shipped.is_empty() {
752                        Report::Clean
753                    } else {
754                        Report::Vulnerabilities(shipped)
755                    };
756                }
757            }
758            attribute(report, out, |krate| {
759                cargo_tree_inverse(settings, dir, krate)
760            })
761        },
762    ) else {
763        return Outcome::Unavailable;
764    };
765    conclude_waivable(
766        settings,
767        "audit-rust",
768        merged.report,
769        release,
770        &merged.full,
771        merged.waivable,
772    )
773}
774
775/// `cargo tree -i <crate>` over the edges a RELEASE ships: normal and build
776/// dependencies (a dependency's build script runs on every machine that
777/// compiles it), for every target and every feature, so nothing a consumer
778/// could switch on is mistaken for dev-only. None if it could not run.
779fn cargo_tree_shipped(
780    settings: &crate::config::Settings,
781    dir: &std::path::Path,
782    spec: &str,
783) -> Option<String> {
784    let argv = vec![
785        common::program("cargo"),
786        "tree".into(),
787        "--invert".into(),
788        spec.into(),
789        "--edges".into(),
790        "normal,build".into(),
791        "--target".into(),
792        "all".into(),
793        "--all-features".into(),
794        "--color".into(),
795        "never".into(),
796    ];
797    // Unlike the message-only inverse tree, a failure here must not read as
798    // "nothing reaches it": an error prints no local crate either.
799    audited_in(settings, &argv, dir).and_then(|(ok, out)| ok.then_some(out))
800}
801
802/// Does a shipped-edges inverse tree say the crate is dev-only? Only on
803/// positive evidence: no local crate reaches it AND cargo said there is
804/// nothing to print. Anything else — an unreadable tree — ships.
805fn tree_says_dev_only(tree: &str) -> bool {
806    local_dependents(tree).is_empty() && tree.contains("nothing to print")
807}
808
809/// Split a release's advisory ids into those that ship and those only the
810/// dev tree carries. An id whose crate is unknown, or whose tree could not
811/// be read, ships: an unknown is not a pass.
812fn split_shipped_crates(
813    ids: Vec<String>,
814    out: &str,
815    shipped_tree: impl Fn(&str) -> Option<String>,
816) -> (Vec<String>, Vec<String>) {
817    let specs = ids_with_specs(out);
818    let mut verdicts: Vec<(String, bool)> = Vec::new();
819    let (mut shipped, mut dev_only) = (Vec::new(), Vec::new());
820    for id in ids {
821        let spec = specs
822            .iter()
823            .find(|(pid, spec)| *pid == id && spec.is_some())
824            .and_then(|(_, spec)| spec.clone());
825        let ships = match spec {
826            None => true,
827            // Cached by name@version: two locked versions of one crate are
828            // two answers, and one being dev-only says nothing of the other.
829            Some(spec) => match verdicts.iter().find(|(s, _)| *s == spec) {
830                Some((_, v)) => *v,
831                None => {
832                    let v = shipped_tree(&spec).map_or(true, |t| !tree_says_dev_only(&t));
833                    verdicts.push((spec, v));
834                    v
835                }
836            },
837        };
838        if ships {
839            shipped.push(id);
840        } else {
841            dev_only.push(id);
842        }
843    }
844    (shipped, dev_only)
845}
846
847/// Each advisory id with the package its OWN block names: `crate@version`
848/// when cargo audit printed a `Version:` in that block, the bare crate name
849/// otherwise, None for an id outside any block.
850fn ids_with_specs(out: &str) -> Vec<(String, Option<String>)> {
851    let mut pairs = Vec::new();
852    let (mut krate, mut version): (Option<String>, Option<String>) = (None, None);
853    for line in out.lines().map(str::trim) {
854        if let Some(rest) = line.strip_prefix("Crate:") {
855            krate = rest.split_whitespace().next().map(str::to_string);
856            version = None;
857        } else if let Some(rest) = line.strip_prefix("Version:") {
858            version = rest.split_whitespace().next().map(str::to_string);
859        } else if let Some(rest) = line.strip_prefix("ID:") {
860            if let Some(id) = rest.split_whitespace().next().filter(|w| is_advisory_id(w)) {
861                let spec = krate.take().map(|k| match version.take() {
862                    Some(v) => format!("{k}@{v}"),
863                    None => k,
864                });
865                pairs.push((id.to_string(), spec));
866            }
867        }
868    }
869    pairs
870}
871
872/// Name what a release does not ship and therefore does not refuse.
873fn warn_dev_only(tool: &str, dev_only: &[String]) {
874    if !dev_only.is_empty() {
875        common::warn(&format!(
876            "{tool}: only in development dependencies, which the release does not ship — not blocking: {}",
877            dev_only.join("; ")
878        ));
879    }
880}
881
882/// `cargo tree -i <crate>`, or None if it could not be run. Failure here is
883/// never fatal: attribution is an improvement to a message, and an advisory
884/// reported without it is still an advisory reported.
885fn cargo_tree_inverse(
886    settings: &crate::config::Settings,
887    dir: &std::path::Path,
888    krate: &str,
889) -> Option<String> {
890    let argv = vec![
891        common::program("cargo"),
892        "tree".into(),
893        "--invert".into(),
894        krate.into(),
895        "--edges".into(),
896        "normal".into(),
897        "--color".into(),
898        "never".into(),
899    ];
900    audited_in(settings, &argv, dir).map(|(_, out)| out)
901}
902
903/// Name the crate behind each advisory, and which of this workspace's crates
904/// reach it.
905///
906/// Deliberately spawns NOTHING on a clean report, which is every run that
907/// matters: the `cargo tree` calls happen once per distinct affected crate,
908/// only when there is already something to say. A hook on the push path does
909/// not pay for a message nobody will read.
910fn attribute(report: Report, out: &str, tree: impl Fn(&str) -> Option<String>) -> Report {
911    match report {
912        Report::Advisories(ids) => Report::Advisories(described(ids, out, tree)),
913        Report::Vulnerabilities(ids) => Report::Vulnerabilities(described(ids, out, tree)),
914        // Clean and CouldNotCheck carry no ids, so there is nothing to
915        // attribute and — the part that matters — nothing to spawn.
916        other => other,
917    }
918}
919
920/// Each id, rewritten with its crate and what reaches it where both are
921/// known. One `cargo tree` per distinct crate, not per advisory: `lru`
922/// carried two advisories in the run that prompted this.
923fn described(ids: Vec<String>, out: &str, tree: impl Fn(&str) -> Option<String>) -> Vec<String> {
924    let pairs = ids_with_crates(out);
925    let mut seen: Vec<(String, Vec<String>)> = Vec::new();
926    ids.iter()
927        .map(|id| {
928            let krate = pairs
929                .iter()
930                .find(|(pid, k)| pid == id && k.is_some())
931                .and_then(|(_, k)| k.clone());
932            let Some(k) = krate else {
933                return id.clone();
934            };
935            if let Some((_, reaches)) = seen.iter().find(|(name, _)| *name == k) {
936                return describe(id, Some(&k), reaches);
937            }
938            let reaches = tree(&k).map(|t| local_dependents(&t)).unwrap_or_default();
939            seen.push((k.clone(), reaches.clone()));
940            describe(id, Some(&k), &reaches)
941        })
942        .collect()
943}
944
945/// Does the repository carry `lockfile` anywhere in its index? An audit
946/// without a resolved tree audits a guess — and one asked about a
947/// repository in another language has nothing to audit at all. That is
948/// `Inert`, not "could not run": the dispatcher asks the same question
949/// from the registry's scope, and this is the answer for a check invoked
950/// by name.
951fn has_lockfile(lockfile: &str) -> bool {
952    crate::tracked_paths()
953        .iter()
954        .any(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
955}
956
957/// The directories holding a tracked `lockfile`, repo-relative ("" for the
958/// root), sorted. npm resolves a project from its own directory, so a
959/// repository whose packages live in subdirectories (`web/`, `mcp/`) has no
960/// lockfile at the root at all.
961fn lockfile_dirs(lockfile: &str) -> Vec<String> {
962    let mut dirs: Vec<String> = crate::tracked_paths()
963        .iter()
964        .filter(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
965        .map(|p| {
966            p.rsplit_once('/')
967                .map(|(d, _)| d.to_string())
968                .unwrap_or_default()
969        })
970        .collect();
971    dirs.sort();
972    dirs.dedup();
973    dirs
974}
975
976/// One JS package manager `audit-js` knows how to ask: the lockfile that
977/// opts a directory in, the tool that reads it, and how to read its answer.
978struct JsAuditor {
979    lockfile: &'static str,
980    tool: &'static str,
981    read: fn(bool, &str) -> Report,
982    /// Extra arguments for a project in `dir`.
983    args: fn(&str) -> Vec<String>,
984    /// The flag that limits the audit to what the project ships — its
985    /// production dependencies, without the dev tree.
986    prod_only: &'static str,
987}
988
989/// pnpm answers for the WORKSPACE it finds above a directory, not for the
990/// directory: a standalone project with its own pnpm-lock.yaml inside a
991/// workspace (a spike, an example) was reported with the workspace root's
992/// findings, all of them, under its own name. `--ignore-workspace` makes it
993/// read the lockfile it stands next to — except at a workspace's own root,
994/// where the workspace IS the project and every member must be audited.
995fn pnpm_args(dir: &str) -> Vec<String> {
996    let manifest = if dir.is_empty() {
997        "pnpm-workspace.yaml".to_string()
998    } else {
999        format!("{dir}/pnpm-workspace.yaml")
1000    };
1001    if crate::tracked_paths().contains(&manifest) {
1002        vec![]
1003    } else {
1004        vec!["--ignore-workspace".into()]
1005    }
1006}
1007
1008const JS_AUDITORS: [JsAuditor; 2] = [
1009    JsAuditor {
1010        lockfile: "package-lock.json",
1011        tool: "npm",
1012        read: read_npm_audit,
1013        args: |_| vec![],
1014        prod_only: "--omit=dev",
1015    },
1016    JsAuditor {
1017        lockfile: "pnpm-lock.yaml",
1018        tool: "pnpm",
1019        read: read_pnpm_audit,
1020        args: pnpm_args,
1021        prod_only: "--prod",
1022    },
1023];
1024
1025/// `npm audit` in every directory that tracks a `package-lock.json`, and
1026/// `pnpm audit` in every directory that tracks a `pnpm-lock.yaml`.
1027///
1028/// It used to run once at the repository root, where a repository whose
1029/// packages live in subdirectories has no lockfile: npm answered ENOLOCK,
1030/// the check said "could not complete", and a tree with 17 known
1031/// vulnerabilities (9 high) went unaudited release after release. Each
1032/// project is audited on its own; any finding counts, labelled with its
1033/// directory, and a project the tool could not answer for keeps the whole
1034/// result from reading clean.
1035///
1036/// And it only ever knew npm: a pnpm workspace has no `package-lock.json`,
1037/// so its whole tree — 28 vulnerable versions, two critical, in one
1038/// repository — was never audited and nothing said so. pnpm audits its own
1039/// lockfile, from the lockfile alone, the same way.
1040///
1041/// On a release, what blocks is what SHIPS: a finding is audited again with
1042/// the production dependencies only (`--omit=dev` / `--prod`), and one that
1043/// lives only in the dev tree — a build script's toolchain, a test runner —
1044/// is named but does not refuse the tag. Nobody who installs the package
1045/// installs it. A finding whose production re-audit cannot answer still
1046/// blocks: an unknown is not a pass.
1047pub fn js(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
1048    let projects: Vec<(&JsAuditor, String)> = JS_AUDITORS
1049        .iter()
1050        .flat_map(|a| lockfile_dirs(a.lockfile).into_iter().map(move |d| (a, d)))
1051        .collect();
1052    if projects.is_empty() {
1053        return Outcome::Inert;
1054    }
1055    let root = std::path::PathBuf::from(common::repo_root());
1056    let release = releasing(refs);
1057    let mut found = Vec::new();
1058    let mut dev_only = Vec::new();
1059    let mut unchecked = Vec::new();
1060    let mut full = String::new();
1061    for (auditor, dir) in &projects {
1062        let label = if dir.is_empty() {
1063            ".".to_string()
1064        } else {
1065            dir.clone()
1066        };
1067        let mut argv = vec![common::program(auditor.tool), "audit".into()];
1068        argv.extend((auditor.args)(dir));
1069        let Some((exit_ok, out)) = audited_in(settings, &argv, &root.join(dir)) else {
1070            common::warn(&format!(
1071                "audit-js: {} could not run in {label} — the audit did NOT run",
1072                auditor.tool
1073            ));
1074            return Outcome::Unavailable;
1075        };
1076        match (auditor.read)(exit_ok, &out) {
1077            Report::Vulnerabilities(what) if release => {
1078                let mut prod = argv.clone();
1079                prod.push(auditor.prod_only.into());
1080                match audited_in(settings, &prod, &root.join(dir))
1081                    .map(|(ok, pout)| ((auditor.read)(ok, &pout), pout))
1082                {
1083                    Some((Report::Clean | Report::Advisories(_), _)) => {
1084                        dev_only.extend(what.into_iter().map(|w| format!("{label}: {w}")));
1085                    }
1086                    Some((Report::Vulnerabilities(shipped), pout)) => {
1087                        found.extend(shipped.into_iter().map(|w| format!("{label}: {w}")));
1088                        full.push_str(&format!(
1089                            "── {} audit {} in {label}\n{pout}\n",
1090                            auditor.tool, auditor.prod_only
1091                        ));
1092                    }
1093                    Some((Report::CouldNotCheck, _)) | None => {
1094                        found.extend(what.into_iter().map(|w| format!("{label}: {w}")));
1095                        full.push_str(&format!("── {} audit in {label}\n{out}\n", auditor.tool));
1096                    }
1097                }
1098            }
1099            Report::Vulnerabilities(what) => {
1100                found.extend(what.into_iter().map(|w| format!("{label}: {w}")));
1101                full.push_str(&format!("── {} audit in {label}\n{out}\n", auditor.tool));
1102            }
1103            Report::CouldNotCheck => unchecked.push(format!("{label} ({})", auditor.tool)),
1104            Report::Clean | Report::Advisories(_) => {}
1105        }
1106    }
1107    if !unchecked.is_empty() {
1108        common::warn(&format!(
1109            "audit-js: the audit could not answer in {} — those projects were NOT checked",
1110            unchecked.join(", ")
1111        ));
1112    }
1113    warn_dev_only("audit-js", &dev_only);
1114    let waivable = unchecked.is_empty();
1115    let report = if !found.is_empty() {
1116        Report::Vulnerabilities(found)
1117    } else if !unchecked.is_empty() {
1118        Report::CouldNotCheck
1119    } else {
1120        Report::Clean
1121    };
1122    conclude_waivable(settings, "audit-js", report, release, &full, waivable)
1123}
1124
1125/// Go needs no production filter: modules have no dev-dependency set, and
1126/// `govulncheck ./...` without `-test` reports only vulnerabilities the
1127/// module's non-test code can reach — what ships, already.
1128pub fn go(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
1129    if !has_lockfile("go.sum") {
1130        return Outcome::Inert;
1131    }
1132    if common::which("govulncheck").is_none() {
1133        common::warn(
1134            "audit-go: govulncheck is not installed \
1135             (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
1136        );
1137        return Outcome::Unavailable;
1138    }
1139    let argv = vec![common::program("govulncheck"), "./...".into()];
1140    let Some(merged) = audit_each_dir(
1141        settings,
1142        "audit-go",
1143        "go.sum",
1144        |_| Some(argv.clone()),
1145        read_govulncheck,
1146        |_, report, _| report,
1147    ) else {
1148        return Outcome::Unavailable;
1149    };
1150    conclude_waivable(
1151        settings,
1152        "audit-go",
1153        merged.report,
1154        releasing(refs),
1155        &merged.full,
1156        merged.waivable,
1157    )
1158}
1159
1160pub fn python(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
1161    if !has_lockfile("requirements.txt") && !has_lockfile("pyproject.toml") {
1162        return Outcome::Inert;
1163    }
1164    if common::which("pip-audit").is_none() {
1165        common::warn(
1166            "audit-python: pip-audit is not installed (pip install pip-audit) — \
1167             the audit did NOT run",
1168        );
1169        return Outcome::Unavailable;
1170    }
1171    let root = common::repo_root();
1172    // A requirements.txt is the production list by convention, audited in
1173    // its own directory — wherever the repository keeps one (amont#305).
1174    if !lockfile_dirs("requirements.txt").is_empty() {
1175        let argv = vec![
1176            common::program("pip-audit"),
1177            "-r".into(),
1178            "requirements.txt".into(),
1179        ];
1180        let Some(merged) = audit_each_dir(
1181            settings,
1182            "audit-python",
1183            "requirements.txt",
1184            |_| Some(argv.clone()),
1185            read_pip_audit,
1186            |_, report, _| report,
1187        ) else {
1188            return Outcome::Unavailable;
1189        };
1190        // A row whose advisory id is not one a waiver can name makes the
1191        // whole report unwaivable.
1192        let waivable = merged.waivable && pip_rows_all_named(&merged.full);
1193        return conclude_waivable(
1194            settings,
1195            "audit-python",
1196            merged.report,
1197            releasing(refs),
1198            &merged.full,
1199            waivable,
1200        );
1201    }
1202    let argv = if let Some(site_packages) = venv_site_packages(&root) {
1203        // A uv/PEP-621 project has no requirements.txt, and EXPORTING one
1204        // does not work either: `uv export` emits the workspace's own
1205        // members and any private-index dependency, and pip-audit resolves
1206        // a requirements file in a throwaway venv that can reach neither —
1207        // it dies on "No matching distribution found". Auditing the
1208        // INSTALLED tree resolves nothing, and is the truer question
1209        // anyway: these are the versions actually imported.
1210        vec![
1211            common::program("pip-audit"),
1212            "--path".into(),
1213            site_packages,
1214            // Workspace members are installed editable and are not on
1215            // PyPI; without this each one is a line of noise.
1216            "--skip-editable".into(),
1217        ]
1218    } else {
1219        common::warn(
1220            "audit-python: no requirements.txt, and no virtualenv to audit \
1221             (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
1222        );
1223        return Outcome::Unavailable;
1224    };
1225    let Some((exit_ok, out)) = audited(settings, &argv) else {
1226        return Outcome::Unavailable;
1227    };
1228    let release = releasing(refs);
1229    let mut report = read_pip_audit(exit_ok, &out);
1230    // A requirements.txt is the production list by convention (dev pins
1231    // live beside it, in requirements-dev.txt). A virtualenv holds the dev
1232    // groups too, so on a release its findings are checked against what
1233    // `uv export --no-dev` says ships.
1234    let venv_mode = argv.iter().any(|a| a == "--path");
1235    // A row whose advisory id is not one a waiver can name (MAL-, a new
1236    // scheme) makes the whole report unwaivable, in every mode.
1237    let mut waivable = pip_rows_all_named(&out);
1238    if release && venv_mode && matches!(report, Report::Vulnerabilities(_)) {
1239        let rows = pip_vulnerable_rows(&out);
1240        if let (false, Some(prod)) = (rows.is_empty(), uv_production_names(settings)) {
1241            let (shipped, dev_only): (Vec<String>, Vec<String>) = rows
1242                .into_iter()
1243                .map(|(name, id)| (prod.contains(&name), format!("{name} {id}")))
1244                .fold((Vec::new(), Vec::new()), |(mut s, mut d), (ships, row)| {
1245                    if ships {
1246                        s.push(row);
1247                    } else {
1248                        d.push(row);
1249                    }
1250                    (s, d)
1251                });
1252            warn_dev_only("audit-python", &dev_only);
1253            waivable = waivable && shipped.iter().all(|row| !advisory_ids(row).is_empty());
1254            report = if shipped.is_empty() {
1255                Report::Clean
1256            } else {
1257                Report::Vulnerabilities(shipped)
1258            };
1259        }
1260    }
1261    conclude_waivable(settings, "audit-python", report, release, &out, waivable)
1262}
1263
1264/// PEP 503 normalised: `Foo_Bar.baz` and `foo-bar-baz` are one package.
1265fn normalise(name: &str) -> String {
1266    name.to_ascii_lowercase()
1267        .split(['-', '_', '.'])
1268        .filter(|p| !p.is_empty())
1269        .collect::<Vec<_>>()
1270        .join("-")
1271}
1272
1273/// The packages pip-audit's table names, one row per advisory:
1274/// `Name Version ID Fix Versions`. Only rows whose third column is an
1275/// advisory id count, so the header, the rule and the summary never do.
1276fn pip_vulnerable_rows(out: &str) -> Vec<(String, String)> {
1277    let mut rows: Vec<(String, String)> = out
1278        .lines()
1279        .filter_map(|l| {
1280            let cols: Vec<&str> = l.split_whitespace().collect();
1281            let id = cols.get(2)?;
1282            ["PYSEC-", "GHSA-", "CVE-", "OSV-"]
1283                .iter()
1284                .any(|p| id.starts_with(p))
1285                .then(|| (normalise(cols[0]), (*id).to_string()))
1286        })
1287        .collect();
1288    rows.sort();
1289    rows.dedup();
1290    rows
1291}
1292
1293/// Does every advisory row of pip-audit's table carry an id a waiver can
1294/// name? A row is `name version id …`: a version starting with a digit and
1295/// an id shaped `PREFIX-…`, whatever the prefix.
1296fn pip_rows_all_named(out: &str) -> bool {
1297    out.lines().all(|l| {
1298        let cols: Vec<&str> = l.split_whitespace().collect();
1299        let is_row = cols.len() >= 3
1300            && cols[1].starts_with(|c: char| c.is_ascii_digit())
1301            && cols[2].contains('-')
1302            && cols[2].starts_with(|c: char| c.is_ascii_uppercase());
1303        !is_row || !advisory_ids(cols[2]).is_empty()
1304    })
1305}
1306
1307/// What a uv project ships: `uv export --no-dev` of its lock, names only.
1308/// None when there is no `uv.lock`, no `uv`, or the export fails — the
1309/// caller then keeps every finding, because an unknown is not a pass.
1310fn uv_production_names(
1311    settings: &crate::config::Settings,
1312) -> Option<std::collections::HashSet<String>> {
1313    if !has_lockfile("uv.lock") || common::which("uv").is_none() {
1314        return None;
1315    }
1316    let argv = vec![
1317        common::program("uv"),
1318        "export".into(),
1319        "--frozen".into(),
1320        "--no-dev".into(),
1321        // Optional extras ship: a consumer who asks for one installs it.
1322        "--all-extras".into(),
1323        "--no-hashes".into(),
1324        "--no-emit-workspace".into(),
1325        "--format".into(),
1326        "requirements-txt".into(),
1327    ];
1328    let (ok, out) = audited(settings, &argv)?;
1329    if !ok {
1330        return None;
1331    }
1332    Some(
1333        out.lines()
1334            .map(str::trim)
1335            .filter(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with('-'))
1336            .filter_map(|l| {
1337                l.split(|c: char| "=<>!~;[ ".contains(c))
1338                    .next()
1339                    .map(normalise)
1340            })
1341            .collect(),
1342    )
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347    use super::*;
1348
1349    /// uv projects have no `requirements.txt`, so the venv is the only thing
1350    /// left to audit — and before this, `audit-python` looked for nothing
1351    /// else and reported "the audit did NOT run" forever. Six repositories
1352    /// in one fleet were in exactly that state, one of them carrying 53
1353    /// known vulnerabilities nobody had been told about.
1354    #[test]
1355    fn a_uv_project_is_audited_through_its_venv() {
1356        let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
1357        let _ = std::fs::remove_dir_all(&root);
1358
1359        // Nothing on disk: nothing to audit, and we say so rather than
1360        // inventing a target.
1361        std::fs::create_dir_all(&root).unwrap();
1362        assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
1363
1364        // The posix layout, with the python version DISCOVERED — hard-coding
1365        // `python3.13` would silently stop finding it after an upgrade.
1366        // Joined segment by segment, NOT as one "a/b/c" literal: on Windows
1367        // the literal keeps its forward slashes while the code under test
1368        // returns backslashes, and the test fails on a difference that is
1369        // only in the expectation.
1370        let sp = root
1371            .join(".venv")
1372            .join("lib")
1373            .join("python3.13")
1374            .join("site-packages");
1375        std::fs::create_dir_all(&sp).unwrap();
1376        assert_eq!(
1377            venv_site_packages(root.to_str().unwrap()),
1378            Some(sp.to_string_lossy().into_owned())
1379        );
1380
1381        // The Windows layout, which has no version directory at all.
1382        let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
1383        let _ = std::fs::remove_dir_all(&win);
1384        let wsp = win.join(".venv").join("Lib").join("site-packages");
1385        std::fs::create_dir_all(&wsp).unwrap();
1386        assert_eq!(
1387            venv_site_packages(win.to_str().unwrap()),
1388            Some(wsp.to_string_lossy().into_owned())
1389        );
1390
1391        let _ = std::fs::remove_dir_all(&root);
1392        let _ = std::fs::remove_dir_all(&win);
1393    }
1394
1395    fn tag(name: &str) -> PushRef {
1396        PushRef {
1397            local_ref: name.to_string(),
1398            local_oid: "a".repeat(40),
1399            remote_ref: name.to_string(),
1400            remote_oid: "0".repeat(40),
1401        }
1402    }
1403
1404    /// `v` + digit gates; a branch, a bare-word tag, or a tag merely
1405    /// starting with the letter v does not.
1406    #[test]
1407    fn pip_audit_rows_name_their_packages_normalised() {
1408        let out = "Name       Version ID                  Fix Versions\n\
1409                   ---------- ------- ------------------- ------------\n\
1410                   Pytest_Cov 4.0.0   GHSA-aaaa-bbbb-cccc 4.1.0\n\
1411                   requests   2.31.0  PYSEC-2023-74       2.31.1\n\
1412                   requests   2.31.0  CVE-2024-35195      2.32.0\n\
1413                   Found 3 known vulnerabilities in 2 packages\n";
1414        let names: Vec<String> = pip_vulnerable_rows(out)
1415            .into_iter()
1416            .map(|(n, _)| n)
1417            .collect();
1418        assert_eq!(names, vec!["pytest-cov", "requests", "requests"]);
1419        assert_eq!(normalise("Foo_Bar.baz"), "foo-bar-baz");
1420    }
1421
1422    #[test]
1423    fn advisory_ids_are_found_wherever_they_sit() {
1424        let text = "More info https://github.com/advisories/GHSA-h3mg-xc3c-68pw\n\
1425                    ID: RUSTSEC-2025-0001, GO-2024-2687 PYSEC-2023-74 CVE-2024-35195 OSV-2024-12 GHSA-bad";
1426        assert_eq!(
1427            advisory_ids(text),
1428            vec![
1429                "CVE-2024-35195",
1430                "GHSA-h3mg-xc3c-68pw",
1431                "GO-2024-2687",
1432                "OSV-2024-12",
1433                "PYSEC-2023-74",
1434                "RUSTSEC-2025-0001"
1435            ]
1436        );
1437    }
1438
1439    #[test]
1440    fn a_waiver_is_reasoned_dated_and_bounded() {
1441        let today = days_from_date("2026-10-05").unwrap();
1442        let w = Waivers::parse(
1443            "# id expires reason\n\
1444             GHSA-aaaa-bbbb-cccc 2026-12-01 no patched version; build-time only\n\
1445             GHSA-dddd-eeee-ffff 2026-10-01 expired one\n\
1446             GHSA-gggg-hhhh-iiii 2027-02-01 too far ahead\n\
1447             GHSA-jjjj-kkkk-llll 2026-11-01\n\
1448             GHSA-mmmm-nnnn-oooo soon no date\n",
1449            today,
1450        );
1451        assert!(w.covers("GHSA-aaaa-bbbb-cccc"));
1452        assert!(!w.covers("GHSA-dddd-eeee-ffff"), "expired");
1453        assert!(!w.covers("GHSA-gggg-hhhh-iiii"), "beyond the horizon");
1454        assert!(!w.covers("GHSA-jjjj-kkkk-llll"), "no reason");
1455        assert!(!w.covers("GHSA-mmmm-nnnn-oooo"), "no date");
1456        assert_eq!(w.problems().len(), 4);
1457        assert_eq!(days_from_date("1970-01-01"), Some(0));
1458        assert_eq!(days_from_date("2026-02-30"), None);
1459        assert!(days_from_date("2028-02-29").is_some());
1460        assert_eq!(days_from_date("2026-13-01"), None);
1461    }
1462
1463    #[test]
1464    fn two_locked_versions_are_two_answers() {
1465        let out = "Crate: foo\nVersion: 0.1.0\nID: RUSTSEC-2025-0010\n\
1466                   Crate: foo\nVersion: 0.2.0\nID: RUSTSEC-2025-0011\n";
1467        let ids = vec![
1468            "RUSTSEC-2025-0010".to_string(),
1469            "RUSTSEC-2025-0011".to_string(),
1470        ];
1471        let (shipped, dev) = split_shipped_crates(ids, out, |spec| match spec {
1472            "foo@0.1.0" => Some("warning: nothing to print.\n".into()),
1473            "foo@0.2.0" => Some("foo v0.2.0\n└── me v0.1.0 (/repo)\n".into()),
1474            _ => None,
1475        });
1476        assert_eq!(shipped, vec!["RUSTSEC-2025-0011"]);
1477        assert_eq!(dev, vec!["RUSTSEC-2025-0010"]);
1478    }
1479
1480    #[test]
1481    fn a_pip_row_without_a_nameable_id_is_unwaivable() {
1482        let named = "requests 2.31.0 PYSEC-2023-74 2.31.1\nFound 1 known vulnerability\n";
1483        let unnamed = "evil 1.0.0 MAL-2024-1234\nrequests 2.31.0 PYSEC-2023-74 2.31.1\n";
1484        assert!(pip_rows_all_named(named));
1485        assert!(!pip_rows_all_named(unnamed));
1486    }
1487
1488    #[test]
1489    fn a_crate_ships_unless_its_tree_says_otherwise() {
1490        let out = "Crate: dev\nVersion: 0.2.0\nID: RUSTSEC-2025-0001\n\
1491                   Crate: app\nID: RUSTSEC-2025-0002\n\
1492                   Crate: odd\nID: RUSTSEC-2025-0004\nRUSTSEC-2025-0003\n";
1493        let ids = vec![
1494            "RUSTSEC-2025-0001".to_string(),
1495            "RUSTSEC-2025-0002".to_string(),
1496            "RUSTSEC-2025-0003".to_string(),
1497            "RUSTSEC-2025-0004".to_string(),
1498        ];
1499        let (shipped, dev) = split_shipped_crates(ids, out, |spec| match spec {
1500            // Asked by name@version when cargo audit gave the version.
1501            "dev@0.2.0" => Some("warning: nothing to print.\n".into()),
1502            "app" => Some("app v1.0.0\n└── me v0.1.0 (/repo)\n".into()),
1503            // A tree that says neither: not proof of dev-only.
1504            "odd" => Some("odd v1.0.0\n".into()),
1505            _ => None,
1506        });
1507        // 0003 has no crate, 0004's tree is unclear: unknowns ship.
1508        assert_eq!(
1509            shipped,
1510            vec![
1511                "RUSTSEC-2025-0002",
1512                "RUSTSEC-2025-0003",
1513                "RUSTSEC-2025-0004"
1514            ]
1515        );
1516        assert_eq!(dev, vec!["RUSTSEC-2025-0001"]);
1517    }
1518
1519    #[test]
1520    fn a_release_is_a_v_number_tag() {
1521        assert!(releasing(&[tag("refs/tags/v1.6.6")]));
1522        assert!(releasing(&[tag("refs/tags/v2")]));
1523        assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
1524        assert!(!releasing(&[tag("refs/tags/release")]));
1525        assert!(!releasing(&[tag("refs/heads/v1-styles")]));
1526        assert!(!releasing(&[tag("refs/heads/main")]));
1527        // A mixed push gates: the tag is in there.
1528        assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
1529    }
1530
1531    /// ci.yaml's lesson, pinned at the unit level: the ids decide, the exit
1532    /// code only classifies them.
1533    #[test]
1534    fn cargo_audit_ids_decide_not_the_exit_code() {
1535        assert_eq!(
1536            read_cargo_audit(true, "ok, 312 crates checked"),
1537            Report::Clean
1538        );
1539        assert_eq!(
1540            read_cargo_audit(false, "error: couldn't fetch advisory database"),
1541            Report::CouldNotCheck
1542        );
1543        let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
1544        assert_eq!(
1545            read_cargo_audit(true, warn),
1546            Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
1547        );
1548        let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
1549        assert_eq!(
1550            read_cargo_audit(false, vuln),
1551            Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
1552        );
1553        // A lookalike is not an id.
1554        assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
1555    }
1556
1557    /// The real report shape, trimmed from the run that prompted this: two
1558    /// advisories against ONE crate, and one against another.
1559    fn real_report() -> String {
1560        [
1561            "Crate:     paste",
1562            "Version:   1.0.15",
1563            "Warning:   unmaintained",
1564            "ID:        RUSTSEC-2024-0436",
1565            "URL:       https://rustsec.org/advisories/RUSTSEC-2024-0436",
1566            "",
1567            "Crate:     lru",
1568            "Version:   0.12.5",
1569            "Warning:   unsound",
1570            "ID:        RUSTSEC-2026-0253",
1571            "",
1572            "Crate:     lru",
1573            "Version:   0.12.5",
1574            "ID:        RUSTSEC-2026-0002",
1575            "",
1576            "warning: 3 allowed warnings found",
1577        ]
1578        .join("\n")
1579    }
1580
1581    #[test]
1582    fn an_advisory_is_paired_with_its_crate() {
1583        let pairs = ids_with_crates(&real_report());
1584        assert_eq!(
1585            pairs,
1586            vec![
1587                ("RUSTSEC-2024-0436".into(), Some("paste".into())),
1588                ("RUSTSEC-2026-0253".into(), Some("lru".into())),
1589                ("RUSTSEC-2026-0002".into(), Some("lru".into())),
1590            ]
1591        );
1592    }
1593
1594    /// A `Crate:` binds to ONE id. An id printed loose reports no crate
1595    /// rather than inheriting whichever block happened to precede it.
1596    #[test]
1597    fn a_loose_id_borrows_no_crate() {
1598        let out = "Crate:     paste\nID:        RUSTSEC-2024-0436\nsee also RUSTSEC-2025-0001";
1599        assert_eq!(
1600            ids_with_crates(out),
1601            vec![
1602                ("RUSTSEC-2024-0436".into(), Some("paste".into())),
1603                ("RUSTSEC-2025-0001".into(), None),
1604            ]
1605        );
1606    }
1607
1608    /// Local crates carry a path; registry crates do not. That is the whole
1609    /// discriminator, and it has to work without knowing the member names.
1610    #[test]
1611    fn only_local_crates_are_named_as_reached() {
1612        let tree =
1613            "lru v0.12.5\n└── ratatui v0.29.0\n    └── amont-fleet v1.32.0 (/w/crates/amont-fleet)";
1614        assert_eq!(local_dependents(tree), vec!["amont-fleet".to_string()]);
1615        assert!(local_dependents("lru v0.12.5\n└── ratatui v0.29.0").is_empty());
1616    }
1617
1618    /// End to end on the real report, with the tree calls faked: each id
1619    /// names its crate and the workspace crate that reaches it, and `lru`'s
1620    /// two advisories cost ONE lookup.
1621    #[test]
1622    fn the_warning_names_the_crate_and_what_reaches_it() {
1623        let calls = std::cell::RefCell::new(Vec::new());
1624        let fake = |k: &str| {
1625            calls.borrow_mut().push(k.to_string());
1626            Some(format!(
1627                "{k} v1\n└── ratatui v0.29.0\n    └── amont-fleet v1.32.0 (/w/crates/amont-fleet)"
1628            ))
1629        };
1630        let out = real_report();
1631        let got = attribute(read_cargo_audit(true, &out), &out, fake);
1632        assert_eq!(
1633            got,
1634            Report::Advisories(vec![
1635                "RUSTSEC-2024-0436 (paste → amont-fleet)".into(),
1636                "RUSTSEC-2026-0002 (lru → amont-fleet)".into(),
1637                "RUSTSEC-2026-0253 (lru → amont-fleet)".into(),
1638            ])
1639        );
1640        assert_eq!(
1641            calls.into_inner(),
1642            vec!["paste", "lru"],
1643            "one call per crate"
1644        );
1645    }
1646
1647    /// A lock-file-only crate — an optional dependency of a feature nobody
1648    /// enabled — is reported as such. `cargo audit` reads Cargo.lock, so this
1649    /// is a real and confusing case, and naming it is the point.
1650    #[test]
1651    fn a_crate_outside_the_build_graph_says_so() {
1652        let out = "Crate:     wezterm-input-types\nID:        RUSTSEC-2025-0001";
1653        let got = attribute(read_cargo_audit(true, out), out, |_| {
1654            Some("warning: nothing to print.".into())
1655        });
1656        assert_eq!(
1657            got,
1658            Report::Advisories(vec![
1659                "RUSTSEC-2025-0001 (wezterm-input-types, not in the build graph)".into()
1660            ])
1661        );
1662    }
1663
1664    /// Attribution is a better message, never a gate. A clean report spawns
1665    /// nothing, and a `cargo tree` that cannot run still reports the id.
1666    #[test]
1667    fn attribution_never_changes_the_verdict() {
1668        let spawned = std::cell::Cell::new(false);
1669        let clean = attribute(read_cargo_audit(true, "0 vulnerabilities"), "", |_| {
1670            spawned.set(true);
1671            None
1672        });
1673        assert_eq!(clean, Report::Clean);
1674        assert!(!spawned.get(), "a clean report must spawn nothing");
1675
1676        let out = "Crate:     paste\nID:        RUSTSEC-2024-0436";
1677        let blind = attribute(read_cargo_audit(false, out), out, |_| None);
1678        assert_eq!(
1679            blind,
1680            Report::Vulnerabilities(vec![
1681                "RUSTSEC-2024-0436 (paste, not in the build graph)".into()
1682            ])
1683        );
1684    }
1685
1686    #[test]
1687    fn npm_audit_summary_decides() {
1688        assert_eq!(
1689            read_npm_audit(true, "found 0 vulnerabilities\n"),
1690            Report::Clean
1691        );
1692        assert_eq!(
1693            read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
1694            Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
1695        );
1696        // npm 7+ dropped the verb: this is what every current npm prints,
1697        // and what read as "never answered" until the parser learned it.
1698        assert_eq!(
1699            read_npm_audit(
1700                false,
1701                "# npm audit report\n\nvite  6.0.0 - 6.1.5\nSeverity: high\n\n\
1702                 17 vulnerabilities (8 moderate, 9 high)\n\nTo address all issues, run:\n  npm audit fix\n"
1703            ),
1704            Report::Vulnerabilities(vec!["17 vulnerabilities (8 moderate, 9 high)".into()])
1705        );
1706        assert_eq!(
1707            read_npm_audit(false, "1 vulnerability (1 high)\n"),
1708            Report::Vulnerabilities(vec!["1 vulnerability (1 high)".into()])
1709        );
1710        assert_eq!(
1711            read_npm_audit(true, "up to date, audited 100 packages\n"),
1712            Report::Clean
1713        );
1714        assert_eq!(
1715            read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
1716            Report::CouldNotCheck
1717        );
1718    }
1719
1720    #[test]
1721    fn pnpm_audit_summary_decides() {
1722        assert_eq!(
1723            read_pnpm_audit(true, "No known vulnerabilities found\n"),
1724            Report::Clean
1725        );
1726        // What pnpm prints after its table: the count, then the severities,
1727        // which the finding carries so the warning says how bad.
1728        assert_eq!(
1729            read_pnpm_audit(
1730                false,
1731                "│ More info │ https://github.com/advisories/GHSA-395f │\n\
1732                 └───────────┴──────────────────────────────────────────┘\n\
1733                 107 vulnerabilities found\n\
1734                 Severity: 6 low | 45 moderate | 54 high | 2 critical\n"
1735            ),
1736            Report::Vulnerabilities(vec![
1737                "107 vulnerabilities found (6 low | 45 moderate | 54 high | 2 critical)".into()
1738            ])
1739        );
1740        assert_eq!(
1741            read_pnpm_audit(false, "1 vulnerabilities found\n"),
1742            Report::Vulnerabilities(vec!["1 vulnerabilities found".into()])
1743        );
1744        assert_eq!(
1745            read_pnpm_audit(
1746                false,
1747                " ERR_PNPM_AUDIT_BAD_RESPONSE  The audit endpoint responded with 503\n"
1748            ),
1749            Report::CouldNotCheck
1750        );
1751    }
1752
1753    /// The cargo-audit split, spoken in Go: ids decide, the exit code says
1754    /// whether the analysed code is actually affected.
1755    #[test]
1756    fn govulncheck_ids_decide_not_the_exit_code() {
1757        assert_eq!(
1758            read_govulncheck(true, "No vulnerabilities found.\n"),
1759            Report::Clean
1760        );
1761        assert_eq!(
1762            read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
1763            Report::CouldNotCheck
1764        );
1765        // Informational: the module is vulnerable, the analysed code never
1766        // calls it — exit 0, ids present.
1767        assert_eq!(
1768            read_govulncheck(
1769                true,
1770                "=== Informational ===\nVulnerability #1: GO-2023-1840\n  More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
1771            ),
1772            Report::Advisories(vec!["GO-2023-1840".into()])
1773        );
1774        assert_eq!(
1775            read_govulncheck(
1776                false,
1777                "Vulnerability #1: GO-2022-0969\n  Your code calls it.\nGO-2022-0969 again\n"
1778            ),
1779            Report::Vulnerabilities(vec!["GO-2022-0969".into()])
1780        );
1781        // A lookalike is not an id.
1782        assert_eq!(
1783            read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
1784            Report::Clean
1785        );
1786    }
1787
1788    #[test]
1789    fn pip_audit_sentence_decides() {
1790        assert_eq!(
1791            read_pip_audit(true, "No known vulnerabilities found\n"),
1792            Report::Clean
1793        );
1794        assert_eq!(
1795            read_pip_audit(
1796                false,
1797                "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
1798            ),
1799            Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
1800        );
1801        assert_eq!(
1802            read_pip_audit(false, "ERROR: could not resolve\n"),
1803            Report::CouldNotCheck
1804        );
1805    }
1806}