sbom_tools/serialization/
pruner.rs1use crate::model::{LicenseFamily, NormalizedSbom};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashSet;
10
11use super::ValueExt;
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct TailorConfig {
16 pub include_license_families: Vec<LicenseFamily>,
18 pub exclude_ecosystems: Vec<String>,
20 pub include_types: Vec<String>,
22 pub include_name_pattern: Option<String>,
24 pub include_crypto_types: Vec<String>,
26 pub strip_vulns: bool,
28 pub strip_extensions: bool,
30}
31
32pub fn tailor_sbom_json(
40 raw_json: &str,
41 sbom: &NormalizedSbom,
42 config: &TailorConfig,
43) -> anyhow::Result<String> {
44 let mut doc: Value = serde_json::from_str(raw_json)?;
45
46 let mut removal = RemovalSet::default();
51
52 for comp in sbom.components.values() {
53 let mut keep = true;
54
55 if !config.include_license_families.is_empty() {
57 let family = comp
58 .licenses
59 .declared
60 .first()
61 .map(|l| l.family())
62 .unwrap_or(LicenseFamily::Other);
63 if !config.include_license_families.contains(&family) {
64 keep = false;
65 }
66 }
67
68 if !config.exclude_ecosystems.is_empty()
70 && let Some(eco) = &comp.ecosystem
71 {
72 let eco_str = format!("{eco:?}").to_lowercase();
73 if config
74 .exclude_ecosystems
75 .iter()
76 .any(|e| e.to_lowercase() == eco_str)
77 {
78 keep = false;
79 }
80 }
81
82 if !config.include_types.is_empty() {
86 let comp_type = normalize_type_token(&comp.component_type.to_string());
87 if !config
88 .include_types
89 .iter()
90 .any(|t| normalize_type_token(t) == comp_type)
91 {
92 keep = false;
93 }
94 }
95
96 if let Some(pattern) = &config.include_name_pattern
99 && !name_matches_pattern(&comp.name, pattern)
100 {
101 keep = false;
102 }
103
104 if !config.include_crypto_types.is_empty() {
106 if let Some(cp) = &comp.crypto_properties {
107 let asset_str = cp.asset_type.to_string().to_lowercase();
108 if !config
109 .include_crypto_types
110 .iter()
111 .any(|t| t.to_lowercase() == asset_str)
112 {
113 keep = false;
114 }
115 } else {
116 keep = false;
118 }
119 }
120
121 if !keep {
122 removal.add(comp);
123 }
124 }
125
126 if doc.get("bomFormat").is_some() {
128 prune_cyclonedx(&mut doc, &removal, config);
129 } else if doc.get("@context").is_some() {
130 prune_spdx3(&mut doc, &removal, config);
131 } else {
132 prune_spdx2(&mut doc, &removal, config);
133 }
134
135 Ok(serde_json::to_string_pretty(&doc)?)
136}
137
138#[derive(Default)]
146struct RemovalSet {
147 ids: HashSet<String>,
149 name_fallback: HashSet<String>,
151}
152
153impl RemovalSet {
154 fn add(&mut self, comp: &crate::model::Component) {
155 if !comp.identifiers.format_id.is_empty() {
156 self.ids.insert(comp.identifiers.format_id.clone());
157 }
158 if let Some(purl) = &comp.identifiers.purl {
159 self.ids.insert(purl.clone());
160 }
161 if comp.identifiers.purl.is_none()
165 && (comp.identifiers.format_id.is_empty() || comp.identifiers.format_id == comp.name)
166 {
167 self.name_fallback.insert(comp.name.clone());
168 }
169 }
170
171 fn matches_cyclonedx(&self, comp: &Value) -> bool {
173 let bom_ref = comp.str_field("bom-ref");
174 let purl = comp.str_field("purl");
175 (!bom_ref.is_empty() && self.ids.contains(bom_ref))
176 || (!purl.is_empty() && self.ids.contains(purl))
177 || (bom_ref.is_empty()
178 && purl.is_empty()
179 && self.name_fallback.contains(comp.str_field("name")))
180 }
181
182 fn matches_spdx2(&self, pkg: &Value) -> bool {
184 let spdx_id = pkg.str_field("SPDXID");
185 (!spdx_id.is_empty() && self.ids.contains(spdx_id))
186 || (spdx_id.is_empty() && self.name_fallback.contains(pkg.str_field("name")))
187 }
188
189 fn matches_spdx3(&self, elem: &Value) -> bool {
191 let spdx_id = elem.str_field("spdxId");
192 (!spdx_id.is_empty() && self.ids.contains(spdx_id))
193 || (spdx_id.is_empty() && self.name_fallback.contains(elem.str_field("name")))
194 }
195
196 fn matches_ref(&self, reference: &str) -> bool {
198 !reference.is_empty() && self.ids.contains(reference)
199 }
200}
201
202fn name_matches_pattern(name: &str, pattern: &str) -> bool {
210 let name = name.to_lowercase();
211 let pattern = pattern.to_lowercase();
212
213 if !pattern.contains('*') {
214 return name.contains(&pattern);
215 }
216
217 let segments: Vec<&str> = pattern.split('*').collect();
218 let last = segments.len() - 1;
219 let mut pos = 0usize;
220 for (i, seg) in segments.iter().enumerate() {
221 if seg.is_empty() {
222 continue;
223 }
224 if i == 0 {
225 if !name.starts_with(seg) {
227 return false;
228 }
229 pos = seg.len();
230 } else if i == last {
231 if !name.ends_with(seg) || name.len() - seg.len() < pos {
234 return false;
235 }
236 pos = name.len();
237 } else {
238 match name[pos..].find(seg) {
240 Some(idx) => pos = pos + idx + seg.len(),
241 None => return false,
242 }
243 }
244 }
245 true
246}
247
248fn normalize_type_token(token: &str) -> String {
254 let normalized: String = token
255 .chars()
256 .filter(|c| *c != '-' && *c != '_')
257 .collect::<String>()
258 .to_lowercase();
259 if normalized == "cryptographicasset" {
260 "cryptographic".to_string()
261 } else {
262 normalized
263 }
264}
265
266fn prune_cyclonedx(doc: &mut Value, removal: &RemovalSet, config: &TailorConfig) {
267 if let Some(components) = doc.get_mut("components").and_then(Value::as_array_mut) {
269 components.retain(|comp| !removal.matches_cyclonedx(comp));
270 }
271
272 if let Some(deps) = doc.get_mut("dependencies").and_then(Value::as_array_mut) {
274 deps.retain(|dep| !removal.matches_ref(dep.str_field("ref")));
275
276 for dep in deps.iter_mut() {
278 if let Some(depends_on) = dep.get_mut("dependsOn").and_then(Value::as_array_mut) {
279 depends_on.retain(|d| !removal.matches_ref(d.as_str().unwrap_or("")));
280 }
281 }
282 }
283
284 if config.strip_vulns {
286 doc.as_object_mut().map(|o| o.remove("vulnerabilities"));
287 }
288
289 if config.strip_extensions
291 && let Some(components) = doc.get_mut("components").and_then(Value::as_array_mut)
292 {
293 for comp in components {
294 comp.as_object_mut().map(|o| o.remove("properties"));
295 }
296 }
297}
298
299fn prune_spdx3(doc: &mut Value, removal: &RemovalSet, config: &TailorConfig) {
300 let key = if doc.get("element").is_some() {
301 "element"
302 } else {
303 "@graph"
304 };
305 let elements = doc.get_mut(key).and_then(Value::as_array_mut);
306
307 if let Some(elems) = elements {
308 elems.retain(|elem| {
309 let elem_type = elem.str_field("type");
310
311 if !elem_type.contains("Package") && !elem_type.contains("package") {
313 if config.strip_vulns && elem_type.contains("Vulnerability") {
315 return false;
316 }
317 return true;
318 }
319
320 !removal.matches_spdx3(elem)
321 });
322 }
323}
324
325fn prune_spdx2(doc: &mut Value, removal: &RemovalSet, config: &TailorConfig) {
326 if let Some(packages) = doc.get_mut("packages").and_then(Value::as_array_mut) {
328 packages.retain(|pkg| !removal.matches_spdx2(pkg));
329 }
330
331 if let Some(rels) = doc.get_mut("relationships").and_then(Value::as_array_mut) {
333 rels.retain(|rel| {
334 let elem = rel
335 .get("spdxElementId")
336 .and_then(Value::as_str)
337 .unwrap_or("");
338 let related = rel
339 .get("relatedSpdxElement")
340 .and_then(Value::as_str)
341 .unwrap_or("");
342 !removal.matches_ref(elem) && !removal.matches_ref(related)
343 });
344 }
345
346 if config.strip_vulns
352 && let Some(annots) = doc.get_mut("annotations").and_then(Value::as_array_mut)
353 {
354 annots.retain(|annotation| {
355 !(annotation.str_field("annotator") == "Tool: sbom-tools"
356 && annotation
357 .str_field("comment")
358 .starts_with("Vulnerability "))
359 });
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::model::Component;
367
368 #[test]
369 fn tailor_by_name_pattern() {
370 let raw = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
371 {"bom-ref":"id-keep","name":"keep-me","version":"1.0"},
372 {"bom-ref":"id-remove","name":"remove-me","version":"2.0"}
373 ]}"#;
374
375 let mut sbom = NormalizedSbom::default();
376 let keep = Component::new("keep-me".to_string(), "id-keep".to_string());
377 let remove = Component::new("remove-me".to_string(), "id-remove".to_string());
378 sbom.components.insert(keep.canonical_id.clone(), keep);
379 sbom.components.insert(remove.canonical_id.clone(), remove);
380
381 let config = TailorConfig {
382 include_name_pattern: Some("keep".to_string()),
383 ..Default::default()
384 };
385
386 let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
387 assert!(result.contains("keep-me"));
388 assert!(!result.contains("remove-me"));
389 }
390
391 #[test]
392 fn strip_vulns() {
393 let raw = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[],"vulnerabilities":[{"id":"CVE-1"}]}"#;
394 let sbom = NormalizedSbom::default();
395 let config = TailorConfig {
396 strip_vulns: true,
397 ..Default::default()
398 };
399
400 let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
401 assert!(!result.contains("vulnerabilities"));
402 }
403
404 #[test]
407 fn exclude_ecosystem_keeps_same_name_other_ecosystem() {
408 use crate::model::Ecosystem;
409
410 let raw = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
411 {"bom-ref":"foo-npm","name":"foo","version":"1.0","purl":"pkg:npm/foo@1.0"},
412 {"bom-ref":"foo-pypi","name":"foo","version":"2.0","purl":"pkg:pypi/foo@2.0"}
413 ]}"#;
414
415 let mut sbom = NormalizedSbom::default();
416 let mut foo_npm = Component::new("foo".to_string(), "foo-npm".to_string())
417 .with_purl("pkg:npm/foo@1.0".to_string());
418 foo_npm.ecosystem = Some(Ecosystem::Npm);
419 let mut foo_pypi = Component::new("foo".to_string(), "foo-pypi".to_string())
420 .with_purl("pkg:pypi/foo@2.0".to_string());
421 foo_pypi.ecosystem = Some(Ecosystem::PyPi);
422 sbom.components
423 .insert(foo_npm.canonical_id.clone(), foo_npm);
424 sbom.components
425 .insert(foo_pypi.canonical_id.clone(), foo_pypi);
426
427 let config = TailorConfig {
428 exclude_ecosystems: vec!["npm".to_string()],
429 ..Default::default()
430 };
431
432 let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
433 let doc: Value = serde_json::from_str(&result).unwrap();
434 let kept: Vec<&str> = doc["components"]
435 .as_array()
436 .unwrap()
437 .iter()
438 .filter_map(|c| c["purl"].as_str())
439 .collect();
440 assert_eq!(
441 kept,
442 vec!["pkg:pypi/foo@2.0"],
443 "foo@pypi must survive excluding npm"
444 );
445 }
446
447 #[test]
450 fn include_name_glob_pattern() {
451 assert!(name_matches_pattern("my-org/pkg-a", "my-org/*"));
452 assert!(name_matches_pattern("my-org/pkg-b", "MY-ORG/*"));
453 assert!(!name_matches_pattern("other/pkg-c", "my-org/*"));
454 assert!(name_matches_pattern("libfoo-core", "*foo*"));
455 assert!(name_matches_pattern("foo-middle-bar", "foo*bar"));
456 assert!(!name_matches_pattern("foo-middle-baz", "foo*bar"));
457 assert!(!name_matches_pattern("xfoobar", "foo*bar"));
458 assert!(!name_matches_pattern("foob", "foo*ob"));
460 assert!(name_matches_pattern("my-org/pkg-a", "org/pkg"));
462 assert!(!name_matches_pattern("my-org/pkg-a", "other"));
463 }
464
465 #[test]
468 fn include_types_accepts_spec_and_debug_spellings() {
469 use crate::model::ComponentType;
470
471 let raw = r#"{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
472 {"bom-ref":"lib1","name":"libfoo","version":"1.0"},
473 {"bom-ref":"mlm","name":"bert-base","version":"1.0"}
474 ]}"#;
475
476 let mut sbom = NormalizedSbom::default();
477 let lib = Component::new("libfoo".to_string(), "lib1".to_string());
478 let mut mlm = Component::new("bert-base".to_string(), "mlm".to_string());
479 mlm.component_type = ComponentType::MachineLearningModel;
480 sbom.components.insert(lib.canonical_id.clone(), lib);
481 sbom.components.insert(mlm.canonical_id.clone(), mlm);
482
483 for spelling in ["machine-learning-model", "MachineLearningModel"] {
484 let config = TailorConfig {
485 include_types: vec![spelling.to_string()],
486 ..Default::default()
487 };
488 let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
489 let doc: Value = serde_json::from_str(&result).unwrap();
490 let kept: Vec<&str> = doc["components"]
491 .as_array()
492 .unwrap()
493 .iter()
494 .filter_map(|c| c["name"].as_str())
495 .collect();
496 assert_eq!(kept, vec!["bert-base"], "spelling {spelling} must match");
497 }
498
499 assert_eq!(normalize_type_token("cryptographic-asset"), "cryptographic");
501 assert_eq!(normalize_type_token("Cryptographic"), "cryptographic");
502 }
503
504 #[test]
507 fn strip_vulns_spdx2_keeps_non_vuln_annotations() {
508 let raw = r#"{
509 "spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT",
510 "packages":[{"SPDXID":"SPDXRef-a","name":"a"}],
511 "annotations":[
512 {"annotator":"Person: Jane Reviewer","annotationType":"REVIEW",
513 "comment":"Manually reviewed licensing"},
514 {"annotator":"Tool: sbom-tools","annotationType":"REVIEW",
515 "comment":"Vulnerability CVE-2024-0001: A bad bug"}
516 ]
517 }"#;
518 let sbom = NormalizedSbom::default();
519 let config = TailorConfig {
520 strip_vulns: true,
521 ..Default::default()
522 };
523
524 let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
525 let doc: Value = serde_json::from_str(&result).unwrap();
526 let annots = doc["annotations"].as_array().unwrap();
527 assert_eq!(annots.len(), 1, "only the vuln annotation is removed");
528 assert_eq!(annots[0]["annotator"], "Person: Jane Reviewer");
529 }
530}