use compliance_core::models::{CveAlert, CveSource, SbomEntry, VulnRef}; use compliance_core::CoreError; pub struct CveScanner { http: reqwest::Client, searxng_url: Option, nvd_api_key: Option, } impl CveScanner { pub fn new(http: reqwest::Client, searxng_url: Option, nvd_api_key: Option) -> Self { Self { http, searxng_url, nvd_api_key } } pub async fn scan_dependencies( &self, repo_id: &str, entries: &mut [SbomEntry], ) -> Result, CoreError> { let mut alerts = Vec::new(); // Batch query OSV.dev let osv_results = self.query_osv_batch(entries).await?; for (idx, vulns) in osv_results.into_iter().enumerate() { if let Some(entry) = entries.get_mut(idx) { for vuln in &vulns { entry.known_vulnerabilities.push(VulnRef { id: vuln.id.clone(), source: "osv".to_string(), severity: vuln.severity.clone(), url: Some(format!("https://osv.dev/vulnerability/{}", vuln.id)), }); let mut alert = CveAlert::new( vuln.id.clone(), repo_id.to_string(), entry.name.clone(), entry.version.clone(), CveSource::Osv, ); alert.summary = vuln.summary.clone(); alerts.push(alert); } } } // Enrich with NVD CVSS scores for alert in &mut alerts { if let Ok(Some(cvss)) = self.query_nvd(&alert.cve_id).await { alert.cvss_score = Some(cvss); } } Ok(alerts) } async fn query_osv_batch(&self, entries: &[SbomEntry]) -> Result>, CoreError> { let queries: Vec<_> = entries .iter() .filter_map(|e| { e.purl.as_ref().map(|purl| { serde_json::json!({ "package": { "purl": purl } }) }) }) .collect(); if queries.is_empty() { return Ok(Vec::new()); } let body = serde_json::json!({ "queries": queries }); let resp = self .http .post("https://api.osv.dev/v1/querybatch") .json(&body) .send() .await .map_err(|e| CoreError::Http(format!("OSV.dev request failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); tracing::warn!("OSV.dev returned {status}: {body}"); return Ok(Vec::new()); } let result: OsvBatchResponse = resp.json().await.map_err(|e| { CoreError::Http(format!("Failed to parse OSV.dev response: {e}")) })?; let vulns = result .results .into_iter() .map(|r| { r.vulns .unwrap_or_default() .into_iter() .map(|v| OsvVuln { id: v.id, summary: v.summary, severity: v.database_specific .and_then(|d| d.get("severity").and_then(|s| s.as_str()).map(String::from)), }) .collect() }) .collect(); Ok(vulns) } async fn query_nvd(&self, cve_id: &str) -> Result, CoreError> { if !cve_id.starts_with("CVE-") { return Ok(None); } let url = format!("https://services.nvd.nist.gov/rest/json/cves/2.0?cveId={cve_id}"); let mut req = self.http.get(&url); if let Some(key) = &self.nvd_api_key { req = req.header("apiKey", key.as_str()); } let resp = req.send().await.map_err(|e| { CoreError::Http(format!("NVD request failed: {e}")) })?; if !resp.status().is_success() { return Ok(None); } let body: serde_json::Value = resp.json().await.map_err(|e| { CoreError::Http(format!("Failed to parse NVD response: {e}")) })?; // Extract CVSS v3.1 base score let score = body["vulnerabilities"] .as_array() .and_then(|v| v.first()) .and_then(|v| v["cve"]["metrics"]["cvssMetricV31"].as_array()) .and_then(|m| m.first()) .and_then(|m| m["cvssData"]["baseScore"].as_f64()); Ok(score) } pub async fn search_context(&self, cve_id: &str) -> Result, CoreError> { let Some(searxng_url) = &self.searxng_url else { return Ok(Vec::new()); }; let url = format!("{}/search?q={cve_id}&format=json&engines=duckduckgo", searxng_url.trim_end_matches('/')); let resp = self.http.get(&url).send().await.map_err(|e| { CoreError::Http(format!("SearXNG request failed: {e}")) })?; if !resp.status().is_success() { return Ok(Vec::new()); } let body: serde_json::Value = resp.json().await.unwrap_or_default(); let results = body["results"] .as_array() .map(|arr| { arr.iter() .take(5) .filter_map(|r| r["url"].as_str().map(String::from)) .collect() }) .unwrap_or_default(); Ok(results) } } #[derive(serde::Deserialize)] struct OsvBatchResponse { results: Vec, } #[derive(serde::Deserialize)] struct OsvBatchResult { vulns: Option>, } #[derive(serde::Deserialize)] struct OsvVulnEntry { id: String, summary: Option, database_specific: Option, } struct OsvVuln { id: String, summary: Option, severity: Option, }