1use crate::check::Outcome;
26use crate::pushrefs::PushRef;
27
28use super::common;
29
30#[derive(Debug, PartialEq, Eq)]
32enum Report {
33 Clean,
34 Advisories(Vec<String>),
37 Vulnerabilities(Vec<String>),
39 CouldNotCheck,
41}
42
43fn releasing(refs: &[PushRef]) -> bool {
47 refs.iter().any(|r| {
48 r.remote_ref
49 .strip_prefix("refs/tags/")
50 .and_then(|t| t.strip_prefix('v'))
51 .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
52 })
53}
54
55fn conclude(tool: &str, report: Report, releasing: bool, full: &str) -> Outcome {
59 match report {
60 Report::Clean => {
61 common::ok(&format!("{tool}: no known vulnerabilities"));
62 Outcome::Passed
63 }
64 Report::Advisories(ids) => {
65 common::warn(&format!(
66 "{tool}: advisories against the dependency tree (warnings — unmaintained/unsound): {}",
67 ids.join(", ")
68 ));
69 Outcome::Warned
70 }
71 Report::Vulnerabilities(what) => {
72 if releasing {
73 for line in full.lines() {
74 crate::say!("{line}");
75 }
76 common::fail(&format!(
77 "{tool}: known vulnerabilities in the dependency tree — a v* tag \
78 does not ship with these: {}",
79 what.join(", ")
80 ));
81 Outcome::Failed
82 } else {
83 common::warn(&format!(
84 "{tool}: known vulnerabilities in the dependency tree ({}) — \
85 this will BLOCK a v* tag push",
86 what.join(", ")
87 ));
88 Outcome::Warned
89 }
90 }
91 Report::CouldNotCheck => {
92 common::warn(&format!(
93 "{tool} could not complete — the dependency tree was NOT checked. \
94 This is not a clean result."
95 ));
96 Outcome::Unavailable
97 }
98 }
99}
100
101fn read_cargo_audit(exit_ok: bool, out: &str) -> Report {
104 let mut ids: Vec<String> = out
105 .split_whitespace()
106 .filter(|w| {
107 w.len() == 17
108 && w.starts_with("RUSTSEC-")
109 && w[8..12].bytes().all(|b| b.is_ascii_digit())
110 && w.as_bytes()[12] == b'-'
111 && w[13..17].bytes().all(|b| b.is_ascii_digit())
112 })
113 .map(|w| w.to_string())
114 .collect();
115 ids.sort();
116 ids.dedup();
117 match (ids.is_empty(), exit_ok) {
118 (true, true) => Report::Clean,
119 (true, false) => Report::CouldNotCheck,
120 (false, true) => Report::Advisories(ids),
121 (false, false) => Report::Vulnerabilities(ids),
122 }
123}
124
125fn read_npm_audit(exit_ok: bool, out: &str) -> Report {
130 let summary = out
131 .lines()
132 .rev()
133 .map(str::trim)
134 .find(|l| l.starts_with("found ") && l.contains("vulnerabilit"));
135 match summary {
136 Some(l) if l.starts_with("found 0 ") => Report::Clean,
137 Some(l) => Report::Vulnerabilities(vec![l.to_string()]),
138 None if exit_ok => Report::Clean,
139 None => Report::CouldNotCheck,
140 }
141}
142
143fn read_govulncheck(exit_ok: bool, out: &str) -> Report {
150 let mut ids: Vec<String> = out
151 .split_whitespace()
152 .map(|w| w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'))
153 .filter(|w| {
154 w.len() >= 12
155 && w.starts_with("GO-")
156 && w[3..7].bytes().all(|b| b.is_ascii_digit())
157 && w.as_bytes()[7] == b'-'
158 && w[8..].bytes().all(|b| b.is_ascii_digit())
159 })
160 .map(|w| w.to_string())
161 .collect();
162 ids.sort();
163 ids.dedup();
164 match (ids.is_empty(), exit_ok) {
165 (true, true) => Report::Clean,
166 (true, false) => Report::CouldNotCheck,
167 (false, true) => Report::Advisories(ids),
168 (false, false) => Report::Vulnerabilities(ids),
169 }
170}
171
172fn read_pip_audit(exit_ok: bool, out: &str) -> Report {
174 if out.contains("No known vulnerabilities found") {
175 return Report::Clean;
176 }
177 if let Some(line) = out
178 .lines()
179 .map(str::trim)
180 .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
181 {
182 return Report::Vulnerabilities(vec![line.to_string()]);
183 }
184 if exit_ok {
185 Report::Clean
186 } else {
187 Report::CouldNotCheck
188 }
189}
190
191fn venv_site_packages(root: &str) -> Option<String> {
200 let candidates = std::env::var_os("VIRTUAL_ENV")
201 .map(std::path::PathBuf::from)
202 .into_iter()
203 .chain(std::iter::once(std::path::Path::new(root).join(".venv")));
204 for venv in candidates {
205 let windows = venv.join("Lib").join("site-packages");
206 if windows.is_dir() {
207 return Some(windows.to_string_lossy().into_owned());
208 }
209 let Ok(entries) = std::fs::read_dir(venv.join("lib")) else {
210 continue;
211 };
212 for e in entries.flatten() {
213 if !e.file_name().to_string_lossy().starts_with("python") {
214 continue;
215 }
216 let sp = e.path().join("site-packages");
217 if sp.is_dir() {
218 return Some(sp.to_string_lossy().into_owned());
219 }
220 }
221 }
222 None
223}
224
225fn audited(argv: &[String]) -> Option<(bool, String)> {
227 let root = common::repo_root();
228 let mut cmd = std::process::Command::new(&argv[0]);
229 cmd.args(&argv[1..])
230 .current_dir(&root)
231 .stdin(std::process::Stdio::null());
232 common::strip_git_env(&mut cmd);
233 let (ran, out) = common::capture_within(&mut cmd)?;
234 match ran {
235 common::Ran::Status(s) => Some((s.success(), out)),
236 common::Ran::TimedOut(budget) => {
237 common::say_timed_out(&argv[0], budget);
238 None
239 }
240 }
241}
242
243pub fn rust(refs: &[PushRef]) -> Outcome {
244 if common::which("cargo-audit").is_none() {
245 common::warn(
246 "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
247 the audit did NOT run",
248 );
249 return Outcome::Unavailable;
250 }
251 let argv = vec![
252 common::program("cargo"),
253 "audit".into(),
254 "--color".into(),
255 "never".into(),
256 ];
257 let Some((exit_ok, out)) = audited(&argv) else {
258 return Outcome::Unavailable;
259 };
260 conclude(
261 "audit-rust",
262 read_cargo_audit(exit_ok, &out),
263 releasing(refs),
264 &out,
265 )
266}
267
268pub fn js(refs: &[PushRef]) -> Outcome {
269 let argv = vec![common::program("npm"), "audit".into()];
270 let Some((exit_ok, out)) = audited(&argv) else {
271 common::warn("audit-js: npm could not run — the audit did NOT run");
272 return Outcome::Unavailable;
273 };
274 conclude(
275 "audit-js",
276 read_npm_audit(exit_ok, &out),
277 releasing(refs),
278 &out,
279 )
280}
281
282pub fn go(refs: &[PushRef]) -> Outcome {
283 if common::which("govulncheck").is_none() {
284 common::warn(
285 "audit-go: govulncheck is not installed \
286 (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
287 );
288 return Outcome::Unavailable;
289 }
290 let argv = vec![common::program("govulncheck"), "./...".into()];
291 let Some((exit_ok, out)) = audited(&argv) else {
292 return Outcome::Unavailable;
293 };
294 conclude(
295 "audit-go",
296 read_govulncheck(exit_ok, &out),
297 releasing(refs),
298 &out,
299 )
300}
301
302pub fn python(refs: &[PushRef]) -> Outcome {
303 if common::which("pip-audit").is_none() {
304 common::warn(
305 "audit-python: pip-audit is not installed (pip install pip-audit) — \
306 the audit did NOT run",
307 );
308 return Outcome::Unavailable;
309 }
310 let root = common::repo_root();
311 let argv = if std::path::Path::new(&root)
312 .join("requirements.txt")
313 .exists()
314 {
315 vec![
316 common::program("pip-audit"),
317 "-r".into(),
318 "requirements.txt".into(),
319 ]
320 } else if let Some(site_packages) = venv_site_packages(&root) {
321 vec![
329 common::program("pip-audit"),
330 "--path".into(),
331 site_packages,
332 "--skip-editable".into(),
335 ]
336 } else {
337 common::warn(
338 "audit-python: no requirements.txt, and no virtualenv to audit \
339 (looked at $VIRTUAL_ENV and .venv) — the audit did NOT run",
340 );
341 return Outcome::Unavailable;
342 };
343 let Some((exit_ok, out)) = audited(&argv) else {
344 return Outcome::Unavailable;
345 };
346 conclude(
347 "audit-python",
348 read_pip_audit(exit_ok, &out),
349 releasing(refs),
350 &out,
351 )
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
364 fn a_uv_project_is_audited_through_its_venv() {
365 let root = std::env::temp_dir().join(format!("audit-venv-{}", std::process::id()));
366 let _ = std::fs::remove_dir_all(&root);
367
368 std::fs::create_dir_all(&root).unwrap();
371 assert_eq!(venv_site_packages(root.to_str().unwrap()), None);
372
373 let sp = root
380 .join(".venv")
381 .join("lib")
382 .join("python3.13")
383 .join("site-packages");
384 std::fs::create_dir_all(&sp).unwrap();
385 assert_eq!(
386 venv_site_packages(root.to_str().unwrap()),
387 Some(sp.to_string_lossy().into_owned())
388 );
389
390 let win = std::env::temp_dir().join(format!("audit-venv-win-{}", std::process::id()));
392 let _ = std::fs::remove_dir_all(&win);
393 let wsp = win.join(".venv").join("Lib").join("site-packages");
394 std::fs::create_dir_all(&wsp).unwrap();
395 assert_eq!(
396 venv_site_packages(win.to_str().unwrap()),
397 Some(wsp.to_string_lossy().into_owned())
398 );
399
400 let _ = std::fs::remove_dir_all(&root);
401 let _ = std::fs::remove_dir_all(&win);
402 }
403
404 fn tag(name: &str) -> PushRef {
405 PushRef {
406 local_ref: name.to_string(),
407 local_oid: "a".repeat(40),
408 remote_ref: name.to_string(),
409 remote_oid: "0".repeat(40),
410 }
411 }
412
413 #[test]
416 fn a_release_is_a_v_number_tag() {
417 assert!(releasing(&[tag("refs/tags/v1.6.6")]));
418 assert!(releasing(&[tag("refs/tags/v2")]));
419 assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
420 assert!(!releasing(&[tag("refs/tags/release")]));
421 assert!(!releasing(&[tag("refs/heads/v1-styles")]));
422 assert!(!releasing(&[tag("refs/heads/main")]));
423 assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
425 }
426
427 #[test]
430 fn cargo_audit_ids_decide_not_the_exit_code() {
431 assert_eq!(
432 read_cargo_audit(true, "ok, 312 crates checked"),
433 Report::Clean
434 );
435 assert_eq!(
436 read_cargo_audit(false, "error: couldn't fetch advisory database"),
437 Report::CouldNotCheck
438 );
439 let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
440 assert_eq!(
441 read_cargo_audit(true, warn),
442 Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
443 );
444 let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
445 assert_eq!(
446 read_cargo_audit(false, vuln),
447 Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
448 );
449 assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
451 }
452
453 #[test]
454 fn npm_audit_summary_decides() {
455 assert_eq!(
456 read_npm_audit(true, "found 0 vulnerabilities\n"),
457 Report::Clean
458 );
459 assert_eq!(
460 read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
461 Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
462 );
463 assert_eq!(
464 read_npm_audit(true, "up to date, audited 100 packages\n"),
465 Report::Clean
466 );
467 assert_eq!(
468 read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
469 Report::CouldNotCheck
470 );
471 }
472
473 #[test]
476 fn govulncheck_ids_decide_not_the_exit_code() {
477 assert_eq!(
478 read_govulncheck(true, "No vulnerabilities found.\n"),
479 Report::Clean
480 );
481 assert_eq!(
482 read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
483 Report::CouldNotCheck
484 );
485 assert_eq!(
488 read_govulncheck(
489 true,
490 "=== Informational ===\nVulnerability #1: GO-2023-1840\n More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
491 ),
492 Report::Advisories(vec!["GO-2023-1840".into()])
493 );
494 assert_eq!(
495 read_govulncheck(
496 false,
497 "Vulnerability #1: GO-2022-0969\n Your code calls it.\nGO-2022-0969 again\n"
498 ),
499 Report::Vulnerabilities(vec!["GO-2022-0969".into()])
500 );
501 assert_eq!(
503 read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
504 Report::Clean
505 );
506 }
507
508 #[test]
509 fn pip_audit_sentence_decides() {
510 assert_eq!(
511 read_pip_audit(true, "No known vulnerabilities found\n"),
512 Report::Clean
513 );
514 assert_eq!(
515 read_pip_audit(
516 false,
517 "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
518 ),
519 Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
520 );
521 assert_eq!(
522 read_pip_audit(false, "ERROR: could not resolve\n"),
523 Report::CouldNotCheck
524 );
525 }
526}