Skip to main content

sbom_tools/diff/changes/
vulnerabilities.rs

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