Skip to main content

icebox/modules/
vuln_scanner.rs

1use crate::core::module::{Module, ModuleError, ModuleResult};
2use async_trait::async_trait;
3use icebox_macro::module;
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use std::collections::{HashMap, HashSet};
7use std::time::Duration;
8
9#[derive(Serialize)]
10struct OsvQueryRequest {
11    #[serde(rename = "package")]
12    package: OsvPackage,
13    version: String,
14}
15
16#[derive(Serialize)]
17struct OsvPackage {
18    name: String,
19    ecosystem: String,
20}
21
22#[derive(Debug, Deserialize)]
23struct OsvQueryResponse {
24    #[serde(default)]
25    vulns: Vec<OsvVuln>,
26}
27
28#[derive(Debug, Deserialize)]
29struct OsvVuln {
30    id: String,
31    #[serde(default)]
32    summary: String,
33    #[serde(default)]
34    details: String,
35    #[serde(default)]
36    aliases: Vec<String>,
37    #[serde(default)]
38    severity: Vec<OsvSeverity>,
39    #[serde(default)]
40    affected: Vec<OsvAffected>,
41}
42
43#[derive(Debug, Deserialize)]
44struct OsvSeverity {
45    #[serde(rename = "type")]
46    severity_type: String,
47    score: String,
48}
49
50#[derive(Debug, Deserialize)]
51struct OsvAffected {
52    #[serde(default)]
53    ranges: Vec<OsvRange>,
54}
55
56#[derive(Debug, Deserialize)]
57struct OsvRange {
58    #[serde(rename = "type")]
59    range_type: String,
60    #[serde(default)]
61    events: Vec<OsvEvent>,
62}
63
64#[derive(Debug, Deserialize)]
65#[allow(dead_code)]
66struct OsvEvent {
67    introduced: Option<String>,
68    fixed: Option<String>,
69    last_affected: Option<String>,
70}
71
72#[derive(Debug, Deserialize)]
73struct EpssResponse {
74    data: Vec<EpssEntry>,
75}
76
77#[derive(Debug, Deserialize)]
78struct EpssEntry {
79    cve: String,
80    #[serde(deserialize_with = "deserialize_epss_str")]
81    epss: Option<f64>,
82}
83
84fn deserialize_epss_str<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
85where
86    D: serde::Deserializer<'de>,
87{
88    #[derive(Deserialize)]
89    #[serde(untagged)]
90    enum FloatOrString {
91        F(f64),
92        S(String),
93    }
94    match Option::<FloatOrString>::deserialize(deserializer)? {
95        Some(FloatOrString::F(n)) => Ok(Some(n)),
96        Some(FloatOrString::S(s)) => Ok(s.parse::<f64>().ok()),
97        None => Ok(None),
98    }
99}
100
101#[derive(Debug, Deserialize)]
102struct CargoMetadata {
103    packages: Vec<CargoPackage>,
104}
105
106#[derive(Debug, Deserialize)]
107struct CargoPackage {
108    name: String,
109    version: String,
110    #[serde(default)]
111    source: Option<String>,
112}
113
114#[derive(Debug, Clone, Default, Serialize)]
115struct VulnerabilityFinding {
116    cve: String,
117    package: String,
118    installed_version: String,
119    fixed_version: Option<String>,
120    summary: String,
121    cvss_v31: Option<f64>,
122    epss: Option<f64>,
123    kev: bool,
124    #[serde(default)]
125    new_finding: bool,
126    severity: String,
127}
128
129impl VulnerabilityFinding {
130    fn severity_label(cvss: Option<f64>) -> String {
131        match cvss {
132            Some(s) if s >= 9.0 => "critical".into(),
133            Some(s) if s >= 7.0 => "high".into(),
134            Some(s) if s >= 4.0 => "medium".into(),
135            Some(s) if s > 0.0 => "low".into(),
136            _ => "unknown".into(),
137        }
138    }
139
140    fn key(&self) -> String {
141        format!("{}/{}", self.package, self.cve)
142    }
143}
144
145use tokio::sync::OnceCell;
146
147static KEV_CACHE: OnceCell<HashSet<String>> = OnceCell::const_new();
148
149async fn fetch_kev_list() -> HashSet<String> {
150    let mut set = HashSet::new();
151    if let Ok(resp) = reqwest::get(
152        "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
153    )
154    .await
155    {
156        if let Ok(json) = resp.json::<serde_json::Value>().await {
157            if let Some(vulns) = json.get("vulnerabilities").and_then(|v| v.as_array()) {
158                for vuln in vulns {
159                    if let Some(cve) = vuln.get("cveID").and_then(|c| c.as_str()) {
160                        set.insert(cve.to_uppercase());
161                    }
162                }
163            }
164        }
165    }
166    set
167}
168
169async fn is_kev(cve_id: &str) -> bool {
170    let upper = cve_id.to_uppercase();
171    let cache = KEV_CACHE.get_or_init(fetch_kev_list).await;
172    cache.contains(&upper)
173}
174
175struct ScanCycleResult {
176    cycle: usize,
177    findings: Vec<VulnerabilityFinding>,
178    new_cves: Vec<VulnerabilityFinding>,
179    errors: Vec<String>,
180    elapsed_secs: u64,
181}
182
183#[module(
184    name = "vuln_scanner",
185    kind = "Analysis",
186    description = "Dependency vulnerability scanner with optional --watch mode - parses Cargo.toml, queries OSV.dev API for CVEs, enriches with EPSS and KEV data",
187    author = "ICEBOX"
188)]
189pub struct VulnScanner {
190    #[option(
191        required = true,
192        help = "Path to project directory containing Cargo.toml"
193    )]
194    pub project_dir: String,
195    #[option(help = "Timeout per API call in milliseconds (default 15000)")]
196    pub timeout_ms: u64,
197    #[option(
198        help = "Enable watch mode: re-check dependencies periodically (true/false, default: false)"
199    )]
200    pub watch: bool,
201    #[option(help = "Interval between watch-mode scans in seconds (default: 3600)")]
202    pub watch_interval_secs: u64,
203    #[option(
204        help = "Number of watch-mode scans to run (default: 3, 0 = infinite, use with caution)"
205    )]
206    pub watch_scans: usize,
207    #[option(help = "Alert on new CVEs only (suppress full re-list, default: false)")]
208    pub alert_on_new_only: bool,
209}
210
211#[async_trait]
212impl Module for VulnScanner {
213    fn options_json(&self) -> serde_json::Value {
214        serde_json::to_value(&VulnScannerOptions {
215            project_dir: self.project_dir.clone(),
216            timeout_ms: self.timeout_ms,
217            watch: self.watch,
218            watch_interval_secs: self.watch_interval_secs,
219            watch_scans: self.watch_scans,
220            alert_on_new_only: self.alert_on_new_only,
221        })
222        .unwrap_or(serde_json::Value::Null)
223    }
224
225    fn set_option(&mut self, name: &str, value: &str) -> Result<(), ModuleError> {
226        let mut o = VulnScannerOptions {
227            project_dir: self.project_dir.clone(),
228            timeout_ms: self.timeout_ms,
229            watch: self.watch,
230            watch_interval_secs: self.watch_interval_secs,
231            watch_scans: self.watch_scans,
232            alert_on_new_only: self.alert_on_new_only,
233        };
234        o.set(name, value)?;
235        self.project_dir = o.project_dir;
236        self.timeout_ms = o.timeout_ms;
237        self.watch = o.watch;
238        self.watch_interval_secs = o.watch_interval_secs;
239        self.watch_scans = o.watch_scans;
240        self.alert_on_new_only = o.alert_on_new_only;
241        Ok(())
242    }
243
244    fn validate(&self) -> Result<(), ModuleError> {
245        VulnScannerOptions {
246            project_dir: self.project_dir.clone(),
247            timeout_ms: self.timeout_ms,
248            watch: self.watch,
249            watch_interval_secs: self.watch_interval_secs,
250            watch_scans: self.watch_scans,
251            alert_on_new_only: self.alert_on_new_only,
252        }
253        .validate()?;
254        let path = std::path::Path::new(&self.project_dir);
255        if !path.exists() {
256            return Err(ModuleError::Other(format!(
257                "project directory does not exist: {}",
258                self.project_dir
259            )));
260        }
261        let cargo_toml = path.join("Cargo.toml");
262        if !cargo_toml.exists() {
263            return Err(ModuleError::Other(format!(
264                "Cargo.toml not found in: {}",
265                self.project_dir
266            )));
267        }
268        Ok(())
269    }
270
271    async fn run(&self) -> Result<ModuleResult, ModuleError> {
272        let timeout = Duration::from_millis(if self.timeout_ms > 0 {
273            self.timeout_ms
274        } else {
275            15000
276        });
277        let project_dir = self.project_dir.trim().to_string();
278        let watch = self.watch;
279        let interval = Duration::from_secs(if self.watch_interval_secs > 0 {
280            self.watch_interval_secs
281        } else {
282            3600
283        });
284        let max_scans = if watch && self.watch_scans == 0 {
285            usize::MAX
286        } else if watch {
287            self.watch_scans.max(1)
288        } else {
289            1
290        };
291        let alert_on_new = self.alert_on_new_only;
292
293        let packages = match fetch_cargo_metadata(&project_dir).await {
294            Ok(p) => p,
295            Err(e) => {
296                return Ok(ModuleResult {
297                    success: false,
298                    finding: None,
299                    evidence: vec![],
300                    error: Some(e.to_string()),
301                    data: serde_json::Value::Null,
302                    session_id: None,
303                });
304            }
305        };
306        if packages.is_empty() {
307            return Ok(ModuleResult {
308                success: true,
309                finding: Some("No registry dependencies found in Cargo.toml".into()),
310                evidence: vec![],
311                data: json!({"project": project_dir, "dependencies": 0}),
312                ..Default::default()
313            });
314        }
315        let dep_count = packages.len();
316
317        let client = reqwest::Client::builder()
318            .timeout(timeout)
319            .user_agent("ICEBOX-vuln-scanner/1.0")
320            .build()
321            .map_err(|e| ModuleError::Other(format!("HTTP client: {e}")))?;
322
323        let mut all_cves_seen: HashSet<String> = HashSet::new();
324        let mut cycles: Vec<ScanCycleResult> = Vec::new();
325        let mut global_new_cves: Vec<VulnerabilityFinding> = Vec::new();
326
327        for cycle in 0..max_scans {
328            if cycle > 0 && cycle < max_scans {
329                tokio::time::sleep(interval).await;
330            }
331
332            let cycle_start = std::time::Instant::now();
333            let mut cycle_errors: Vec<String> = Vec::new();
334
335            let mut cycle_findings: Vec<VulnerabilityFinding> = Vec::new();
336            let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(5));
337            let mut handles = Vec::new();
338
339            for pkg in &packages {
340                let permit = match semaphore.clone().acquire_owned().await {
341                    Ok(p) => p,
342                    Err(_) => continue,
343                };
344                let name = pkg.name.clone();
345                let version = pkg.version.clone();
346                let client = client.clone();
347
348                handles.push(tokio::spawn(async move {
349                    let _permit = permit;
350                    query_osv(&client, &name, &version, timeout).await
351                }));
352            }
353
354            for h in handles {
355                match h.await {
356                    Ok(Ok(mut vulns)) => cycle_findings.append(&mut vulns),
357                    Ok(Err(e)) => cycle_errors.push(e),
358                    Err(e) => cycle_errors.push(format!("task join: {e}")),
359                }
360            }
361
362            let cve_ids: Vec<String> = cycle_findings.iter().map(|f| f.cve.clone()).collect();
363            if !cve_ids.is_empty() {
364                match query_epss(&client, &cve_ids, timeout).await {
365                    Ok(epss_map) => {
366                        for finding in &mut cycle_findings {
367                            if let Some(epss) = epss_map.get(&finding.cve) {
368                                finding.epss = Some(*epss);
369                            }
370                        }
371                    }
372                    Err(e) => cycle_errors.push(format!("EPSS enrichment failed: {e}")),
373                }
374            }
375
376            for finding in &mut cycle_findings {
377                if is_kev(&finding.cve).await {
378                    finding.kev = true;
379                }
380            }
381
382            let mut cycle_new: Vec<VulnerabilityFinding> = Vec::new();
383            for finding in &mut cycle_findings {
384                let key = finding.key();
385                if !all_cves_seen.contains(&key) {
386                    finding.new_finding = true;
387                    cycle_new.push(finding.clone());
388                    global_new_cves.push(finding.clone());
389                }
390                all_cves_seen.insert(key);
391            }
392
393            let elapsed = cycle_start.elapsed().as_secs();
394
395            cycles.push(ScanCycleResult {
396                cycle,
397                findings: cycle_findings,
398                new_cves: cycle_new,
399                errors: cycle_errors,
400                elapsed_secs: elapsed,
401            });
402
403            if !watch {
404                break;
405            }
406        }
407
408        let mut all_findings_deduped: Vec<VulnerabilityFinding> = Vec::new();
409        let mut seen: HashSet<String> = HashSet::new();
410        for cycle in &cycles {
411            for finding in &cycle.findings {
412                let key = finding.key();
413                if !seen.contains(&key) {
414                    let mut f = finding.clone();
415                    if global_new_cves.iter().any(|n| n.key() == key) {
416                        f.new_finding = true;
417                    }
418                    all_findings_deduped.push(f);
419                    seen.insert(key);
420                }
421            }
422        }
423
424        let evidence: Vec<String> = if alert_on_new && !global_new_cves.is_empty() {
425            global_new_cves
426                .iter()
427                .map(|f| format_new_evidence(f, true))
428                .collect()
429        } else {
430            all_findings_deduped
431                .iter()
432                .map(|f| format_new_evidence(f, f.new_finding))
433                .collect()
434        };
435
436        let structured_evidence: Vec<serde_json::Value> = all_findings_deduped
437            .iter()
438            .map(|f| {
439                json!({
440                    "cve": f.cve,
441                    "package": f.package,
442                    "installed": f.installed_version,
443                    "fixed": f.fixed_version,
444                    "cvss_v31": f.cvss_v31,
445                    "epss": f.epss,
446                    "kev": f.kev,
447                    "new_finding": f.new_finding,
448                    "severity": f.severity,
449                    "summary": f.summary,
450                })
451            })
452            .collect();
453
454        let cycles_data: Vec<serde_json::Value> = cycles
455            .iter()
456            .map(|c| {
457                json!({
458                    "cycle": c.cycle,
459                    "elapsed_secs": c.elapsed_secs,
460                    "vulns": c.findings.len(),
461                    "new_cves_this_cycle": c.new_cves.len(),
462                    "errors": c.errors,
463                })
464            })
465            .collect();
466
467        let critical_count = all_findings_deduped
468            .iter()
469            .filter(|f| f.severity == "critical")
470            .count();
471        let high_count = all_findings_deduped
472            .iter()
473            .filter(|f| f.severity == "high")
474            .count();
475        let kev_count = all_findings_deduped.iter().filter(|f| f.kev).count();
476        let new_count = global_new_cves.len();
477        let total_cycles = cycles.len();
478
479        let finding_summary = if all_findings_deduped.is_empty() {
480            format!("Scanned {dep_count} dependencies, 0 known vulnerabilities found")
481        } else if watch && new_count > 0 {
482            let new_detail: Vec<String> = global_new_cves
483                .iter()
484                .map(|f| format!("{} {} ({})", f.package, f.cve, f.severity))
485                .collect();
486            format!(
487                "[WATCH] {} scan cycles | {} total CVEs ({} critical, {} high, {} KEV) | {} NEW since last scan: {}",
488                total_cycles,
489                all_findings_deduped.len(),
490                critical_count,
491                high_count,
492                kev_count,
493                new_count,
494                new_detail.join("; "),
495            )
496        } else {
497            format!(
498                "Scanned {dep_count} dependencies, found {} vulnerabilities ({} critical, {} high, {} KEV) | {} scan cycles",
499                all_findings_deduped.len(),
500                critical_count,
501                high_count,
502                kev_count,
503                total_cycles,
504            )
505        };
506
507        Ok(ModuleResult {
508            success: true,
509            finding: Some(finding_summary),
510            evidence,
511            data: json!({
512                "project": project_dir,
513                "dependencies": dep_count,
514                "watch_enabled": watch,
515                "scan_cycles": total_cycles,
516                "new_cves_discovered": new_count,
517                "new_cves": global_new_cves.iter().map(|f| json!({
518                    "cve": f.cve,
519                    "package": f.package,
520                    "installed": f.installed_version,
521                    "fixed": f.fixed_version,
522                    "cvss_v31": f.cvss_v31,
523                    "epss": f.epss,
524                    "kev": f.kev,
525                    "severity": f.severity,
526                    "summary": f.summary,
527                })).collect::<Vec<_>>(),
528                "findings": structured_evidence,
529                "summary": {
530                    "total": all_findings_deduped.len(),
531                    "critical": critical_count,
532                    "high": high_count,
533                    "medium": all_findings_deduped.iter().filter(|f| f.severity == "medium").count(),
534                    "low": all_findings_deduped.iter().filter(|f| f.severity == "low").count(),
535                    "kev": kev_count,
536                    "new": new_count,
537                },
538                "cycles": cycles_data,
539            }),
540            ..Default::default()
541        })
542    }
543}
544
545fn format_new_evidence(finding: &VulnerabilityFinding, is_new: bool) -> String {
546    let new_flag = if is_new { " [NEW]" } else { "" };
547    let kev_flag = if finding.kev { " [KEV]" } else { "" };
548    let cvss_str = finding
549        .cvss_v31
550        .map(|s| format!(" cvss={s:.1}"))
551        .unwrap_or_default();
552    let epss_str = finding
553        .epss
554        .map(|s| format!(" epss={s:.4}"))
555        .unwrap_or_default();
556    format!(
557        "vuln/{}/{}{}{}{}{}",
558        finding.package, finding.cve, cvss_str, epss_str, kev_flag, new_flag,
559    )
560}
561
562async fn fetch_cargo_metadata(project_dir: &str) -> Result<Vec<CargoPackage>, ModuleError> {
563    let output = tokio::process::Command::new("cargo")
564        .arg("metadata")
565        .arg("--format-version")
566        .arg("1")
567        .current_dir(project_dir)
568        .output()
569        .await
570        .map_err(|e| ModuleError::Other(format!("cargo metadata failed: {e}")))?;
571
572    if !output.status.success() {
573        let stderr = String::from_utf8_lossy(&output.stderr);
574        return Err(ModuleError::Other(format!(
575            "cargo metadata error: {stderr}"
576        )));
577    }
578
579    let stdout = String::from_utf8_lossy(&output.stdout);
580    let meta: CargoMetadata = serde_json::from_str(&stdout)
581        .map_err(|e| ModuleError::Other(format!("failed to parse cargo metadata: {e}")))?;
582
583    let packages: Vec<CargoPackage> = meta
584        .packages
585        .into_iter()
586        .filter(|p| {
587            p.source
588                .as_deref()
589                .is_some_and(|s| s.starts_with("registry+"))
590        })
591        .collect();
592
593    Ok(packages)
594}
595
596async fn query_osv(
597    client: &reqwest::Client,
598    name: &str,
599    version: &str,
600    timeout: Duration,
601) -> Result<Vec<VulnerabilityFinding>, String> {
602    let url = "https://api.osv.dev/v1/query";
603    let body = OsvQueryRequest {
604        package: OsvPackage {
605            name: name.to_string(),
606            ecosystem: "crates.io".into(),
607        },
608        version: version.to_string(),
609    };
610
611    let resp = client
612        .post(url)
613        .json(&body)
614        .timeout(timeout)
615        .send()
616        .await
617        .map_err(|e| format!("OSV query for {name}@{version}: {e}"))?;
618
619    if !resp.status().is_success() {
620        return Err(format!(
621            "OSV query for {name}@{version}: HTTP {}",
622            resp.status()
623        ));
624    }
625
626    let osv_resp: OsvQueryResponse = resp
627        .json()
628        .await
629        .map_err(|e| format!("OSV parse error for {name}@{version}: {e}"))?;
630
631    let mut findings = Vec::new();
632
633    for vuln in &osv_resp.vulns {
634        let cvss = vuln.severity.iter().find_map(|s| {
635            if s.severity_type == "CVSS_V3" || s.severity_type == "CVSS" {
636                s.score.parse::<f64>().ok()
637            } else {
638                None
639            }
640        });
641
642        let fixed_version = vuln.affected.iter().find_map(|a| {
643            a.ranges.iter().find_map(|r| {
644                if r.range_type == "SEMVER" || r.range_type == "ECOSYSTEM" {
645                    r.events.iter().find_map(|e| e.fixed.clone())
646                } else {
647                    None
648                }
649            })
650        });
651
652        let cve_id = vuln
653            .aliases
654            .iter()
655            .find(|a| a.starts_with("CVE-"))
656            .cloned()
657            .unwrap_or_else(|| vuln.id.clone());
658
659        let summary = if vuln.summary.is_empty() {
660            vuln.details.chars().take(200).collect::<String>()
661        } else {
662            vuln.summary.clone()
663        };
664
665        let severity = VulnerabilityFinding::severity_label(cvss);
666
667        findings.push(VulnerabilityFinding {
668            cve: cve_id,
669            package: name.to_string(),
670            installed_version: version.to_string(),
671            fixed_version,
672            summary,
673            cvss_v31: cvss,
674            epss: None,
675            kev: false,
676            new_finding: false,
677            severity,
678        });
679    }
680
681    Ok(findings)
682}
683
684async fn query_epss(
685    client: &reqwest::Client,
686    cve_ids: &[String],
687    timeout: Duration,
688) -> Result<HashMap<String, f64>, String> {
689    if cve_ids.is_empty() {
690        return Ok(HashMap::new());
691    }
692
693    let cve_param = cve_ids.join(",");
694    let url = format!("https://api.first.org/data/v1/epss?cve={cve_param}");
695
696    let resp = client
697        .get(&url)
698        .timeout(timeout)
699        .send()
700        .await
701        .map_err(|e| format!("EPSS query: {e}"))?;
702
703    if !resp.status().is_success() {
704        return Ok(HashMap::new());
705    }
706
707    let mut epss_map = HashMap::new();
708    if let Ok(epss_resp) = resp.json::<EpssResponse>().await {
709        for entry in epss_resp.data {
710            if let Some(score) = entry.epss {
711                epss_map.insert(entry.cve, score);
712            }
713        }
714    }
715
716    Ok(epss_map)
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    #[tokio::test]
724    async fn test_kev_detection() {
725        assert!(is_kev("CVE-2021-44228").await);
726        assert!(is_kev("cve-2021-44228").await);
727        assert!(is_kev("CVE-2022-22965").await);
728        assert!(!is_kev("CVE-2024-99999").await);
729    }
730
731    #[test]
732    fn test_severity_label() {
733        assert_eq!(VulnerabilityFinding::severity_label(Some(9.5)), "critical");
734        assert_eq!(VulnerabilityFinding::severity_label(Some(7.5)), "high");
735        assert_eq!(VulnerabilityFinding::severity_label(Some(5.0)), "medium");
736        assert_eq!(VulnerabilityFinding::severity_label(Some(2.0)), "low");
737        assert_eq!(VulnerabilityFinding::severity_label(None), "unknown");
738    }
739
740    #[test]
741    fn test_finding_key_and_new_flag() {
742        let f1 = VulnerabilityFinding {
743            cve: "CVE-2024-27303".into(),
744            package: "tokio".into(),
745            installed_version: "1.0.0".into(),
746            fixed_version: Some("1.35.1".into()),
747            summary: "test".into(),
748            cvss_v31: Some(7.5),
749            epss: Some(0.05),
750            kev: false,
751            new_finding: true,
752            severity: "high".into(),
753        };
754        assert_eq!(f1.key(), "tokio/CVE-2024-27303");
755        assert!(f1.new_finding);
756    }
757
758    #[test]
759    fn test_format_new_evidence() {
760        let finding = VulnerabilityFinding {
761            cve: "CVE-2024-27303".into(),
762            package: "tokio".into(),
763            installed_version: "1.0.0".into(),
764            fixed_version: Some("1.35.1".into()),
765            summary: "test".into(),
766            cvss_v31: Some(7.5),
767            epss: Some(0.05),
768            kev: false,
769            new_finding: true,
770            severity: "high".into(),
771        };
772        let ev = format_new_evidence(&finding, true);
773        assert!(
774            ev.contains("[NEW]"),
775            "new evidence should have [NEW] tag: {ev}"
776        );
777        assert!(ev.contains("cvss=7.5"));
778        assert!(ev.contains("epss=0.0500"));
779
780        let ev2 = format_new_evidence(&finding, false);
781        assert!(!ev2.contains("[NEW]"), "non-new should not have [NEW] tag");
782    }
783
784    #[test]
785    fn test_format_new_evidence_with_kev() {
786        let finding = VulnerabilityFinding {
787            cve: "CVE-2021-44228".into(),
788            package: "log4j".into(),
789            installed_version: "2.0.0".into(),
790            fixed_version: Some("2.17.1".into()),
791            summary: "RCE in Log4j".into(),
792            cvss_v31: Some(10.0),
793            epss: Some(0.95),
794            kev: true,
795            new_finding: true,
796            severity: "critical".into(),
797        };
798        let ev = format_new_evidence(&finding, true);
799        assert!(ev.contains("[NEW]"));
800        assert!(ev.contains("[KEV]"));
801        assert!(ev.contains("cvss=10.0"));
802        assert!(ev.contains("epss=0.9500"));
803        assert!(ev.starts_with("vuln/"));
804    }
805
806    #[test]
807    fn test_delta_detection_basic() {
808        let mut seen: HashSet<String> = HashSet::new();
809
810        let c0_findings = vec![
811            VulnerabilityFinding {
812                cve: "CVE-2024-001".into(),
813                package: "crate_a".into(),
814                installed_version: "1.0".into(),
815                fixed_version: None,
816                summary: "".into(),
817                cvss_v31: Some(5.0),
818                epss: None,
819                kev: false,
820                new_finding: false,
821                severity: "medium".into(),
822            },
823            VulnerabilityFinding {
824                cve: "CVE-2024-002".into(),
825                package: "crate_b".into(),
826                installed_version: "1.0".into(),
827                fixed_version: None,
828                summary: "".into(),
829                cvss_v31: Some(3.0),
830                epss: None,
831                kev: false,
832                new_finding: false,
833                severity: "low".into(),
834            },
835        ];
836
837        for f in &c0_findings {
838            seen.insert(f.key());
839        }
840        assert_eq!(seen.len(), 2, "cycle 0 should register 2 CVEs");
841
842        let c1_findings = vec![
843            VulnerabilityFinding {
844                cve: "CVE-2024-001".into(),
845                package: "crate_a".into(),
846                installed_version: "1.0".into(),
847                fixed_version: None,
848                summary: "".into(),
849                cvss_v31: Some(5.0),
850                epss: None,
851                kev: false,
852                new_finding: false,
853                severity: "medium".into(),
854            },
855            VulnerabilityFinding {
856                cve: "CVE-2024-002".into(),
857                package: "crate_b".into(),
858                installed_version: "1.0".into(),
859                fixed_version: None,
860                summary: "".into(),
861                cvss_v31: Some(3.0),
862                epss: None,
863                kev: false,
864                new_finding: false,
865                severity: "low".into(),
866            },
867            VulnerabilityFinding {
868                cve: "CVE-2024-003".into(),
869                package: "crate_c".into(),
870                installed_version: "1.0".into(),
871                fixed_version: None,
872                summary: "".into(),
873                cvss_v31: Some(9.0),
874                epss: None,
875                kev: false,
876                new_finding: true,
877                severity: "critical".into(),
878            },
879        ];
880
881        let mut new_cves: Vec<VulnerabilityFinding> = Vec::new();
882        for mut f in c1_findings {
883            let key = f.key();
884            if !seen.contains(&key) {
885                f.new_finding = true;
886                new_cves.push(f.clone());
887            }
888            seen.insert(key);
889        }
890
891        assert_eq!(new_cves.len(), 1, "only one new CVE in cycle 1");
892        assert_eq!(new_cves[0].cve, "CVE-2024-003");
893        assert_eq!(new_cves[0].package, "crate_c");
894        assert!(new_cves[0].new_finding);
895
896        let c2_findings: Vec<VulnerabilityFinding> = vec![VulnerabilityFinding {
897            cve: "CVE-2024-003".into(),
898            package: "crate_c".into(),
899            ..Default::default()
900        }];
901        let before = seen.len();
902        for f in &c2_findings {
903            seen.insert(f.key());
904        }
905        assert_eq!(seen.len(), before, "no new CVEs in cycle 2");
906    }
907
908    #[tokio::test]
909    async fn test_osv_query_real() {
910        let client = reqwest::Client::builder()
911            .timeout(Duration::from_secs(10))
912            .user_agent("ICEBOX-test/1.0")
913            .build()
914            .expect("HTTP client");
915
916        let findings = query_osv(&client, "tokio", "1.0.0", Duration::from_secs(10))
917            .await
918            .expect("OSV query must succeed");
919
920        assert!(!findings.is_empty(), "tokio 1.0.0 should have known CVEs");
921        let first = &findings[0];
922        assert!(
923            first.cve.starts_with("CVE-") || first.cve.starts_with("GHSA-"),
924            "first finding: {}",
925            first.cve
926        );
927        assert_eq!(first.package, "tokio");
928        assert_eq!(first.installed_version, "1.0.0");
929    }
930
931    #[tokio::test]
932    async fn test_osv_query_no_vulns() {
933        let client = reqwest::Client::builder()
934            .timeout(Duration::from_secs(10))
935            .user_agent("ICEBOX-test/1.0")
936            .build()
937            .expect("HTTP client");
938
939        let findings = query_osv(
940            &client,
941            "zzzz_invalid_crate_000",
942            "0.1.0",
943            Duration::from_secs(10),
944        )
945        .await
946        .expect("OSV query must succeed");
947
948        assert!(
949            findings.is_empty(),
950            "non-existent crate should have no CVEs"
951        );
952    }
953
954    #[test]
955    fn test_finding_json_has_cvss_fields() {
956        let finding = VulnerabilityFinding {
957            cve: "CVE-2024-27303".into(),
958            package: "tokio".into(),
959            installed_version: "1.0.0".into(),
960            fixed_version: Some("1.35.1".into()),
961            summary: "Resource exhaustion in tokio.epoll".into(),
962            cvss_v31: Some(7.5),
963            epss: Some(0.05),
964            kev: false,
965            new_finding: true,
966            severity: "high".into(),
967        };
968
969        let json_val = serde_json::to_value(&finding).expect("must serialize");
970        assert_eq!(json_val["cve"], "CVE-2024-27303");
971        assert_eq!(json_val["cvss_v31"], 7.5);
972        assert!((json_val["epss"].as_f64().unwrap() - 0.05).abs() < 0.001);
973        assert_eq!(json_val["kev"], false);
974        assert_eq!(json_val["new_finding"], true);
975        assert_eq!(json_val["severity"], "high");
976    }
977
978    #[tokio::test]
979    async fn test_epss_query_real() {
980        let client = reqwest::Client::builder()
981            .timeout(Duration::from_secs(10))
982            .user_agent("ICEBOX-test/1.0")
983            .build()
984            .expect("HTTP client");
985
986        let cves = vec!["CVE-2021-44228".to_string()];
987        let epss_map = query_epss(&client, &cves, Duration::from_secs(10))
988            .await
989            .expect("EPSS query must succeed");
990
991        assert!(
992            epss_map.contains_key("CVE-2021-44228"),
993            "Log4Shell should have EPSS data"
994        );
995        if let Some(score) = epss_map.get("CVE-2021-44228") {
996            assert!(*score > 0.0, "EPSS score should be > 0");
997            assert!(*score <= 1.0, "EPSS score should be <= 1.0");
998        }
999    }
1000}