From 6006c14f993c79a44c2995b04cb03cc0948daea1 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:04:23 +0200 Subject: [PATCH] feat(cve): match the CODESYS runtime against NVD by CPE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSV-by-purl can't match `pkg:codesys/*` (OSV has no codesys ecosystem), so the CODESYS control-app SBOM produced zero CVE alerts even when the runtime was affected — "No CVE alerts" read as safe when it just meant "not checked" (found live during the demo). Add CveScanner::scan_codesys: find the CODESYS **runtime** component (the version advisories key off), pull `cpe:2.3:a:codesys:*` CVEs from NVD, and keep the ones whose affected-version range (versionStart/End Including/Excluding, or an exact CPE version) covers the runtime version. Wired into persist_control_app_sbom alongside the OSV scan (best-effort, timeout-bounded). Pure logic unit-tested (runtime detection, dotted-version compare, CPE range matching, NVD-config parsing against a fixture): a 4.17.0.0 runtime matches a `<=4.20` CVE but not an old `<4.9` one. Co-Authored-By: Claude Opus 4.8 --- compliance-agent/src/pipeline/cve.rs | 283 ++++++++++++++++++ compliance-agent/src/pipeline/orchestrator.rs | 14 +- 2 files changed, 296 insertions(+), 1 deletion(-) diff --git a/compliance-agent/src/pipeline/cve.rs b/compliance-agent/src/pipeline/cve.rs index e8f133c..f5fcc7c 100644 --- a/compliance-agent/src/pipeline/cve.rs +++ b/compliance-agent/src/pipeline/cve.rs @@ -204,6 +204,202 @@ impl CveScanner { 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)] @@ -228,3 +424,90 @@ struct OsvVuln { 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)); + } +} diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index eae76a0..e0bc70f 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -590,7 +590,7 @@ impl PipelineOrchestrator { k.expose_secret().to_string() }), ); - let alerts = match tokio::time::timeout( + let mut alerts = match tokio::time::timeout( std::time::Duration::from_secs(600), cve_scanner.scan_dependencies(target_id, &mut entries), ) @@ -606,6 +606,18 @@ impl PipelineOrchestrator { Vec::new() } }; + // OSV can't match `pkg:codesys/*` (no such ecosystem); CODESYS advisories + // live in NVD keyed by CPE + runtime version. Add those (best-effort). + if let Ok(codesys) = tokio::time::timeout( + std::time::Duration::from_secs(120), + cve_scanner.scan_codesys(target_id, &mut entries), + ) + .await + { + alerts.extend(codesys); + } else { + tracing::warn!(target_id, "CODESYS CVE match timed out"); + } for entry in &entries { let filter = doc! { -- 2.54.0