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