use crate::sbom::Sbom;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Severity {
None,
Low,
Medium,
High,
Critical,
}
impl Severity {
pub fn from_cvss(score: f64) -> Severity {
let s = if score.is_nan() {
0.0
} else {
score.clamp(0.0, 10.0)
};
if s <= 0.0 {
Severity::None
} else if s < 4.0 {
Severity::Low
} else if s < 7.0 {
Severity::Medium
} else if s < 9.0 {
Severity::High
} else {
Severity::Critical
}
}
pub fn tag(&self) -> &'static str {
match self {
Severity::None => "none",
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}
}
pub fn cvss_band(score: f64) -> &'static str {
Severity::from_cvss(score).tag()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CvssVector {
pub base_score: f64,
pub vector: Option<String>,
}
impl CvssVector {
pub fn from_score(base_score: f64) -> Self {
CvssVector {
base_score,
vector: None,
}
}
pub fn with_vector(base_score: f64, vector: impl Into<String>) -> Self {
CvssVector {
base_score,
vector: Some(vector.into()),
}
}
pub fn severity(&self) -> Severity {
Severity::from_cvss(self.base_score)
}
pub fn validate(&self) -> Result<(), VulnerabilityError> {
if !self.base_score.is_finite() || !(0.0..=10.0).contains(&self.base_score) {
return Err(VulnerabilityError::InvalidCvssScore(self.base_score));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Vulnerability {
pub id: String,
pub description: Option<String>,
pub cvss: CvssVector,
pub affected_purls: Vec<String>,
pub affected_cpes: Vec<String>,
pub fixed_versions: Vec<String>,
}
impl Vulnerability {
pub fn new(id: impl Into<String>, base_score: f64) -> Self {
Vulnerability {
id: id.into(),
description: None,
cvss: CvssVector::from_score(base_score),
affected_purls: Vec::new(),
affected_cpes: Vec::new(),
fixed_versions: Vec::new(),
}
}
pub fn severity(&self) -> Severity {
self.cvss.severity()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VexStatus {
NotAffected,
Affected,
Fixed,
UnderInvestigation,
}
impl VexStatus {
pub fn tag(&self) -> &'static str {
match self {
VexStatus::NotAffected => "not_affected",
VexStatus::Affected => "affected",
VexStatus::Fixed => "fixed",
VexStatus::UnderInvestigation => "under_investigation",
}
}
pub fn suppresses(&self) -> bool {
matches!(self, VexStatus::NotAffected | VexStatus::Fixed)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VexStatement {
pub vuln_id: String,
pub component_bom_ref: String,
pub status: VexStatus,
pub justification: Option<String>,
}
impl VexStatement {
pub fn new(
vuln_id: impl Into<String>,
component_bom_ref: impl Into<String>,
status: VexStatus,
) -> Self {
VexStatement {
vuln_id: vuln_id.into(),
component_bom_ref: component_bom_ref.into(),
status,
justification: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct VulnerabilityMatch {
pub vuln_id: String,
pub component_bom_ref: String,
pub severity: Severity,
pub matched_by: String,
}
pub fn match_vulnerabilities(sbom: &Sbom, vulns: &[Vulnerability]) -> Vec<VulnerabilityMatch> {
let mut matches = Vec::new();
for component in &sbom.components {
for vuln in vulns {
if let Some(matched_by) = match_kind(component, vuln) {
matches.push(VulnerabilityMatch {
vuln_id: vuln.id.clone(),
component_bom_ref: component.bom_ref.clone(),
severity: vuln.severity(),
matched_by: matched_by.to_string(),
});
}
}
}
matches.sort();
matches
}
fn match_kind(component: &crate::sbom::Component, vuln: &Vulnerability) -> Option<&'static str> {
if let Some(purl) = component.purl.as_deref() {
if vuln.affected_purls.iter().any(|p| p == purl) {
return Some("purl");
}
}
if let Some(cpe) = component.cpe.as_deref() {
if vuln.affected_cpes.iter().any(|c| c == cpe) {
return Some("cpe");
}
}
let name = component.name.trim();
if !name.is_empty() {
let in_purls = vuln.affected_purls.iter().any(|p| p.contains(name));
let in_cpes = vuln.affected_cpes.iter().any(|c| c.contains(name));
if in_purls || in_cpes {
return Some("name");
}
}
None
}
pub fn apply_vex(matches: &[VulnerabilityMatch], vex: &[VexStatement]) -> Vec<VulnerabilityMatch> {
let suppressed: BTreeSet<(&str, &str)> = vex
.iter()
.filter(|s| s.status.suppresses())
.map(|s| (s.vuln_id.as_str(), s.component_bom_ref.as_str()))
.collect();
matches
.iter()
.filter(|m| !suppressed.contains(&(m.vuln_id.as_str(), m.component_bom_ref.as_str())))
.cloned()
.collect()
}
pub fn propagate_risk(
sbom: &Sbom,
matches: &[VulnerabilityMatch],
) -> Vec<(String, String, Severity)> {
let mut reachable: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
for component in &sbom.components {
let deps: BTreeSet<String> = sbom
.transitive_dependencies(&component.bom_ref)
.into_iter()
.collect();
reachable.insert(component.bom_ref.as_str(), deps);
}
let mut impacted: BTreeMap<(String, String), Severity> = BTreeMap::new();
for m in matches {
let affected = m.component_bom_ref.as_str();
for component in &sbom.components {
let inherits = component.bom_ref == affected
|| reachable
.get(component.bom_ref.as_str())
.is_some_and(|deps| deps.contains(affected));
if inherits {
impacted
.entry((component.bom_ref.clone(), m.vuln_id.clone()))
.and_modify(|sev| {
if m.severity > *sev {
*sev = m.severity;
}
})
.or_insert(m.severity);
}
}
}
impacted
.into_iter()
.map(|((bom_ref, vuln_id), severity)| (bom_ref, vuln_id, severity))
.collect()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VulnerabilityReport {
pub total_components: usize,
pub total_matches: usize,
pub by_severity: BTreeMap<String, usize>,
pub max_severity: Severity,
pub exploitable_after_vex: usize,
}
pub fn build_report(
sbom: &Sbom,
vulns: &[Vulnerability],
vex: &[VexStatement],
) -> VulnerabilityReport {
let matches = match_vulnerabilities(sbom, vulns);
let surviving = apply_vex(&matches, vex);
let mut by_severity: BTreeMap<String, usize> = BTreeMap::new();
let mut max_severity = Severity::None;
for m in &matches {
*by_severity.entry(m.severity.tag().to_string()).or_insert(0) += 1;
if m.severity > max_severity {
max_severity = m.severity;
}
}
VulnerabilityReport {
total_components: sbom.components.len(),
total_matches: matches.len(),
by_severity,
max_severity,
exploitable_after_vex: surviving.len(),
}
}
#[derive(Debug, thiserror::Error)]
pub enum VulnerabilityError {
#[error("invalid CVSS base score: {0} (must be within 0.0..=10.0)")]
InvalidCvssScore(f64),
#[error("unknown component: {0}")]
UnknownComponent(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sbom::{Component, Dependency, Sbom, SbomFormat};
fn comp_with_ids(
bom_ref: &str,
name: &str,
version: &str,
purl: Option<&str>,
cpe: Option<&str>,
) -> Component {
let mut c = Component::library(bom_ref, name, version);
c.purl = purl.map(|s| s.to_string());
c.cpe = cpe.map(|s| s.to_string());
c
}
fn chain_sbom() -> Sbom {
let mut sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
sbom.components.push(comp_with_ids(
"A",
"app-a",
"1.0",
Some("pkg:cargo/app-a@1.0"),
None,
));
sbom.components.push(comp_with_ids(
"B",
"lib-b",
"2.0",
Some("pkg:cargo/lib-b@2.0"),
None,
));
sbom.components.push(comp_with_ids(
"C",
"lib-c",
"3.0",
Some("pkg:cargo/lib-c@3.0"),
Some("cpe:2.3:a:vendor:lib-c:3.0:*:*:*:*:*:*:*"),
));
sbom.dependencies.push(Dependency {
dependent: "A".to_string(),
depends_on: vec!["B".to_string()],
});
sbom.dependencies.push(Dependency {
dependent: "B".to_string(),
depends_on: vec!["C".to_string()],
});
sbom.canonicalize();
sbom
}
fn vuln_on_purl(id: &str, score: f64, purl: &str) -> Vulnerability {
let mut v = Vulnerability::new(id, score);
v.affected_purls.push(purl.to_string());
v
}
#[test]
fn cvss_banding_at_boundaries() {
assert_eq!(Severity::from_cvss(0.0), Severity::None);
assert_eq!(Severity::from_cvss(0.1), Severity::Low);
assert_eq!(Severity::from_cvss(3.9), Severity::Low);
assert_eq!(Severity::from_cvss(4.0), Severity::Medium);
assert_eq!(Severity::from_cvss(6.9), Severity::Medium);
assert_eq!(Severity::from_cvss(7.0), Severity::High);
assert_eq!(Severity::from_cvss(8.9), Severity::High);
assert_eq!(Severity::from_cvss(9.0), Severity::Critical);
assert_eq!(Severity::from_cvss(10.0), Severity::Critical);
}
#[test]
fn cvss_band_free_function_matches_enum() {
assert_eq!(cvss_band(0.0), "none");
assert_eq!(cvss_band(2.0), "low");
assert_eq!(cvss_band(5.5), "medium");
assert_eq!(cvss_band(7.5), "high");
assert_eq!(cvss_band(9.8), "critical");
}
#[test]
fn cvss_vector_severity_and_clamping() {
assert_eq!(CvssVector::from_score(9.1).severity(), Severity::Critical);
assert_eq!(Severity::from_cvss(11.0), Severity::Critical);
assert_eq!(Severity::from_cvss(-1.0), Severity::None);
assert_eq!(Severity::from_cvss(f64::NAN), Severity::None);
}
#[test]
fn severity_ordering_is_monotonic() {
assert!(Severity::Critical > Severity::High);
assert!(Severity::High > Severity::Medium);
assert!(Severity::Medium > Severity::Low);
assert!(Severity::Low > Severity::None);
}
#[test]
fn matches_by_exact_purl() {
let sbom = chain_sbom();
let vulns = vec![vuln_on_purl("CVE-2024-0001", 7.5, "pkg:cargo/lib-c@3.0")];
let matches = match_vulnerabilities(&sbom, &vulns);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].component_bom_ref, "C");
assert_eq!(matches[0].matched_by, "purl");
assert_eq!(matches[0].severity, Severity::High);
}
#[test]
fn matches_by_exact_cpe_when_no_purl() {
let sbom = chain_sbom();
let mut v = Vulnerability::new("CVE-2024-0002", 5.0);
v.affected_cpes
.push("cpe:2.3:a:vendor:lib-c:3.0:*:*:*:*:*:*:*".to_string());
let matches = match_vulnerabilities(&sbom, &[v]);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].component_bom_ref, "C");
assert_eq!(matches[0].matched_by, "cpe");
}
#[test]
fn matches_by_name_substring_fallback() {
let sbom = chain_sbom();
let mut v = Vulnerability::new("CVE-2024-0003", 8.0);
v.affected_purls.push("pkg:npm/lib-b@99.0".to_string());
let matches = match_vulnerabilities(&sbom, &[v]);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].component_bom_ref, "B");
assert_eq!(matches[0].matched_by, "name");
}
#[test]
fn purl_match_takes_priority_over_name() {
let sbom = chain_sbom();
let mut v = Vulnerability::new("CVE-2024-0004", 9.5);
v.affected_purls.push("pkg:cargo/lib-c@3.0".to_string());
let matches = match_vulnerabilities(&sbom, &[v]);
let c_match = matches
.iter()
.find(|m| m.component_bom_ref == "C")
.expect("C should match");
assert_eq!(c_match.matched_by, "purl");
}
#[test]
fn no_match_yields_empty() {
let sbom = chain_sbom();
let vulns = vec![vuln_on_purl(
"CVE-2024-9999",
5.0,
"pkg:cargo/not-present@0.0",
)];
assert!(match_vulnerabilities(&sbom, &vulns).is_empty());
}
#[test]
fn matches_are_sorted_deterministically() {
let mut sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
sbom.components.push(comp_with_ids(
"Z",
"zeta",
"1.0",
Some("pkg:cargo/zeta@1.0"),
None,
));
sbom.components.push(comp_with_ids(
"A",
"alpha",
"1.0",
Some("pkg:cargo/alpha@1.0"),
None,
));
let vulns = vec![
vuln_on_purl("CVE-2", 5.0, "pkg:cargo/zeta@1.0"),
vuln_on_purl("CVE-1", 5.0, "pkg:cargo/alpha@1.0"),
];
let matches = match_vulnerabilities(&sbom, &vulns);
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].component_bom_ref, "A");
assert_eq!(matches[1].component_bom_ref, "Z");
}
#[test]
fn vex_not_affected_suppresses_match() {
let sbom = chain_sbom();
let vulns = vec![vuln_on_purl("CVE-2024-0010", 7.5, "pkg:cargo/lib-c@3.0")];
let matches = match_vulnerabilities(&sbom, &vulns);
let vex = vec![VexStatement::new(
"CVE-2024-0010",
"C",
VexStatus::NotAffected,
)];
let surviving = apply_vex(&matches, &vex);
assert!(surviving.is_empty());
}
#[test]
fn vex_fixed_suppresses_match() {
let sbom = chain_sbom();
let vulns = vec![vuln_on_purl("CVE-2024-0011", 7.5, "pkg:cargo/lib-c@3.0")];
let matches = match_vulnerabilities(&sbom, &vulns);
let vex = vec![VexStatement::new("CVE-2024-0011", "C", VexStatus::Fixed)];
assert!(apply_vex(&matches, &vex).is_empty());
}
#[test]
fn vex_affected_and_under_investigation_kept() {
let sbom = chain_sbom();
let vulns = vec![vuln_on_purl("CVE-2024-0012", 7.5, "pkg:cargo/lib-c@3.0")];
let matches = match_vulnerabilities(&sbom, &vulns);
let affected = vec![VexStatement::new("CVE-2024-0012", "C", VexStatus::Affected)];
assert_eq!(apply_vex(&matches, &affected).len(), 1);
let investigating = vec![VexStatement::new(
"CVE-2024-0012",
"C",
VexStatus::UnderInvestigation,
)];
assert_eq!(apply_vex(&matches, &investigating).len(), 1);
assert_eq!(apply_vex(&matches, &[]).len(), 1);
}
#[test]
fn vex_only_suppresses_matching_pair() {
let sbom = chain_sbom();
let vulns = vec![
vuln_on_purl("CVE-A", 7.5, "pkg:cargo/lib-c@3.0"),
vuln_on_purl("CVE-B", 5.0, "pkg:cargo/lib-b@2.0"),
];
let matches = match_vulnerabilities(&sbom, &vulns);
assert_eq!(matches.len(), 2);
let vex = vec![VexStatement::new("CVE-A", "C", VexStatus::NotAffected)];
let surviving = apply_vex(&matches, &vex);
assert_eq!(surviving.len(), 1);
assert_eq!(surviving[0].vuln_id, "CVE-B");
}
#[test]
fn risk_propagates_through_three_level_chain() {
let sbom = chain_sbom();
let vulns = vec![vuln_on_purl("CVE-PROP", 9.0, "pkg:cargo/lib-c@3.0")];
let matches = match_vulnerabilities(&sbom, &vulns);
let impacted = propagate_risk(&sbom, &matches);
let refs: Vec<&str> = impacted.iter().map(|(r, _, _)| r.as_str()).collect();
assert!(refs.contains(&"A"), "A inherits via A->B->C");
assert!(refs.contains(&"B"), "B inherits via B->C");
assert!(refs.contains(&"C"), "C is directly affected");
assert_eq!(impacted.len(), 3);
assert!(impacted
.iter()
.all(|(_, _, sev)| *sev == Severity::Critical));
}
#[test]
fn risk_does_not_propagate_to_unrelated_components() {
let mut sbom = chain_sbom();
sbom.components.push(comp_with_ids(
"D",
"lib-d",
"1.0",
Some("pkg:cargo/lib-d@1.0"),
None,
));
sbom.canonicalize();
let vulns = vec![vuln_on_purl("CVE-ISO", 5.0, "pkg:cargo/lib-c@3.0")];
let matches = match_vulnerabilities(&sbom, &vulns);
let impacted = propagate_risk(&sbom, &matches);
let refs: Vec<&str> = impacted.iter().map(|(r, _, _)| r.as_str()).collect();
assert!(!refs.contains(&"D"), "D is unrelated to C");
}
#[test]
fn risk_propagation_is_sorted_and_deduplicated() {
let sbom = chain_sbom();
let vulns = vec![vuln_on_purl("CVE-DUP", 7.0, "pkg:cargo/lib-c@3.0")];
let mut matches = match_vulnerabilities(&sbom, &vulns);
let dup = matches[0].clone();
matches.push(dup);
let impacted = propagate_risk(&sbom, &matches);
assert_eq!(impacted.len(), 3, "A, B, C — no duplicates");
let refs: Vec<&str> = impacted.iter().map(|(r, _, _)| r.as_str()).collect();
assert_eq!(refs, vec!["A", "B", "C"]);
}
#[test]
fn risk_propagation_empty_when_no_matches() {
let sbom = chain_sbom();
assert!(propagate_risk(&sbom, &[]).is_empty());
}
#[test]
fn report_aggregates_counts_and_max_severity() {
let sbom = chain_sbom();
let vulns = vec![
vuln_on_purl("CVE-HI", 7.5, "pkg:cargo/lib-c@3.0"), vuln_on_purl("CVE-CR", 9.5, "pkg:cargo/lib-b@2.0"), ];
let report = build_report(&sbom, &vulns, &[]);
assert_eq!(report.total_components, 3);
assert_eq!(report.total_matches, 2);
assert_eq!(report.max_severity, Severity::Critical);
assert_eq!(report.by_severity.get("high"), Some(&1));
assert_eq!(report.by_severity.get("critical"), Some(&1));
assert_eq!(report.exploitable_after_vex, 2);
}
#[test]
fn report_reflects_vex_suppression_in_exploitable_count() {
let sbom = chain_sbom();
let vulns = vec![
vuln_on_purl("CVE-X", 7.5, "pkg:cargo/lib-c@3.0"),
vuln_on_purl("CVE-Y", 5.0, "pkg:cargo/lib-b@2.0"),
];
let vex = vec![VexStatement::new("CVE-X", "C", VexStatus::NotAffected)];
let report = build_report(&sbom, &vulns, &vex);
assert_eq!(report.total_matches, 2);
assert_eq!(report.exploitable_after_vex, 1);
}
#[test]
fn report_empty_sbom_and_vulns() {
let sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
let report = build_report(&sbom, &[], &[]);
assert_eq!(report.total_components, 0);
assert_eq!(report.total_matches, 0);
assert_eq!(report.max_severity, Severity::None);
assert_eq!(report.exploitable_after_vex, 0);
assert!(report.by_severity.is_empty());
}
#[test]
fn cvss_validate_rejects_out_of_range() {
assert!(matches!(
CvssVector::from_score(11.0).validate(),
Err(VulnerabilityError::InvalidCvssScore(_))
));
assert!(matches!(
CvssVector::from_score(-0.1).validate(),
Err(VulnerabilityError::InvalidCvssScore(_))
));
assert!(matches!(
CvssVector::from_score(f64::INFINITY).validate(),
Err(VulnerabilityError::InvalidCvssScore(_))
));
}
#[test]
fn cvss_validate_accepts_in_range() {
assert!(CvssVector::from_score(0.0).validate().is_ok());
assert!(CvssVector::from_score(10.0).validate().is_ok());
assert!(CvssVector::with_vector(5.5, "CVSS:3.1/AV:N")
.validate()
.is_ok());
}
#[test]
fn vex_status_tags_and_suppression() {
assert_eq!(VexStatus::NotAffected.tag(), "not_affected");
assert_eq!(VexStatus::UnderInvestigation.tag(), "under_investigation");
assert!(VexStatus::NotAffected.suppresses());
assert!(VexStatus::Fixed.suppresses());
assert!(!VexStatus::Affected.suppresses());
assert!(!VexStatus::UnderInvestigation.suppresses());
}
#[test]
fn unknown_component_error_displays() {
let e = VulnerabilityError::UnknownComponent("missing".to_string());
assert!(e.to_string().contains("missing"));
}
}