Skip to main content

sbom_tools/diff/changes/
vulnerabilities.rs

1//! Vulnerability change computer implementation.
2
3use crate::diff::traits::{ChangeComputer, ComponentMatches, VulnerabilityChangeSet};
4use crate::diff::VulnerabilityDetail;
5use crate::model::{CanonicalId, NormalizedSbom};
6use std::collections::{HashMap, HashSet, VecDeque};
7
8/// Computes vulnerability-level changes between SBOMs.
9pub struct VulnerabilityChangeComputer;
10
11impl VulnerabilityChangeComputer {
12    /// Create a new vulnerability change computer.
13    pub fn new() -> Self {
14        Self
15    }
16}
17
18impl Default for VulnerabilityChangeComputer {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24/// Compute component depths from SBOM dependency edges using BFS.
25/// Returns a map of component ID -> depth (1 = direct, 2+ = transitive).
26fn compute_depths(sbom: &NormalizedSbom) -> HashMap<CanonicalId, u32> {
27    let mut depths = HashMap::with_capacity(sbom.components.len());
28
29    // Build forward edge map: parent -> [children]
30    let mut edges: HashMap<&CanonicalId, Vec<&CanonicalId>> =
31        HashMap::with_capacity(sbom.components.len());
32    let mut has_parents: HashSet<&CanonicalId> = HashSet::with_capacity(sbom.components.len());
33
34    for edge in &sbom.edges {
35        edges.entry(&edge.from).or_default().push(&edge.to);
36        has_parents.insert(&edge.to);
37    }
38
39    // Find roots (components with no incoming edges)
40    let roots: Vec<&CanonicalId> = sbom
41        .components
42        .keys()
43        .filter(|id| !has_parents.contains(id))
44        .collect();
45
46    // BFS from roots to compute minimum depths
47    let mut queue: VecDeque<(&CanonicalId, u32)> = VecDeque::new();
48
49    // Roots are at depth 0 (the "product" level)
50    for root in &roots {
51        queue.push_back((*root, 0));
52    }
53
54    while let Some((id, depth)) = queue.pop_front() {
55        // Skip if we've already found a shorter path
56        if let Some(&existing) = depths.get(id) {
57            if depth >= existing {
58                continue;
59            }
60        }
61        depths.insert(id.clone(), depth);
62
63        // Process children at depth + 1
64        if let Some(children) = edges.get(id) {
65            for child in children {
66                let child_depth = depth + 1;
67                // Only queue if we haven't seen a shorter path
68                if depths.get(*child).is_none_or(|&d| d > child_depth) {
69                    queue.push_back((*child, child_depth));
70                }
71            }
72        }
73    }
74
75    depths
76}
77
78impl ChangeComputer for VulnerabilityChangeComputer {
79    type ChangeSet = VulnerabilityChangeSet;
80
81    fn compute(
82        &self,
83        old: &NormalizedSbom,
84        new: &NormalizedSbom,
85        _matches: &ComponentMatches,
86    ) -> VulnerabilityChangeSet {
87        let mut result = VulnerabilityChangeSet::new();
88
89        // Compute component depths for both SBOMs
90        let old_depths = compute_depths(old);
91        let new_depths = compute_depths(new);
92
93        // Estimate vulnerability counts for pre-allocation
94        let old_vuln_count: usize = old.components.values().map(|c| c.vulnerabilities.len()).sum();
95        let new_vuln_count: usize = new.components.values().map(|c| c.vulnerabilities.len()).sum();
96
97        // Collect old vulnerabilities with depth info
98        let mut old_vulns: HashMap<String, VulnerabilityDetail> =
99            HashMap::with_capacity(old_vuln_count);
100        for (id, comp) in &old.components {
101            let depth = old_depths.get(id).copied();
102            for vuln in &comp.vulnerabilities {
103                let key = format!("{}:{}", vuln.id, id);
104                old_vulns.insert(key, VulnerabilityDetail::from_ref_with_depth(vuln, comp, depth));
105            }
106        }
107
108        // Collect new vulnerabilities with depth info
109        let mut new_vulns: HashMap<String, VulnerabilityDetail> =
110            HashMap::with_capacity(new_vuln_count);
111        for (id, comp) in &new.components {
112            let depth = new_depths.get(id).copied();
113            for vuln in &comp.vulnerabilities {
114                let key = format!("{}:{}", vuln.id, id);
115                new_vulns.insert(key, VulnerabilityDetail::from_ref_with_depth(vuln, comp, depth));
116            }
117        }
118
119        // Find introduced vulnerabilities (in new but not old)
120        for detail in new_vulns.values() {
121            // Check by vuln ID only (component might have been renamed/matched)
122            let vuln_id = &detail.id;
123            let exists_in_old = old_vulns.values().any(|v| &v.id == vuln_id);
124            if !exists_in_old {
125                result.introduced.push(detail.clone());
126            }
127        }
128
129        // Find resolved vulnerabilities (in old but not new)
130        for detail in old_vulns.values() {
131            let vuln_id = &detail.id;
132            let exists_in_new = new_vulns.values().any(|v| &v.id == vuln_id);
133            if !exists_in_new {
134                result.resolved.push(detail.clone());
135            }
136        }
137
138        // Find persistent vulnerabilities (in both)
139        for detail in new_vulns.values() {
140            let vuln_id = &detail.id;
141            let exists_in_old = old_vulns.values().any(|v| &v.id == vuln_id);
142            if exists_in_old {
143                result.persistent.push(detail.clone());
144            }
145        }
146
147        // Sort by severity
148        result.sort_by_severity();
149
150        result
151    }
152
153    fn name(&self) -> &str {
154        "VulnerabilityChangeComputer"
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_vulnerability_change_computer_default() {
164        let computer = VulnerabilityChangeComputer::default();
165        assert_eq!(computer.name(), "VulnerabilityChangeComputer");
166    }
167
168    #[test]
169    fn test_empty_sboms() {
170        let computer = VulnerabilityChangeComputer::default();
171        let old = NormalizedSbom::default();
172        let new = NormalizedSbom::default();
173        let matches = ComponentMatches::new();
174
175        let result = computer.compute(&old, &new, &matches);
176        assert!(result.is_empty());
177    }
178}