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}