Files
compliance-scanner-agent/compliance-agent/src/pipeline/cve.rs
Sharang Parnerkar 03ee69834d
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m15s
CI / Security Audit (push) Successful in 1m34s
CI / Tests (push) Successful in 3m4s
Fix formatting and clippy warnings across workspace
- Run cargo fmt on all crates
- Fix regex patterns using unsupported lookahead in patterns.rs
- Replace unwrap() calls with compile_regex() helper
- Fix never type fallback in GitHub tracker
- Fix redundant field name in findings page
- Allow enum_variant_names for Dioxus Route enum
- Fix &mut Vec -> &mut [T] clippy lint in sbom.rs
- Mark unused-but-intended APIs with #[allow(dead_code)]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:41:03 +01:00

220 lines
6.3 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,
}
}
pub async fn scan_dependencies(
&self,
repo_id: &str,
entries: &mut [SbomEntry],
) -> Result<Vec<CveAlert>, CoreError> {
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> {
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 body = serde_json::json!({ "queries": queries });
let resp = self
.http
.post("https://api.osv.dev/v1/querybatch")
.json(&body)
.send()
.await
.map_err(|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}");
return Ok(Vec::new());
}
let result: OsvBatchResponse = resp
.json()
.await
.map_err(|e| CoreError::Http(format!("Failed to parse OSV.dev response: {e}")))?;
let 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()
})
.collect();
Ok(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)
}
}
#[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>,
}