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 {
217 let summary = out.lines().rev().map(str::trim).find(|l| {
218 l.contains("vulnerabilit")
219 && (l.starts_with("found ")
220 || l.split_whitespace()
221 .next()
222 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())))
223 });
224 match summary {
225 Some(l) if l.starts_with("found 0 ") || l.starts_with("0 ") => Report::Clean,
226 Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
227 None if exit_ok => Report::Clean,
228 None => Report::CouldNotCheck,
229 }
230}
231
232fn read_pnpm_audit(exit_ok: bool, out: &str) -> Report {
238 let lines: Vec<&str> = out.lines().map(str::trim).collect();
239 if lines
240 .iter()
241 .any(|l| l.starts_with("No known vulnerabilities found"))
242 {
243 return Report::Clean;
244 }
245 let found = lines.iter().enumerate().rev().find(|(_, l)| {
246 let mut w = l.split_whitespace();
247 w.next()
248 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
249 && w.next().is_some_and(|v| v.starts_with("vulnerabilit"))
250 && w.next() == Some("found")
251 });
252 match found {
253 Some((_, l)) if l.starts_with("0 ") => Report::Clean,
254 Some((i, l)) => {
255 let severity = lines
256 .get(i + 1)
257 .and_then(|n| n.strip_prefix("Severity:"))
258 .map(str::trim);
259 Report::Vulnerabilities(vec![match severity {
260 Some(sev) => format!("{l} ({sev})"),
261 None => l.to_string(),
262 }])
263 }
264 None if exit_ok => Report::Clean,
265 None => Report::CouldNotCheck,
266 }
267}
268
269fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
276 let mut ids: Vec<String> = out
277 .split_whitespace()
278 .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
279 .filter(|w| {
280 w.len() >= 12
281 && w.starts_with("GO-")
282 && w[3..7].bytes().all(|b| b.is_ascii_digit())
283 && w.as_bytes()[7] == b'-'
284 && w[8..].bytes().all(|b| b.is_ascii_digit())
285 })
286 .map(|w| w.to_string())
287 .collect();
288 ids.sort();
289 ids.dedup();
290 match (ids.is_empty(), exit_ok) {
291 (true, true) => Report::Clean,
292 (true, false) => Report::CouldNotCheck,
293 (false, true) => Report::Advisories(ids),
294 (false, false) => Report::Vulnerabilities(ids),
295 }
296}
297
298fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
300 if out.contains("No known vulnerabilities found") {
301 return Report::Clean;
302 }
303 if let Some(line) = out
304 .lines()
305 .map(str::trim)
306 .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
307 {
308 return Report::Vulnerabilities(vec![line.to_string()]);
309 }
310 if exit_ok {
311 Report::Clean
312 } else {
313 Report::CouldNotCheck
314 }
315}
316
317fn venv_site_packages(root: &str) -> Option<String> {
326 let candidates = std::env::var_os("VIRTUAL_ENV")
327 .map(std::path::PathBuf::from)
328 .into_iter()
329 .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
330 for venv in candidates {
331 let windows = venv.join("Lib").join("site-packages");
332 if windows.is_dir() {
333 return Some(windows.to_string_lossy().into_owned());
334 }
335 let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
336 continue;
337 };
338 for e in entries.flatten() {
339 if !e.file_name().to_string_lossy().starts_with("python") {
340 continue;
341 }
342 let sp = e.path().join("site-packages");
343 if sp.is_dir() {
344 return Some(sp.to_string_lossy().into_owned());
345 }
346 }
347 }
348 None
349}
350
351fn audited(settings: &crate::config::Settings, argv: &[String]) -> Option<(bool, String)> {
353 audited_in(settings, argv, std::path::Path::new(&common::repo_root()))
354}
355
356fn audited_in(
358 settings: &crate::config::Settings,
359 argv: &[String],
360 dir: &std::path::Path,
361) -> Option<(bool, String)> {
362 let mut cmd = std::process::Command::new(&argv[0]);
363 cmd.args(&argv[1..])
364 .current_dir(dir)
365 .stdin(std::process::Stdio::null());
366 common::strip_git_env(&mut cmd);
367 let (ran, out) = common::capture_within(settings, &mut cmd)?;
368 match ran {
369 common::Ran::Status(s) => Some((s.success(), out)),
370 common::Ran::TimedOut(budget) => {
371 common::say_timed_out(&argv[0], budget);
372 None
373 }
374 }
375}
376
377pub fn rust(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
378 if !has_lockfile("Cargo.lock") {
379 return Outcome::Inert;
380 }
381 if common::which("cargo-audit").is_none() {
382 common::warn(
383 "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
384 the audit did NOT run",
385 );
386 return Outcome::Unavailable;
387 }
388 let argv = vec![
389 common::program("cargo"),
390 "audit".into(),
391 "--color".into(),
392 "never".into(),
393 ];
394 let Some((exit_ok, out)) = audited(settings, &argv) else {
395 return Outcome::Unavailable;
396 };
397 conclude(
398 settings,
399 "audit-rust",
400 attribute(read_cargo_audit(exit_ok, &out), &out, |krate| {
401 cargo_tree_inverse(settings, krate)
402 }),
403 releasing(refs),
404 &out,
405 )
406}
407
408fn cargo_tree_inverse(settings: &crate::config::Settings, krate: &str) -> Option<String> {
412 let argv = vec![
413 common::program("cargo"),
414 "tree".into(),
415 "--invert".into(),
416 krate.into(),
417 "--edges".into(),
418 "normal".into(),
419 "--color".into(),
420 "never".into(),
421 ];
422 audited(settings, &argv).map(|(_, out)| out)
423}
424
425fn attribute(report: Report, out: &str, tree: impl Fn(&str) -> Option<String>) -> Report {
433 match report {
434 Report::Advisories(ids) => Report::Advisories(described(ids, out, tree)),
435 Report::Vulnerabilities(ids) => Report::Vulnerabilities(described(ids, out, tree)),
436 other => other,
439 }
440}
441
442fn described(ids: Vec<String>, out: &str, tree: impl Fn(&str) -> Option<String>) -> Vec<String> {
446 let pairs = ids_with_crates(out);
447 let mut seen: Vec<(String, Vec<String>)> = Vec::new();
448 ids.iter()
449 .map(|id| {
450 let krate = pairs
451 .iter()
452 .find(|(pid, k)| pid == id && k.is_some())
453 .and_then(|(_, k)| k.clone());
454 let Some(k) = krate else {
455 return id.clone();
456 };
457 if let Some((_, reaches)) = seen.iter().find(|(name, _)| *name == k) {
458 return describe(id, Some(&k), reaches);
459 }
460 let reaches = tree(&k).map(|t| local_dependents(&t)).unwrap_or_default();
461 seen.push((k.clone(), reaches.clone()));
462 describe(id, Some(&k), &reaches)
463 })
464 .collect()
465}
466
467fn has_lockfile(lockfile: &str) -> bool {
474 crate::tracked_paths()
475 .iter()
476 .any(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
477}
478
479fn lockfile_dirs(lockfile: &str) -> Vec<String> {
484 let mut dirs: Vec<String> = crate::tracked_paths()
485 .iter()
486 .filter(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
487 .map(|p| {
488 p.rsplit_once('/')
489 .map(|(d, _)| d.to_string())
490 .unwrap_or_default()
491 })
492 .collect();
493 dirs.sort();
494 dirs.dedup();
495 dirs
496}
497
498struct JsAuditor {
501 lockfile: &'static str,
502 tool: &'static str,
503 read: fn(bool, &str) -> Report,
504 args: fn(&str) -> Vec<String>,
506}
507
508fn pnpm_args(dir: &str) -> Vec<String> {
515 let manifest = if dir.is_empty() {
516 "pnpm-workspace.yaml".to_string()
517 } else {
518 format!("{dir}/pnpm-workspace.yaml")
519 };
520 if crate::tracked_paths().contains(&manifest) {
521 vec![]
522 } else {
523 vec!["--ignore-workspace".into()]
524 }
525}
526
527const JS_AUDITORS: [JsAuditor; 2] = [
528 JsAuditor {
529 lockfile: "package-lock.json",
530 tool: "npm",
531 read: read_npm_audit,
532 args: |_| vec![],
533 },
534 JsAuditor {
535 lockfile: "pnpm-lock.yaml",
536 tool: "pnpm",
537 read: read_pnpm_audit,
538 args: pnpm_args,
539 },
540];
541
542pub fn js(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
558 let projects: Vec<(&JsAuditor, String)> = JS_AUDITORS
559 .iter()
560 .flat_map(|a| lockfile_dirs(a.lockfile).into_iter().map(move |d| (a, d)))
561 .collect();
562 if projects.is_empty() {
563 return Outcome::Inert;
564 }
565 let root = std::path::PathBuf::from(common::repo_root());
566 let mut found = Vec::new();
567 let mut unchecked = Vec::new();
568 let mut full = String::new();
569 for (auditor, dir) in &projects {
570 let label = if dir.is_empty() {
571 ".".to_string()
572 } else {
573 dir.clone()
574 };
575 let mut argv = vec![common::program(auditor.tool), "audit".into()];
576 argv.extend((auditor.args)(dir));
577 let Some((exit_ok, out)) = audited_in(settings, &argv, &root.join(dir)) else {
578 common::warn(&format!(
579 "audit-js: {} could not run in {label} — the audit did NOT run",
580 auditor.tool
581 ));
582 return Outcome::Unavailable;
583 };
584 match (auditor.read)(exit_ok, &out) {
585 Report::Vulnerabilities(what) => {
586 found.extend(what.into_iter().map(|w| format!("{label}: {w}")));
587 full.push_str(&format!("── {} audit in {label}\n{out}\n", auditor.tool));
588 }
589 Report::CouldNotCheck => unchecked.push(format!("{label} ({})", auditor.tool)),
590 Report::Clean | Report::Advisories(_) => {}
591 }
592 }
593 if !unchecked.is_empty() {
594 common::warn(&format!(
595 "audit-js: the audit could not answer in {} — those projects were NOT checked",
596 unchecked.join(", ")
597 ));
598 }
599 let report = if !found.is_empty() {
600 Report::Vulnerabilities(found)
601 } else if !unchecked.is_empty() {
602 Report::CouldNotCheck
603 } else {
604 Report::Clean
605 };
606 conclude(settings, "audit-js", report, releasing(refs), &full)
607}
608
609pub fn go(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
610 if !has_lockfile("go.sum") {
611 return Outcome::Inert;
612 }
613 if common::which("govulncheck").is_none() {
614 common::warn(
615 "audit-go: govulncheck is not installed \
616 (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
617 );
618 return Outcome::Unavailable;
619 }
620 let argv = vec![common::program("govulncheck"), "./...".into()];
621 let Some((exit_ok, out)) = audited(settings, &argv) else {
622 return Outcome::Unavailable;
623 };
624 conclude(
625 settings,
626 "audit-go",
627 read_govulncheck(exit_ok, &out),
628 releasing(refs),
629 &out,
630 )
631}
632
633pub fn python(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
634 if !has_lockfile("requirements.txt") && !has_lockfile("pyproject.toml") {
635 return Outcome::Inert;
636 }
637 if common::which("pip-audit").is_none() {
638 common::warn(
639 "audit-python: pip-audit is not installed (pip install pip-audit) — \
640 the audit did NOT run",
641 );
642 return Outcome::Unavailable;
643 }
644 let root = common::repo_root();
645 let argv = if std::path::Path::new(&root)
646 .join("requirements.txt")
647 .exists()
648 {
649 vec![
650 common::program("pip-audit"),
651 "-r".into(),
652 "requirements.txt".into(),
653 ]
654 } else if let Some(site_packages) = venv_site_packages(&root) {
655 vec![
663 common::program("pip-audit"),
664 "--path".into(),
665 site_packages,
666 "--skip-editable".into(),
669 ]
670 } else {
671 common::warn(
672 "audit-python: no requirements.txt, and no virtualenv to audit \
673 (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
674 );
675 return Outcome::Unavailable;
676 };
677 let Some((exit_ok, out)) = audited(settings, &argv) else {
678 return Outcome::Unavailable;
679 };
680 conclude(
681 settings,
682 "audit-python",
683 read_pip_audit(exit_ok, &out),
684 releasing(refs),
685 &out,
686 )
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
699 fn a_uv_project_is_audited_through_its_venv() {
700 let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
701 let _ = std::fs::remove_dir_all(&root);
702
703 std::fs::create_dir_all(&root).unwrap();
706 assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
707
708 let sp = root
715 .join(".venv")
716 .join("lib")
717 .join("python3.13")
718 .join("site-packages");
719 std::fs::create_dir_all(&sp).unwrap();
720 assert_eq!(
721 venv_site_packages(root.to_str().unwrap()),
722 Some(sp.to_string_lossy().into_owned())
723 );
724
725 let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
727 let _ = std::fs::remove_dir_all(&win);
728 let wsp = win.join(".venv").join("Lib").join("site-packages");
729 std::fs::create_dir_all(&wsp).unwrap();
730 assert_eq!(
731 venv_site_packages(win.to_str().unwrap()),
732 Some(wsp.to_string_lossy().into_owned())
733 );
734
735 let _ = std::fs::remove_dir_all(&root);
736 let _ = std::fs::remove_dir_all(&win);
737 }
738
739 fn tag(name: &str) -> PushRef {
740 PushRef {
741 local_ref: name.to_string(),
742 local_oid: "a".repeat(40),
743 remote_ref: name.to_string(),
744 remote_oid: "0".repeat(40),
745 }
746 }
747
748 #[test]
751 fn a_release_is_a_v_number_tag() {
752 assert!(releasing(&[tag("refs/tags/v1.6.6")]));
753 assert!(releasing(&[tag("refs/tags/v2")]));
754 assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
755 assert!(!releasing(&[tag("refs/tags/release")]));
756 assert!(!releasing(&[tag("refs/heads/v1-styles")]));
757 assert!(!releasing(&[tag("refs/heads/main")]));
758 assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
760 }
761
762 #[test]
765 fn cargo_audit_ids_decide_not_the_exit_code() {
766 assert_eq!(
767 read_cargo_audit(true, "ok, 312 crates checked"),
768 Report::Clean
769 );
770 assert_eq!(
771 read_cargo_audit(false, "error: couldn't fetch advisory database"),
772 Report::CouldNotCheck
773 );
774 let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
775 assert_eq!(
776 read_cargo_audit(true, warn),
777 Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
778 );
779 let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
780 assert_eq!(
781 read_cargo_audit(false, vuln),
782 Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
783 );
784 assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
786 }
787
788 fn real_report() -> String {
791 [
792 "Crate: paste",
793 "Version: 1.0.15",
794 "Warning: unmaintained",
795 "ID: RUSTSEC-2024-0436",
796 "URL: https://rustsec.org/advisories/RUSTSEC-2024-0436",
797 "",
798 "Crate: lru",
799 "Version: 0.12.5",
800 "Warning: unsound",
801 "ID: RUSTSEC-2026-0253",
802 "",
803 "Crate: lru",
804 "Version: 0.12.5",
805 "ID: RUSTSEC-2026-0002",
806 "",
807 "warning: 3 allowed warnings found",
808 ]
809 .join("\n")
810 }
811
812 #[test]
813 fn an_advisory_is_paired_with_its_crate() {
814 let pairs = ids_with_crates(&real_report());
815 assert_eq!(
816 pairs,
817 vec![
818 ("RUSTSEC-2024-0436".into(), Some("paste".into())),
819 ("RUSTSEC-2026-0253".into(), Some("lru".into())),
820 ("RUSTSEC-2026-0002".into(), Some("lru".into())),
821 ]
822 );
823 }
824
825 #[test]
828 fn a_loose_id_borrows_no_crate() {
829 let out = "Crate: paste\nID: RUSTSEC-2024-0436\nsee also RUSTSEC-2025-0001";
830 assert_eq!(
831 ids_with_crates(out),
832 vec![
833 ("RUSTSEC-2024-0436".into(), Some("paste".into())),
834 ("RUSTSEC-2025-0001".into(), None),
835 ]
836 );
837 }
838
839 #[test]
842 fn only_local_crates_are_named_as_reached() {
843 let tree =
844 "lru v0.12.5\n└── ratatui v0.29.0\n └── amont-fleet v1.32.0 (/w/crates/amont-fleet)";
845 assert_eq!(local_dependents(tree), vec!["amont-fleet".to_string()]);
846 assert!(local_dependents("lru v0.12.5\n└── ratatui v0.29.0").is_empty());
847 }
848
849 #[test]
853 fn the_warning_names_the_crate_and_what_reaches_it() {
854 let calls = std::cell::RefCell::new(Vec::new());
855 let fake = |k: &str| {
856 calls.borrow_mut().push(k.to_string());
857 Some(format!(
858 "{k} v1\n└── ratatui v0.29.0\n └── amont-fleet v1.32.0 (/w/crates/amont-fleet)"
859 ))
860 };
861 let out = real_report();
862 let got = attribute(read_cargo_audit(true, &out), &out, fake);
863 assert_eq!(
864 got,
865 Report::Advisories(vec![
866 "RUSTSEC-2024-0436 (paste → amont-fleet)".into(),
867 "RUSTSEC-2026-0002 (lru → amont-fleet)".into(),
868 "RUSTSEC-2026-0253 (lru → amont-fleet)".into(),
869 ])
870 );
871 assert_eq!(
872 calls.into_inner(),
873 vec!["paste", "lru"],
874 "one call per crate"
875 );
876 }
877
878 #[test]
882 fn a_crate_outside_the_build_graph_says_so() {
883 let out = "Crate: wezterm-input-types\nID: RUSTSEC-2025-0001";
884 let got = attribute(read_cargo_audit(true, out), out, |_| {
885 Some("warning: nothing to print.".into())
886 });
887 assert_eq!(
888 got,
889 Report::Advisories(vec![
890 "RUSTSEC-2025-0001 (wezterm-input-types, not in the build graph)".into()
891 ])
892 );
893 }
894
895 #[test]
898 fn attribution_never_changes_the_verdict() {
899 let spawned = std::cell::Cell::new(false);
900 let clean = attribute(read_cargo_audit(true, "0 vulnerabilities"), "", |_| {
901 spawned.set(true);
902 None
903 });
904 assert_eq!(clean, Report::Clean);
905 assert!(!spawned.get(), "a clean report must spawn nothing");
906
907 let out = "Crate: paste\nID: RUSTSEC-2024-0436";
908 let blind = attribute(read_cargo_audit(false, out), out, |_| None);
909 assert_eq!(
910 blind,
911 Report::Vulnerabilities(vec![
912 "RUSTSEC-2024-0436 (paste, not in the build graph)".into()
913 ])
914 );
915 }
916
917 #[test]
918 fn npm_audit_summary_decides() {
919 assert_eq!(
920 read_npm_audit(true, "found 0 vulnerabilities\n"),
921 Report::Clean
922 );
923 assert_eq!(
924 read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
925 Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
926 );
927 assert_eq!(
930 read_npm_audit(
931 false,
932 "# npm audit report\n\nvite 6.0.0 - 6.1.5\nSeverity: high\n\n\
933 17 vulnerabilities (8 moderate, 9 high)\n\nTo address all issues, run:\n npm audit fix\n"
934 ),
935 Report::Vulnerabilities(vec!["17 vulnerabilities (8 moderate, 9 high)".into()])
936 );
937 assert_eq!(
938 read_npm_audit(false, "1 vulnerability (1 high)\n"),
939 Report::Vulnerabilities(vec!["1 vulnerability (1 high)".into()])
940 );
941 assert_eq!(
942 read_npm_audit(true, "up to date, audited 100 packages\n"),
943 Report::Clean
944 );
945 assert_eq!(
946 read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
947 Report::CouldNotCheck
948 );
949 }
950
951 #[test]
952 fn pnpm_audit_summary_decides() {
953 assert_eq!(
954 read_pnpm_audit(true, "No known vulnerabilities found\n"),
955 Report::Clean
956 );
957 assert_eq!(
960 read_pnpm_audit(
961 false,
962 "│ More info │ https://github.com/advisories/GHSA-395f │\n\
963 └───────────┴──────────────────────────────────────────┘\n\
964 107 vulnerabilities found\n\
965 Severity: 6 low | 45 moderate | 54 high | 2 critical\n"
966 ),
967 Report::Vulnerabilities(vec![
968 "107 vulnerabilities found (6 low | 45 moderate | 54 high | 2 critical)".into()
969 ])
970 );
971 assert_eq!(
972 read_pnpm_audit(false, "1 vulnerabilities found\n"),
973 Report::Vulnerabilities(vec!["1 vulnerabilities found".into()])
974 );
975 assert_eq!(
976 read_pnpm_audit(
977 false,
978 " ERR_PNPM_AUDIT_BAD_RESPONSE The audit endpoint responded with 503\n"
979 ),
980 Report::CouldNotCheck
981 );
982 }
983
984 #[test]
987 fn govulncheck_ids_decide_not_the_exit_code() {
988 assert_eq!(
989 read_govulncheck(true, "No vulnerabilities found.\n"),
990 Report::Clean
991 );
992 assert_eq!(
993 read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
994 Report::CouldNotCheck
995 );
996 assert_eq!(
999 read_govulncheck(
1000 true,
1001 "=== Informational ===\nVulnerability #1: GO-2023-1840\n More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
1002 ),
1003 Report::Advisories(vec!["GO-2023-1840".into()])
1004 );
1005 assert_eq!(
1006 read_govulncheck(
1007 false,
1008 "Vulnerability #1: GO-2022-0969\n Your code calls it.\nGO-2022-0969 again\n"
1009 ),
1010 Report::Vulnerabilities(vec!["GO-2022-0969".into()])
1011 );
1012 assert_eq!(
1014 read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
1015 Report::Clean
1016 );
1017 }
1018
1019 #[test]
1020 fn pip_audit_sentence_decides() {
1021 assert_eq!(
1022 read_pip_audit(true, "No known vulnerabilities found\n"),
1023 Report::Clean
1024 );
1025 assert_eq!(
1026 read_pip_audit(
1027 false,
1028 "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
1029 ),
1030 Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
1031 );
1032 assert_eq!(
1033 read_pip_audit(false, "ERROR: could not resolve\n"),
1034 Report::CouldNotCheck
1035 );
1036 }
1037}