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(tool: &str, report: Report, releasing: bool, full: &str) -> Outcome {
59 match report {
60 Report::Clean => {
61 common::ok(&format!("{tool}: no known vulnerabilities"));
62 Outcome::Passed
63 }
64 Report::Advisories(ids) => {
65 common::warn(&format!(
66 "{tool}: advisories against the dependency tree (warnings — unmaintained/unsound): {}",
67 ids.join(", ")
68 ));
69 Outcome::Warned
70 }
71 Report::Vulnerabilities(what) => {
72 if releasing {
73 for line in full.lines() {
74 crate::say!("{line}");
75 }
76 common::fail(&format!(
77 "{tool}: known vulnerabilities in the dependency tree — a v* tag \
78 does not ship with these: {}",
79 what.join(", ")
80 ));
81 Outcome::Failed
82 } else {
83 common::warn(&format!(
84 "{tool}: known vulnerabilities in the dependency tree ({}) — \
85 this will BLOCK a v* tag push",
86 what.join(", ")
87 ));
88 Outcome::Warned
89 }
90 }
91 Report::CouldNotCheck => {
92 common::warn(&format!(
93 "{tool} could not complete — the dependency tree was NOT checked. \
94 This is not a clean result."
95 ));
96 Outcome::Unavailable
97 }
98 }
99}
100
101fn is_advisory_id(w: &str) -> bool {
103 w.len() == 17
104 && w.starts_with("RUSTSEC-")
105 && w[8..12].bytes().all(|b| b.is_ascii_digit())
106 && w.as_bytes()[12] == b'-'
107 && w[13..17].bytes().all(|b| b.is_ascii_digit())
108}
109
110fn ids_with_crates(out: &str) -> Vec<(String, Option<String>)> {
121 let mut pairs: Vec<(String, Option<String>)> = Vec::new();
122 let mut pending: Option<String> = None;
123 for line in out.lines() {
124 let t = line.trim();
125 if let Some(rest) = t.strip_prefix("Crate:") {
126 pending = rest.split_whitespace().next().map(str::to_string);
127 continue;
128 }
129 if let Some(rest) = t.strip_prefix("ID:") {
130 if let Some(id) = rest.split_whitespace().next().filter(|w| is_advisory_id(w)) {
131 pairs.push((id.to_string(), pending.take()));
132 continue;
133 }
134 }
135 for w in t.split_whitespace().filter(|w| is_advisory_id(w)) {
138 pairs.push((w.to_string(), None));
139 }
140 }
141 pairs
142}
143
144fn local_dependents(tree_out: &str) -> Vec<String> {
156 let mut names: Vec<String> = tree_out
157 .lines()
158 .filter(|l| l.contains(" (/"))
159 .filter_map(|l| {
160 l.split_whitespace()
161 .find(|w| !w.is_empty() && w.chars().next().is_some_and(|c| c.is_alphanumeric()))
162 .map(str::to_string)
163 })
164 .collect();
165 names.sort();
166 names.dedup();
167 names
168}
169
170fn describe(id: &str, krate: Option<&str>, reaches: &[String]) -> String {
173 match (krate, reaches.is_empty()) {
174 (None, _) => id.to_string(),
175 (Some(k), true) => format!("{id} ({k}, not in the build graph)"),
176 (Some(k), false) => format!("{id} ({k} → {})", reaches.join(", ")),
177 }
178}
179
180fn read_cargo_audit(exit_ok: bool, out: &str) -> Report {
183 let mut ids: Vec<String> = out
184 .split_whitespace()
185 .filter(|w| {
186 w.len() == 17
187 && w.starts_with("RUSTSEC-")
188 && w[8..12].bytes().all(|b| b.is_ascii_digit())
189 && w.as_bytes()[12] == b'-'
190 && w[13..17].bytes().all(|b| b.is_ascii_digit())
191 })
192 .map(|w| w.to_string())
193 .collect();
194 ids.sort();
195 ids.dedup();
196 match (ids.is_empty(), exit_ok) {
197 (true, true) => Report::Clean,
198 (true, false) => Report::CouldNotCheck,
199 (false, true) => Report::Advisories(ids),
200 (false, false) => Report::Vulnerabilities(ids),
201 }
202}
203
204fn read_npm_audit(exit_ok: bool, out: &str) -> Report {
209 let summary = out
210 .lines()
211 .rev()
212 .map(str::trim)
213 .find(|l| l.starts_with("found ") && l.contains("vulnerabilit"));
214 match summary {
215 Some(l) if l.starts_with("found 0 ") => Report::Clean,
216 Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
217 None if exit_ok => Report::Clean,
218 None => Report::CouldNotCheck,
219 }
220}
221
222fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
229 let mut ids: Vec<String> = out
230 .split_whitespace()
231 .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
232 .filter(|w| {
233 w.len() >= 12
234 && w.starts_with("GO-")
235 && w[3..7].bytes().all(|b| b.is_ascii_digit())
236 && w.as_bytes()[7] == b'-'
237 && w[8..].bytes().all(|b| b.is_ascii_digit())
238 })
239 .map(|w| w.to_string())
240 .collect();
241 ids.sort();
242 ids.dedup();
243 match (ids.is_empty(), exit_ok) {
244 (true, true) => Report::Clean,
245 (true, false) => Report::CouldNotCheck,
246 (false, true) => Report::Advisories(ids),
247 (false, false) => Report::Vulnerabilities(ids),
248 }
249}
250
251fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
253 if out.contains("No known vulnerabilities found") {
254 return Report::Clean;
255 }
256 if let Some(line) = out
257 .lines()
258 .map(str::trim)
259 .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
260 {
261 return Report::Vulnerabilities(vec![line.to_string()]);
262 }
263 if exit_ok {
264 Report::Clean
265 } else {
266 Report::CouldNotCheck
267 }
268}
269
270fn venv_site_packages(root: &str) -> Option<String> {
279 let candidates = std::env::var_os("VIRTUAL_ENV")
280 .map(std::path::PathBuf::from)
281 .into_iter()
282 .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
283 for venv in candidates {
284 let windows = venv.join("Lib").join("site-packages");
285 if windows.is_dir() {
286 return Some(windows.to_string_lossy().into_owned());
287 }
288 let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
289 continue;
290 };
291 for e in entries.flatten() {
292 if !e.file_name().to_string_lossy().starts_with("python") {
293 continue;
294 }
295 let sp = e.path().join("site-packages");
296 if sp.is_dir() {
297 return Some(sp.to_string_lossy().into_owned());
298 }
299 }
300 }
301 None
302}
303
304fn audited(argv: &[String]) -> Option<(bool, String)> {
306 let root = common::repo_root();
307 let mut cmd = std::process::Command::new(&argv[0]);
308 cmd.args(&argv[1..])
309 .current_dir(&root)
310 .stdin(std::process::Stdio::null());
311 common::strip_git_env(&mut cmd);
312 let (ran, out) = common::capture_within(&mut cmd)?;
313 match ran {
314 common::Ran::Status(s) => Some((s.success(), out)),
315 common::Ran::TimedOut(budget) => {
316 common::say_timed_out(&argv[0], budget);
317 None
318 }
319 }
320}
321
322pub fn rust(refs: &[PushRef]) -> Outcome {
323 if common::which("cargo-audit").is_none() {
324 common::warn(
325 "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
326 the audit did NOT run",
327 );
328 return Outcome::Unavailable;
329 }
330 let argv = vec![
331 common::program("cargo"),
332 "audit".into(),
333 "--color".into(),
334 "never".into(),
335 ];
336 let Some((exit_ok, out)) = audited(&argv) else {
337 return Outcome::Unavailable;
338 };
339 conclude(
340 "audit-rust",
341 attribute(read_cargo_audit(exit_ok, &out), &out, cargo_tree_inverse),
342 releasing(refs),
343 &out,
344 )
345}
346
347fn cargo_tree_inverse(krate: &str) -> Option<String> {
351 let argv = vec![
352 common::program("cargo"),
353 "tree".into(),
354 "--invert".into(),
355 krate.into(),
356 "--edges".into(),
357 "normal".into(),
358 "--color".into(),
359 "never".into(),
360 ];
361 audited(&argv).map(|(_, out)| out)
362}
363
364fn attribute(report: Report, out: &str, tree: impl Fn(&str) -> Option<String>) -> Report {
372 match report {
373 Report::Advisories(ids) => Report::Advisories(described(ids, out, tree)),
374 Report::Vulnerabilities(ids) => Report::Vulnerabilities(described(ids, out, tree)),
375 other => other,
378 }
379}
380
381fn described(ids: Vec<String>, out: &str, tree: impl Fn(&str) -> Option<String>) -> Vec<String> {
385 let pairs = ids_with_crates(out);
386 let mut seen: Vec<(String, Vec<String>)> = Vec::new();
387 ids.iter()
388 .map(|id| {
389 let krate = pairs
390 .iter()
391 .find(|(pid, k)| pid == id && k.is_some())
392 .and_then(|(_, k)| k.clone());
393 let Some(k) = krate else {
394 return id.clone();
395 };
396 if let Some((_, reaches)) = seen.iter().find(|(name, _)| *name == k) {
397 return describe(id, Some(&k), reaches);
398 }
399 let reaches = tree(&k).map(|t| local_dependents(&t)).unwrap_or_default();
400 seen.push((k.clone(), reaches.clone()));
401 describe(id, Some(&k), &reaches)
402 })
403 .collect()
404}
405
406pub fn js(refs: &[PushRef]) -> Outcome {
407 let argv = vec![common::program("npm"), "audit".into()];
408 let Some((exit_ok, out)) = audited(&argv) else {
409 common::warn("audit-js: npm could not run — the audit did NOT run");
410 return Outcome::Unavailable;
411 };
412 conclude(
413 "audit-js",
414 read_npm_audit(exit_ok, &out),
415 releasing(refs),
416 &out,
417 )
418}
419
420pub fn go(refs: &[PushRef]) -> Outcome {
421 if common::which("govulncheck").is_none() {
422 common::warn(
423 "audit-go: govulncheck is not installed \
424 (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
425 );
426 return Outcome::Unavailable;
427 }
428 let argv = vec![common::program("govulncheck"), "./...".into()];
429 let Some((exit_ok, out)) = audited(&argv) else {
430 return Outcome::Unavailable;
431 };
432 conclude(
433 "audit-go",
434 read_govulncheck(exit_ok, &out),
435 releasing(refs),
436 &out,
437 )
438}
439
440pub fn python(refs: &[PushRef]) -> Outcome {
441 if common::which("pip-audit").is_none() {
442 common::warn(
443 "audit-python: pip-audit is not installed (pip install pip-audit) — \
444 the audit did NOT run",
445 );
446 return Outcome::Unavailable;
447 }
448 let root = common::repo_root();
449 let argv = if std::path::Path::new(&root)
450 .join("requirements.txt")
451 .exists()
452 {
453 vec![
454 common::program("pip-audit"),
455 "-r".into(),
456 "requirements.txt".into(),
457 ]
458 } else if let Some(site_packages) = venv_site_packages(&root) {
459 vec![
467 common::program("pip-audit"),
468 "--path".into(),
469 site_packages,
470 "--skip-editable".into(),
473 ]
474 } else {
475 common::warn(
476 "audit-python: no requirements.txt, and no virtualenv to audit \
477 (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
478 );
479 return Outcome::Unavailable;
480 };
481 let Some((exit_ok, out)) = audited(&argv) else {
482 return Outcome::Unavailable;
483 };
484 conclude(
485 "audit-python",
486 read_pip_audit(exit_ok, &out),
487 releasing(refs),
488 &out,
489 )
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
502 fn a_uv_project_is_audited_through_its_venv() {
503 let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
504 let _ = std::fs::remove_dir_all(&root);
505
506 std::fs::create_dir_all(&root).unwrap();
509 assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
510
511 let sp = root
518 .join(".venv")
519 .join("lib")
520 .join("python3.13")
521 .join("site-packages");
522 std::fs::create_dir_all(&sp).unwrap();
523 assert_eq!(
524 venv_site_packages(root.to_str().unwrap()),
525 Some(sp.to_string_lossy().into_owned())
526 );
527
528 let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
530 let _ = std::fs::remove_dir_all(&win);
531 let wsp = win.join(".venv").join("Lib").join("site-packages");
532 std::fs::create_dir_all(&wsp).unwrap();
533 assert_eq!(
534 venv_site_packages(win.to_str().unwrap()),
535 Some(wsp.to_string_lossy().into_owned())
536 );
537
538 let _ = std::fs::remove_dir_all(&root);
539 let _ = std::fs::remove_dir_all(&win);
540 }
541
542 fn tag(name: &str) -> PushRef {
543 PushRef {
544 local_ref: name.to_string(),
545 local_oid: "a".repeat(40),
546 remote_ref: name.to_string(),
547 remote_oid: "0".repeat(40),
548 }
549 }
550
551 #[test]
554 fn a_release_is_a_v_number_tag() {
555 assert!(releasing(&[tag("refs/tags/v1.6.6")]));
556 assert!(releasing(&[tag("refs/tags/v2")]));
557 assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
558 assert!(!releasing(&[tag("refs/tags/release")]));
559 assert!(!releasing(&[tag("refs/heads/v1-styles")]));
560 assert!(!releasing(&[tag("refs/heads/main")]));
561 assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
563 }
564
565 #[test]
568 fn cargo_audit_ids_decide_not_the_exit_code() {
569 assert_eq!(
570 read_cargo_audit(true, "ok, 312 crates checked"),
571 Report::Clean
572 );
573 assert_eq!(
574 read_cargo_audit(false, "error: couldn't fetch advisory database"),
575 Report::CouldNotCheck
576 );
577 let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
578 assert_eq!(
579 read_cargo_audit(true, warn),
580 Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
581 );
582 let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
583 assert_eq!(
584 read_cargo_audit(false, vuln),
585 Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
586 );
587 assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
589 }
590
591 fn real_report() -> String {
594 [
595 "Crate: paste",
596 "Version: 1.0.15",
597 "Warning: unmaintained",
598 "ID: RUSTSEC-2024-0436",
599 "URL: https://rustsec.org/advisories/RUSTSEC-2024-0436",
600 "",
601 "Crate: lru",
602 "Version: 0.12.5",
603 "Warning: unsound",
604 "ID: RUSTSEC-2026-0253",
605 "",
606 "Crate: lru",
607 "Version: 0.12.5",
608 "ID: RUSTSEC-2026-0002",
609 "",
610 "warning: 3 allowed warnings found",
611 ]
612 .join("\n")
613 }
614
615 #[test]
616 fn an_advisory_is_paired_with_its_crate() {
617 let pairs = ids_with_crates(&real_report());
618 assert_eq!(
619 pairs,
620 vec![
621 ("RUSTSEC-2024-0436".into(), Some("paste".into())),
622 ("RUSTSEC-2026-0253".into(), Some("lru".into())),
623 ("RUSTSEC-2026-0002".into(), Some("lru".into())),
624 ]
625 );
626 }
627
628 #[test]
631 fn a_loose_id_borrows_no_crate() {
632 let out = "Crate: paste\nID: RUSTSEC-2024-0436\nsee also RUSTSEC-2025-0001";
633 assert_eq!(
634 ids_with_crates(out),
635 vec![
636 ("RUSTSEC-2024-0436".into(), Some("paste".into())),
637 ("RUSTSEC-2025-0001".into(), None),
638 ]
639 );
640 }
641
642 #[test]
645 fn only_local_crates_are_named_as_reached() {
646 let tree =
647 "lru v0.12.5\n└── ratatui v0.29.0\n └── amont-fleet v1.32.0 (/w/crates/amont-fleet)";
648 assert_eq!(local_dependents(tree), vec!["amont-fleet".to_string()]);
649 assert!(local_dependents("lru v0.12.5\n└── ratatui v0.29.0").is_empty());
650 }
651
652 #[test]
656 fn the_warning_names_the_crate_and_what_reaches_it() {
657 let calls = std::cell::RefCell::new(Vec::new());
658 let fake = |k: &str| {
659 calls.borrow_mut().push(k.to_string());
660 Some(format!(
661 "{k} v1\n└── ratatui v0.29.0\n └── amont-fleet v1.32.0 (/w/crates/amont-fleet)"
662 ))
663 };
664 let out = real_report();
665 let got = attribute(read_cargo_audit(true, &out), &out, fake);
666 assert_eq!(
667 got,
668 Report::Advisories(vec![
669 "RUSTSEC-2024-0436 (paste → amont-fleet)".into(),
670 "RUSTSEC-2026-0002 (lru → amont-fleet)".into(),
671 "RUSTSEC-2026-0253 (lru → amont-fleet)".into(),
672 ])
673 );
674 assert_eq!(
675 calls.into_inner(),
676 vec!["paste", "lru"],
677 "one call per crate"
678 );
679 }
680
681 #[test]
685 fn a_crate_outside_the_build_graph_says_so() {
686 let out = "Crate: wezterm-input-types\nID: RUSTSEC-2025-0001";
687 let got = attribute(read_cargo_audit(true, out), out, |_| {
688 Some("warning: nothing to print.".into())
689 });
690 assert_eq!(
691 got,
692 Report::Advisories(vec![
693 "RUSTSEC-2025-0001 (wezterm-input-types, not in the build graph)".into()
694 ])
695 );
696 }
697
698 #[test]
701 fn attribution_never_changes_the_verdict() {
702 let spawned = std::cell::Cell::new(false);
703 let clean = attribute(read_cargo_audit(true, "0 vulnerabilities"), "", |_| {
704 spawned.set(true);
705 None
706 });
707 assert_eq!(clean, Report::Clean);
708 assert!(!spawned.get(), "a clean report must spawn nothing");
709
710 let out = "Crate: paste\nID: RUSTSEC-2024-0436";
711 let blind = attribute(read_cargo_audit(false, out), out, |_| None);
712 assert_eq!(
713 blind,
714 Report::Vulnerabilities(vec![
715 "RUSTSEC-2024-0436 (paste, not in the build graph)".into()
716 ])
717 );
718 }
719
720 #[test]
721 fn npm_audit_summary_decides() {
722 assert_eq!(
723 read_npm_audit(true, "found 0 vulnerabilities\n"),
724 Report::Clean
725 );
726 assert_eq!(
727 read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
728 Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
729 );
730 assert_eq!(
731 read_npm_audit(true, "up to date, audited 100 packages\n"),
732 Report::Clean
733 );
734 assert_eq!(
735 read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
736 Report::CouldNotCheck
737 );
738 }
739
740 #[test]
743 fn govulncheck_ids_decide_not_the_exit_code() {
744 assert_eq!(
745 read_govulncheck(true, "No vulnerabilities found.\n"),
746 Report::Clean
747 );
748 assert_eq!(
749 read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
750 Report::CouldNotCheck
751 );
752 assert_eq!(
755 read_govulncheck(
756 true,
757 "=== Informational ===\nVulnerability #1: GO-2023-1840\n More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
758 ),
759 Report::Advisories(vec!["GO-2023-1840".into()])
760 );
761 assert_eq!(
762 read_govulncheck(
763 false,
764 "Vulnerability #1: GO-2022-0969\n Your code calls it.\nGO-2022-0969 again\n"
765 ),
766 Report::Vulnerabilities(vec!["GO-2022-0969".into()])
767 );
768 assert_eq!(
770 read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
771 Report::Clean
772 );
773 }
774
775 #[test]
776 fn pip_audit_sentence_decides() {
777 assert_eq!(
778 read_pip_audit(true, "No known vulnerabilities found\n"),
779 Report::Clean
780 );
781 assert_eq!(
782 read_pip_audit(
783 false,
784 "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
785 ),
786 Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
787 );
788 assert_eq!(
789 read_pip_audit(false, "ERROR: could not resolve\n"),
790 Report::CouldNotCheck
791 );
792 }
793}