Skip to main content

sbom_tools/diff/changes/
vulnerabilities.rs

1//! Vulnerability change computer implementation.
2
3use crate::diff::VulnerabilityDetail;
4use crate::diff::result::VexStatusChange;
5use crate::diff::traits::{ChangeComputer, ComponentMatches, VulnerabilityChangeSet};
6use crate::model::{CanonicalId, NormalizedSbom};
7use std::collections::{HashMap, HashSet, VecDeque};
8
9/// Computes vulnerability-level changes between SBOMs.
10pub struct VulnerabilityChangeComputer;
11
12impl VulnerabilityChangeComputer {
13    /// Create a new vulnerability change computer.
14    #[must_use]
15    pub const fn new() -> Self {
16        Self
17    }
18}
19
20impl Default for VulnerabilityChangeComputer {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26/// Compute component depths from SBOM dependency edges using BFS.
27/// Returns a map of component ID -> depth (1 = direct, 2+ = transitive).
28fn compute_depths(sbom: &NormalizedSbom) -> HashMap<CanonicalId, u32> {
29    let mut depths = HashMap::with_capacity(sbom.components.len());
30
31    // Build forward edge map: parent -> [children]
32    let mut edges: HashMap<&CanonicalId, Vec<&CanonicalId>> =
33        HashMap::with_capacity(sbom.components.len());
34    let mut has_parents: HashSet<&CanonicalId> = HashSet::with_capacity(sbom.components.len());
35
36    for edge in &sbom.edges {
37        edges.entry(&edge.from).or_default().push(&edge.to);
38        has_parents.insert(&edge.to);
39    }
40
41    // Find roots (components with no incoming edges)
42    let roots: Vec<&CanonicalId> = sbom
43        .components
44        .keys()
45        .filter(|id| !has_parents.contains(id))
46        .collect();
47
48    // BFS from roots to compute minimum depths
49    let mut queue: VecDeque<(&CanonicalId, u32)> = VecDeque::new();
50
51    // Roots are at depth 0 (the "product" level)
52    for root in &roots {
53        queue.push_back((*root, 0));
54    }
55
56    while let Some((id, depth)) = queue.pop_front() {
57        // Skip if we've already found a shorter path
58        if let Some(&existing) = depths.get(id)
59            && depth >= existing
60        {
61            continue;
62        }
63        depths.insert(id.clone(), depth);
64
65        // Process children at depth + 1
66        if let Some(children) = edges.get(id) {
67            for child in children {
68                let child_depth = depth + 1;
69                // Only queue if we haven't seen a shorter path
70                if depths.get(*child).is_none_or(|&d| d > child_depth) {
71                    queue.push_back((*child, child_depth));
72                }
73            }
74        }
75    }
76
77    depths
78}
79
80impl ChangeComputer for VulnerabilityChangeComputer {
81    type ChangeSet = VulnerabilityChangeSet;
82
83    fn compute(
84        &self,
85        old: &NormalizedSbom,
86        new: &NormalizedSbom,
87        _matches: &ComponentMatches,
88    ) -> VulnerabilityChangeSet {
89        let mut result = VulnerabilityChangeSet::new();
90
91        // Compute component depths for both SBOMs
92        let old_depths = compute_depths(old);
93        let new_depths = compute_depths(new);
94
95        // Estimate vulnerability counts for pre-allocation
96        let old_vuln_count: usize = old
97            .components
98            .values()
99            .map(|c| c.vulnerabilities.len())
100            .sum();
101        let new_vuln_count: usize = new
102            .components
103            .values()
104            .map(|c| c.vulnerabilities.len())
105            .sum();
106
107        // Collect old vulnerabilities with depth info
108        let mut old_vulns: HashMap<String, VulnerabilityDetail> =
109            HashMap::with_capacity(old_vuln_count);
110        for (id, comp) in &old.components {
111            let depth = old_depths.get(id).copied();
112            for vuln in &comp.vulnerabilities {
113                let key = format!("{}:{}", vuln.id, id);
114                old_vulns.insert(
115                    key,
116                    VulnerabilityDetail::from_ref_with_depth(vuln, comp, depth),
117                );
118            }
119        }
120
121        // Collect new vulnerabilities with depth info
122        let mut new_vulns: HashMap<String, VulnerabilityDetail> =
123            HashMap::with_capacity(new_vuln_count);
124        for (id, comp) in &new.components {
125            let depth = new_depths.get(id).copied();
126            for vuln in &comp.vulnerabilities {
127                let key = format!("{}:{}", vuln.id, id);
128                new_vulns.insert(
129                    key,
130                    VulnerabilityDetail::from_ref_with_depth(vuln, comp, depth),
131                );
132            }
133        }
134
135        // Find introduced vulnerabilities (in new but not old)
136        for detail in new_vulns.values() {
137            // Check by vuln ID only (component might have been renamed/matched)
138            let vuln_id = &detail.id;
139            let exists_in_old = old_vulns.values().any(|v| &v.id == vuln_id);
140            if !exists_in_old {
141                result.introduced.push(detail.clone());
142            }
143        }
144
145        // Find resolved vulnerabilities (in old but not new)
146        for detail in old_vulns.values() {
147            let vuln_id = &detail.id;
148            let exists_in_new = new_vulns.values().any(|v| &v.id == vuln_id);
149            if !exists_in_new {
150                result.resolved.push(detail.clone());
151            }
152        }
153
154        // Find persistent vulnerabilities (in both)
155        let mut vex_changes = Vec::new();
156        for (key, detail) in &new_vulns {
157            let vuln_id = &detail.id;
158            let exists_in_old = old_vulns.values().any(|v| &v.id == vuln_id);
159            if exists_in_old {
160                result.persistent.push(detail.clone());
161
162                // Compare VEX states between old and new for this vuln+component pair
163                if let Some(old_detail) = old_vulns.get(key)
164                    && old_detail.vex_state != detail.vex_state
165                {
166                    vex_changes.push(VexStatusChange {
167                        vuln_id: detail.id.clone(),
168                        component_name: detail.component_name.clone(),
169                        old_state: old_detail.vex_state.clone(),
170                        new_state: detail.vex_state.clone(),
171                    });
172                }
173            }
174        }
175        result.vex_changes = vex_changes;
176
177        // Sort by severity
178        result.sort_by_severity();
179
180        result
181    }
182
183    fn name(&self) -> &'static str {
184        "VulnerabilityChangeComputer"
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_vulnerability_change_computer_default() {
194        let computer = VulnerabilityChangeComputer;
195        assert_eq!(computer.name(), "VulnerabilityChangeComputer");
196    }
197
198    #[test]
199    fn test_empty_sboms() {
200        let computer = VulnerabilityChangeComputer;
201        let old = NormalizedSbom::default();
202        let new = NormalizedSbom::default();
203        let matches = ComponentMatches::new();
204
205        let result = computer.compute(&old, &new, &matches);
206        assert!(result.is_empty());
207    }
208
209    #[test]
210    fn test_vex_state_change_detection() {
211        use crate::model::{Component, VexState, VexStatus, VulnerabilityRef, VulnerabilitySource};
212
213        let computer = VulnerabilityChangeComputer;
214
215        // Build old SBOM with a vuln that has VexState::NotAffected
216        let mut old_comp = Component::new("libfoo".to_string(), "pkg:npm/libfoo@1.0".to_string());
217        let old_vuln = VulnerabilityRef::new("CVE-2023-1234".to_string(), VulnerabilitySource::Osv)
218            .with_vex_status(VexStatus::new(VexState::NotAffected));
219        old_comp.vulnerabilities.push(old_vuln);
220
221        let mut old_sbom = NormalizedSbom::default();
222        let old_id = old_comp.canonical_id.clone();
223        old_sbom.components.insert(old_id, old_comp);
224
225        // Build new SBOM with the same vuln but VexState::Affected
226        let mut new_comp = Component::new("libfoo".to_string(), "pkg:npm/libfoo@1.0".to_string());
227        let new_vuln = VulnerabilityRef::new("CVE-2023-1234".to_string(), VulnerabilitySource::Osv)
228            .with_vex_status(VexStatus::new(VexState::Affected));
229        new_comp.vulnerabilities.push(new_vuln);
230
231        let mut new_sbom = NormalizedSbom::default();
232        let new_id = new_comp.canonical_id.clone();
233        new_sbom.components.insert(new_id, new_comp);
234
235        let matches = ComponentMatches::new();
236        let result = computer.compute(&old_sbom, &new_sbom, &matches);
237
238        // The vuln should appear as persistent (present in both SBOMs)
239        assert_eq!(result.persistent.len(), 1);
240        assert!(result.introduced.is_empty());
241        assert!(result.resolved.is_empty());
242
243        // A VEX state change should be detected
244        assert_eq!(result.vex_changes.len(), 1);
245        let change = &result.vex_changes[0];
246        assert_eq!(change.vuln_id, "CVE-2023-1234");
247        assert_eq!(change.component_name, "libfoo");
248        assert_eq!(change.old_state, Some(VexState::NotAffected));
249        assert_eq!(change.new_state, Some(VexState::Affected));
250    }
251
252    #[test]
253    fn test_no_vex_change_when_states_equal() {
254        use crate::model::{Component, VexState, VexStatus, VulnerabilityRef, VulnerabilitySource};
255
256        let computer = VulnerabilityChangeComputer;
257
258        // Both SBOMs have the same VEX state
259        let mut old_comp = Component::new("libbar".to_string(), "pkg:npm/libbar@2.0".to_string());
260        let old_vuln = VulnerabilityRef::new("CVE-2023-5678".to_string(), VulnerabilitySource::Nvd)
261            .with_vex_status(VexStatus::new(VexState::Fixed));
262        old_comp.vulnerabilities.push(old_vuln);
263
264        let mut old_sbom = NormalizedSbom::default();
265        let old_id = old_comp.canonical_id.clone();
266        old_sbom.components.insert(old_id, old_comp);
267
268        let mut new_comp = Component::new("libbar".to_string(), "pkg:npm/libbar@2.0".to_string());
269        let new_vuln = VulnerabilityRef::new("CVE-2023-5678".to_string(), VulnerabilitySource::Nvd)
270            .with_vex_status(VexStatus::new(VexState::Fixed));
271        new_comp.vulnerabilities.push(new_vuln);
272
273        let mut new_sbom = NormalizedSbom::default();
274        let new_id = new_comp.canonical_id.clone();
275        new_sbom.components.insert(new_id, new_comp);
276
277        let matches = ComponentMatches::new();
278        let result = computer.compute(&old_sbom, &new_sbom, &matches);
279
280        assert_eq!(result.persistent.len(), 1);
281        // No VEX changes since both have the same state
282        assert!(result.vex_changes.is_empty());
283    }
284
285    #[test]
286    fn test_vex_state_change_from_none_to_some() {
287        use crate::model::{Component, VexState, VexStatus, VulnerabilityRef, VulnerabilitySource};
288
289        let computer = VulnerabilityChangeComputer;
290
291        // Old SBOM: vuln without any VEX status
292        let mut old_comp = Component::new("libqux".to_string(), "pkg:npm/libqux@1.0".to_string());
293        let old_vuln =
294            VulnerabilityRef::new("CVE-2024-0001".to_string(), VulnerabilitySource::Ghsa);
295        old_comp.vulnerabilities.push(old_vuln);
296
297        let mut old_sbom = NormalizedSbom::default();
298        let old_id = old_comp.canonical_id.clone();
299        old_sbom.components.insert(old_id, old_comp);
300
301        // New SBOM: same vuln now has VEX status UnderInvestigation
302        let mut new_comp = Component::new("libqux".to_string(), "pkg:npm/libqux@1.0".to_string());
303        let new_vuln =
304            VulnerabilityRef::new("CVE-2024-0001".to_string(), VulnerabilitySource::Ghsa)
305                .with_vex_status(VexStatus::new(VexState::UnderInvestigation));
306        new_comp.vulnerabilities.push(new_vuln);
307
308        let mut new_sbom = NormalizedSbom::default();
309        let new_id = new_comp.canonical_id.clone();
310        new_sbom.components.insert(new_id, new_comp);
311
312        let matches = ComponentMatches::new();
313        let result = computer.compute(&old_sbom, &new_sbom, &matches);
314
315        assert_eq!(result.persistent.len(), 1);
316        assert_eq!(result.vex_changes.len(), 1);
317        let change = &result.vex_changes[0];
318        assert_eq!(change.vuln_id, "CVE-2024-0001");
319        assert_eq!(change.old_state, None);
320        assert_eq!(change.new_state, Some(VexState::UnderInvestigation));
321    }
322}