Skip to main content

resq_cli/commands/
audit.rs

1/*
2 * Copyright 2026 ResQ
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! Blockchain audit command.
18//!
19//! Queries Neo N3 and Solana blockchains for ResQ event records,
20//! providing audit trails for incident response and delivery verification.
21
22use 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// ── CLI Args ─────────────────────────────────────────────────────────────────
31
32/// CLI arguments for the security and quality audit command.
33#[derive(clap::Args, Debug)]
34pub struct AuditArgs {
35    /// Root directory to start search from
36    #[arg(long, default_value = ".")]
37    pub root: PathBuf,
38
39    // ── npm audit-ci ─────────────────────────────────────────────────────────
40    /// Minimum npm vulnerability severity to fail on (critical, high, moderate, low)
41    #[arg(long, default_value = "critical")]
42    pub level: String,
43
44    /// audit-ci report verbosity (important, full, summary)
45    #[arg(long, default_value = "important")]
46    pub report_type: String,
47
48    /// Skip the yarn.lock generation step required by audit-ci
49    #[arg(long)]
50    pub skip_prepare: bool,
51
52    /// Skip the npm audit-ci pass
53    #[arg(long)]
54    pub skip_npm: bool,
55
56    // ── OSV Scanner ──────────────────────────────────────────────────────────
57    /// Skip the Google OSV Scanner pass (covers Rust, npm, Python, .NET, C/C++)
58    #[arg(long)]
59    pub skip_osv: bool,
60
61    /// OSV Scanner output format (table, json, sarif, gh-annotations)
62    #[arg(long, default_value = "table")]
63    pub osv_format: String,
64
65    // ── React Doctor ─────────────────────────────────────────────────────────
66    /// Skip the react-doctor pass on the web dashboard
67    #[arg(long)]
68    pub skip_react: bool,
69
70    /// Path to the React/Next.js project for react-doctor
71    /// (default: <root>/services/web-dashboard)
72    #[arg(long)]
73    pub react_target: Option<PathBuf>,
74
75    /// Only scan React files changed vs this base branch (e.g. "main")
76    #[arg(long)]
77    pub react_diff: Option<String>,
78
79    /// Minimum react-doctor health score to pass (0–100)
80    #[arg(long, default_value_t = 75)]
81    pub react_min_score: u8,
82}
83
84// ── Helpers ───────────────────────────────────────────────────────────────────
85
86#[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
104// ── Pass 1: OSV Scanner ───────────────────────────────────────────────────────
105
106/// Returns true for OSV scanner stdout lines that are scan-walk progress noise
107/// rather than actual vulnerability findings.
108fn 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
120/// Runs `osv-scanner scan source -r <root>` covering all lock files in the
121/// monorepo (Cargo.lock, package-lock.json, requirements.txt, *.csproj, …).
122fn run_osv_scanner(root: &Path, args: &AuditArgs, failures: &mut Vec<String>) {
123    header("OSV Scanner (cross-ecosystem)");
124
125    // Gracefully skip when the binary is not installed.
126    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    // Explicitly pass config if found in root (must come before positional args)
150    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            // Filter stdout: suppress OSV scanner's scan-walk progress lines
172            // (emitted on stdout mixed with the vulnerability table).
173            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
192// ── Pass 2: npm audit-ci ──────────────────────────────────────────────────────
193
194/// Returns true if the `bun` binary is installed and can execute without crashing.
195/// On machines without AVX2 (e.g. Intel Celeron N5100), `bun` exits with SIGILL.
196fn 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
206/// Runs `audit-ci` against the root and every npm workspace package.
207/// Uses bun when available, falls back to npm/npx when bun cannot run.
208fn 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
290// ── Pass 3: React Doctor ──────────────────────────────────────────────────────
291
292/// Runs `npx react-doctor@latest` against the Next.js web dashboard.
293/// Streams full diagnostic output to the terminal, then does a second
294/// lightweight `--score` pass to enforce the numeric health threshold.
295fn 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    // ── Full diagnostic run ───────────────────────────────────────────────────
310    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    // Inherit stdio — let react-doctor write directly to the terminal.
323    let _ = full_cmd.status();
324
325    // ── Score check ───────────────────────────────────────────────────────────
326    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
367// ── Entry Point ───────────────────────────────────────────────────────────────
368
369/// Run the security and quality audit.
370pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
405
406#[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}