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_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 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
158const WAIVERS_FILE: &str = ".amont-audit-waivers";
160
161const MAX_WAIVER_DAYS: i64 = 90;
165
166#[derive(Debug)]
168struct Waiver {
169 id: String,
170 expires: String,
171 reason: String,
172 valid: bool,
173}
174
175#[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
266fn 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 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
302fn 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
310fn 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
337fn 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
346fn 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 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
380fn 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
406fn 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
416fn 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
440fn 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
462fn 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
499fn 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
528fn 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
547fn 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
581fn audited(settings: &crate::config::Settings, argv: &[String]) -> Option<(bool, String)> {
583 audited_in(settings, argv, std::path::Path::new(&common::repo_root()))
584}
585
586fn 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
607fn 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
630struct Merged {
632 report: Report,
633 full: String,
636 waivable: bool,
637}
638
639fn 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 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
775fn 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 audited_in(settings, &argv, dir).and_then(|(ok, out)| ok.then_some(out))
800}
801
802fn tree_says_dev_only(tree: &str) -> bool {
806 local_dependents(tree).is_empty() && tree.contains("nothing to print")
807}
808
809fn 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 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
847fn 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
872fn 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
882fn 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
903fn 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 other => other,
917 }
918}
919
920fn 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
945fn has_lockfile(lockfile: &str) -> bool {
952 crate::tracked_paths()
953 .iter()
954 .any(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
955}
956
957fn 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
976struct JsAuditor {
979 lockfile: &'static str,
980 tool: &'static str,
981 read: fn(bool, &str) -> Report,
982 args: fn(&str) -> Vec<String>,
984 prod_only: &'static str,
987}
988
989fn 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
1025pub 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
1125pub 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 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 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 vec![
1211 common::program("pip-audit"),
1212 "--path".into(),
1213 site_packages,
1214 "--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 let venv_mode = argv.iter().any(|a| a == "--path");
1235 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
1264fn normalise(name: &str) -> String {
1266 name.to_ascii_lowercase()
1267 .split(['-', '_', '.'])
1268 .filter(|p| !p.is_empty())
1269 .collect::<Vec<_>>()
1270 .join("-")
1271}
1272
1273fn 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
1293fn 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
1307fn 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 "--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 #[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 std::fs::create_dir_all(&root).unwrap();
1362 assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
1363
1364 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 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 #[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 "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 "odd" => Some("odd v1.0.0\n".into()),
1505 _ => None,
1506 });
1507 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 assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
1529 }
1530
1531 #[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 assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
1555 }
1556
1557 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 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}