1use 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
9pub struct VulnerabilityChangeComputer;
11
12impl VulnerabilityChangeComputer {
13 #[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
26fn compute_depths(sbom: &NormalizedSbom) -> HashMap<CanonicalId, u32> {
29 let mut depths = HashMap::with_capacity(sbom.components.len());
30
31 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 let roots: Vec<&CanonicalId> = sbom
43 .components
44 .keys()
45 .filter(|id| !has_parents.contains(id))
46 .collect();
47
48 let mut queue: VecDeque<(&CanonicalId, u32)> = VecDeque::new();
50
51 for root in &roots {
53 queue.push_back((*root, 0));
54 }
55
56 while let Some((id, depth)) = queue.pop_front() {
57 if let Some(&existing) = depths.get(id)
59 && depth >= existing
60 {
61 continue;
62 }
63 depths.insert(id.clone(), depth);
64
65 if let Some(children) = edges.get(id) {
67 for child in children {
68 let child_depth = depth + 1;
69 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 let old_depths = compute_depths(old);
93 let new_depths = compute_depths(new);
94
95 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 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 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 for detail in new_vulns.values() {
137 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 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 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 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 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 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 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 assert_eq!(result.persistent.len(), 1);
240 assert!(result.introduced.is_empty());
241 assert!(result.resolved.is_empty());
242
243 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 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 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 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 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}