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    #[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            if depth >= existing {
59                continue;
60            }
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.components.values().map(|c| c.vulnerabilities.len()).sum();
96        let new_vuln_count: usize = new.components.values().map(|c| c.vulnerabilities.len()).sum();
97
98        // Collect old vulnerabilities with depth info
99        let mut old_vulns: HashMap<String, VulnerabilityDetail> =
100            HashMap::with_capacity(old_vuln_count);
101        for (id, comp) in &old.components {
102            let depth = old_depths.get(id).copied();
103            for vuln in &comp.vulnerabilities {
104                let key = format!("{}:{}", vuln.id, id);
105                old_vulns.insert(key, VulnerabilityDetail::from_ref_with_depth(vuln, comp, depth));
106            }
107        }
108
109        // Collect new vulnerabilities with depth info
110        let mut new_vulns: HashMap<String, VulnerabilityDetail> =
111            HashMap::with_capacity(new_vuln_count);
112        for (id, comp) in &new.components {
113            let depth = new_depths.get(id).copied();
114            for vuln in &comp.vulnerabilities {
115                let key = format!("{}:{}", vuln.id, id);
116                new_vulns.insert(key, VulnerabilityDetail::from_ref_with_depth(vuln, comp, depth));
117            }
118        }
119
120        // Find introduced vulnerabilities (in new but not old)
121        for detail in new_vulns.values() {
122            // Check by vuln ID only (component might have been renamed/matched)
123            let vuln_id = &detail.id;
124            let exists_in_old = old_vulns.values().any(|v| &v.id == vuln_id);
125            if !exists_in_old {
126                result.introduced.push(detail.clone());
127            }
128        }
129
130        // Find resolved vulnerabilities (in old but not new)
131        for detail in old_vulns.values() {
132            let vuln_id = &detail.id;
133            let exists_in_new = new_vulns.values().any(|v| &v.id == vuln_id);
134            if !exists_in_new {
135                result.resolved.push(detail.clone());
136            }
137        }
138
139        // Find persistent vulnerabilities (in both)
140        for detail in new_vulns.values() {
141            let vuln_id = &detail.id;
142            let exists_in_old = old_vulns.values().any(|v| &v.id == vuln_id);
143            if exists_in_old {
144                result.persistent.push(detail.clone());
145            }
146        }
147
148        // Sort by severity
149        result.sort_by_severity();
150
151        result
152    }
153
154    fn name(&self) -> &'static str {
155        "VulnerabilityChangeComputer"
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_vulnerability_change_computer_default() {
165        let computer = VulnerabilityChangeComputer::default();
166        assert_eq!(computer.name(), "VulnerabilityChangeComputer");
167    }
168
169    #[test]
170    fn test_empty_sboms() {
171        let computer = VulnerabilityChangeComputer::default();
172        let old = NormalizedSbom::default();
173        let new = NormalizedSbom::default();
174        let matches = ComponentMatches::new();
175
176        let result = computer.compute(&old, &new, &matches);
177        assert!(result.is_empty());
178    }
179}