Skip to main content

aperion_shield/scan/
mod.rs

1//! v1.0: `--scan` -- pre-install audit of an MCP server.
2//!
3//! Point-in-time audit BEFORE a server is ever wired into the IDE.
4//! Complements (does not replace) runtime enforcement: scan catches a
5//! bad server at install time, TOFU pinning catches the rug pull
6//! three weeks later, and the engine blocks whatever slips through at
7//! call time.
8//!
9//! Three passes:
10//!   (a) static source signatures -- exfiltration (credential reads,
11//!       env harvesting near network calls), dynamic execution
12//!       (eval/exec/child_process), obfuscation (runtime base64/hex
13//!       decoding, charcode assembly);
14//!   (b) supply-chain metadata -- npm registry age / maintainers /
15//!       weekly downloads, plus known vulnerabilities from OSV.dev
16//!       (best-effort, skipped when offline);
17//!   (c) live catalog audit -- launch the server (under the v1.0
18//!       sandbox), issue `initialize` + `tools/list`, and run the
19//!       engine's `tool_description` rules against the catalog
20//!       without ever exposing it to an agent.
21//!
22//! Targets: a local path, a GitHub URL (shallow clone), or an npm
23//! package name (`npm pack`, no install scripts executed).
24
25use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27use std::time::Duration;
28
29use anyhow::{anyhow, Context};
30use once_cell::sync::Lazy;
31use regex::Regex;
32use serde::Serialize;
33
34use crate::engine::{Adjustments, Engine, Scope, Severity};
35
36// ───────────────────────────── targets ─────────────────────────────
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Target {
40    LocalPath(PathBuf),
41    Github(String),
42    Npm(String),
43}
44
45impl Target {
46    /// `./path`, `/abs/path`, `https://github.com/owner/repo`,
47    /// `npm:package`, or a bare npm package name.
48    pub fn parse(s: &str) -> anyhow::Result<Self> {
49        if let Some(pkg) = s.strip_prefix("npm:") {
50            return Ok(Target::Npm(pkg.to_string()));
51        }
52        if s.starts_with("https://github.com/") || s.starts_with("git@github.com:") {
53            return Ok(Target::Github(s.to_string()));
54        }
55        if s.starts_with("http://") || s.starts_with("https://") {
56            anyhow::bail!("only GitHub URLs are supported for --scan (got '{s}')");
57        }
58        let p = PathBuf::from(s);
59        if p.exists() {
60            return Ok(Target::LocalPath(p));
61        }
62        if s.starts_with('.') || s.starts_with('/') || s.starts_with('~') {
63            anyhow::bail!("--scan path '{s}' does not exist");
64        }
65        // Bare name: treat as npm, the dominant MCP packaging today.
66        Ok(Target::Npm(s.to_string()))
67    }
68}
69
70// ───────────────────────────── findings ────────────────────────────
71
72#[derive(Debug, Clone, Serialize)]
73pub struct Finding {
74    pub pass: &'static str, // "static" | "metadata" | "catalog"
75    pub id: String,
76    pub severity: Severity,
77    pub detail: String,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub location: Option<String>, // file:line for static findings
80}
81
82#[derive(Debug, Serialize)]
83pub struct Report {
84    pub target: String,
85    pub findings: Vec<Finding>,
86    pub passes_run: Vec<&'static str>,
87    pub passes_skipped: Vec<(&'static str, String)>,
88    pub verdict: Verdict,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
92pub enum Verdict {
93    Pass,
94    Caution,
95    Fail,
96}
97
98impl Report {
99    fn finalize(&mut self) {
100        let worst = self.findings.iter().map(|f| f.severity).max();
101        self.verdict = match worst {
102            Some(Severity::Critical) | Some(Severity::High) => Verdict::Fail,
103            Some(Severity::Medium) => Verdict::Caution,
104            Some(Severity::Low) | None => Verdict::Pass,
105        };
106    }
107
108    pub fn exit_code(&self) -> i32 {
109        match self.verdict {
110            Verdict::Pass => 0,
111            Verdict::Caution => 1,
112            Verdict::Fail => 2,
113        }
114    }
115
116    pub fn render_text(&self) -> String {
117        let mut out = String::new();
118        out.push_str(&format!("scan target: {}\n", self.target));
119        out.push_str(&format!(
120            "passes: {}{}\n",
121            self.passes_run.join(", "),
122            if self.passes_skipped.is_empty() {
123                String::new()
124            } else {
125                format!(
126                    " (skipped: {})",
127                    self.passes_skipped
128                        .iter()
129                        .map(|(p, why)| format!("{p} -- {why}"))
130                        .collect::<Vec<_>>()
131                        .join("; ")
132                )
133            }
134        ));
135        if self.findings.is_empty() {
136            out.push_str("findings: none\n");
137        } else {
138            out.push_str(&format!("findings: {}\n", self.findings.len()));
139            for f in &self.findings {
140                out.push_str(&format!(
141                    "  [{:?}] {} ({}): {}{}\n",
142                    f.severity,
143                    f.id,
144                    f.pass,
145                    f.detail,
146                    f.location
147                        .as_ref()
148                        .map(|l| format!(" @ {l}"))
149                        .unwrap_or_default()
150                ));
151            }
152        }
153        out.push_str(&format!("verdict: {:?}\n", self.verdict));
154        out
155    }
156}
157
158// ─────────────────────── pass (a): static scan ─────────────────────
159
160struct StaticSig {
161    id: &'static str,
162    severity: Severity,
163    detail: &'static str,
164    /// File extensions this signature applies to; empty = all text.
165    exts: &'static [&'static str],
166    re: &'static str,
167}
168
169const JS: &[&str] = &["js", "mjs", "cjs", "ts", "mts", "cts", "jsx", "tsx"];
170const PY: &[&str] = &["py"];
171const ANY: &[&str] = &[];
172
173/// Seeded from the bundled shieldset patterns plus the classic MCP
174/// supply-chain incident write-ups. Intentionally conservative: each
175/// signature is something a benign MCP server has no business doing.
176static STATIC_SIGS: &[StaticSig] = &[
177    // exfiltration: credential reads
178    StaticSig { id: "scan.static.ssh_key_read", severity: Severity::Critical, exts: ANY,
179        detail: "reads SSH private key material",
180        re: r#"(?i)[~$./\\A-Za-z_]*\.ssh[/\\](id_[a-z0-9]+|authorized_keys|known_hosts)"# },
181    StaticSig { id: "scan.static.cloud_creds_read", severity: Severity::Critical, exts: ANY,
182        detail: "reads cloud credential files",
183        re: r#"(?i)\.(aws[/\\]credentials|kube[/\\]config|gnupg|netrc|docker[/\\]config\.json)"# },
184    StaticSig { id: "scan.static.browser_secrets", severity: Severity::Critical, exts: ANY,
185        detail: "touches browser credential / cookie stores",
186        re: r#"(?i)(Login Data|Cookies|Local State)['"].{0,40}(Chrome|Chromium|Brave|Edge)|keychain-db"# },
187    // exfiltration: env harvesting shipped over the network
188    StaticSig { id: "scan.static.env_exfil_js", severity: Severity::High, exts: JS,
189        detail: "serializes the entire process environment (pair with any network call = exfil)",
190        re: r#"JSON\.stringify\(\s*process\.env\s*\)|Object\.(entries|keys)\(\s*process\.env\s*\)"# },
191    StaticSig { id: "scan.static.env_exfil_py", severity: Severity::High, exts: PY,
192        detail: "serializes the entire process environment",
193        re: r#"(json\.dumps|str)\(\s*(dict\(\s*)?os\.environ"# },
194    // dynamic execution
195    StaticSig { id: "scan.static.dynamic_eval_js", severity: Severity::High, exts: JS,
196        detail: "dynamic code execution (eval / new Function)",
197        re: r#"\beval\s*\(\s*[^'")\s]|new\s+Function\s*\("# },
198    StaticSig { id: "scan.static.child_process_js", severity: Severity::Medium, exts: JS,
199        detail: "spawns shell subprocesses (child_process)",
200        re: r#"require\(\s*['"]child_process['"]\s*\)|from\s+['"](node:)?child_process['"]"# },
201    StaticSig { id: "scan.static.dynamic_require", severity: Severity::High, exts: JS,
202        detail: "dynamic require/import of a computed module path",
203        re: r#"require\s*\(\s*[A-Za-z_$][\w$]*(\[|\.|\+| )|import\s*\(\s*[A-Za-z_$][\w$]*[\s+\[]"# },
204    StaticSig { id: "scan.static.dynamic_exec_py", severity: Severity::High, exts: PY,
205        detail: "dynamic code execution (exec/eval on non-literal)",
206        re: r#"\b(exec|eval)\s*\(\s*[A-Za-z_]"# },
207    StaticSig { id: "scan.static.shell_true_py", severity: Severity::Medium, exts: PY,
208        detail: "subprocess with shell=True",
209        re: r#"subprocess\.[A-Za-z_]+\([^)]*shell\s*=\s*True"# },
210    // obfuscation
211    StaticSig { id: "scan.static.b64_exec", severity: Severity::Critical, exts: ANY,
212        detail: "decodes base64 then executes it",
213        re: r#"(?i)(eval|exec|Function|spawn|system)\s*\(\s*[^)]{0,60}(atob|b64decode|from(?:_base64)?\s*\(\s*[^)]{0,40}['"]base64)"# },
214    StaticSig { id: "scan.static.charcode_assembly", severity: Severity::High, exts: JS,
215        detail: "assembles strings from character codes (classic obfuscation)",
216        re: r#"String\.fromCharCode\s*\((\s*\d+\s*,){8,}"# },
217    StaticSig { id: "scan.static.hex_blob_decode", severity: Severity::Medium, exts: ANY,
218        detail: "decodes a large embedded hex/base64 blob at runtime",
219        re: r#"(?i)(atob|b64decode|fromhex|Buffer\.from)\s*\(\s*['"][A-Za-z0-9+/=]{200,}"# },
220    // install-time hooks (npm)
221    StaticSig { id: "scan.static.install_script", severity: Severity::Medium, exts: &["json"],
222        detail: "package.json declares an install-time script hook",
223        re: r#""(pre|post)?install"\s*:"# },
224];
225
226static COMPILED_SIGS: Lazy<Vec<(usize, Regex)>> = Lazy::new(|| {
227    STATIC_SIGS
228        .iter()
229        .enumerate()
230        .map(|(i, s)| (i, Regex::new(s.re).expect("static scan signature must compile")))
231        .collect()
232});
233
234const MAX_FILE_BYTES: u64 = 2_000_000;
235const SKIP_DIRS: &[&str] = &["node_modules", ".git", "dist", "build", "target", "__pycache__", ".venv", "venv"];
236
237fn walk(root: &Path, files: &mut Vec<PathBuf>) {
238    let Ok(entries) = std::fs::read_dir(root) else { return };
239    for e in entries.flatten() {
240        let p = e.path();
241        let name = e.file_name().to_string_lossy().to_string();
242        if p.is_dir() {
243            if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
244                walk(&p, files);
245            }
246        } else if p.is_file() {
247            files.push(p);
248        }
249    }
250}
251
252pub fn static_scan(root: &Path) -> Vec<Finding> {
253    let mut files = Vec::new();
254    walk(root, &mut files);
255    let mut findings = Vec::new();
256    // Cap per-signature reporting so one pattern repeated 500 times
257    // doesn't drown the report.
258    let mut per_sig: BTreeMap<&'static str, usize> = BTreeMap::new();
259    for f in files {
260        let ext = f.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
261        if let Ok(meta) = f.metadata() {
262            if meta.len() > MAX_FILE_BYTES {
263                continue;
264            }
265        }
266        let Ok(content) = std::fs::read_to_string(&f) else { continue };
267        // package.json install hooks only matter in package.json.
268        for (i, re) in COMPILED_SIGS.iter() {
269            let sig = &STATIC_SIGS[*i];
270            if !sig.exts.is_empty() && !sig.exts.contains(&ext.as_str()) {
271                continue;
272            }
273            if sig.id == "scan.static.install_script"
274                && f.file_name().and_then(|n| n.to_str()) != Some("package.json")
275            {
276                continue;
277            }
278            if let Some(m) = re.find(&content) {
279                let count = per_sig.entry(sig.id).or_insert(0);
280                *count += 1;
281                if *count > 5 {
282                    continue;
283                }
284                let line = content[..m.start()].matches('\n').count() + 1;
285                findings.push(Finding {
286                    pass: "static",
287                    id: sig.id.to_string(),
288                    severity: sig.severity,
289                    detail: sig.detail.to_string(),
290                    location: Some(format!("{}:{}", f.display(), line)),
291                });
292            }
293        }
294    }
295    findings
296}
297
298// ───────────────────── pass (b): supply metadata ────────────────────
299
300const YOUNG_PACKAGE_DAYS: i64 = 30;
301const LOW_DOWNLOADS_WEEKLY: u64 = 50;
302
303pub async fn npm_metadata_scan(pkg: &str) -> anyhow::Result<Vec<Finding>> {
304    let client = reqwest::Client::builder()
305        .timeout(Duration::from_secs(10))
306        .user_agent("aperion-shield-scan")
307        .build()?;
308    let mut findings = Vec::new();
309
310    let meta: serde_json::Value = client
311        .get(format!("https://registry.npmjs.org/{}", pkg))
312        .send()
313        .await?
314        .error_for_status()
315        .context("npm registry lookup failed")?
316        .json()
317        .await?;
318
319    if let Some(created) = meta
320        .pointer("/time/created")
321        .and_then(|v| v.as_str())
322        .and_then(|s| chrono_lite_days_since(s))
323    {
324        if created < YOUNG_PACKAGE_DAYS {
325            findings.push(Finding {
326                pass: "metadata",
327                id: "scan.meta.young_package".into(),
328                severity: Severity::Medium,
329                detail: format!("package is only {created} days old"),
330                location: None,
331            });
332        }
333    }
334    let maintainers = meta
335        .get("maintainers")
336        .and_then(|v| v.as_array())
337        .map(|a| a.len())
338        .unwrap_or(0);
339    if maintainers <= 1 {
340        findings.push(Finding {
341            pass: "metadata",
342            id: "scan.meta.single_maintainer".into(),
343            severity: Severity::Low,
344            detail: format!("{maintainers} maintainer(s) on npm"),
345            location: None,
346        });
347    }
348
349    if let Ok(resp) = client
350        .get(format!("https://api.npmjs.org/downloads/point/last-week/{}", pkg))
351        .send()
352        .await
353    {
354        if let Ok(dl) = resp.json::<serde_json::Value>().await {
355            if let Some(n) = dl.get("downloads").and_then(|v| v.as_u64()) {
356                if n < LOW_DOWNLOADS_WEEKLY {
357                    findings.push(Finding {
358                        pass: "metadata",
359                        id: "scan.meta.low_adoption".into(),
360                        severity: Severity::Low,
361                        detail: format!("{n} downloads in the last week"),
362                        location: None,
363                    });
364                }
365            }
366        }
367    }
368
369    // Known vulnerabilities via OSV.dev.
370    let osv: serde_json::Value = client
371        .post("https://api.osv.dev/v1/query")
372        .json(&serde_json::json!({
373            "package": {"name": pkg, "ecosystem": "npm"}
374        }))
375        .send()
376        .await?
377        .json()
378        .await
379        .unwrap_or_else(|_| serde_json::json!({}));
380    if let Some(vulns) = osv.get("vulns").and_then(|v| v.as_array()) {
381        for v in vulns.iter().take(5) {
382            let id = v.get("id").and_then(|x| x.as_str()).unwrap_or("OSV-unknown");
383            let summary = v.get("summary").and_then(|x| x.as_str()).unwrap_or("");
384            findings.push(Finding {
385                pass: "metadata",
386                id: "scan.meta.known_vuln".into(),
387                severity: Severity::High,
388                detail: format!("{id}: {summary}"),
389                location: None,
390            });
391        }
392    }
393    Ok(findings)
394}
395
396/// Days since an RFC3339 timestamp, without pulling in chrono: parse
397/// the date part and diff against the system clock at day resolution.
398fn chrono_lite_days_since(rfc3339: &str) -> Option<i64> {
399    let date = rfc3339.split('T').next()?;
400    let mut it = date.split('-');
401    let (y, m, d): (i64, i64, i64) = (
402        it.next()?.parse().ok()?,
403        it.next()?.parse().ok()?,
404        it.next()?.parse().ok()?,
405    );
406    // Days since civil epoch (Howard Hinnant's algorithm).
407    let civil = |y: i64, m: i64, d: i64| -> i64 {
408        let y = if m <= 2 { y - 1 } else { y };
409        let era = if y >= 0 { y } else { y - 399 } / 400;
410        let yoe = y - era * 400;
411        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
412        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
413        era * 146097 + doe - 719468
414    };
415    let now_days = (std::time::SystemTime::now()
416        .duration_since(std::time::UNIX_EPOCH)
417        .ok()?
418        .as_secs()
419        / 86400) as i64;
420    Some(now_days - civil(y, m, d))
421}
422
423// ───────────────────── pass (c): catalog audit ──────────────────────
424
425/// Launch the server (caller passes the argv, already sandbox-wrapped
426/// if requested), issue `initialize` + `tools/list`, and run the
427/// engine's `tool_description` rules over every tool in the catalog.
428/// The catalog never reaches an agent.
429pub async fn catalog_audit(launch: &[String], engine: &Engine) -> anyhow::Result<Vec<Finding>> {
430    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
431
432    let (program, args) = launch
433        .split_first()
434        .ok_or_else(|| anyhow!("empty launch command for catalog audit"))?;
435    let mut child = tokio::process::Command::new(program)
436        .args(args)
437        .stdin(std::process::Stdio::piped())
438        .stdout(std::process::Stdio::piped())
439        .stderr(std::process::Stdio::null())
440        .kill_on_drop(true)
441        .spawn()
442        .with_context(|| format!("failed to launch '{program}' for catalog audit"))?;
443    let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no child stdin"))?;
444    let stdout = child.stdout.take().ok_or_else(|| anyhow!("no child stdout"))?;
445    let mut lines = BufReader::new(stdout).lines();
446
447    let send = |frame: serde_json::Value| {
448        let mut s = frame.to_string();
449        s.push('\n');
450        s
451    };
452    stdin
453        .write_all(
454            send(serde_json::json!({
455                "jsonrpc": "2.0", "id": 1, "method": "initialize",
456                "params": {"protocolVersion": "2025-03-26", "capabilities": {},
457                            "clientInfo": {"name": "aperion-shield-scan", "version": env!("CARGO_PKG_VERSION")}}
458            }))
459            .as_bytes(),
460        )
461        .await?;
462    stdin
463        .write_all(send(serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).as_bytes())
464        .await?;
465    stdin
466        .write_all(send(serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).as_bytes())
467        .await?;
468    stdin.flush().await?;
469
470    let tools = tokio::time::timeout(Duration::from_secs(20), async {
471        while let Some(line) = lines.next_line().await? {
472            let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
473            if v.get("id").and_then(|i| i.as_i64()) == Some(2) {
474                return Ok::<_, anyhow::Error>(
475                    v.pointer("/result/tools").cloned().unwrap_or(serde_json::json!([])),
476                );
477            }
478        }
479        anyhow::bail!("upstream closed stdout before answering tools/list")
480    })
481    .await
482    .context("timed out waiting for tools/list (20s)")??;
483
484    let _ = child.kill().await;
485
486    let mut findings = Vec::new();
487    let empty = vec![];
488    let tool_list = tools.as_array().unwrap_or(&empty);
489    for t in tool_list {
490        let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("<unnamed>");
491        let desc = t.get("description").and_then(|v| v.as_str()).unwrap_or("");
492        let schema = t.get("inputSchema").map(|s| s.to_string()).unwrap_or_default();
493        let surface = format!("{desc}\n{schema}");
494        let eval = engine.evaluate_scoped_text(
495            Scope::ToolDescription,
496            Some(name),
497            &surface,
498            Adjustments::default(),
499        );
500        for m in eval.matches {
501            findings.push(Finding {
502                pass: "catalog",
503                id: m.rule_id,
504                severity: m.severity,
505                detail: format!("tool '{name}': {}", m.reason),
506                location: None,
507            });
508        }
509    }
510    if tool_list.is_empty() {
511        findings.push(Finding {
512            pass: "catalog",
513            id: "scan.catalog.empty".into(),
514            severity: Severity::Low,
515            detail: "server advertised zero tools (nothing to audit; suspicious for an MCP server)".into(),
516            location: None,
517        });
518    }
519    Ok(findings)
520}
521
522// ───────────────────────── orchestration ────────────────────────────
523
524pub struct ScanOptions {
525    pub target: String,
526    /// argv to launch the server for the live catalog pass (pass (c)
527    /// is skipped when empty). The caller sandbox-wraps it first.
528    pub launch: Vec<String>,
529    pub offline: bool,
530}
531
532/// Resolve the target into a local directory to static-scan. Network
533/// targets are fetched into `workdir` WITHOUT executing anything:
534/// `npm pack` (tarball, --ignore-scripts semantics: pack never runs
535/// install hooks locally) and `git clone --depth 1`.
536pub fn fetch_target(target: &Target, workdir: &Path) -> anyhow::Result<PathBuf> {
537    match target {
538        Target::LocalPath(p) => Ok(p.clone()),
539        Target::Github(url) => {
540            let dst = workdir.join("repo");
541            let out = std::process::Command::new("git")
542                .args(["clone", "--depth", "1", url])
543                .arg(&dst)
544                .output()
545                .context("running git clone")?;
546            if !out.status.success() {
547                anyhow::bail!("git clone failed: {}", String::from_utf8_lossy(&out.stderr));
548            }
549            Ok(dst)
550        }
551        Target::Npm(pkg) => {
552            let out = std::process::Command::new("npm")
553                .args(["pack", pkg, "--silent"])
554                .current_dir(workdir)
555                .output()
556                .context("running npm pack (is npm installed?)")?;
557            if !out.status.success() {
558                anyhow::bail!("npm pack failed: {}", String::from_utf8_lossy(&out.stderr));
559            }
560            let tarball = String::from_utf8_lossy(&out.stdout).trim().lines().last().map(str::to_string)
561                .ok_or_else(|| anyhow!("npm pack produced no tarball name"))?;
562            let tar_out = std::process::Command::new("tar")
563                .args(["xzf", &tarball])
564                .current_dir(workdir)
565                .output()
566                .context("extracting npm tarball")?;
567            if !tar_out.status.success() {
568                anyhow::bail!("tar extract failed: {}", String::from_utf8_lossy(&tar_out.stderr));
569            }
570            // npm tarballs unpack to package/
571            Ok(workdir.join("package"))
572        }
573    }
574}
575
576pub async fn run_scan(opts: &ScanOptions, engine: &Engine) -> anyhow::Result<Report> {
577    let target = Target::parse(&opts.target)?;
578    let mut report = Report {
579        target: opts.target.clone(),
580        findings: Vec::new(),
581        passes_run: Vec::new(),
582        passes_skipped: Vec::new(),
583        verdict: Verdict::Pass,
584    };
585
586    let tmp = tempfile::tempdir().context("creating scan workdir")?;
587    let root = fetch_target(&target, tmp.path())?;
588
589    // (a) static
590    report.findings.extend(static_scan(&root));
591    report.passes_run.push("static");
592
593    // (b) metadata
594    if opts.offline {
595        report.passes_skipped.push(("metadata", "--scan-offline".into()));
596    } else if let Target::Npm(pkg) = &target {
597        match npm_metadata_scan(pkg).await {
598            Ok(f) => {
599                report.findings.extend(f);
600                report.passes_run.push("metadata");
601            }
602            Err(e) => report.passes_skipped.push(("metadata", format!("{e:#}"))),
603        }
604    } else {
605        report
606            .passes_skipped
607            .push(("metadata", "only npm targets have registry metadata today".into()));
608    }
609
610    // (c) live catalog
611    if opts.launch.is_empty() {
612        report.passes_skipped.push((
613            "catalog",
614            "no launch command given (append `-- <cmd...>` to run the live catalog audit)".into(),
615        ));
616    } else {
617        match catalog_audit(&opts.launch, engine).await {
618            Ok(f) => {
619                report.findings.extend(f);
620                report.passes_run.push("catalog");
621            }
622            Err(e) => report.passes_skipped.push(("catalog", format!("{e:#}"))),
623        }
624    }
625
626    report.finalize();
627    Ok(report)
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn all_static_signatures_compile() {
636        assert_eq!(COMPILED_SIGS.len(), STATIC_SIGS.len());
637    }
638
639    #[test]
640    fn target_parsing() {
641        assert_eq!(Target::parse("npm:foo").unwrap(), Target::Npm("foo".into()));
642        assert_eq!(
643            Target::parse("https://github.com/o/r").unwrap(),
644            Target::Github("https://github.com/o/r".into())
645        );
646        assert_eq!(Target::parse(".").unwrap(), Target::LocalPath(".".into()));
647        assert_eq!(Target::parse("some-package").unwrap(), Target::Npm("some-package".into()));
648        assert!(Target::parse("./does-not-exist-xyz").is_err());
649        assert!(Target::parse("https://gitlab.com/o/r").is_err());
650    }
651
652    fn scan_str(name: &str, content: &str) -> Vec<String> {
653        let dir = tempfile::tempdir().unwrap();
654        std::fs::write(dir.path().join(name), content).unwrap();
655        static_scan(dir.path()).into_iter().map(|f| f.id).collect()
656    }
657
658    #[test]
659    fn static_scan_catches_ssh_read() {
660        let ids = scan_str("index.js", r#"const k = fs.readFileSync(home + "/.ssh/id_rsa");"#);
661        assert!(ids.contains(&"scan.static.ssh_key_read".to_string()), "{ids:?}");
662    }
663
664    #[test]
665    fn static_scan_catches_env_exfil() {
666        let ids = scan_str("x.js", "fetch(url, {body: JSON.stringify(process.env)})");
667        assert!(ids.contains(&"scan.static.env_exfil_js".to_string()), "{ids:?}");
668    }
669
670    #[test]
671    fn static_scan_catches_b64_exec() {
672        let ids = scan_str("x.js", "eval(atob(payload))");
673        assert!(ids.contains(&"scan.static.b64_exec".to_string()), "{ids:?}");
674    }
675
676    #[test]
677    fn static_scan_install_hook_only_in_package_json() {
678        let ids = scan_str("package.json", r#"{"scripts": {"postinstall": "node evil.js"}}"#);
679        assert!(ids.contains(&"scan.static.install_script".to_string()), "{ids:?}");
680        let ids = scan_str("README.json", r#"{"scripts": {"postinstall": "node evil.js"}}"#);
681        assert!(!ids.contains(&"scan.static.install_script".to_string()), "{ids:?}");
682    }
683
684    #[test]
685    fn benign_source_is_clean() {
686        let ids = scan_str(
687            "server.js",
688            r#"
689import { Server } from "@modelcontextprotocol/sdk/server/index.js";
690const server = new Server({ name: "weather", version: "1.0.0" });
691server.setRequestHandler(ListToolsRequestSchema, async () => ({
692  tools: [{ name: "get_forecast", description: "Get weather forecast for a city" }],
693}));
694"#,
695        );
696        assert!(ids.is_empty(), "{ids:?}");
697    }
698
699    #[test]
700    fn days_since_parses_rfc3339() {
701        let d = chrono_lite_days_since("2020-01-01T00:00:00.000Z").unwrap();
702        assert!(d > 2000, "{d}");
703        let recent = chrono_lite_days_since("2099-01-01T00:00:00Z").unwrap();
704        assert!(recent < 0);
705    }
706
707    #[test]
708    fn verdict_mapping() {
709        let mut r = Report {
710            target: "t".into(), findings: vec![], passes_run: vec![],
711            passes_skipped: vec![], verdict: Verdict::Pass,
712        };
713        r.finalize();
714        assert_eq!(r.verdict, Verdict::Pass);
715        r.findings.push(Finding {
716            pass: "static", id: "x".into(), severity: Severity::Medium,
717            detail: "".into(), location: None,
718        });
719        r.finalize();
720        assert_eq!(r.verdict, Verdict::Caution);
721        assert_eq!(r.exit_code(), 1);
722        r.findings.push(Finding {
723            pass: "static", id: "y".into(), severity: Severity::Critical,
724            detail: "".into(), location: None,
725        });
726        r.finalize();
727        assert_eq!(r.verdict, Verdict::Fail);
728        assert_eq!(r.exit_code(), 2);
729    }
730}