Files
compliance-scanner-agent/compliance-agent/src/pipeline/cve.rs
T
sharang 097f86243e
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m45s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
feat(cve): match the CODESYS runtime against NVD by CPE (#180)
2026-07-16 20:10:18 +00:00

514 lines
17 KiB
Rust

use compliance_core::models::{CveAlert, CveSource, SbomEntry, VulnRef};
use compliance_core::CoreError;
pub struct CveScanner {
http: reqwest::Client,
#[allow(dead_code)]
searxng_url: Option<String>,
nvd_api_key: Option<String>,
}
impl CveScanner {
pub fn new(
http: reqwest::Client,
searxng_url: Option<String>,
nvd_api_key: Option<String>,
) -> 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<Vec<CveAlert>, 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<Vec<Vec<OsvVuln>>, 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<OsvVuln>> = 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<Option<f64>, 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<Vec<String>, 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<CveAlert> {
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<String>,
cvss: Option<f64>,
}
/// Version constraints from an NVD `cpeMatch` node.
#[derive(Default)]
struct CpeRange {
exact: Option<String>,
start_incl: Option<String>,
start_excl: Option<String>,
end_incl: Option<String>,
end_excl: Option<String>,
}
/// 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<CodesysCve> {
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<String> {
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<u64> = a.split('.').map(|x| x.parse().unwrap_or(0)).collect();
let pb: Vec<u64> = 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<OsvBatchResult>,
}
#[derive(serde::Deserialize)]
struct OsvBatchResult {
vulns: Option<Vec<OsvVulnEntry>>,
}
#[derive(serde::Deserialize)]
struct OsvVulnEntry {
id: String,
summary: Option<String>,
database_specific: Option<serde_json::Value>,
}
struct OsvVuln {
id: String,
summary: Option<String>,
severity: Option<String>,
}
#[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));
}
}