1use 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#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Target {
40 LocalPath(PathBuf),
41 Github(String),
42 Npm(String),
43}
44
45impl Target {
46 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 Ok(Target::Npm(s.to_string()))
67 }
68}
69
70#[derive(Debug, Clone, Serialize)]
73pub struct Finding {
74 pub pass: &'static str, pub id: String,
76 pub severity: Severity,
77 pub detail: String,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub location: Option<String>, }
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
158struct StaticSig {
161 id: &'static str,
162 severity: Severity,
163 detail: &'static str,
164 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
173static STATIC_SIGS: &[StaticSig] = &[
177 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 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 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 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 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 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 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
298const 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 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
396fn 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 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
423pub 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
522pub struct ScanOptions {
525 pub target: String,
526 pub launch: Vec<String>,
529 pub offline: bool,
530}
531
532pub 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 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 report.findings.extend(static_scan(&root));
591 report.passes_run.push("static");
592
593 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 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}