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}