From 8a41c5ebcdc5d6660f672f74ac25e7748cfc425d Mon Sep 17 00:00:00 2001 From: juangaitanv Date: Thu, 11 Jun 2026 07:50:05 +0200 Subject: [PATCH] Add warn-only npm audit second opinion to the install gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_tree now returns TreeResolution{packages, audit}: for npm, the dry-run temp dir moves into a detached thread that runs `npm audit --json --package-lock-only` (5s deadline, kill on timeout; stdout parsed regardless of exit code — audit exits 1 when it finds advisories). run_tree_pass collects the summary via recv_timeout(2s) after the verdict pool so the two overlap; any failure is a silent skip. The signal is supplementary only: should_block_install never consults it. When total>0 a note prints to stderr; --json carries an `npm_audit` object (or null) in the tree arm. CORGEA_NO_NPM_AUDIT=1 disables. --- skills/corgea/SKILL.md | 3 +- src/precheck/mod.rs | 52 +++++- src/precheck/tree.rs | 225 +++++++++++++++++++++++++- tests/cli_npm_audit.rs | 359 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 629 insertions(+), 10 deletions(-) create mode 100644 tests/cli_npm_audit.rs diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index b034506..13df903 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -160,7 +160,8 @@ or `null` when any advisory has no known fix. Recency gating needs no token; the vuln verdict uses the configured Corgea token when present. Overrides for testing: `CORGEA_PYPI_REGISTRY`, `CORGEA_NPM_REGISTRY`, -`CORGEA_VULN_API_URL`. +`CORGEA_VULN_API_URL`; `CORGEA_NO_NPM_AUDIT=1` disables the warn-only `npm audit` +second opinion. ### Deps — `corgea deps ` diff --git a/src/precheck/mod.rs b/src/precheck/mod.rs index 22d13ff..e7e25ce 100644 --- a/src/precheck/mod.rs +++ b/src/precheck/mod.rs @@ -186,6 +186,9 @@ pub enum TreeReport { resolved_count: usize, /// Verdicts for resolved packages beyond the named targets. transitive: Vec, + /// Warn-only `npm audit` second opinion (npm only; `None` when + /// unavailable, disabled, or failed). Never consulted for blocking. + audit: Option, }, /// Resolution unavailable or failed — only named targets were verified. NamedOnly { reason: String }, @@ -364,6 +367,20 @@ fn run_parsed_install( "warning: transitive dependencies not checked ({reason}); only named packages were verified." ); } + // Warn-only npm audit second opinion: never blocks, never changes + // exit codes (`should_block_install` ignores it by design). + if let Some(TreeReport::Full { + audit: Some(audit), .. + }) = &tree + { + if audit.total > 0 { + eprintln!( + "note: npm audit reports {} advisories ({} high/critical) — supplementary signal, not blocking", + audit.total, + audit.high + audit.critical + ); + } + } // The requirements note only matters when the tree pass did *not* cover // those files (fallback to named-only, or recency-only mode). if !matches!(&tree, Some(TreeReport::Full { .. })) { @@ -431,8 +448,11 @@ fn run_tree_pass( outcomes: &mut [TargetOutcome], opts: &PrecheckOptions, ) -> TreeReport { - let set = match tree::resolve_tree(manager, rest) { - Ok(Some(set)) => set, + let tree::TreeResolution { + packages: set, + audit: audit_rx, + } = match tree::resolve_tree(manager, rest) { + Ok(Some(resolution)) => resolution, Ok(None) => { run_verdict_pass(manager, outcomes, opts); return TreeReport::NamedOnly { @@ -474,10 +494,15 @@ fn run_tree_pass( .as_ref() .expect("tree pass requires verdict config"); let results = verdict_pool(jobs, cfg, manager, opts.concurrency); + // Collect the warn-only npm audit second opinion only after the verdict + // pool so the two truly overlap; any failure (timeout, disconnected + // sender) is a silent skip. + let audit = audit_rx.and_then(|rx| rx.recv_timeout(Duration::from_secs(2)).ok()); let transitive = apply_verdicts(manager, results, outcomes); TreeReport::Full { resolved_count, transitive, + audit, } } @@ -768,6 +793,7 @@ fn print_text(report: &PrecheckReport) { Some(TreeReport::Full { resolved_count, transitive, + .. }) => { println!( " tree: {} packages resolved, {} transitive checked", @@ -875,6 +901,23 @@ fn verdict_json(verdict: &VerdictStatus) -> serde_json::Value { } } +/// JSON shape for the warn-only npm audit second opinion in the tree arm. +fn npm_audit_json(audit: &tree::AuditSummary) -> serde_json::Value { + use serde_json::json; + json!({ + "total": audit.total, + "critical": audit.critical, + "high": audit.high, + "moderate": audit.moderate, + "low": audit.low, + "info": audit.info, + "top": audit.top.iter().map(|(name, severity)| json!({ + "name": name, + "severity": severity, + })).collect::>(), + }) +} + fn print_json(report: &PrecheckReport, opts: &PrecheckOptions) { use serde_json::json; let outcomes: Vec<_> = report @@ -930,7 +973,7 @@ fn print_json(report: &PrecheckReport, opts: &PrecheckOptions) { "verdict_mode": if opts.verdict.is_some() { "full" } else { "recency-only" }, "results": outcomes, "tree": report.tree.as_ref().map(|t| match t { - TreeReport::Full { resolved_count, transitive } => json!({ + TreeReport::Full { resolved_count, transitive, audit } => json!({ "mode": "full", "reason": serde_json::Value::Null, "resolved_count": resolved_count, @@ -939,12 +982,14 @@ fn print_json(report: &PrecheckReport, opts: &PrecheckOptions) { "version": o.version, "verdict": verdict_json(&o.verdict), })).collect::>(), + "npm_audit": audit.as_ref().map(npm_audit_json), }), TreeReport::NamedOnly { reason } => json!({ "mode": "named-only", "reason": reason, "resolved_count": 0, "transitive": [], + "npm_audit": serde_json::Value::Null, }), }), }); @@ -1161,6 +1206,7 @@ mod tests { version: "0.4.2".to_string(), verdict: VerdictStatus::Vulnerable(vec![]), }], + audit: None, }); assert_eq!(report.vulnerable_count(), 1); diff --git a/src/precheck/tree.rs b/src/precheck/tree.rs index 5f4db68..1c1f2c6 100644 --- a/src/precheck/tree.rs +++ b/src/precheck/tree.rs @@ -4,7 +4,10 @@ //! pip: `--only-binary :all:` prevents sdist builds (pypa/pip#13091). //! npm: `--ignore-scripts` guards npm/cli#2787. -use std::process::Command; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; use super::PackageManager; @@ -14,6 +17,28 @@ pub struct TreePackage { pub version: String, } +/// Warn-only `npm audit` second opinion: counts from +/// `metadata.vulnerabilities` plus the worst few advisories. Never blocks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditSummary { + pub total: u64, + pub critical: u64, + pub high: u64, + pub moderate: u64, + pub low: u64, + pub info: u64, + /// Worst advisories as `(package name, severity)`, capped at + /// `AUDIT_TOP_LIMIT`, severest first. + pub top: Vec<(String, String)>, +} + +/// What `resolve_tree` hands back: the would-install set, plus (npm only) +/// a receiver for the concurrent `npm audit` second opinion. +pub struct TreeResolution { + pub packages: Vec, + pub audit: Option>, +} + /// Whether this manager's resolver has anything to resolve for the parsed /// install. pip's dry-run also reads `-r` requirements files, so those make /// a pip install eligible even with no named targets. @@ -27,9 +52,16 @@ pub fn covers_input(manager: PackageManager, parsed: &super::parse::ParsedInstal pub fn resolve_tree( manager: PackageManager, install_args: &[String], -) -> Result>, String> { +) -> Result, String> { match manager { - PackageManager::Pip => resolve_pip_tree(manager.binary_name(), install_args).map(Some), + PackageManager::Pip => { + resolve_pip_tree(manager.binary_name(), install_args).map(|packages| { + Some(TreeResolution { + packages, + audit: None, + }) + }) + } PackageManager::Npm => resolve_npm_tree(manager.binary_name(), install_args).map(Some), // yarn/pnpm/uv have no safe dry-run for installs. _ => Ok(None), @@ -100,7 +132,7 @@ fn parse_pip_report(json: &str) -> Result, String> { /// /// `--ignore-scripts` because npm has run lifecycle scripts under /// `--package-lock-only` before (npm/cli#2787). -fn resolve_npm_tree(binary: &str, install_args: &[String]) -> Result, String> { +fn resolve_npm_tree(binary: &str, install_args: &[String]) -> Result { let resolved = which::which(binary).map_err(|e| format!("{binary} not found on PATH: {e}"))?; let work = tempfile::tempdir().map_err(|e| format!("create temp dir: {e}"))?; for manifest in [ @@ -114,7 +146,7 @@ fn resolve_npm_tree(binary: &str, install_args: &[String]) -> Result Result bool { + std::env::var("CORGEA_NO_NPM_AUDIT").is_ok_and(|v| !v.trim().is_empty()) +} + +/// Kill the audit subprocess if it hasn't finished by then. +const AUDIT_DEADLINE: Duration = Duration::from_secs(5); + +/// Cap on `AuditSummary::top` advisory entries. +const AUDIT_TOP_LIMIT: usize = 5; + +/// Run `npm audit --json` in the dry-run temp dir, concurrent with the +/// verdict pool. The thread owns `work` so the dir outlives the resolver and +/// is cleaned up when the audit finishes. Any failure (spawn error, timeout, +/// unparsable output) drops the sender — the receiver sees a disconnect and +/// the gate silently skips the second opinion. +fn spawn_audit(work: tempfile::TempDir, npm: PathBuf) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + if let Some(summary) = run_audit(work.path(), &npm) { + let _ = tx.send(summary); + } + drop(work); + }); + rx +} + +/// `npm audit` exits 1 when it finds advisories — that's the success case, +/// so stdout is parsed regardless of exit code. Stdout goes through a file +/// (not a pipe) so the deadline poll can't deadlock on a full pipe buffer. +/// `--package-lock-only` because the work dir holds only manifests and the +/// generated lockfile — never a `node_modules`. +fn run_audit(work: &std::path::Path, npm: &std::path::Path) -> Option { + let stdout_path = work.join("corgea-npm-audit.json"); + let stdout_file = std::fs::File::create(&stdout_path).ok()?; + let mut child = Command::new(npm) + .args(["audit", "--json", "--package-lock-only"]) + .current_dir(work) + .stdin(Stdio::null()) + .stdout(stdout_file) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let deadline = Instant::now() + AUDIT_DEADLINE; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + parse_npm_audit(&std::fs::read_to_string(&stdout_path).ok()?) +} + +/// Parse npm audit report v2 (npm 7+): counts from `metadata.vulnerabilities`, +/// `top` from the `vulnerabilities` map, severest first. +fn parse_npm_audit(json: &str) -> Option { + let report: serde_json::Value = serde_json::from_str(json).ok()?; + let counts = report.get("metadata")?.get("vulnerabilities")?; + let count = |k: &str| counts.get(k).and_then(|v| v.as_u64()).unwrap_or(0); + let (critical, high, moderate, low, info) = ( + count("critical"), + count("high"), + count("moderate"), + count("low"), + count("info"), + ); + let total = counts + .get("total") + .and_then(|v| v.as_u64()) + .unwrap_or(critical + high + moderate + low + info); + let mut top: Vec<(String, String)> = report + .get("vulnerabilities") + .and_then(|v| v.as_object()) + .map(|vulns| { + vulns + .values() + .filter_map(|entry| { + Some(( + entry.get("name")?.as_str()?.to_string(), + entry.get("severity")?.as_str()?.to_string(), + )) + }) + .collect() + }) + .unwrap_or_default(); + top.sort_by(|a, b| (severity_rank(&a.1), &a.0).cmp(&(severity_rank(&b.1), &b.0))); + top.truncate(AUDIT_TOP_LIMIT); + Some(AuditSummary { + total, + critical, + high, + moderate, + low, + info, + top, + }) +} + +/// Sort key for npm audit severities, severest first. +fn severity_rank(severity: &str) -> u8 { + match severity { + "critical" => 0, + "high" => 1, + "moderate" => 2, + "low" => 3, + "info" => 4, + _ => 5, + } } fn parse_npm_lockfile(json: &str) -> Result, String> { @@ -274,6 +424,69 @@ mod tests { assert!(err.contains("no packages map"), "got: {err}"); } + // npm audit report v2 shape: per-package `vulnerabilities` map plus + // `metadata.vulnerabilities` counts. + const AUDIT_REPORT: &str = r#"{ + "auditReportVersion": 2, + "vulnerabilities": { + "minimist": {"name": "minimist", "severity": "critical", "via": []}, + "lodash": {"name": "lodash", "severity": "high", "via": []}, + "ms": {"name": "ms", "severity": "moderate", "via": []} + }, + "metadata": {"vulnerabilities": + {"info": 0, "low": 0, "moderate": 1, "high": 1, "critical": 1, "total": 3}} + }"#; + + #[test] + fn parse_npm_audit_counts_and_top() { + let summary = parse_npm_audit(AUDIT_REPORT).expect("parse audit report"); + assert_eq!(summary.total, 3); + assert_eq!(summary.critical, 1); + assert_eq!(summary.high, 1); + assert_eq!(summary.moderate, 1); + assert_eq!(summary.low, 0); + assert_eq!(summary.info, 0); + // Severest first: critical, high, moderate. + assert_eq!( + summary.top, + vec![ + ("minimist".to_string(), "critical".to_string()), + ("lodash".to_string(), "high".to_string()), + ("ms".to_string(), "moderate".to_string()), + ] + ); + } + + #[test] + fn parse_npm_audit_caps_top_entries() { + let entries: Vec = (0..8) + .map(|i| format!(r#""p{i}": {{"name": "p{i}", "severity": "low"}}"#)) + .collect(); + let json = format!( + r#"{{"vulnerabilities": {{{}}}, + "metadata": {{"vulnerabilities": {{"low": 8, "total": 8}}}}}}"#, + entries.join(",") + ); + let summary = parse_npm_audit(&json).expect("parse audit report"); + assert_eq!(summary.total, 8); + assert_eq!(summary.top.len(), AUDIT_TOP_LIMIT); + } + + #[test] + fn parse_npm_audit_missing_total_sums_levels() { + let json = r#"{"vulnerabilities": {}, + "metadata": {"vulnerabilities": {"high": 2, "low": 1}}}"#; + let summary = parse_npm_audit(json).expect("parse audit report"); + assert_eq!(summary.total, 3); + } + + #[test] + fn parse_npm_audit_rejects_garbage() { + assert_eq!(parse_npm_audit("not json"), None); + assert_eq!(parse_npm_audit("{}"), None); + assert_eq!(parse_npm_audit(r#"{"metadata": {}}"#), None); + } + #[test] fn name_from_lock_path_handles_nested_and_scoped() { assert_eq!( diff --git a/tests/cli_npm_audit.rs b/tests/cli_npm_audit.rs new file mode 100644 index 0000000..b721187 --- /dev/null +++ b/tests/cli_npm_audit.rs @@ -0,0 +1,359 @@ +//! Hermetic e2e tests for the warn-only `npm audit` second opinion +//! (`corgea npm install …` with a token + vuln-api stub). +//! +//! Extends the `cli_tree.rs` harness pattern with an audit-aware fake npm: +//! a `--package-lock-only` invocation writes a canned lockfile (the tree +//! pass), an `audit` invocation emits a canned audit report on stdout (real +//! `npm audit` exits 1 when it finds advisories — that's the success case), +//! and any other invocation records its argv to a marker. The audit is a +//! supplementary signal only: it must never block, never unblock, and never +//! change exit codes. + +#![cfg(unix)] + +mod common; + +use common::corgea_isolated; +use corgea::vuln_api_stub::{self, PackageKey}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::thread; +use tempfile::TempDir; + +fn key(eco: &str, name: &str, ver: &str) -> PackageKey { + (eco.to_string(), name.to_string(), ver.to_string()) +} + +/// npm lockfile-v3 fixture: named `oldpkg` 1.0.0 + transitive `evildep` 0.4.2. +const NPM_LOCK: &str = r#"{"name":"proj","lockfileVersion":3,"packages":{ + "":{"name":"proj","version":"1.0.0"}, + "node_modules/oldpkg":{"version":"1.0.0"}, + "node_modules/evildep":{"version":"0.4.2"}}}"#; + +/// npm audit report v2 with two advisories: 1 critical + 1 high. +const AUDIT_ADVISORIES: &str = r#"{"auditReportVersion":2, + "vulnerabilities":{ + "minimist":{"name":"minimist","severity":"critical","via":[]}, + "lodash":{"name":"lodash","severity":"high","via":[]}}, + "metadata":{"vulnerabilities": + {"info":0,"low":0,"moderate":0,"high":1,"critical":1,"total":2}}}"#; + +/// npm audit report v2 with no advisories. +const AUDIT_CLEAN: &str = r#"{"auditReportVersion":2,"vulnerabilities":{}, + "metadata":{"vulnerabilities": + {"info":0,"low":0,"moderate":0,"high":0,"critical":0,"total":0}}}"#; + +fn vulnerable_evildep_body() -> String { + r#"{"ecosystem":"npm","package_name":"evildep","version":"0.4.2","is_vulnerable":true, + "matches":[{"advisory_id":"MAL-2024-0002","severity_level":"critical","tier":1, + "vulnerable_version_range":null,"fixed_version":null}]}"# + .to_string() +} + +/// How the fake npm behaves on its `audit --json` invocation. +#[derive(Clone, Copy)] +enum AuditScenario { + /// Emits `AUDIT_ADVISORIES` and exits 1 — real npm audit's + /// advisories-found behaviour. + Advisories, + /// Emits `AUDIT_CLEAN` and exits 0. + Clean, + /// Emits nothing and exits 1 — unparsable output must be a silent skip. + Broken, + /// Never answers — the gate's `recv_timeout` must move on without it. + Hang, +} + +/// Registry stub serving the `/oldpkg` npm packument, published 2020 → +/// never recent. Everything else 404s. +fn spawn_registry_stub() -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub"); + let base_url = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut buf = Vec::with_capacity(4096); + let mut chunk = [0u8; 1024]; + while let Ok(n) = stream.read(&mut chunk) { + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + if buf.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let req = String::from_utf8_lossy(&buf); + let path = req + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .unwrap_or(""); + + let (status, body) = if path == "/oldpkg" { + ( + "200 OK", + r#"{"dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{}},"time":{"1.0.0":"2020-01-01T00:00:00Z"}}"#, + ) + } else { + ("404 Not Found", r#"{"message":"not found"}"#) + }; + let response = format!( + "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + status, + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + base_url +} + +/// Shell loop that emits `path` line by line — works under the locked-down +/// test PATH (no `cat`); the `|| [ -n "$line" ]` guard keeps a final line +/// with no trailing newline. +fn emit(path: &Path) -> String { + format!( + "while IFS= read -r line || [ -n \"$line\" ]; do printf '%s\\n' \"$line\"; done < '{}'", + path.display() + ) +} + +/// Write an executable fake npm into `dir`: +/// * `audit` (checked first — the audit argv also carries +/// `--package-lock-only`) → records argv to `audit_marker`, then acts out +/// `scenario`; +/// * `--package-lock-only` → writes `NPM_LOCK` to `./package-lock.json` +/// (cwd is the resolver's throwaway temp dir), exits 0 — the tree pass; +/// * anything else → records argv to `marker`, exits 0 — the real install. +fn write_fake_npm(dir: &Path, marker: &Path, audit_marker: &Path, scenario: AuditScenario) { + use std::os::unix::fs::PermissionsExt; + let lock_payload = dir.join("npm-lock-payload.json"); + std::fs::write(&lock_payload, NPM_LOCK).expect("write lock payload"); + let audit_branch = match scenario { + AuditScenario::Advisories | AuditScenario::Clean => { + let (body, code) = match scenario { + AuditScenario::Advisories => (AUDIT_ADVISORIES, 1), + _ => (AUDIT_CLEAN, 0), + }; + let audit_payload = dir.join("npm-audit-payload.json"); + std::fs::write(&audit_payload, body).expect("write audit payload"); + format!("{}; exit {code}", emit(&audit_payload)) + } + AuditScenario::Broken => "exit 1".to_string(), + AuditScenario::Hang => "/bin/sleep 10; exit 0".to_string(), + }; + let script = format!( + "#!/bin/sh\ncase \" $* \" in\n\ + *\" audit \"*) printf '%s' \"$*\" > '{audit_marker}'; {audit_branch};;\n\ + *\" --package-lock-only \"*) {lock} > package-lock.json; exit 0;;\n\ + esac\nprintf '%s' \"$*\" > '{marker}'\nexit 0\n", + lock = emit(&lock_payload), + audit_marker = audit_marker.display(), + marker = marker.display(), + ); + let path = dir.join("npm"); + std::fs::write(&path, script).expect("write fake npm"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod"); +} + +/// `corgea` wired to the registry stub, an audit-aware fake npm, and a +/// vuln-api stub. +struct AuditHarness { + cmd: Command, + marker: PathBuf, + audit_marker: PathBuf, + _home: TempDir, + _bin: TempDir, +} + +impl AuditHarness { + fn new(checks: HashMap, scenario: AuditScenario) -> Self { + let (mut cmd, home) = corgea_isolated(); + let bin = TempDir::new().expect("temp bin dir"); + let marker = bin.path().join("pm-argv.txt"); + let audit_marker = bin.path().join("audit-argv.txt"); + write_fake_npm(bin.path(), &marker, &audit_marker, scenario); + let registry = spawn_registry_stub(); + let vuln_stub = vuln_api_stub::spawn_with_statuses(checks, HashMap::new()); + cmd.env("PATH", bin.path()) + .env("CORGEA_NPM_REGISTRY", ®istry) + .env("CORGEA_VULN_API_URL", &vuln_stub.base_url) + .env("CORGEA_TOKEN", "test-token") + .env_remove("CORGEA_NO_NPM_AUDIT"); + Self { + cmd, + marker, + audit_marker, + _home: home, + _bin: bin, + } + } + + fn recorded_argv(&self) -> Option { + std::fs::read_to_string(&self.marker).ok() + } +} + +#[test] +fn audit_advisories_warn_on_stderr_without_blocking() { + // Verdicts all clean; only npm audit complains → note on stderr, the + // install still runs, exit code stays 0. + let mut h = AuditHarness::new(HashMap::new(), AuditScenario::Advisories); + let out = h + .cmd + .args(["npm", "install", "oldpkg@1.0.0"]) + .output() + .expect("run corgea"); + assert_eq!(out.status.code(), Some(0), "audit findings must not block"); + assert_eq!(h.recorded_argv().as_deref(), Some("install oldpkg@1.0.0")); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains( + "note: npm audit reports 2 advisories (2 high/critical) — supplementary signal, not blocking" + ), + "stderr: {stderr}" + ); + assert_eq!( + std::fs::read_to_string(&h.audit_marker).as_deref().ok(), + Some("audit --json --package-lock-only"), + "audit must run as `npm audit --json --package-lock-only`" + ); +} + +#[test] +fn audit_clean_report_prints_no_note() { + let mut h = AuditHarness::new(HashMap::new(), AuditScenario::Clean); + let out = h + .cmd + .args(["npm", "install", "oldpkg@1.0.0"]) + .output() + .expect("run corgea"); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(h.recorded_argv().as_deref(), Some("install oldpkg@1.0.0")); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("npm audit reports"), + "zero advisories must stay silent: {stderr}" + ); +} + +#[test] +fn audit_json_object_in_tree_arm() { + let mut h = AuditHarness::new(HashMap::new(), AuditScenario::Advisories); + let out = h + .cmd + .args(["npm", "--json", "install", "oldpkg@1.0.0"]) + .output() + .expect("run corgea"); + assert_eq!(out.status.code(), Some(0)); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + let audit = &parsed["tree"]["npm_audit"]; + assert_eq!(audit["total"], 2); + assert_eq!(audit["critical"], 1); + assert_eq!(audit["high"], 1); + assert_eq!(audit["moderate"], 0); + // `top` is sorted severest first. + assert_eq!(audit["top"][0]["name"], "minimist"); + assert_eq!(audit["top"][0]["severity"], "critical"); + assert_eq!(audit["top"][1]["name"], "lodash"); + assert_eq!(audit["top"][1]["severity"], "high"); +} + +#[test] +fn audit_disabled_by_env_var() { + let mut h = AuditHarness::new(HashMap::new(), AuditScenario::Advisories); + let out = h + .cmd + .env("CORGEA_NO_NPM_AUDIT", "1") + .args(["npm", "--json", "install", "oldpkg@1.0.0"]) + .output() + .expect("run corgea"); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(h.recorded_argv().as_deref(), Some("install oldpkg@1.0.0")); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!stderr.contains("npm audit reports"), "stderr: {stderr}"); + assert!( + !h.audit_marker.exists(), + "CORGEA_NO_NPM_AUDIT=1 must skip the audit subprocess entirely" + ); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + assert_eq!(parsed["tree"]["mode"], "full"); + assert!(parsed["tree"]["npm_audit"].is_null()); +} + +#[test] +fn audit_failure_is_a_silent_skip() { + // Audit exits 1 with no output (unparsable) → no note, null in JSON, + // gate result untouched. + let mut h = AuditHarness::new(HashMap::new(), AuditScenario::Broken); + let out = h + .cmd + .args(["npm", "--json", "install", "oldpkg@1.0.0"]) + .output() + .expect("run corgea"); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(h.recorded_argv().as_deref(), Some("install oldpkg@1.0.0")); + assert!( + !String::from_utf8_lossy(&out.stderr).contains("npm audit"), + "a failed audit must stay silent" + ); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + assert!(parsed["tree"]["npm_audit"].is_null()); +} + +#[test] +fn audit_hang_is_skipped_within_the_collect_window() { + // The fake audit sleeps 10s; the gate's recv_timeout(2s) must move on. + let started = std::time::Instant::now(); + let mut h = AuditHarness::new(HashMap::new(), AuditScenario::Hang); + let out = h + .cmd + .args(["npm", "install", "oldpkg@1.0.0"]) + .output() + .expect("run corgea"); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(h.recorded_argv().as_deref(), Some("install oldpkg@1.0.0")); + assert!( + !String::from_utf8_lossy(&out.stderr).contains("npm audit"), + "a timed-out audit must stay silent" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(8), + "gate must not wait out the hung audit (took {:?})", + started.elapsed() + ); +} + +#[test] +fn audit_never_unblocks_a_vulnerable_verdict() { + // Transitive `evildep` is flagged by the verdict; the audit also has + // findings. Block behaviour and exit code are the verdict's alone — the + // audit note still prints as a supplementary signal. + let mut checks = HashMap::new(); + checks.insert(key("npm", "evildep", "0.4.2"), vulnerable_evildep_body()); + let mut h = AuditHarness::new(checks, AuditScenario::Advisories); + let out = h + .cmd + .args(["npm", "install", "oldpkg@1.0.0"]) + .output() + .expect("run corgea"); + assert_eq!(out.status.code(), Some(1), "verdict block must stand"); + assert_eq!( + h.recorded_argv(), + None, + "npm must not run on a vulnerable verdict regardless of audit" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("npm audit reports 2 advisories"), + "stderr: {stderr}" + ); +}