use compliance_core::models::{CveAlert, CveSource, SbomEntry, VulnRef}; use compliance_core::CoreError; pub struct CveScanner { http: reqwest::Client, #[allow(dead_code)] 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, } } #[tracing::instrument(skip_all)] pub async fn scan_dependencies( &self, repo_id: &str, entries: &mut [SbomEntry], ) -> Result, CoreError> { tracing::info!("scanning {} SBOM entries for known CVEs", entries.len()); 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> { const OSV_BATCH_SIZE: usize = 500; 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 mut all_vulns: Vec> = Vec::with_capacity(queries.len()); for chunk in queries.chunks(OSV_BATCH_SIZE) { let body = serde_json::json!({ "queries": chunk }); let resp = self .http .post("https://api.osv.dev/v1/querybatch") .json(&body) .send() .await .map_err(|e| { tracing::warn!("OSV.dev API call failed: {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}"); // Push empty results for this chunk so indices stay aligned all_vulns.extend(std::iter::repeat_with(Vec::new).take(chunk.len())); continue; } let result: OsvBatchResponse = resp.json().await.map_err(|e| { tracing::warn!("failed to parse OSV.dev response: {e}"); CoreError::Http(format!("Failed to parse OSV.dev response: {e}")) })?; let chunk_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() }); all_vulns.extend(chunk_vulns); } Ok(all_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) } #[allow(dead_code)] 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) } /// Match the CODESYS **runtime** component against NVD by CPE. /// /// CODESYS advisories (the CoDe16 cluster and friends) are indexed in NVD by /// CPE (`cpe:2.3:a:codesys:control*`) keyed off the *runtime* version — not by /// the internal `Cmp*`/`Sys*` library names OSV-by-purl would look up. So we /// find the runtime SBOM entry, pull every `cpe:2.3:a:codesys:*` CVE from NVD, /// and keep the ones whose affected-version range covers our runtime version. /// Best-effort: returns empty without an NVD key, on a network error, or when /// no CODESYS runtime component is present. pub async fn scan_codesys(&self, repo_id: &str, entries: &mut [SbomEntry]) -> Vec { let Some((name, version)) = codesys_runtime(entries) else { return Vec::new(); }; let url = "https://services.nvd.nist.gov/rest/json/cves/2.0\ ?virtualMatchString=cpe:2.3:a:codesys"; let mut req = self.http.get(url); if let Some(key) = &self.nvd_api_key { req = req.header("apiKey", key.as_str()); } let body: serde_json::Value = match req.send().await { Ok(r) if r.status().is_success() => match r.json().await { Ok(b) => b, Err(e) => { tracing::warn!("CODESYS NVD parse failed: {e}"); return Vec::new(); } }, Ok(r) => { tracing::warn!("CODESYS NVD returned {}", r.status()); return Vec::new(); } Err(e) => { tracing::warn!("CODESYS NVD request failed: {e}"); return Vec::new(); } }; let matched = parse_codesys_nvd(&body, &version); let mut alerts = Vec::new(); for cve in matched { if let Some(e) = entries .iter_mut() .find(|e| e.name == name && e.version == version) { e.known_vulnerabilities.push(VulnRef { id: cve.id.clone(), source: "nvd".to_string(), severity: None, url: Some(format!("https://nvd.nist.gov/vuln/detail/{}", cve.id)), }); } let mut alert = CveAlert::new( cve.id, repo_id.to_string(), name.clone(), version.clone(), CveSource::Nvd, ); alert.summary = cve.summary; alert.cvss_score = cve.cvss; alerts.push(alert); } tracing::info!(runtime = %name, version = %version, cves = alerts.len(), "CODESYS CVE match"); alerts } } /// The CODESYS runtime component (name + version) from an SBOM, if present. The /// runtime carries the version CODESYS advisories key off; the internal library /// components do not. fn codesys_runtime(entries: &[SbomEntry]) -> Option<(String, String)> { entries .iter() .find(|e| e.package_manager == "codesys" && e.name.starts_with("CODESYS Control")) .map(|e| (e.name.clone(), e.version.clone())) } /// A parsed NVD CVE that affects the CODESYS runtime. struct CodesysCve { id: String, summary: Option, cvss: Option, } /// Version constraints from an NVD `cpeMatch` node. #[derive(Default)] struct CpeRange { exact: Option, start_incl: Option, start_excl: Option, end_incl: Option, end_excl: Option, } /// Parse an NVD CVE-list response and keep the CVEs whose CODESYS CPE match covers /// `runtime_version`. fn parse_codesys_nvd(body: &serde_json::Value, runtime_version: &str) -> Vec { let mut out = Vec::new(); let Some(vulns) = body["vulnerabilities"].as_array() else { return out; }; for v in vulns { let cve = &v["cve"]; let Some(id) = cve["id"].as_str() else { continue; }; let covered = cve["configurations"] .as_array() .into_iter() .flatten() .flat_map(|c| c["nodes"].as_array().into_iter().flatten()) .flat_map(|n| n["cpeMatch"].as_array().into_iter().flatten()) .any(|cm| { cm["vulnerable"].as_bool() == Some(true) && cm["criteria"] .as_str() .is_some_and(|c| c.contains(":codesys:")) && version_matches(runtime_version, &cpe_range(cm)) }); if covered { let summary = cve["descriptions"] .as_array() .and_then(|d| d.iter().find(|x| x["lang"].as_str() == Some("en"))) .and_then(|x| x["value"].as_str()) .map(String::from); let cvss = cve["metrics"]["cvssMetricV31"] .as_array() .and_then(|m| m.first()) .and_then(|m| m["cvssData"]["baseScore"].as_f64()); out.push(CodesysCve { id: id.to_string(), summary, cvss, }); } } out } /// Build a [`CpeRange`] from an NVD `cpeMatch` object. fn cpe_range(cm: &serde_json::Value) -> CpeRange { let exact = cm["criteria"] .as_str() .and_then(cpe_version) .filter(|v| v != "*" && v != "-" && !v.is_empty()); CpeRange { exact, start_incl: cm["versionStartIncluding"].as_str().map(String::from), start_excl: cm["versionStartExcluding"].as_str().map(String::from), end_incl: cm["versionEndIncluding"].as_str().map(String::from), end_excl: cm["versionEndExcluding"].as_str().map(String::from), } } /// The version field (6th component) of a CPE 2.3 string. fn cpe_version(criteria: &str) -> Option { criteria.split(':').nth(5).map(String::from) } /// Whether `v` satisfies a CPE version range. fn version_matches(v: &str, r: &CpeRange) -> bool { use std::cmp::Ordering::{Equal, Greater, Less}; if let Some(exact) = &r.exact { return cmp_dotted(v, exact) == Equal; } let mut ok = true; if let Some(s) = &r.start_incl { ok &= cmp_dotted(v, s) != Less; } if let Some(s) = &r.start_excl { ok &= cmp_dotted(v, s) == Greater; } if let Some(e) = &r.end_incl { ok &= cmp_dotted(v, e) != Greater; } if let Some(e) = &r.end_excl { ok &= cmp_dotted(v, e) == Less; } ok } /// Compare two dotted numeric versions (`4.17.0.0` vs `4.9.0.0`); missing /// components count as 0, non-numeric components as 0. fn cmp_dotted(a: &str, b: &str) -> std::cmp::Ordering { let pa: Vec = a.split('.').map(|x| x.parse().unwrap_or(0)).collect(); let pb: Vec = b.split('.').map(|x| x.parse().unwrap_or(0)).collect(); for i in 0..pa.len().max(pb.len()) { let x = pa.get(i).copied().unwrap_or(0); let y = pb.get(i).copied().unwrap_or(0); match x.cmp(&y) { std::cmp::Ordering::Equal => continue, other => return other, } } std::cmp::Ordering::Equal } #[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, } #[cfg(test)] mod tests { use super::*; use std::cmp::Ordering::{Equal, Greater, Less}; fn entry(name: &str, ver: &str, pm: &str) -> SbomEntry { SbomEntry::new("t".into(), name.into(), ver.into(), pm.into()) } #[test] fn finds_the_codesys_runtime_component() { let entries = vec![ entry("Standard", "3.5.18.0", "codesys"), entry("CODESYS Control for Linux ARM SL", "4.17.0.0", "codesys"), ]; assert_eq!( codesys_runtime(&entries), Some(("CODESYS Control for Linux ARM SL".into(), "4.17.0.0".into())) ); // Internal library components are not the runtime. assert!(codesys_runtime(&[entry("Util", "3.5.21.0", "codesys")]).is_none()); } #[test] fn dotted_version_comparison() { assert_eq!(cmp_dotted("4.17.0.0", "4.9.0.0"), Greater); assert_eq!(cmp_dotted("4.9.0.0", "4.17.0.0"), Less); assert_eq!(cmp_dotted("3.5.18.0", "3.5.18.0"), Equal); assert_eq!(cmp_dotted("4.2", "4.2.0.0"), Equal); // missing components = 0 } #[test] fn version_range_matching() { let end_excl = CpeRange { end_excl: Some("4.9.0.0".into()), ..Default::default() }; assert!(!version_matches("4.17.0.0", &end_excl)); // patched assert!(version_matches("4.5.0.0", &end_excl)); // affected let exact = CpeRange { exact: Some("3.5.16.0".into()), ..Default::default() }; assert!(version_matches("3.5.16.0", &exact)); assert!(!version_matches("3.5.17.0", &exact)); let span = CpeRange { start_incl: Some("3.0.0.0".into()), end_incl: Some("3.5.16.0".into()), ..Default::default() }; assert!(version_matches("3.5.16.0", &span)); assert!(!version_matches("3.5.17.0", &span)); } #[test] fn parses_nvd_and_matches_by_runtime_version() { // Two CODESYS CVEs: one affects < 4.9 (our 4.17 is patched), one affects // <= 4.20 (our 4.17 is affected). Only the latter should match. let body = serde_json::json!({ "vulnerabilities": [ {"cve": {"id":"CVE-2023-0001", "descriptions":[{"lang":"en","value":"old CmpBlkDrvTcp bug"}], "metrics":{"cvssMetricV31":[{"cvssData":{"baseScore":7.5}}]}, "configurations":[{"nodes":[{"cpeMatch":[ {"vulnerable":true, "criteria":"cpe:2.3:a:codesys:control_for_linux_sl:*:*:*:*:*:*:*:*", "versionEndExcluding":"4.9.0.0"} ]}]}]}}, {"cve": {"id":"CVE-2024-0002", "descriptions":[{"lang":"en","value":"recent runtime bug"}], "metrics":{"cvssMetricV31":[{"cvssData":{"baseScore":9.8}}]}, "configurations":[{"nodes":[{"cpeMatch":[ {"vulnerable":true, "criteria":"cpe:2.3:a:codesys:control_for_linux_sl:*:*:*:*:*:*:*:*", "versionEndIncluding":"4.20.0.0"} ]}]}]}} ] }); let matched = parse_codesys_nvd(&body, "4.17.0.0"); let ids: Vec<&str> = matched.iter().map(|c| c.id.as_str()).collect(); assert_eq!(ids, vec!["CVE-2024-0002"]); assert_eq!(matched[0].cvss, Some(9.8)); } }