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 audited(argv: &[String]) -> Option<(bool, String)> {
193 let root = common::repo_root();
194 let mut cmd = std::process::Command::new(&argv[0]);
195 cmd.args(&argv[1..])
196 .current_dir(&root)
197 .stdin(std::process::Stdio::null());
198 common::strip_git_env(&mut cmd);
199 let (ran, out) = common::capture_within(&mut cmd)?;
200 match ran {
201 common::Ran::Status(s) => Some((s.success(), out)),
202 common::Ran::TimedOut(budget) => {
203 common::say_timed_out(&argv[0], budget);
204 None
205 }
206 }
207}
208
209pub fn rust(refs: &[PushRef]) -> Outcome {
210 if common::which("cargo-audit").is_none() {
211 common::warn(
212 "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
213 the audit did NOT run",
214 );
215 return Outcome::Unavailable;
216 }
217 let argv = vec![
218 common::program("cargo"),
219 "audit".into(),
220 "--color".into(),
221 "never".into(),
222 ];
223 let Some((exit_ok, out)) = audited(&argv) else {
224 return Outcome::Unavailable;
225 };
226 conclude(
227 "audit-rust",
228 read_cargo_audit(exit_ok, &out),
229 releasing(refs),
230 &out,
231 )
232}
233
234pub fn js(refs: &[PushRef]) -> Outcome {
235 let argv = vec![common::program("npm"), "audit".into()];
236 let Some((exit_ok, out)) = audited(&argv) else {
237 common::warn("audit-js: npm could not run — the audit did NOT run");
238 return Outcome::Unavailable;
239 };
240 conclude(
241 "audit-js",
242 read_npm_audit(exit_ok, &out),
243 releasing(refs),
244 &out,
245 )
246}
247
248pub fn go(refs: &[PushRef]) -> Outcome {
249 if common::which("govulncheck").is_none() {
250 common::warn(
251 "audit-go: govulncheck is not installed \
252 (go install golang.org/x/vuln/cmd/govulncheck@latest) — the audit did NOT run",
253 );
254 return Outcome::Unavailable;
255 }
256 let argv = vec![common::program("govulncheck"), "./...".into()];
257 let Some((exit_ok, out)) = audited(&argv) else {
258 return Outcome::Unavailable;
259 };
260 conclude(
261 "audit-go",
262 read_govulncheck(exit_ok, &out),
263 releasing(refs),
264 &out,
265 )
266}
267
268pub fn python(refs: &[PushRef]) -> Outcome {
269 if common::which("pip-audit").is_none() {
270 common::warn(
271 "audit-python: pip-audit is not installed (pip install pip-audit) — \
272 the audit did NOT run",
273 );
274 return Outcome::Unavailable;
275 }
276 let argv = vec![
277 common::program("pip-audit"),
278 "-r".into(),
279 "requirements.txt".into(),
280 ];
281 let Some((exit_ok, out)) = audited(&argv) else {
282 return Outcome::Unavailable;
283 };
284 conclude(
285 "audit-python",
286 read_pip_audit(exit_ok, &out),
287 releasing(refs),
288 &out,
289 )
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 fn tag(name: &str) -> PushRef {
297 PushRef {
298 local_ref: name.to_string(),
299 local_oid: "a".repeat(40),
300 remote_ref: name.to_string(),
301 remote_oid: "0".repeat(40),
302 }
303 }
304
305 #[test]
308 fn a_release_is_a_v_number_tag() {
309 assert!(releasing(&[tag("refs/tags/v1.6.6")]));
310 assert!(releasing(&[tag("refs/tags/v2")]));
311 assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
312 assert!(!releasing(&[tag("refs/tags/release")]));
313 assert!(!releasing(&[tag("refs/heads/v1-styles")]));
314 assert!(!releasing(&[tag("refs/heads/main")]));
315 assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
317 }
318
319 #[test]
322 fn cargo_audit_ids_decide_not_the_exit_code() {
323 assert_eq!(
324 read_cargo_audit(true, "ok, 312 crates checked"),
325 Report::Clean
326 );
327 assert_eq!(
328 read_cargo_audit(false, "error: couldn't fetch advisory database"),
329 Report::CouldNotCheck
330 );
331 let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
332 assert_eq!(
333 read_cargo_audit(true, warn),
334 Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
335 );
336 let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
337 assert_eq!(
338 read_cargo_audit(false, vuln),
339 Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
340 );
341 assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
343 }
344
345 #[test]
346 fn npm_audit_summary_decides() {
347 assert_eq!(
348 read_npm_audit(true, "found 0 vulnerabilities\n"),
349 Report::Clean
350 );
351 assert_eq!(
352 read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
353 Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
354 );
355 assert_eq!(
356 read_npm_audit(true, "up to date, audited 100 packages\n"),
357 Report::Clean
358 );
359 assert_eq!(
360 read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
361 Report::CouldNotCheck
362 );
363 }
364
365 #[test]
368 fn govulncheck_ids_decide_not_the_exit_code() {
369 assert_eq!(
370 read_govulncheck(true, "No vulnerabilities found.\n"),
371 Report::Clean
372 );
373 assert_eq!(
374 read_govulncheck(false, "vulncheck: fetching vulnerability database: dial tcp: lookup vuln.go.dev: no such host\n"),
375 Report::CouldNotCheck
376 );
377 assert_eq!(
380 read_govulncheck(
381 true,
382 "=== Informational ===\nVulnerability #1: GO-2023-1840\n More info: https://pkg.go.dev/vuln/GO-2023-1840\n"
383 ),
384 Report::Advisories(vec!["GO-2023-1840".into()])
385 );
386 assert_eq!(
387 read_govulncheck(
388 false,
389 "Vulnerability #1: GO-2022-0969\n Your code calls it.\nGO-2022-0969 again\n"
390 ),
391 Report::Vulnerabilities(vec!["GO-2022-0969".into()])
392 );
393 assert_eq!(
395 read_govulncheck(true, "GO-20XX-0001 GO-2023-1"),
396 Report::Clean
397 );
398 }
399
400 #[test]
401 fn pip_audit_sentence_decides() {
402 assert_eq!(
403 read_pip_audit(true, "No known vulnerabilities found\n"),
404 Report::Clean
405 );
406 assert_eq!(
407 read_pip_audit(
408 false,
409 "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
410 ),
411 Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
412 );
413 assert_eq!(
414 read_pip_audit(false, "ERROR: could not resolve\n"),
415 Report::CouldNotCheck
416 );
417 }
418}