514 lines
17 KiB
Rust
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));
|
|
}
|
|
}
|