resq_cli/commands/
audit.rs1use anyhow::{Context, Result};
23use glob::glob;
24use serde::Deserialize;
25use std::fs;
26use std::io::{BufRead, BufReader};
27use std::path::{Path, PathBuf};
28use std::process::{Command, Stdio};
29
30#[derive(clap::Args, Debug)]
34pub struct AuditArgs {
35 #[arg(long, default_value = ".")]
37 pub root: PathBuf,
38
39 #[arg(long, default_value = "critical")]
42 pub level: String,
43
44 #[arg(long, default_value = "important")]
46 pub report_type: String,
47
48 #[arg(long)]
50 pub skip_prepare: bool,
51
52 #[arg(long)]
54 pub skip_npm: bool,
55
56 #[arg(long)]
59 pub skip_osv: bool,
60
61 #[arg(long, default_value = "table")]
63 pub osv_format: String,
64
65 #[arg(long)]
68 pub skip_react: bool,
69
70 #[arg(long)]
73 pub react_target: Option<PathBuf>,
74
75 #[arg(long)]
77 pub react_diff: Option<String>,
78
79 #[arg(long, default_value_t = 75)]
81 pub react_min_score: u8,
82}
83
84#[derive(Deserialize)]
87struct PackageJson {
88 workspaces: Option<Vec<String>>,
89}
90
91fn resolve_root(root: &Path) -> PathBuf {
92 if root == Path::new(".") {
93 crate::utils::find_project_root()
94 } else {
95 root.to_path_buf()
96 }
97}
98
99fn header(title: &str) {
100 let bar = "━".repeat(74usize.saturating_sub(title.len() + 1));
101 println!("\n━━━ {title} {bar}");
102}
103
104fn is_osv_noise(line: &str) -> bool {
109 line.starts_with("Scanning dir ")
110 || line.starts_with("Starting filesystem walk")
111 || (line.starts_with("Scanned ") && line.contains("file and found"))
112 || line.starts_with("End status:")
113 || line.starts_with("Filtered ")
114 || (line.len() > 3
115 && line[..3].eq_ignore_ascii_case("cve")
116 && line.contains("has been filtered"))
117 || line == "No issues found"
118}
119
120fn run_osv_scanner(root: &Path, args: &AuditArgs, failures: &mut Vec<String>) {
123 header("OSV Scanner (cross-ecosystem)");
124
125 if Command::new("osv-scanner")
127 .arg("--version")
128 .stdout(Stdio::null())
129 .stderr(Stdio::null())
130 .status()
131 .is_err()
132 {
133 println!(" ⚠️ osv-scanner not found — skipping.");
134 println!(
135 " Install: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest"
136 );
137 return;
138 }
139
140 println!(
141 " 🔍 Scanning {} (format: {})...",
142 root.display(),
143 args.osv_format
144 );
145
146 let mut cmd = Command::new("osv-scanner");
147 cmd.arg("scan");
148
149 let config_path = root.join("osv-scanner.toml");
151 if config_path.exists() {
152 cmd.arg("--config").arg(&config_path);
153 }
154
155 let child = cmd
156 .arg("--format")
157 .arg(&args.osv_format)
158 .arg("-r")
159 .arg(root)
160 .stdout(Stdio::piped())
161 .stderr(Stdio::null())
162 .spawn();
163
164 let success = match child {
165 Err(e) => {
166 println!(" ❌ Failed to run: {e}");
167 failures.push(format!("osv-scanner (exec: {e})"));
168 return;
169 }
170 Ok(mut child) => {
171 if let Some(stdout) = child.stdout.take() {
174 for line in BufReader::new(stdout).lines().map_while(Result::ok) {
175 if !is_osv_noise(&line) {
176 println!("{line}");
177 }
178 }
179 }
180 child.wait().map(|s| s.success()).unwrap_or(false)
181 }
182 };
183
184 if success {
185 println!(" ✅ No vulnerabilities found.");
186 } else {
187 println!(" ❌ Vulnerabilities detected.");
188 failures.push("osv-scanner".to_string());
189 }
190}
191
192fn bun_available() -> bool {
197 Command::new("bun")
198 .arg("--version")
199 .stdout(Stdio::null())
200 .stderr(Stdio::null())
201 .status()
202 .map(|s| s.success())
203 .unwrap_or(false)
204}
205
206fn run_npm_audit(root: &Path, args: &AuditArgs, failures: &mut Vec<String>) -> Result<()> {
209 header("npm audit-ci");
210
211 let pkg_path = root.join("package.json");
212 if !pkg_path.exists() {
213 println!(" ⚠️ No package.json at {} — skipping.", root.display());
214 return Ok(());
215 }
216
217 let pkg_content = fs::read_to_string(&pkg_path).context("Failed to read package.json")?;
218 let pkg: PackageJson =
219 serde_json::from_str(&pkg_content).context("Failed to parse package.json")?;
220
221 let mut dirs_to_check = vec![root.to_path_buf()];
222 if let Some(workspaces) = pkg.workspaces {
223 for ws_glob in workspaces {
224 let pattern = root.join(&ws_glob).to_string_lossy().to_string();
225 for path in glob(&pattern).context("Invalid glob pattern")?.flatten() {
226 if path.is_dir() {
227 dirs_to_check.push(path);
228 }
229 }
230 }
231 }
232
233 for dir in dirs_to_check {
234 if !dir.join("package.json").exists() {
235 continue;
236 }
237
238 println!("\n 🔍 Auditing: {}", dir.display());
239
240 if !bun_available() {
241 println!(" ⚠️ bun unavailable on this host — skipping npm audit.");
242 continue;
243 }
244
245 if !args.skip_prepare {
246 println!(" 📦 Generating yarn.lock...");
247 let yarn_lock_file = fs::File::create(dir.join("yarn.lock"))
248 .context(format!("Cannot create yarn.lock in {}", dir.display()))?;
249
250 let ok = Command::new("bun")
251 .args(["install", "--yarn"])
252 .stdout(yarn_lock_file)
253 .current_dir(&dir)
254 .status()
255 .map(|s| s.success())
256 .unwrap_or(false);
257
258 if !ok {
259 println!(" ❌ yarn.lock generation failed.");
260 failures.push(format!("npm-prepare: {}", dir.display()));
261 continue;
262 }
263 }
264
265 println!(
266 " 🛡️ audit-ci (level: {}, report: {})...",
267 args.level, args.report_type
268 );
269
270 let ok = Command::new("bunx")
271 .arg("audit-ci@^7.1.0")
272 .arg(format!("--{}", args.level))
273 .args(["--report-type", &args.report_type])
274 .current_dir(&dir)
275 .status()
276 .map(|s| s.success())
277 .unwrap_or(false);
278
279 if ok {
280 println!(" ✅ Passed.");
281 } else {
282 println!(" ❌ Vulnerabilities at or above '{}' level.", args.level);
283 failures.push(format!("npm-audit: {}", dir.display()));
284 }
285 }
286
287 Ok(())
288}
289
290fn run_react_doctor(root: &Path, args: &AuditArgs, failures: &mut Vec<String>) {
296 header("React Doctor (web-dashboard)");
297
298 let target = args
299 .react_target
300 .clone()
301 .unwrap_or_else(|| root.join("services/web-dashboard"));
302
303 if !target.exists() {
304 println!(" ⚠️ Target not found: {} — skipping.", target.display());
305 println!(" Override with --react-target <path>");
306 return;
307 }
308
309 println!(" 🏥 Diagnosing: {} ...\n", target.display());
311
312 let mut full_cmd = Command::new("npx");
313 full_cmd
314 .args(["-y", "react-doctor@latest"])
315 .arg(&target)
316 .args(["--verbose", "--yes"]);
317
318 if let Some(ref base) = args.react_diff {
319 full_cmd.args(["--diff", base]);
320 }
321
322 let _ = full_cmd.status();
324
325 let mut score_cmd = Command::new("npx");
327 score_cmd
328 .args(["-y", "react-doctor@latest"])
329 .arg(&target)
330 .args(["--score", "--yes"])
331 .stdout(Stdio::piped())
332 .stderr(Stdio::null());
333
334 if let Some(ref base) = args.react_diff {
335 score_cmd.args(["--diff", base]);
336 }
337
338 let score: Option<u8> = score_cmd
339 .output()
340 .ok()
341 .and_then(|o| String::from_utf8(o.stdout).ok())
342 .and_then(|s| s.trim().parse().ok());
343
344 match score {
345 Some(s) if s >= args.react_min_score => {
346 println!(
347 "\n ✅ Health score: {s}/100 (threshold: {}).",
348 args.react_min_score
349 );
350 }
351 Some(s) => {
352 println!(
353 "\n ❌ Health score: {s}/100 — below threshold of {}.",
354 args.react_min_score
355 );
356 failures.push(format!(
357 "react-doctor: score {s} < {}",
358 args.react_min_score
359 ));
360 }
361 None => {
362 println!("\n ⚠️ Could not parse react-doctor score — skipping threshold check.");
363 }
364 }
365}
366
367pub async fn run(args: AuditArgs) -> Result<()> {
371 let root = resolve_root(&args.root);
372
373 println!("🔒 ResQ Security & Quality Audit");
374 println!(" Root: {}", root.display());
375
376 let mut failures: Vec<String> = Vec::new();
377
378 if !args.skip_osv {
379 run_osv_scanner(&root, &args, &mut failures);
380 }
381
382 if !args.skip_npm {
383 run_npm_audit(&root, &args, &mut failures)?;
384 }
385
386 if !args.skip_react {
387 run_react_doctor(&root, &args, &mut failures);
388 }
389
390 println!("\n{}", "━".repeat(76));
391
392 if failures.is_empty() {
393 println!("✅ All audit passes completed successfully.");
394 Ok(())
395 } else {
396 eprintln!("❌ {} pass(es) failed:", failures.len());
397 for f in &failures {
398 eprintln!(" • {f}");
399 }
400 anyhow::bail!("Audit failed")
401 }
402}
403
404#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn osv_noise_scanning_dir() {
412 assert!(is_osv_noise("Scanning dir /home/user/project"));
413 }
414
415 #[test]
416 fn osv_noise_starting_walk() {
417 assert!(is_osv_noise("Starting filesystem walk"));
418 }
419
420 #[test]
421 fn osv_noise_scanned_files() {
422 assert!(is_osv_noise("Scanned 42 file and found 3 packages"));
423 }
424
425 #[test]
426 fn osv_noise_end_status() {
427 assert!(is_osv_noise("End status: 0"));
428 }
429
430 #[test]
431 fn osv_noise_filtered() {
432 assert!(is_osv_noise("Filtered 2 vulnerabilities"));
433 }
434
435 #[test]
436 fn osv_noise_cve_filtered() {
437 assert!(is_osv_noise("CVE-2024-1234 has been filtered"));
438 }
439
440 #[test]
441 fn osv_noise_no_issues() {
442 assert!(is_osv_noise("No issues found"));
443 }
444
445 #[test]
446 fn osv_noise_real_output_is_not_noise() {
447 assert!(!is_osv_noise(
448 "GHSA-xxxx-yyyy-zzzz: critical vulnerability in lodash"
449 ));
450 assert!(!is_osv_noise(" lodash 4.17.20 CVE-2021-23337"));
451 assert!(!is_osv_noise("╭───────────────────────────────────╮"));
452 }
453}