feat(cve): match the CODESYS runtime against NVD by CPE (#180)
This commit was merged in pull request #180.
This commit is contained in:
@@ -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<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)]
|
||||
@@ -228,3 +424,90 @@ struct OsvVuln {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,7 +601,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),
|
||||
)
|
||||
@@ -617,6 +617,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! {
|
||||
|
||||
Reference in New Issue
Block a user