1use crate::check::Outcome;
26use crate::pushrefs::PushRef;
27
28use super::common;
29
30#[derive(Debug, PartialEq, Eq)]
32enum Report {
33 Clean,
34 Advisories(Vec<String>),
37 Vulnerabilities(Vec<String>),
39 CouldNotCheck,
41}
42
43fn 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
55fn conclude(
59 settings: &crate::config::Settings,
60 tool: &str,
61 report: Report,
62 releasing: bool,
63 full: &str,
64) -> Outcome {
65 match report {
66 Report::Clean => {
67 common::ok(settings, &format!("{tool}: no known vulnerabilities"));
68 Outcome::Passed
69 }
70 Report::Advisories(ids) => {
71 common::warn(&format!(
72 "{tool}: advisories against the dependency tree (warnings — unmaintained/unsound): {}",
73 ids.join(", ")
74 ));
75 Outcome::Warned
76 }
77 Report::Vulnerabilities(what) => {
78 if releasing {
79 for line in full.lines() {
80 crate::say!("{line}");
81 }
82 common::fail(&format!(
83 "{tool}: known vulnerabilities in the dependency tree — a v* tag \
84 does not ship with these: {}",
85 what.join(", ")
86 ));
87 Outcome::Failed
88 } else {
89 common::warn(&format!(
90 "{tool}: known vulnerabilities in the dependency tree ({}) — \
91 this will BLOCK a v* tag push",
92 what.join(", ")
93 ));
94 Outcome::Warned
95 }
96 }
97 Report::CouldNotCheck => {
98 common::warn(&format!(
99 "{tool} could not complete — the dependency tree was NOT checked. \
100 This is not a clean result."
101 ));
102 Outcome::Unavailable
103 }
104 }
105}
106
107fn is_advisory_id(w: &str) -> bool {
109 w.len() == 17
110 && w.starts_with("RUSTSEC-")
111 && w[8..12].bytes().all(|b| b.is_ascii_digit())
112 && w.as_bytes()[12] == b'-'
113 && w[13..17].bytes().all(|b| b.is_ascii_digit())
114}
115
116fn ids_with_crates(out: &str) -> Vec<(String, Option<String>)> {
127 let mut pairs: Vec<(String, Option<String>)> = Vec::new();
128 let mut pending: Option<String> = None;
129 for line in out.lines() {
130 let t = line.trim();
131 if let Some(rest) = t.strip_prefix("Crate:") {
132 pending = rest.split_whitespace().next().map(str::to_string);
133 continue;
134 }
135 if let Some(rest) = t.strip_prefix("ID:") {
136 if let Some(id) = rest.split_whitespace().next().filter(|w| is_advisory_id(w)) {
137 pairs.push((id.to_string(), pending.take()));
138 continue;
139 }
140 }
141 for w in t.split_whitespace().filter(|w| is_advisory_id(w)) {
144 pairs.push((w.to_string(), None));
145 }
146 }
147 pairs
148}
149
150fn local_dependents(tree_out: &str) -> Vec<String> {
162 let mut names: Vec<String> = tree_out
163 .lines()
164 .filter(|l| l.contains(" (/"))
165 .filter_map(|l| {
166 l.split_whitespace()
167 .find(|w| !w.is_empty() && w.chars().next().is_some_and(|c| c.is_alphanumeric()))
168 .map(str::to_string)
169 })
170 .collect();
171 names.sort();
172 names.dedup();
173 names
174}
175
176fn describe(id: &str, krate: Option<&str>, reaches: &[String]) -> String {
179 match (krate, reaches.is_empty()) {
180 (None, _) => id.to_string(),
181 (Some(k), true) => format!("{id} ({k}, not in the build graph)"),
182 (Some(k), false) => format!("{id} ({k} → {})", reaches.join(", ")),
183 }
184}
185
186fn read_cargo_audit(exit_ok: bool, out: &str) -> Report {
189 let mut ids: Vec<String> = out
190 .split_whitespace()
191 .filter(|w| {
192 w.len() == 17
193 && w.starts_with("RUSTSEC-")
194 && w[8..12].bytes().all(|b| b.is_ascii_digit())
195 && w.as_bytes()[12] == b'-'
196 && w[13..17].bytes().all(|b| b.is_ascii_digit())
197 })
198 .map(|w| w.to_string())
199 .collect();
200 ids.sort();
201 ids.dedup();
202 match (ids.is_empty(), exit_ok) {
203 (true, true) => Report::Clean,
204 (true, false) => Report::CouldNotCheck,
205 (false, true) => Report::Advisories(ids),
206 (false, false) => Report::Vulnerabilities(ids),
207 }
208}
209
210fn read_npm_audit(exit_ok: bool, out: &str) -> Report {
215 let summary = out
216 .lines()
217 .rev()
218 .map(str::trim)
219 .find(|l| l.starts_with("found ") && l.contains("vulnerabilit"));
220 match summary {
221 Some(l) if l.starts_with("found 0 ") => Report::Clean,
222 Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
223 None if exit_ok => Report::Clean,
224 None => Report::CouldNotCheck,
225 }
226}
227
228fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
235 let mut ids: Vec<String> = out
236 .split_whitespace()
237 .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
238 .filter(|w| {
239 w.len() >= 12
240 && w.starts_with("GO-")
241 && w[3..7].bytes().all(|b| b.is_ascii_digit())
242 && w.as_bytes()[7] == b'-'
243 && w[8..].bytes().all(|b| b.is_ascii_digit())
244 })
245 .map(|w| w.to_string())
246 .collect();
247 ids.sort();
248 ids.dedup();
249 match (ids.is_empty(), exit_ok) {
250 (true, true) => Report::Clean,
251 (true, false) => Report::CouldNotCheck,
252 (false, true) => Report::Advisories(ids),
253 (false, false) => Report::Vulnerabilities(ids),
254 }
255}
256
257fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
259 if out.contains("No known vulnerabilities found") {
260 return Report::Clean;
261 }
262 if let Some(line) = out
263 .lines()
264 .map(str::trim)
265 .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
266 {
267 return Report::Vulnerabilities(vec![line.to_string()]);
268 }
269 if exit_ok {
270 Report::Clean
271 } else {
272 Report::CouldNotCheck
273 }
274}
275
276fn venv_site_packages(root: &str) -> Option<String> {
285 let candidates = std::env::var_os("VIRTUAL_ENV")
286 .map(std::path::PathBuf::from)
287 .into_iter()
288 .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
289 for venv in candidates {
290 let windows = venv.join("Lib").join("site-packages");
291 if windows.is_dir() {
292 return Some(windows.to_string_lossy().into_owned());
293 }
294 let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
295 continue;
296 };
297 for e in entries.flatten() {
298 if !e.file_name().to_string_lossy().starts_with("python") {
299 continue;
300 }
301 let sp = e.path().join("site-packages");
302 if sp.is_dir() {
303 return Some(sp.to_string_lossy().into_owned());
304 }
305 }
306 }
307 None
308}
309
310fn audited(settings: &crate::config::Settings, argv: &[String]) -> Option<(bool, String)> {
312 let root = common::repo_root();
313 let mut cmd = std::process::Command::new(&argv[0]);
314 cmd.args(&argv[1..])
315 .current_dir(&root)
316 .stdin(std::process::Stdio::null());
317 common::strip_git_env(&mut cmd);
318 let (ran, out) = common::capture_within(settings, &mut cmd)?;
319 match ran {
320 common::Ran::Status(s) => Some((s.success(), out)),
321 common::Ran::TimedOut(budget) => {
322 common::say_timed_out(&argv[0], budget);
323 None
324 }
325 }
326}
327
328pub fn rust(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
329 if !has_lockfile("Cargo.lock") {
330 return Outcome::Inert;
331 }
332 if common::which("cargo-audit").is_none() {
333 common::warn(
334 "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
335 the audit did NOT run",
336 );
337 return Outcome::Unavailable;
338 }
339 let argv = vec![
340 common::program("cargo"),
341 "audit".into(),
342 "--color".into(),
343 "never".into(),
344 ];
345 let Some((exit_ok, out)) = audited(settings, &argv) else {
346 return Outcome::Unavailable;
347 };
348 conclude(
349 settings,
350 "audit-rust",
351 attribute(read_cargo_audit(exit_ok, &out), &out, |krate| {
352 cargo_tree_inverse(settings, krate)
353 }),
354 releasing(refs),
355 &out,
356 )
357}
358
359fn cargo_tree_inverse(settings: &crate::config::Settings, krate: &str) -> Option<String> {
363 let argv = vec![
364 common::program("cargo"),
365 "tree".into(),
366 "--invert".into(),
367 krate.into(),
368 "--edges".into(),
369 "normal".into(),
370 "--color".into(),
371 "never".into(),
372 ];
373 audited(settings, &argv).map(|(_, out)| out)
374}
375
376fn attribute(report: Report, out: &str, tree: impl Fn(&str) -> Option<String>) -> Report {
384 match report {
385 Report::Advisories(ids) => Report::Advisories(described(ids, out, tree)),
386 Report::Vulnerabilities(ids) => Report::Vulnerabilities(described(ids, out, tree)),
387 other => other,
390 }
391}
392
393fn described(ids: Vec<String>, out: &str, tree: impl Fn(&str) -> Option<String>) -> Vec<String> {
397 let pairs = ids_with_crates(out);
398 let mut seen: Vec<(String, Vec<String>)> = Vec::new();
399 ids.iter()
400 .map(|id| {
401 let krate = pairs
402 .iter()
403 .find(|(pid, k)| pid == id && k.is_some())
404 .and_then(|(_, k)| k.clone());
405 let Some(k) = krate else {
406 return id.clone();
407 };
408 if let Some((_, reaches)) = seen.iter().find(|(name, _)| *name == k) {
409 return describe(id, Some(&k), reaches);
410 }
411 let reaches = tree(&k).map(|t| local_dependents(&t)).unwrap_or_default();
412 seen.push((k.clone(), reaches.clone()));
413 describe(id, Some(&k), &reaches)
414 })
415 .collect()
416}
417
418fn has_lockfile(lockfile: &str) -> bool {
425 crate::tracked_paths()
426 .iter()
427 .any(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
428}
429
430pub fn js(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
431 if !has_lockfile("package-lock.json") {
432 return Outcome::Inert;
433 }
434 let argv = vec![common::program("npm"), "audit".into()];
435 let Some((exit_ok, out)) = audited(settings, &argv) else {
436 common::warn("audit-js: npm could not run — the audit did NOT run");
437 return Outcome::Unavailable;
438 };
439 conclude(
440 settings,
441 "audit-js",
442 read_npm_audit(exit_ok, &out),
443 releasing(refs),
444 &out,
445 )
446}
447
448pub fn go(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
449 if !has_lockfile("go.sum") {
450 return Outcome::Inert;
451 }
452 if common::which("govulncheck").is_none() {
453 common::warn(
454 "audit-go: govulncheck is not installed \
455 (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
456 );
457 return Outcome::Unavailable;
458 }
459 let argv = vec![common::program("govulncheck"), "./...".into()];
460 let Some((exit_ok, out)) = audited(settings, &argv) else {
461 return Outcome::Unavailable;
462 };
463 conclude(
464 settings,
465 "audit-go",
466 read_govulncheck(exit_ok, &out),
467 releasing(refs),
468 &out,
469 )
470}
471
472pub fn python(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
473 if !has_lockfile("requirements.txt") && !has_lockfile("pyproject.toml") {
474 return Outcome::Inert;
475 }
476 if common::which("pip-audit").is_none() {
477 common::warn(
478 "audit-python: pip-audit is not installed (pip install pip-audit) — \
479 the audit did NOT run",
480 );
481 return Outcome::Unavailable;
482 }
483 let root = common::repo_root();
484 let argv = if std::path::Path::new(&root)
485 .join("requirements.txt")
486 .exists()
487 {
488 vec![
489 common::program("pip-audit"),
490 "-r".into(),
491 "requirements.txt".into(),
492 ]
493 } else if let Some(site_packages) = venv_site_packages(&root) {
494 vec![
502 common::program("pip-audit"),
503 "--path".into(),
504 site_packages,
505 "--skip-editable".into(),
508 ]
509 } else {
510 common::warn(
511 "audit-python: no requirements.txt, and no virtualenv to audit \
512 (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
513 );
514 return Outcome::Unavailable;
515 };
516 let Some((exit_ok, out)) = audited(settings, &argv) else {
517 return Outcome::Unavailable;
518 };
519 conclude(
520 settings,
521 "audit-python",
522 read_pip_audit(exit_ok, &out),
523 releasing(refs),
524 &out,
525 )
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
538 fn a_uv_project_is_audited_through_its_venv() {
539 let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
540 let _ = std::fs::remove_dir_all(&root);
541
542 std::fs::create_dir_all(&root).unwrap();
545 assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
546
547 let sp = root
554 .join(".venv")
555 .join("lib")
556 .join("python3.13")
557 .join("site-packages");
558 std::fs::create_dir_all(&sp).unwrap();
559 assert_eq!(
560 venv_site_packages(root.to_str().unwrap()),
561 Some(sp.to_string_lossy().into_owned())
562 );
563
564 let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
566 let _ = std::fs::remove_dir_all(&win);
567 let wsp = win.join(".venv").join("Lib").join("site-packages");
568 std::fs::create_dir_all(&wsp).unwrap();
569 assert_eq!(
570 venv_site_packages(win.to_str().unwrap()),
571 Some(wsp.to_string_lossy().into_owned())
572 );
573
574 let _ = std::fs::remove_dir_all(&root);
575 let _ = std::fs::remove_dir_all(&win);
576 }
577
578 fn tag(name: &str) -> PushRef {
579 PushRef {
580 local_ref: name.to_string(),
581 local_oid: "a".repeat(40),
582 remote_ref: name.to_string(),
583 remote_oid: "0".repeat(40),
584 }
585 }
586
587 #[test]
590 fn a_release_is_a_v_number_tag() {
591 assert!(releasing(&[tag("refs/tags/v1.6.6")]));
592 assert!(releasing(&[tag("refs/tags/v2")]));
593 assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
594 assert!(!releasing(&[tag("refs/tags/release")]));
595 assert!(!releasing(&[tag("refs/heads/v1-styles")]));
596 assert!(!releasing(&[tag("refs/heads/main")]));
597 assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
599 }
600
601 #[test]
604 fn cargo_audit_ids_decide_not_the_exit_code() {
605 assert_eq!(
606 read_cargo_audit(true, "ok, 312 crates checked"),
607 Report::Clean
608 );
609 assert_eq!(
610 read_cargo_audit(false, "error: couldn't fetch advisory database"),
611 Report::CouldNotCheck
612 );
613 let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
614 assert_eq!(
615 read_cargo_audit(true, warn),
616 Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
617 );
618 let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
619 assert_eq!(
620 read_cargo_audit(false, vuln),
621 Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
622 );
623 assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
625 }
626
627 fn real_report() -> String {
630 [
631 "Crate: paste",
632 "Version: 1.0.15",
633 "Warning: unmaintained",
634 "ID: RUSTSEC-2024-0436",
635 "URL: https://rustsec.org/advisories/RUSTSEC-2024-0436",
636 "",
637 "Crate: lru",
638 "Version: 0.12.5",
639 "Warning: unsound",
640 "ID: RUSTSEC-2026-0253",
641 "",
642 "Crate: lru",
643 "Version: 0.12.5",
644 "ID: RUSTSEC-2026-0002",
645 "",
646 "warning: 3 allowed warnings found",
647 ]
648 .join("\n")
649 }
650
651 #[test]
652 fn an_advisory_is_paired_with_its_crate() {
653 let pairs = ids_with_crates(&real_report());
654 assert_eq!(
655 pairs,
656 vec![
657 ("RUSTSEC-2024-0436".into(), Some("paste".into())),
658 ("RUSTSEC-2026-0253".into(), Some("lru".into())),
659 ("RUSTSEC-2026-0002".into(), Some("lru".into())),
660 ]
661 );
662 }
663
664 #[test]
667 fn a_loose_id_borrows_no_crate() {
668 let out = "Crate: paste\nID: RUSTSEC-2024-0436\nsee also RUSTSEC-2025-0001";
669 assert_eq!(
670 ids_with_crates(out),
671 vec![
672 ("RUSTSEC-2024-0436".into(), Some("paste".into())),
673 ("RUSTSEC-2025-0001".into(), None),
674 ]
675 );
676 }
677
678 #[test]
681 fn only_local_crates_are_named_as_reached() {
682 let tree =
683 "lru v0.12.5\n└── ratatui v0.29.0\n └── amont-fleet v1.32.0 (/w/crates/amont-fleet)";
684 assert_eq!(local_dependents(tree), vec!["amont-fleet".to_string()]);
685 assert!(local_dependents("lru v0.12.5\n└── ratatui v0.29.0").is_empty());
686 }
687
688 #[test]
692 fn the_warning_names_the_crate_and_what_reaches_it() {
693 let calls = std::cell::RefCell::new(Vec::new());
694 let fake = |k: &str| {
695 calls.borrow_mut().push(k.to_string());
696 Some(format!(
697 "{k} v1\n└── ratatui v0.29.0\n └── amont-fleet v1.32.0 (/w/crates/amont-fleet)"
698 ))
699 };
700 let out = real_report();
701 let got = attribute(read_cargo_audit(true, &out), &out, fake);
702 assert_eq!(
703 got,
704 Report::Advisories(vec![
705 "RUSTSEC-2024-0436 (paste → amont-fleet)".into(),
706 "RUSTSEC-2026-0002 (lru → amont-fleet)".into(),
707 "RUSTSEC-2026-0253 (lru → amont-fleet)".into(),
708 ])
709 );
710 assert_eq!(
711 calls.into_inner(),
712 vec!["paste", "lru"],
713 "one call per crate"
714 );
715 }
716
717 #[test]
721 fn a_crate_outside_the_build_graph_says_so() {
722 let out = "Crate: wezterm-input-types\nID: RUSTSEC-2025-0001";
723 let got = attribute(read_cargo_audit(true, out), out, |_| {
724 Some("warning: nothing to print.".into())
725 });
726 assert_eq!(
727 got,
728 Report::Advisories(vec![
729 "RUSTSEC-2025-0001 (wezterm-input-types, not in the build graph)".into()
730 ])
731 );
732 }
733
734 #[test]
737 fn attribution_never_changes_the_verdict() {
738 let spawned = std::cell::Cell::new(false);
739 let clean = attribute(read_cargo_audit(true, "0 vulnerabilities"), "", |_| {
740 spawned.set(true);
741 None
742 });
743 assert_eq!(clean, Report::Clean);
744 assert!(!spawned.get(), "a clean report must spawn nothing");
745
746 let out = "Crate: paste\nID: RUSTSEC-2024-0436";
747 let blind = attribute(read_cargo_audit(false, out), out, |_| None);
748 assert_eq!(
749 blind,
750 Report::Vulnerabilities(vec![
751 "RUSTSEC-2024-0436 (paste, not in the build graph)".into()
752 ])
753 );
754 }
755
756 #[test]
757 fn npm_audit_summary_decides() {
758 assert_eq!(
759 read_npm_audit(true, "found 0 vulnerabilities\n"),
760 Report::Clean
761 );
762 assert_eq!(
763 read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
764 Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
765 );
766 assert_eq!(
767 read_npm_audit(true, "up to date, audited 100 packages\n"),
768 Report::Clean
769 );
770 assert_eq!(
771 read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
772 Report::CouldNotCheck
773 );
774 }
775
776 #[test]
779 fn govulncheck_ids_decide_not_the_exit_code() {
780 assert_eq!(
781 read_govulncheck(true, "No vulnerabilities found.\n"),
782 Report::Clean
783 );
784 assert_eq!(
785 read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
786 Report::CouldNotCheck
787 );
788 assert_eq!(
791 read_govulncheck(
792 true,
793 "=== Informational ===\nVulnerability #1: GO-2023-1840\n More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
794 ),
795 Report::Advisories(vec!["GO-2023-1840".into()])
796 );
797 assert_eq!(
798 read_govulncheck(
799 false,
800 "Vulnerability #1: GO-2022-0969\n Your code calls it.\nGO-2022-0969 again\n"
801 ),
802 Report::Vulnerabilities(vec!["GO-2022-0969".into()])
803 );
804 assert_eq!(
806 read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
807 Report::Clean
808 );
809 }
810
811 #[test]
812 fn pip_audit_sentence_decides() {
813 assert_eq!(
814 read_pip_audit(true, "No known vulnerabilities found\n"),
815 Report::Clean
816 );
817 assert_eq!(
818 read_pip_audit(
819 false,
820 "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
821 ),
822 Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
823 );
824 assert_eq!(
825 read_pip_audit(false, "ERROR: could not resolve\n"),
826 Report::CouldNotCheck
827 );
828 }
829}