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_pip_audit(exit_ok: bool, out: &str) -> Report {
145 if out.contains("No known vulnerabilities found") {
146 return Report::Clean;
147 }
148 if let Some(line) = out
149 .lines()
150 .map(str::trim)
151 .find(|l| l.starts_with("Found ") && l.contains("known vulnerabilit"))
152 {
153 return Report::Vulnerabilities(vec![line.to_string()]);
154 }
155 if exit_ok {
156 Report::Clean
157 } else {
158 Report::CouldNotCheck
159 }
160}
161
162fn audited(argv: &[String]) -> Option<(bool, String)> {
164 let root = common::repo_root();
165 let mut cmd = std::process::Command::new(&argv[0]);
166 cmd.args(&argv[1..])
167 .current_dir(&root)
168 .stdin(std::process::Stdio::null());
169 common::strip_git_env(&mut cmd);
170 let (ran, out) = common::capture_within(&mut cmd)?;
171 match ran {
172 common::Ran::Status(s) => Some((s.success(), out)),
173 common::Ran::TimedOut(budget) => {
174 common::say_timed_out(&argv[0], budget);
175 None
176 }
177 }
178}
179
180pub fn rust(refs: &[PushRef]) -> Outcome {
181 if common::which("cargo-audit").is_none() {
182 common::warn(
183 "audit-rust: cargo-audit is not installed (cargo install cargo-audit) — \
184 the audit did NOT run",
185 );
186 return Outcome::Unavailable;
187 }
188 let argv = vec![
189 common::program("cargo"),
190 "audit".into(),
191 "--color".into(),
192 "never".into(),
193 ];
194 let Some((exit_ok, out)) = audited(&argv) else {
195 return Outcome::Unavailable;
196 };
197 conclude(
198 "audit-rust",
199 read_cargo_audit(exit_ok, &out),
200 releasing(refs),
201 &out,
202 )
203}
204
205pub fn js(refs: &[PushRef]) -> Outcome {
206 let argv = vec![common::program("npm"), "audit".into()];
207 let Some((exit_ok, out)) = audited(&argv) else {
208 common::warn("audit-js: npm could not run — the audit did NOT run");
209 return Outcome::Unavailable;
210 };
211 conclude(
212 "audit-js",
213 read_npm_audit(exit_ok, &out),
214 releasing(refs),
215 &out,
216 )
217}
218
219pub fn python(refs: &[PushRef]) -> Outcome {
220 if common::which("pip-audit").is_none() {
221 common::warn(
222 "audit-python: pip-audit is not installed (pip install pip-audit) — \
223 the audit did NOT run",
224 );
225 return Outcome::Unavailable;
226 }
227 let argv = vec![
228 common::program("pip-audit"),
229 "-r".into(),
230 "requirements.txt".into(),
231 ];
232 let Some((exit_ok, out)) = audited(&argv) else {
233 return Outcome::Unavailable;
234 };
235 conclude(
236 "audit-python",
237 read_pip_audit(exit_ok, &out),
238 releasing(refs),
239 &out,
240 )
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn tag(name: &str) -> PushRef {
248 PushRef {
249 local_ref: name.to_string(),
250 local_oid: "a".repeat(40),
251 remote_ref: name.to_string(),
252 remote_oid: "0".repeat(40),
253 }
254 }
255
256 #[test]
259 fn a_release_is_a_v_number_tag() {
260 assert!(releasing(&[tag("refs/tags/v1.6.6")]));
261 assert!(releasing(&[tag("refs/tags/v2")]));
262 assert!(!releasing(&[tag("refs/tags/vendor-drop")]));
263 assert!(!releasing(&[tag("refs/tags/release")]));
264 assert!(!releasing(&[tag("refs/heads/v1-styles")]));
265 assert!(!releasing(&[tag("refs/heads/main")]));
266 assert!(releasing(&[tag("refs/heads/main"), tag("refs/tags/v1.0")]));
268 }
269
270 #[test]
273 fn cargo_audit_ids_decide_not_the_exit_code() {
274 assert_eq!(
275 read_cargo_audit(true, "ok, 312 crates checked"),
276 Report::Clean
277 );
278 assert_eq!(
279 read_cargo_audit(false, "error: couldn't fetch advisory database"),
280 Report::CouldNotCheck
281 );
282 let warn = "warning: unmaintained RUSTSEC-2024-0436 paste";
283 assert_eq!(
284 read_cargo_audit(true, warn),
285 Report::Advisories(vec!["RUSTSEC-2024-0436".into()])
286 );
287 let vuln = "Crate: foo\nID: RUSTSEC-2025-0001\nerror: 1 vulnerability found\nRUSTSEC-2025-0001 again";
288 assert_eq!(
289 read_cargo_audit(false, vuln),
290 Report::Vulnerabilities(vec!["RUSTSEC-2025-0001".into()])
291 );
292 assert_eq!(read_cargo_audit(true, "RUSTSEC-20XX-0001"), Report::Clean);
294 }
295
296 #[test]
297 fn npm_audit_summary_decides() {
298 assert_eq!(
299 read_npm_audit(true, "found 0 vulnerabilities\n"),
300 Report::Clean
301 );
302 assert_eq!(
303 read_npm_audit(false, "found 3 vulnerabilities (1 moderate, 2 high)\n"),
304 Report::Vulnerabilities(vec!["found 3 vulnerabilities (1 moderate, 2 high)".into()])
305 );
306 assert_eq!(
307 read_npm_audit(true, "up to date, audited 100 packages\n"),
308 Report::Clean
309 );
310 assert_eq!(
311 read_npm_audit(false, "npm ERR! network ENOTFOUND\n"),
312 Report::CouldNotCheck
313 );
314 }
315
316 #[test]
317 fn pip_audit_sentence_decides() {
318 assert_eq!(
319 read_pip_audit(true, "No known vulnerabilities found\n"),
320 Report::Clean
321 );
322 assert_eq!(
323 read_pip_audit(
324 false,
325 "Found 2 known vulnerabilities in 1 package\nrequests 2.0 PYSEC-2023-74\n"
326 ),
327 Report::Vulnerabilities(vec!["Found 2 known vulnerabilities in 1 package".into()])
328 );
329 assert_eq!(
330 read_pip_audit(false, "ERROR: could not resolve\n"),
331 Report::CouldNotCheck
332 );
333 }
334}