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 conclude_waivable(settings, tool, report, releasing, full, true)
66}
67
68fn conclude_waivable(
73 settings: &crate::config::Settings,
74 tool: &str,
75 report: Report,
76 releasing: bool,
77 full: &str,
78 waivable: bool,
79) -> Outcome {
80 match report {
81 Report::Clean => {
82 common::ok(settings, &format!("{tool}: no known vulnerabilities"));
83 Outcome::Passed
84 }
85 Report::Advisories(ids) => {
86 common::warn(&format!(
87 "{tool}: advisories against the dependency tree (warnings — unmaintained/unsound): {}",
88 ids.join(", ")
89 ));
90 Outcome::Warned
91 }
92 Report::Vulnerabilities(what) => {
93 let waivers = Waivers::load(&common::repo_root(), today());
94 let mut ids = advisory_ids(&what.join(" "));
97 if ids.is_empty() {
98 ids = advisory_ids(full);
99 }
100 let unwaived: Vec<&String> = ids.iter().filter(|id| !waivers.covers(id)).collect();
101 let all_waived = waivable && !ids.is_empty() && unwaived.is_empty();
102 if releasing {
103 if all_waived {
104 common::warn(&format!(
105 "{tool}: known vulnerabilities shipped under a reviewed waiver \
106 ({WAIVERS_FILE}) — not blocking this release: {}",
107 ids.iter()
108 .map(|id| waivers.describe(id))
109 .collect::<Vec<_>>()
110 .join("; ")
111 ));
112 return Outcome::Warned;
113 }
114 for line in full.lines() {
115 crate::say!("{line}");
116 }
117 for note in waivers.problems() {
118 common::warn(&format!("{tool}: {note}"));
119 }
120 common::fail(&format!(
121 "{tool}: known vulnerabilities in the dependency tree — a v* tag \
122 does not ship with these: {}{}",
123 what.join(", "),
124 if ids.is_empty() {
125 " (no advisory id to match a waiver against)".to_string()
126 } else {
127 format!(
128 " — not waived: {}",
129 unwaived
130 .iter()
131 .map(|s| s.as_str())
132 .collect::<Vec<_>>()
133 .join(", ")
134 )
135 }
136 ));
137 Outcome::Failed
138 } else if all_waived {
139 common::warn(&format!(
140 "{tool}: known vulnerabilities ({}) — covered by a reviewed waiver \
141 ({WAIVERS_FILE}), so a v* tag push will pass while it holds: {}",
142 what.join(", "),
143 ids.iter()
144 .map(|id| waivers.describe(id))
145 .collect::<Vec<_>>()
146 .join("; ")
147 ));
148 Outcome::Warned
149 } else {
150 common::warn(&format!(
151 "{tool}: known vulnerabilities in the dependency tree ({}) — \
152 this will BLOCK a v* tag push",
153 what.join(", ")
154 ));
155 Outcome::Warned
156 }
157 }
158 Report::CouldNotCheck => {
159 common::warn(&format!(
160 "{tool} could not complete — the dependency tree was NOT checked. \
161 This is not a clean result."
162 ));
163 Outcome::Unavailable
164 }
165 }
166}
167
168const WAIVERS_FILE: &str = ".amont-audit-waivers";
170
171const MAX_WAIVER_DAYS: i64 = 90;
175
176#[derive(Debug)]
178struct Waiver {
179 id: String,
180 expires: String,
181 reason: String,
182 valid: bool,
183}
184
185#[derive(Debug, Default)]
194struct Waivers {
195 entries: Vec<Waiver>,
196 problems: Vec<String>,
197}
198
199impl Waivers {
200 fn load(root: &str, today: i64) -> Self {
201 match std::fs::read_to_string(std::path::Path::new(root).join(WAIVERS_FILE)) {
202 Ok(text) => Self::parse(&text, today),
203 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
204 Err(e) => Self {
205 entries: Vec::new(),
206 problems: vec![format!(
207 "{WAIVERS_FILE} could not be read ({e}) — it waives nothing"
208 )],
209 },
210 }
211 }
212
213 fn parse(text: &str, today: i64) -> Self {
214 let mut w = Self::default();
215 for line in text.lines().map(str::trim) {
216 if line.is_empty() || line.starts_with('#') {
217 continue;
218 }
219 let mut parts = line.splitn(3, char::is_whitespace);
220 let id = parts.next().unwrap_or_default().to_string();
221 let expires = parts.next().unwrap_or_default().trim().to_string();
222 let reason = parts.next().unwrap_or_default().trim().to_string();
223 let valid = match days_from_date(&expires) {
224 None => {
225 w.problems.push(format!(
226 "waiver for {id} has no readable expiry date (YYYY-MM-DD) — it waives nothing"
227 ));
228 false
229 }
230 Some(_) if reason.is_empty() => {
231 w.problems.push(format!(
232 "waiver for {id} gives no reason — it waives nothing"
233 ));
234 false
235 }
236 Some(d) if d < today => {
237 w.problems
238 .push(format!("waiver for {id} expired on {expires}"));
239 false
240 }
241 Some(d) if d - today > MAX_WAIVER_DAYS => {
242 w.problems.push(format!(
243 "waiver for {id} runs to {expires}, more than {MAX_WAIVER_DAYS} days \
244 ahead — it waives nothing; date it sooner and revisit"
245 ));
246 false
247 }
248 Some(_) => true,
249 };
250 w.entries.push(Waiver {
251 id,
252 expires,
253 reason,
254 valid,
255 });
256 }
257 w
258 }
259
260 fn covers(&self, id: &str) -> bool {
261 self.entries.iter().any(|w| w.valid && w.id == id)
262 }
263
264 fn describe(&self, id: &str) -> String {
265 match self.entries.iter().find(|w| w.valid && w.id == id) {
266 Some(w) => format!("{id} until {} ({})", w.expires, w.reason),
267 None => id.to_string(),
268 }
269 }
270
271 fn problems(&self) -> &[String] {
272 &self.problems
273 }
274}
275
276fn days_from_date(date: &str) -> Option<i64> {
278 let mut it = date.split('-');
279 let (y, m, d): (i64, i64, i64) = (
280 it.next()?.parse().ok()?,
281 it.next()?.parse().ok()?,
282 it.next()?.parse().ok()?,
283 );
284 let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
285 let month_days = [
286 31,
287 if leap { 29 } else { 28 },
288 31,
289 30,
290 31,
291 30,
292 31,
293 31,
294 30,
295 31,
296 30,
297 31,
298 ];
299 if it.next().is_some() || !(1..=12).contains(&m) || d < 1 || d > month_days[(m - 1) as usize] {
300 return None;
301 }
302 let y = if m <= 2 { y - 1 } else { y };
304 let era = if y >= 0 { y } else { y - 399 } / 400;
305 let yoe = y - era * 400;
306 let mp = (m + 9) % 12;
307 let doy = (153 * mp + 2) / 5 + d - 1;
308 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
309 Some(era * 146_097 + doe - 719_468)
310}
311
312fn today() -> i64 {
314 std::time::SystemTime::now()
315 .duration_since(std::time::UNIX_EPOCH)
316 .map(|d| (d.as_secs() / 86_400) as i64)
317 .unwrap_or(0)
318}
319
320fn advisory_ids(text: &str) -> Vec<String> {
323 let mut ids: Vec<String> = text
324 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
325 .filter(|w| {
326 let parts: Vec<&str> = w.split('-').collect();
327 match parts.as_slice() {
328 ["GHSA", a, b, c] => [a, b, c]
329 .iter()
330 .all(|p| p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphanumeric())),
331 ["RUSTSEC" | "GO" | "PYSEC" | "CVE" | "OSV", year, num] => {
332 year.len() == 4
333 && year.bytes().all(|b| b.is_ascii_digit())
334 && !num.is_empty()
335 && num.bytes().all(|b| b.is_ascii_digit())
336 }
337 _ => false,
338 }
339 })
340 .map(str::to_string)
341 .collect();
342 ids.sort();
343 ids.dedup();
344 ids
345}
346
347fn is_advisory_id(w: &str) -> bool {
349 w.len() == 17
350 && w.starts_with("RUSTSEC-")
351 && w[8..12].bytes().all(|b| b.is_ascii_digit())
352 && w.as_bytes()[12] == b'-'
353 && w[13..17].bytes().all(|b| b.is_ascii_digit())
354}
355
356fn ids_with_crates(out: &str) -> Vec<(String, Option<String>)> {
367 let mut pairs: Vec<(String, Option<String>)> = Vec::new();
368 let mut pending: Option<String> = None;
369 for line in out.lines() {
370 let t = line.trim();
371 if let Some(rest) = t.strip_prefix("Crate:") {
372 pending = rest.split_whitespace().next().map(str::to_string);
373 continue;
374 }
375 if let Some(rest) = t.strip_prefix("ID:") {
376 if let Some(id) = rest.split_whitespace().next().filter(|w| is_advisory_id(w)) {
377 pairs.push((id.to_string(), pending.take()));
378 continue;
379 }
380 }
381 for w in t.split_whitespace().filter(|w| is_advisory_id(w)) {
384 pairs.push((w.to_string(), None));
385 }
386 }
387 pairs
388}
389
390fn local_dependents(tree_out: &str) -> Vec<String> {
402 let mut names: Vec<String> = tree_out
403 .lines()
404 .filter(|l| l.contains(" (/"))
405 .filter_map(|l| {
406 l.split_whitespace()
407 .find(|w| !w.is_empty() && w.chars().next().is_some_and(|c| c.is_alphanumeric()))
408 .map(str::to_string)
409 })
410 .collect();
411 names.sort();
412 names.dedup();
413 names
414}
415
416fn describe(id: &str, krate: Option<&str>, reaches: &[String]) -> String {
419 match (krate, reaches.is_empty()) {
420 (None, _) => id.to_string(),
421 (Some(k), true) => format!("{id} ({k}, not in the build graph)"),
422 (Some(k), false) => format!("{id} ({k} → {})", reaches.join(", ")),
423 }
424}
425
426fn read_cargo_audit(exit_ok: bool, out: &str) -> Report {
429 let mut ids: Vec<String> = out
430 .split_whitespace()
431 .filter(|w| {
432 w.len() == 17
433 && w.starts_with("RUSTSEC-")
434 && w[8..12].bytes().all(|b| b.is_ascii_digit())
435 && w.as_bytes()[12] == b'-'
436 && w[13..17].bytes().all(|b| b.is_ascii_digit())
437 })
438 .map(|w| w.to_string())
439 .collect();
440 ids.sort();
441 ids.dedup();
442 match (ids.is_empty(), exit_ok) {
443 (true, true) => Report::Clean,
444 (true, false) => Report::CouldNotCheck,
445 (false, true) => Report::Advisories(ids),
446 (false, false) => Report::Vulnerabilities(ids),
447 }
448}
449
450fn read_npm_audit(exit_ok: bool, out: &str) -> Report {
457 let summary = out.lines().rev().map(str::trim).find(|l| {
458 l.contains("vulnerabilit")
459 && (l.starts_with("found ")
460 || l.split_whitespace()
461 .next()
462 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())))
463 });
464 match summary {
465 Some(l) if l.starts_with("found 0 ") || l.starts_with("0 ") => Report::Clean,
466 Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
467 None if exit_ok => Report::Clean,
468 None => Report::CouldNotCheck,
469 }
470}
471
472fn read_pnpm_audit(exit_ok: bool, out: &str) -> Report {
478 let lines: Vec<&str> = out.lines().map(str::trim).collect();
479 if lines
480 .iter()
481 .any(|l| l.starts_with("No known vulnerabilities found"))
482 {
483 return Report::Clean;
484 }
485 let found = lines.iter().enumerate().rev().find(|(_, l)| {
486 let mut w = l.split_whitespace();
487 w.next()
488 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
489 && w.next().is_some_and(|v| v.starts_with("vulnerabilit"))
490 && w.next() == Some("found")
491 });
492 match found {
493 Some((_, l)) if l.starts_with("0 ") => Report::Clean,
494 Some((i, l)) => {
495 let severity = lines
496 .get(i + 1)
497 .and_then(|n| n.strip_prefix("Severity:"))
498 .map(str::trim);
499 Report::Vulnerabilities(vec![match severity {
500 Some(sev) => format!("{l} ({sev})"),
501 None => l.to_string(),
502 }])
503 }
504 None if exit_ok => Report::Clean,
505 None => Report::CouldNotCheck,
506 }
507}
508
509fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
516 let mut ids: Vec<String> = out
517 .split_whitespace()
518 .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
519 .filter(|w| {
520 w.len() >= 12
521 && w.starts_with("GO-")
522 && w[3..7].bytes().all(|b| b.is_ascii_digit())
523 && w.as_bytes()[7] == b'-'
524 && w[8..].bytes().all(|b| b.is_ascii_digit())
525 })
526 .map(|w| w.to_string())
527 .collect();
528 ids.sort();
529 ids.dedup();
530 match (ids.is_empty(), exit_ok) {
531 (true, true) => Report::Clean,
532 (true, false) => Report::CouldNotCheck,
533 (false, true) => Report::Advisories(ids),
534 (false, false) => Report::Vulnerabilities(ids),
535 }
536}
537
538fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
540 if out.contains("No known vulnerabilities found") {
541 return Report::Clean;
542 }
543 if let Some(line) = out
544 .lines()
545 .map(str::trim)
546 .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
547 {
548 return Report::Vulnerabilities(vec![line.to_string()]);
549 }
550 if exit_ok {
551 Report::Clean
552 } else {
553 Report::CouldNotCheck
554 }
555}
556
557fn venv_site_packages(root: &str) -> Option<String> {
566 let candidates = std::env::var_os("VIRTUAL_ENV")
567 .map(std::path::PathBuf::from)
568 .into_iter()
569 .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
570 for venv in candidates {
571 let windows = venv.join("Lib").join("site-packages");
572 if windows.is_dir() {
573 return Some(windows.to_string_lossy().into_owned());
574 }
575 let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
576 continue;
577 };
578 for e in entries.flatten() {
579 if !e.file_name().to_string_lossy().starts_with("python") {
580 continue;
581 }
582 let sp = e.path().join("site-packages");
583 if sp.is_dir() {
584 return Some(sp.to_string_lossy().into_owned());
585 }
586 }
587 }
588 None
589}
590
591fn audited(settings: &crate::config::Settings, argv: &[String]) -> Option<(bool, String)> {
593 audited_in(settings, argv, std::path::Path::new(&common::repo_root()))
594}
595
596fn audited_in(
598 settings: &crate::config::Settings,
599 argv: &[String],
600 dir: &std::path::Path,
601) -> Option<(bool, String)> {
602 let mut cmd = std::process::Command::new(&argv[0]);
603 cmd.args(&argv[1..])
604 .current_dir(dir)
605 .stdin(std::process::Stdio::null());
606 common::strip_git_env(&mut cmd);
607 let (ran, out) = common::capture_within(settings, &mut cmd)?;
608 match ran {
609 common::Ran::Status(s) => Some((s.success(), out)),
610 common::Ran::TimedOut(budget) => {
611 common::say_timed_out(&argv[0], budget);
612 None
613 }
614 }
615}
616
617pub fn rust(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
618 if !has_lockfile("Cargo.lock") {
619 return Outcome::Inert;
620 }
621 if common::which("cargo-audit").is_none() {
622 common::warn(
623 "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
624 the audit did NOT run",
625 );
626 return Outcome::Unavailable;
627 }
628 let argv = vec![
629 common::program("cargo"),
630 "audit".into(),
631 "--color".into(),
632 "never".into(),
633 ];
634 let Some((exit_ok, out)) = audited(settings, &argv) else {
635 return Outcome::Unavailable;
636 };
637 let release = releasing(refs);
638 let mut report = read_cargo_audit(exit_ok, &out);
639 if release {
640 if let Report::Vulnerabilities(ids) = report {
641 let (shipped, dev_only) =
642 split_shipped_crates(ids, &out, |krate| cargo_tree_shipped(settings, krate));
643 warn_dev_only("audit-rust", &dev_only);
644 report = if shipped.is_empty() {
645 Report::Clean
646 } else {
647 Report::Vulnerabilities(shipped)
648 };
649 }
650 }
651 conclude(
652 settings,
653 "audit-rust",
654 attribute(report, &out, |krate| cargo_tree_inverse(settings, krate)),
655 release,
656 &out,
657 )
658}
659
660fn cargo_tree_shipped(settings: &crate::config::Settings, spec: &str) -> Option<String> {
665 let argv = vec![
666 common::program("cargo"),
667 "tree".into(),
668 "--invert".into(),
669 spec.into(),
670 "--edges".into(),
671 "normal,build".into(),
672 "--target".into(),
673 "all".into(),
674 "--all-features".into(),
675 "--color".into(),
676 "never".into(),
677 ];
678 audited(settings, &argv).and_then(|(ok, out)| ok.then_some(out))
681}
682
683fn tree_says_dev_only(tree: &str) -> bool {
687 local_dependents(tree).is_empty() && tree.contains("nothing to print")
688}
689
690fn split_shipped_crates(
694 ids: Vec<String>,
695 out: &str,
696 shipped_tree: impl Fn(&str) -> Option<String>,
697) -> (Vec<String>, Vec<String>) {
698 let specs = ids_with_specs(out);
699 let mut verdicts: Vec<(String, bool)> = Vec::new();
700 let (mut shipped, mut dev_only) = (Vec::new(), Vec::new());
701 for id in ids {
702 let spec = specs
703 .iter()
704 .find(|(pid, spec)| *pid == id && spec.is_some())
705 .and_then(|(_, spec)| spec.clone());
706 let ships = match spec {
707 None => true,
708 Some(spec) => match verdicts.iter().find(|(s, _)| *s == spec) {
711 Some((_, v)) => *v,
712 None => {
713 let v = shipped_tree(&spec).map_or(true, |t| !tree_says_dev_only(&t));
714 verdicts.push((spec, v));
715 v
716 }
717 },
718 };
719 if ships {
720 shipped.push(id);
721 } else {
722 dev_only.push(id);
723 }
724 }
725 (shipped, dev_only)
726}
727
728fn ids_with_specs(out: &str) -> Vec<(String, Option<String>)> {
732 let mut pairs = Vec::new();
733 let (mut krate, mut version): (Option<String>, Option<String>) = (None, None);
734 for line in out.lines().map(str::trim) {
735 if let Some(rest) = line.strip_prefix("Crate:") {
736 krate = rest.split_whitespace().next().map(str::to_string);
737 version = None;
738 } else if let Some(rest) = line.strip_prefix("Version:") {
739 version = rest.split_whitespace().next().map(str::to_string);
740 } else if let Some(rest) = line.strip_prefix("ID:") {
741 if let Some(id) = rest.split_whitespace().next().filter(|w| is_advisory_id(w)) {
742 let spec = krate.take().map(|k| match version.take() {
743 Some(v) => format!("{k}@{v}"),
744 None => k,
745 });
746 pairs.push((id.to_string(), spec));
747 }
748 }
749 }
750 pairs
751}
752
753fn warn_dev_only(tool: &str, dev_only: &[String]) {
755 if !dev_only.is_empty() {
756 common::warn(&format!(
757 "{tool}: only in development dependencies, which the release does not ship — not blocking: {}",
758 dev_only.join("; ")
759 ));
760 }
761}
762
763fn cargo_tree_inverse(settings: &crate::config::Settings, krate: &str) -> Option<String> {
767 let argv = vec![
768 common::program("cargo"),
769 "tree".into(),
770 "--invert".into(),
771 krate.into(),
772 "--edges".into(),
773 "normal".into(),
774 "--color".into(),
775 "never".into(),
776 ];
777 audited(settings, &argv).map(|(_, out)| out)
778}
779
780fn attribute(report: Report, out: &str, tree: impl Fn(&str) -> Option<String>) -> Report {
788 match report {
789 Report::Advisories(ids) => Report::Advisories(described(ids, out, tree)),
790 Report::Vulnerabilities(ids) => Report::Vulnerabilities(described(ids, out, tree)),
791 other => other,
794 }
795}
796
797fn described(ids: Vec<String>, out: &str, tree: impl Fn(&str) -> Option<String>) -> Vec<String> {
801 let pairs = ids_with_crates(out);
802 let mut seen: Vec<(String, Vec<String>)> = Vec::new();
803 ids.iter()
804 .map(|id| {
805 let krate = pairs
806 .iter()
807 .find(|(pid, k)| pid == id && k.is_some())
808 .and_then(|(_, k)| k.clone());
809 let Some(k) = krate else {
810 return id.clone();
811 };
812 if let Some((_, reaches)) = seen.iter().find(|(name, _)| *name == k) {
813 return describe(id, Some(&k), reaches);
814 }
815 let reaches = tree(&k).map(|t| local_dependents(&t)).unwrap_or_default();
816 seen.push((k.clone(), reaches.clone()));
817 describe(id, Some(&k), &reaches)
818 })
819 .collect()
820}
821
822fn has_lockfile(lockfile: &str) -> bool {
829 crate::tracked_paths()
830 .iter()
831 .any(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
832}
833
834fn lockfile_dirs(lockfile: &str) -> Vec<String> {
839 let mut dirs: Vec<String> = crate::tracked_paths()
840 .iter()
841 .filter(|p| p.rsplit('/').next().unwrap_or(p) == lockfile)
842 .map(|p| {
843 p.rsplit_once('/')
844 .map(|(d, _)| d.to_string())
845 .unwrap_or_default()
846 })
847 .collect();
848 dirs.sort();
849 dirs.dedup();
850 dirs
851}
852
853struct JsAuditor {
856 lockfile: &'static str,
857 tool: &'static str,
858 read: fn(bool, &str) -> Report,
859 args: fn(&str) -> Vec<String>,
861 prod_only: &'static str,
864}
865
866fn pnpm_args(dir: &str) -> Vec<String> {
873 let manifest = if dir.is_empty() {
874 "pnpm-workspace.yaml".to_string()
875 } else {
876 format!("{dir}/pnpm-workspace.yaml")
877 };
878 if crate::tracked_paths().contains(&manifest) {
879 vec![]
880 } else {
881 vec!["--ignore-workspace".into()]
882 }
883}
884
885const JS_AUDITORS: [JsAuditor; 2] = [
886 JsAuditor {
887 lockfile: "package-lock.json",
888 tool: "npm",
889 read: read_npm_audit,
890 args: |_| vec![],
891 prod_only: "--omit=dev",
892 },
893 JsAuditor {
894 lockfile: "pnpm-lock.yaml",
895 tool: "pnpm",
896 read: read_pnpm_audit,
897 args: pnpm_args,
898 prod_only: "--prod",
899 },
900];
901
902pub fn js(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
925 let projects: Vec<(&JsAuditor, String)> = JS_AUDITORS
926 .iter()
927 .flat_map(|a| lockfile_dirs(a.lockfile).into_iter().map(move |d| (a, d)))
928 .collect();
929 if projects.is_empty() {
930 return Outcome::Inert;
931 }
932 let root = std::path::PathBuf::from(common::repo_root());
933 let release = releasing(refs);
934 let mut found = Vec::new();
935 let mut dev_only = Vec::new();
936 let mut unchecked = Vec::new();
937 let mut full = String::new();
938 for (auditor, dir) in &projects {
939 let label = if dir.is_empty() {
940 ".".to_string()
941 } else {
942 dir.clone()
943 };
944 let mut argv = vec![common::program(auditor.tool), "audit".into()];
945 argv.extend((auditor.args)(dir));
946 let Some((exit_ok, out)) = audited_in(settings, &argv, &root.join(dir)) else {
947 common::warn(&format!(
948 "audit-js: {} could not run in {label} — the audit did NOT run",
949 auditor.tool
950 ));
951 return Outcome::Unavailable;
952 };
953 match (auditor.read)(exit_ok, &out) {
954 Report::Vulnerabilities(what) if release => {
955 let mut prod = argv.clone();
956 prod.push(auditor.prod_only.into());
957 match audited_in(settings, &prod, &root.join(dir))
958 .map(|(ok, pout)| ((auditor.read)(ok, &pout), pout))
959 {
960 Some((Report::Clean | Report::Advisories(_), _)) => {
961 dev_only.extend(what.into_iter().map(|w| format!("{label}: {w}")));
962 }
963 Some((Report::Vulnerabilities(shipped), pout)) => {
964 found.extend(shipped.into_iter().map(|w| format!("{label}: {w}")));
965 full.push_str(&format!(
966 "── {} audit {} in {label}\n{pout}\n",
967 auditor.tool, auditor.prod_only
968 ));
969 }
970 Some((Report::CouldNotCheck, _)) | None => {
971 found.extend(what.into_iter().map(|w| format!("{label}: {w}")));
972 full.push_str(&format!("── {} audit in {label}\n{out}\n", auditor.tool));
973 }
974 }
975 }
976 Report::Vulnerabilities(what) => {
977 found.extend(what.into_iter().map(|w| format!("{label}: {w}")));
978 full.push_str(&format!("── {} audit in {label}\n{out}\n", auditor.tool));
979 }
980 Report::CouldNotCheck => unchecked.push(format!("{label} ({})", auditor.tool)),
981 Report::Clean | Report::Advisories(_) => {}
982 }
983 }
984 if !unchecked.is_empty() {
985 common::warn(&format!(
986 "audit-js: the audit could not answer in {} — those projects were NOT checked",
987 unchecked.join(", ")
988 ));
989 }
990 warn_dev_only("audit-js", &dev_only);
991 let waivable = unchecked.is_empty();
992 let report = if !found.is_empty() {
993 Report::Vulnerabilities(found)
994 } else if !unchecked.is_empty() {
995 Report::CouldNotCheck
996 } else {
997 Report::Clean
998 };
999 conclude_waivable(settings, "audit-js", report, release, &full, waivable)
1000}
1001
1002pub fn go(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
1006 if !has_lockfile("go.sum") {
1007 return Outcome::Inert;
1008 }
1009 if common::which("govulncheck").is_none() {
1010 common::warn(
1011 "audit-go: govulncheck is not installed \
1012 (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
1013 );
1014 return Outcome::Unavailable;
1015 }
1016 let argv = vec![common::program("govulncheck"), "./...".into()];
1017 let Some((exit_ok, out)) = audited(settings, &argv) else {
1018 return Outcome::Unavailable;
1019 };
1020 conclude(
1021 settings,
1022 "audit-go",
1023 read_govulncheck(exit_ok, &out),
1024 releasing(refs),
1025 &out,
1026 )
1027}
1028
1029pub fn python(settings: &crate::config::Settings, refs: &[PushRef]) -> Outcome {
1030 if !has_lockfile("requirements.txt") && !has_lockfile("pyproject.toml") {
1031 return Outcome::Inert;
1032 }
1033 if common::which("pip-audit").is_none() {
1034 common::warn(
1035 "audit-python: pip-audit is not installed (pip install pip-audit) — \
1036 the audit did NOT run",
1037 );
1038 return Outcome::Unavailable;
1039 }
1040 let root = common::repo_root();
1041 let argv = if std::path::Path::new(&root)
1042 .join("requirements.txt")
1043 .exists()
1044 {
1045 vec![
1046 common::program("pip-audit"),
1047 "-r".into(),
1048 "requirements.txt".into(),
1049 ]
1050 } else if let Some(site_packages) = venv_site_packages(&root) {
1051 vec![
1059 common::program("pip-audit"),
1060 "--path".into(),
1061 site_packages,
1062 "--skip-editable".into(),
1065 ]
1066 } else {
1067 common::warn(
1068 "audit-python: no requirements.txt, and no virtualenv to audit \
1069 (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
1070 );
1071 return Outcome::Unavailable;
1072 };
1073 let Some((exit_ok, out)) = audited(settings, &argv) else {
1074 return Outcome::Unavailable;
1075 };
1076 let release = releasing(refs);
1077 let mut report = read_pip_audit(exit_ok, &out);
1078 let venv_mode = argv.iter().any(|a| a == "--path");
1083 let mut waivable = pip_rows_all_named(&out);
1086 if release && venv_mode && matches!(report, Report::Vulnerabilities(_)) {
1087 let rows = pip_vulnerable_rows(&out);
1088 if let (false, Some(prod)) = (rows.is_empty(), uv_production_names(settings)) {
1089 let (shipped, dev_only): (Vec<String>, Vec<String>) = rows
1090 .into_iter()
1091 .map(|(name, id)| (prod.contains(&name), format!("{name} {id}")))
1092 .fold((Vec::new(), Vec::new()), |(mut s, mut d), (ships, row)| {
1093 if ships {
1094 s.push(row);
1095 } else {
1096 d.push(row);
1097 }
1098 (s, d)
1099 });
1100 warn_dev_only("audit-python", &dev_only);
1101 waivable = waivable && shipped.iter().all(|row| !advisory_ids(row).is_empty());
1102 report = if shipped.is_empty() {
1103 Report::Clean
1104 } else {
1105 Report::Vulnerabilities(shipped)
1106 };
1107 }
1108 }
1109 conclude_waivable(settings, "audit-python", report, release, &out, waivable)
1110}
1111
1112fn normalise(name: &str) -> String {
1114 name.to_ascii_lowercase()
1115 .split(['-', '_', '.'])
1116 .filter(|p| !p.is_empty())
1117 .collect::<Vec<_>>()
1118 .join("-")
1119}
1120
1121fn pip_vulnerable_rows(out: &str) -> Vec<(String, String)> {
1125 let mut rows: Vec<(String, String)> = out
1126 .lines()
1127 .filter_map(|l| {
1128 let cols: Vec<&str> = l.split_whitespace().collect();
1129 let id = cols.get(2)?;
1130 ["PYSEC-", "GHSA-", "CVE-", "OSV-"]
1131 .iter()
1132 .any(|p| id.starts_with(p))
1133 .then(|| (normalise(cols[0]), (*id).to_string()))
1134 })
1135 .collect();
1136 rows.sort();
1137 rows.dedup();
1138 rows
1139}
1140
1141fn pip_rows_all_named(out: &str) -> bool {
1145 out.lines().all(|l| {
1146 let cols: Vec<&str> = l.split_whitespace().collect();
1147 let is_row = cols.len() >= 3
1148 && cols[1].starts_with(|c: char| c.is_ascii_digit())
1149 && cols[2].contains('-')
1150 && cols[2].starts_with(|c: char| c.is_ascii_uppercase());
1151 !is_row || !advisory_ids(cols[2]).is_empty()
1152 })
1153}
1154
1155fn uv_production_names(
1159 settings: &crate::config::Settings,
1160) -> Option<std::collections::HashSet<String>> {
1161 if !has_lockfile("uv.lock") || common::which("uv").is_none() {
1162 return None;
1163 }
1164 let argv = vec![
1165 common::program("uv"),
1166 "export".into(),
1167 "--frozen".into(),
1168 "--no-dev".into(),
1169 "--all-extras".into(),
1171 "--no-hashes".into(),
1172 "--no-emit-workspace".into(),
1173 "--format".into(),
1174 "requirements-txt".into(),
1175 ];
1176 let (ok, out) = audited(settings, &argv)?;
1177 if !ok {
1178 return None;
1179 }
1180 Some(
1181 out.lines()
1182 .map(str::trim)
1183 .filter(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with('-'))
1184 .filter_map(|l| {
1185 l.split(|c: char| "=<>!~;[ ".contains(c))
1186 .next()
1187 .map(normalise)
1188 })
1189 .collect(),
1190 )
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196
1197 #[test]
1203 fn a_uv_project_is_audited_through_its_venv() {
1204 let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
1205 let _ = std::fs::remove_dir_all(&root);
1206
1207 std::fs::create_dir_all(&root).unwrap();
1210 assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
1211
1212 let sp = root
1219 .join(".venv")
1220 .join("lib")
1221 .join("python3.13")
1222 .join("site-packages");
1223 std::fs::create_dir_all(&sp).unwrap();
1224 assert_eq!(
1225 venv_site_packages(root.to_str().unwrap()),
1226 Some(sp.to_string_lossy().into_owned())
1227 );
1228
1229 let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
1231 let _ = std::fs::remove_dir_all(&win);
1232 let wsp = win.join(".venv").join("Lib").join("site-packages");
1233 std::fs::create_dir_all(&wsp).unwrap();
1234 assert_eq!(
1235 venv_site_packages(win.to_str().unwrap()),
1236 Some(wsp.to_string_lossy().into_owned())
1237 );
1238
1239 let _ = std::fs::remove_dir_all(&root);
1240 let _ = std::fs::remove_dir_all(&win);
1241 }
1242
1243 fn tag(name: &str) -> PushRef {
1244 PushRef {
1245 local_ref: name.to_string(),
1246 local_oid: "a".repeat(40),
1247 remote_ref: name.to_string(),
1248 remote_oid: "0".repeat(40),
1249 }
1250 }
1251
1252 #[test]
1255 fn pip_audit_rows_name_their_packages_normalised() {
1256 let out = "Name Version ID Fix Versions\n\
1257 ---------- ------- ------------------- ------------\n\
1258 Pytest_Cov 4.0.0 GHSA-aaaa-bbbb-cccc 4.1.0\n\
1259 requests 2.31.0 PYSEC-2023-74 2.31.1\n\
1260 requests 2.31.0 CVE-2024-35195 2.32.0\n\
1261 Found 3 known vulnerabilities in 2 packages\n";
1262 let names: Vec<String> = pip_vulnerable_rows(out)
1263 .into_iter()
1264 .map(|(n, _)| n)
1265 .collect();
1266 assert_eq!(names, vec!["pytest-cov", "requests", "requests"]);
1267 assert_eq!(normalise("Foo_Bar.baz"), "foo-bar-baz");
1268 }
1269
1270 #[test]
1271 fn advisory_ids_are_found_wherever_they_sit() {
1272 let text = "More info https://github.com/advisories/GHSA-h3mg-xc3c-68pw\n\
1273 ID: RUSTSEC-2025-0001, GO-2024-2687 PYSEC-2023-74 CVE-2024-35195 OSV-2024-12 GHSA-bad";
1274 assert_eq!(
1275 advisory_ids(text),
1276 vec![
1277 "CVE-2024-35195",
1278 "GHSA-h3mg-xc3c-68pw",
1279 "GO-2024-2687",
1280 "OSV-2024-12",
1281 "PYSEC-2023-74",
1282 "RUSTSEC-2025-0001"
1283 ]
1284 );
1285 }
1286
1287 #[test]
1288 fn a_waiver_is_reasoned_dated_and_bounded() {
1289 let today = days_from_date("2026-10-05").unwrap();
1290 let w = Waivers::parse(
1291 "# id expires reason\n\
1292 GHSA-aaaa-bbbb-cccc 2026-12-01 no patched version; build-time only\n\
1293 GHSA-dddd-eeee-ffff 2026-10-01 expired one\n\
1294 GHSA-gggg-hhhh-iiii 2027-02-01 too far ahead\n\
1295 GHSA-jjjj-kkkk-llll 2026-11-01\n\
1296 GHSA-mmmm-nnnn-oooo soon no date\n",
1297 today,
1298 );
1299 assert!(w.covers("GHSA-aaaa-bbbb-cccc"));
1300 assert!(!w.covers("GHSA-dddd-eeee-ffff"), "expired");
1301 assert!(!w.covers("GHSA-gggg-hhhh-iiii"), "beyond the horizon");
1302 assert!(!w.covers("GHSA-jjjj-kkkk-llll"), "no reason");
1303 assert!(!w.covers("GHSA-mmmm-nnnn-oooo"), "no date");
1304 assert_eq!(w.problems().len(), 4);
1305 assert_eq!(days_from_date("1970-01-01"), Some(0));
1306 assert_eq!(days_from_date("2026-02-30"), None);
1307 assert!(days_from_date("2028-02-29").is_some());
1308 assert_eq!(days_from_date("2026-13-01"), None);
1309 }
1310
1311 #[test]
1312 fn two_locked_versions_are_two_answers() {
1313 let out = "Crate: foo\nVersion: 0.1.0\nID: RUSTSEC-2025-0010\n\
1314 Crate: foo\nVersion: 0.2.0\nID: RUSTSEC-2025-0011\n";
1315 let ids = vec![
1316 "RUSTSEC-2025-0010".to_string(),
1317 "RUSTSEC-2025-0011".to_string(),
1318 ];
1319 let (shipped, dev) = split_shipped_crates(ids, out, |spec| match spec {
1320 "foo@0.1.0" => Some("warning: nothing to print.\n".into()),
1321 "foo@0.2.0" => Some("foo v0.2.0\n└── me v0.1.0 (/repo)\n".into()),
1322 _ => None,
1323 });
1324 assert_eq!(shipped, vec!["RUSTSEC-2025-0011"]);
1325 assert_eq!(dev, vec!["RUSTSEC-2025-0010"]);
1326 }
1327
1328 #[test]
1329 fn a_pip_row_without_a_nameable_id_is_unwaivable() {
1330 let named = "requests 2.31.0 PYSEC-2023-74 2.31.1\nFound 1 known vulnerability\n";
1331 let unnamed = "evil 1.0.0 MAL-2024-1234\nrequests 2.31.0 PYSEC-2023-74 2.31.1\n";
1332 assert!(pip_rows_all_named(named));
1333 assert!(!pip_rows_all_named(unnamed));
1334 }
1335
1336 #[test]
1337 fn a_crate_ships_unless_its_tree_says_otherwise() {
1338 let out = "Crate: dev\nVersion: 0.2.0\nID: RUSTSEC-2025-0001\n\
1339 Crate: app\nID: RUSTSEC-2025-0002\n\
1340 Crate: odd\nID: RUSTSEC-2025-0004\nRUSTSEC-2025-0003\n";
1341 let ids = vec![
1342 "RUSTSEC-2025-0001".to_string(),
1343 "RUSTSEC-2025-0002".to_string(),
1344 "RUSTSEC-2025-0003".to_string(),
1345 "RUSTSEC-2025-0004".to_string(),
1346 ];
1347 let (shipped, dev) = split_shipped_crates(ids, out, |spec| match spec {
1348 "dev@0.2.0" => Some("warning: nothing to print.\n".into()),
1350 "app" => Some("app v1.0.0\n└── me v0.1.0 (/repo)\n".into()),
1351 "odd" => Some("odd v1.0.0\n".into()),
1353 _ => None,
1354 });
1355 assert_eq!(
1357 shipped,
1358 vec![
1359 "RUSTSEC-2025-0002",
1360 "RUSTSEC-2025-0003",
1361 "RUSTSEC-2025-0004"
1362 ]
1363 );
1364 assert_eq!(dev, vec!["RUSTSEC-2025-0001"]);
1365 }
1366
1367 #[test]
1368 fn a_release_is_a_v_number_tag() {
1369 assert!(releasing(&[tag("refs/tags/v1.6.6")]));
1370 assert!(releasing(&[tag("refs/tags/v2")]));
1371 assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
1372 assert!(!releasing(&[tag("refs/tags/release")]));
1373 assert!(!releasing(&[tag("refs/heads/v1-styles")]));
1374 assert!(!releasing(&[tag("refs/heads/main")]));
1375 assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
1377 }
1378
1379 #[test]
1382 fn cargo_audit_ids_decide_not_the_exit_code() {
1383 assert_eq!(
1384 read_cargo_audit(true, "ok, 312 crates checked"),
1385 Report::Clean
1386 );
1387 assert_eq!(
1388 read_cargo_audit(false, "error: couldn't fetch advisory database"),
1389 Report::CouldNotCheck
1390 );
1391 let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
1392 assert_eq!(
1393 read_cargo_audit(true, warn),
1394 Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
1395 );
1396 let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
1397 assert_eq!(
1398 read_cargo_audit(false, vuln),
1399 Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
1400 );
1401 assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
1403 }
1404
1405 fn real_report() -> String {
1408 [
1409 "Crate: paste",
1410 "Version: 1.0.15",
1411 "Warning: unmaintained",
1412 "ID: RUSTSEC-2024-0436",
1413 "URL: https://rustsec.org/advisories/RUSTSEC-2024-0436",
1414 "",
1415 "Crate: lru",
1416 "Version: 0.12.5",
1417 "Warning: unsound",
1418 "ID: RUSTSEC-2026-0253",
1419 "",
1420 "Crate: lru",
1421 "Version: 0.12.5",
1422 "ID: RUSTSEC-2026-0002",
1423 "",
1424 "warning: 3 allowed warnings found",
1425 ]
1426 .join("\n")
1427 }
1428
1429 #[test]
1430 fn an_advisory_is_paired_with_its_crate() {
1431 let pairs = ids_with_crates(&real_report());
1432 assert_eq!(
1433 pairs,
1434 vec![
1435 ("RUSTSEC-2024-0436".into(), Some("paste".into())),
1436 ("RUSTSEC-2026-0253".into(), Some("lru".into())),
1437 ("RUSTSEC-2026-0002".into(), Some("lru".into())),
1438 ]
1439 );
1440 }
1441
1442 #[test]
1445 fn a_loose_id_borrows_no_crate() {
1446 let out = "Crate: paste\nID: RUSTSEC-2024-0436\nsee also RUSTSEC-2025-0001";
1447 assert_eq!(
1448 ids_with_crates(out),
1449 vec![
1450 ("RUSTSEC-2024-0436".into(), Some("paste".into())),
1451 ("RUSTSEC-2025-0001".into(), None),
1452 ]
1453 );
1454 }
1455
1456 #[test]
1459 fn only_local_crates_are_named_as_reached() {
1460 let tree =
1461 "lru v0.12.5\n└── ratatui v0.29.0\n └── amont-fleet v1.32.0 (/w/crates/amont-fleet)";
1462 assert_eq!(local_dependents(tree), vec!["amont-fleet".to_string()]);
1463 assert!(local_dependents("lru v0.12.5\n└── ratatui v0.29.0").is_empty());
1464 }
1465
1466 #[test]
1470 fn the_warning_names_the_crate_and_what_reaches_it() {
1471 let calls = std::cell::RefCell::new(Vec::new());
1472 let fake = |k: &str| {
1473 calls.borrow_mut().push(k.to_string());
1474 Some(format!(
1475 "{k} v1\n└── ratatui v0.29.0\n └── amont-fleet v1.32.0 (/w/crates/amont-fleet)"
1476 ))
1477 };
1478 let out = real_report();
1479 let got = attribute(read_cargo_audit(true, &out), &out, fake);
1480 assert_eq!(
1481 got,
1482 Report::Advisories(vec![
1483 "RUSTSEC-2024-0436 (paste → amont-fleet)".into(),
1484 "RUSTSEC-2026-0002 (lru → amont-fleet)".into(),
1485 "RUSTSEC-2026-0253 (lru → amont-fleet)".into(),
1486 ])
1487 );
1488 assert_eq!(
1489 calls.into_inner(),
1490 vec!["paste", "lru"],
1491 "one call per crate"
1492 );
1493 }
1494
1495 #[test]
1499 fn a_crate_outside_the_build_graph_says_so() {
1500 let out = "Crate: wezterm-input-types\nID: RUSTSEC-2025-0001";
1501 let got = attribute(read_cargo_audit(true, out), out, |_| {
1502 Some("warning: nothing to print.".into())
1503 });
1504 assert_eq!(
1505 got,
1506 Report::Advisories(vec![
1507 "RUSTSEC-2025-0001 (wezterm-input-types, not in the build graph)".into()
1508 ])
1509 );
1510 }
1511
1512 #[test]
1515 fn attribution_never_changes_the_verdict() {
1516 let spawned = std::cell::Cell::new(false);
1517 let clean = attribute(read_cargo_audit(true, "0 vulnerabilities"), "", |_| {
1518 spawned.set(true);
1519 None
1520 });
1521 assert_eq!(clean, Report::Clean);
1522 assert!(!spawned.get(), "a clean report must spawn nothing");
1523
1524 let out = "Crate: paste\nID: RUSTSEC-2024-0436";
1525 let blind = attribute(read_cargo_audit(false, out), out, |_| None);
1526 assert_eq!(
1527 blind,
1528 Report::Vulnerabilities(vec![
1529 "RUSTSEC-2024-0436 (paste, not in the build graph)".into()
1530 ])
1531 );
1532 }
1533
1534 #[test]
1535 fn npm_audit_summary_decides() {
1536 assert_eq!(
1537 read_npm_audit(true, "found 0 vulnerabilities\n"),
1538 Report::Clean
1539 );
1540 assert_eq!(
1541 read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
1542 Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
1543 );
1544 assert_eq!(
1547 read_npm_audit(
1548 false,
1549 "# npm audit report\n\nvite 6.0.0 - 6.1.5\nSeverity: high\n\n\
1550 17 vulnerabilities (8 moderate, 9 high)\n\nTo address all issues, run:\n npm audit fix\n"
1551 ),
1552 Report::Vulnerabilities(vec!["17 vulnerabilities (8 moderate, 9 high)".into()])
1553 );
1554 assert_eq!(
1555 read_npm_audit(false, "1 vulnerability (1 high)\n"),
1556 Report::Vulnerabilities(vec!["1 vulnerability (1 high)".into()])
1557 );
1558 assert_eq!(
1559 read_npm_audit(true, "up to date, audited 100 packages\n"),
1560 Report::Clean
1561 );
1562 assert_eq!(
1563 read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
1564 Report::CouldNotCheck
1565 );
1566 }
1567
1568 #[test]
1569 fn pnpm_audit_summary_decides() {
1570 assert_eq!(
1571 read_pnpm_audit(true, "No known vulnerabilities found\n"),
1572 Report::Clean
1573 );
1574 assert_eq!(
1577 read_pnpm_audit(
1578 false,
1579 "│ More info │ https://github.com/advisories/GHSA-395f │\n\
1580 └───────────┴──────────────────────────────────────────┘\n\
1581 107 vulnerabilities found\n\
1582 Severity: 6 low | 45 moderate | 54 high | 2 critical\n"
1583 ),
1584 Report::Vulnerabilities(vec![
1585 "107 vulnerabilities found (6 low | 45 moderate | 54 high | 2 critical)".into()
1586 ])
1587 );
1588 assert_eq!(
1589 read_pnpm_audit(false, "1 vulnerabilities found\n"),
1590 Report::Vulnerabilities(vec!["1 vulnerabilities found".into()])
1591 );
1592 assert_eq!(
1593 read_pnpm_audit(
1594 false,
1595 " ERR_PNPM_AUDIT_BAD_RESPONSE The audit endpoint responded with 503\n"
1596 ),
1597 Report::CouldNotCheck
1598 );
1599 }
1600
1601 #[test]
1604 fn govulncheck_ids_decide_not_the_exit_code() {
1605 assert_eq!(
1606 read_govulncheck(true, "No vulnerabilities found.\n"),
1607 Report::Clean
1608 );
1609 assert_eq!(
1610 read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
1611 Report::CouldNotCheck
1612 );
1613 assert_eq!(
1616 read_govulncheck(
1617 true,
1618 "=== Informational ===\nVulnerability #1: GO-2023-1840\n More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
1619 ),
1620 Report::Advisories(vec!["GO-2023-1840".into()])
1621 );
1622 assert_eq!(
1623 read_govulncheck(
1624 false,
1625 "Vulnerability #1: GO-2022-0969\n Your code calls it.\nGO-2022-0969 again\n"
1626 ),
1627 Report::Vulnerabilities(vec!["GO-2022-0969".into()])
1628 );
1629 assert_eq!(
1631 read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
1632 Report::Clean
1633 );
1634 }
1635
1636 #[test]
1637 fn pip_audit_sentence_decides() {
1638 assert_eq!(
1639 read_pip_audit(true, "No known vulnerabilities found\n"),
1640 Report::Clean
1641 );
1642 assert_eq!(
1643 read_pip_audit(
1644 false,
1645 "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
1646 ),
1647 Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
1648 );
1649 assert_eq!(
1650 read_pip_audit(false, "ERROR: could not resolve\n"),
1651 Report::CouldNotCheck
1652 );
1653 }
1654}