use crate::diff::VulnerabilityDetail;
use crate::diff::result::VexStatusChange;
use crate::diff::traits::{ChangeComputer, ComponentMatches, VulnerabilityChangeSet};
use crate::model::{CanonicalId, NormalizedSbom};
use std::collections::{HashMap, HashSet, VecDeque};
pub struct VulnerabilityChangeComputer;
impl VulnerabilityChangeComputer {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Default for VulnerabilityChangeComputer {
fn default() -> Self {
Self::new()
}
}
fn compute_depths(sbom: &NormalizedSbom) -> HashMap<CanonicalId, u32> {
let mut depths = HashMap::with_capacity(sbom.components.len());
let mut edges: HashMap<&CanonicalId, Vec<&CanonicalId>> =
HashMap::with_capacity(sbom.components.len());
let mut has_parents: HashSet<&CanonicalId> = HashSet::with_capacity(sbom.components.len());
for edge in &sbom.edges {
edges.entry(&edge.from).or_default().push(&edge.to);
has_parents.insert(&edge.to);
}
let roots: Vec<&CanonicalId> = sbom
.components
.keys()
.filter(|id| !has_parents.contains(id))
.collect();
let mut queue: VecDeque<(&CanonicalId, u32)> = VecDeque::new();
for root in &roots {
queue.push_back((*root, 0));
}
while let Some((id, depth)) = queue.pop_front() {
if let Some(&existing) = depths.get(id)
&& depth >= existing
{
continue;
}
depths.insert(id.clone(), depth);
if let Some(children) = edges.get(id) {
for child in children {
let child_depth = depth + 1;
if depths.get(*child).is_none_or(|&d| d > child_depth) {
queue.push_back((*child, child_depth));
}
}
}
}
depths
}
impl ChangeComputer for VulnerabilityChangeComputer {
type ChangeSet = VulnerabilityChangeSet;
fn compute(
&self,
old: &NormalizedSbom,
new: &NormalizedSbom,
_matches: &ComponentMatches,
) -> VulnerabilityChangeSet {
let mut result = VulnerabilityChangeSet::new();
let old_depths = compute_depths(old);
let new_depths = compute_depths(new);
let old_vuln_count: usize = old
.components
.values()
.map(|c| c.vulnerabilities.len())
.sum();
let new_vuln_count: usize = new
.components
.values()
.map(|c| c.vulnerabilities.len())
.sum();
let mut old_vulns: HashMap<String, VulnerabilityDetail> =
HashMap::with_capacity(old_vuln_count);
for (id, comp) in &old.components {
let depth = old_depths.get(id).copied();
for vuln in &comp.vulnerabilities {
let key = format!("{}:{}", vuln.id, id);
old_vulns.insert(
key,
VulnerabilityDetail::from_ref_with_depth(vuln, comp, depth),
);
}
}
let mut new_vulns: HashMap<String, VulnerabilityDetail> =
HashMap::with_capacity(new_vuln_count);
for (id, comp) in &new.components {
let depth = new_depths.get(id).copied();
for vuln in &comp.vulnerabilities {
let key = format!("{}:{}", vuln.id, id);
new_vulns.insert(
key,
VulnerabilityDetail::from_ref_with_depth(vuln, comp, depth),
);
}
}
for detail in new_vulns.values() {
let vuln_id = &detail.id;
let exists_in_old = old_vulns.values().any(|v| &v.id == vuln_id);
if !exists_in_old {
result.introduced.push(detail.clone());
}
}
for detail in old_vulns.values() {
let vuln_id = &detail.id;
let exists_in_new = new_vulns.values().any(|v| &v.id == vuln_id);
if !exists_in_new {
result.resolved.push(detail.clone());
}
}
let mut vex_changes = Vec::new();
for (key, detail) in &new_vulns {
let vuln_id = &detail.id;
let exists_in_old = old_vulns.values().any(|v| &v.id == vuln_id);
if exists_in_old {
result.persistent.push(detail.clone());
if let Some(old_detail) = old_vulns.get(key)
&& old_detail.vex_state != detail.vex_state
{
vex_changes.push(VexStatusChange {
vuln_id: detail.id.clone(),
component_name: detail.component_name.clone(),
old_state: old_detail.vex_state.clone(),
new_state: detail.vex_state.clone(),
});
}
}
}
result.vex_changes = vex_changes;
result.sort_by_severity();
result
}
fn name(&self) -> &'static str {
"VulnerabilityChangeComputer"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vulnerability_change_computer_default() {
let computer = VulnerabilityChangeComputer;
assert_eq!(computer.name(), "VulnerabilityChangeComputer");
}
#[test]
fn test_empty_sboms() {
let computer = VulnerabilityChangeComputer;
let old = NormalizedSbom::default();
let new = NormalizedSbom::default();
let matches = ComponentMatches::new();
let result = computer.compute(&old, &new, &matches);
assert!(result.is_empty());
}
#[test]
fn test_vex_state_change_detection() {
use crate::model::{Component, VexState, VexStatus, VulnerabilityRef, VulnerabilitySource};
let computer = VulnerabilityChangeComputer;
let mut old_comp = Component::new("libfoo".to_string(), "pkg:npm/libfoo@1.0".to_string());
let old_vuln = VulnerabilityRef::new("CVE-2023-1234".to_string(), VulnerabilitySource::Osv)
.with_vex_status(VexStatus::new(VexState::NotAffected));
old_comp.vulnerabilities.push(old_vuln);
let mut old_sbom = NormalizedSbom::default();
let old_id = old_comp.canonical_id.clone();
old_sbom.components.insert(old_id, old_comp);
let mut new_comp = Component::new("libfoo".to_string(), "pkg:npm/libfoo@1.0".to_string());
let new_vuln = VulnerabilityRef::new("CVE-2023-1234".to_string(), VulnerabilitySource::Osv)
.with_vex_status(VexStatus::new(VexState::Affected));
new_comp.vulnerabilities.push(new_vuln);
let mut new_sbom = NormalizedSbom::default();
let new_id = new_comp.canonical_id.clone();
new_sbom.components.insert(new_id, new_comp);
let matches = ComponentMatches::new();
let result = computer.compute(&old_sbom, &new_sbom, &matches);
assert_eq!(result.persistent.len(), 1);
assert!(result.introduced.is_empty());
assert!(result.resolved.is_empty());
assert_eq!(result.vex_changes.len(), 1);
let change = &result.vex_changes[0];
assert_eq!(change.vuln_id, "CVE-2023-1234");
assert_eq!(change.component_name, "libfoo");
assert_eq!(change.old_state, Some(VexState::NotAffected));
assert_eq!(change.new_state, Some(VexState::Affected));
}
#[test]
fn test_no_vex_change_when_states_equal() {
use crate::model::{Component, VexState, VexStatus, VulnerabilityRef, VulnerabilitySource};
let computer = VulnerabilityChangeComputer;
let mut old_comp = Component::new("libbar".to_string(), "pkg:npm/libbar@2.0".to_string());
let old_vuln = VulnerabilityRef::new("CVE-2023-5678".to_string(), VulnerabilitySource::Nvd)
.with_vex_status(VexStatus::new(VexState::Fixed));
old_comp.vulnerabilities.push(old_vuln);
let mut old_sbom = NormalizedSbom::default();
let old_id = old_comp.canonical_id.clone();
old_sbom.components.insert(old_id, old_comp);
let mut new_comp = Component::new("libbar".to_string(), "pkg:npm/libbar@2.0".to_string());
let new_vuln = VulnerabilityRef::new("CVE-2023-5678".to_string(), VulnerabilitySource::Nvd)
.with_vex_status(VexStatus::new(VexState::Fixed));
new_comp.vulnerabilities.push(new_vuln);
let mut new_sbom = NormalizedSbom::default();
let new_id = new_comp.canonical_id.clone();
new_sbom.components.insert(new_id, new_comp);
let matches = ComponentMatches::new();
let result = computer.compute(&old_sbom, &new_sbom, &matches);
assert_eq!(result.persistent.len(), 1);
assert!(result.vex_changes.is_empty());
}
#[test]
fn test_vex_state_change_from_none_to_some() {
use crate::model::{Component, VexState, VexStatus, VulnerabilityRef, VulnerabilitySource};
let computer = VulnerabilityChangeComputer;
let mut old_comp = Component::new("libqux".to_string(), "pkg:npm/libqux@1.0".to_string());
let old_vuln =
VulnerabilityRef::new("CVE-2024-0001".to_string(), VulnerabilitySource::Ghsa);
old_comp.vulnerabilities.push(old_vuln);
let mut old_sbom = NormalizedSbom::default();
let old_id = old_comp.canonical_id.clone();
old_sbom.components.insert(old_id, old_comp);
let mut new_comp = Component::new("libqux".to_string(), "pkg:npm/libqux@1.0".to_string());
let new_vuln =
VulnerabilityRef::new("CVE-2024-0001".to_string(), VulnerabilitySource::Ghsa)
.with_vex_status(VexStatus::new(VexState::UnderInvestigation));
new_comp.vulnerabilities.push(new_vuln);
let mut new_sbom = NormalizedSbom::default();
let new_id = new_comp.canonical_id.clone();
new_sbom.components.insert(new_id, new_comp);
let matches = ComponentMatches::new();
let result = computer.compute(&old_sbom, &new_sbom, &matches);
assert_eq!(result.persistent.len(), 1);
assert_eq!(result.vex_changes.len(), 1);
let change = &result.vex_changes[0];
assert_eq!(change.vuln_id, "CVE-2024-0001");
assert_eq!(change.old_state, None);
assert_eq!(change.new_state, Some(VexState::UnderInvestigation));
}
}