Skip to main content

sbom_tools/serialization/
pruner.rs

1//! SBOM tailoring / filtering.
2//!
3//! Removes components from an SBOM based on filter criteria,
4//! preserving the original format structure.
5
6use crate::model::{LicenseFamily, NormalizedSbom};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashSet;
10
11use super::ValueExt;
12
13/// Configuration for SBOM tailoring
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct TailorConfig {
16    /// Include only components matching these license families
17    pub include_license_families: Vec<LicenseFamily>,
18    /// Exclude components matching these ecosystems
19    pub exclude_ecosystems: Vec<String>,
20    /// Include only these component types (library, application, etc.)
21    pub include_types: Vec<String>,
22    /// Include only components matching this name pattern
23    pub include_name_pattern: Option<String>,
24    /// Include only these crypto asset types (algorithm, certificate, key, protocol)
25    pub include_crypto_types: Vec<String>,
26    /// Strip vulnerability data from output
27    pub strip_vulns: bool,
28    /// Strip extension/property data
29    pub strip_extensions: bool,
30}
31
32/// Tailor (filter) an SBOM by removing components that don't match the criteria.
33///
34/// Operates on raw JSON to preserve original format structure.
35///
36/// # Errors
37///
38/// Returns error if JSON parsing fails.
39pub 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    // Canonical identities (bom-ref / SPDXID / spdxId via format_id, plus
47    // purl) of components to remove. NEVER bare names: a same-named component
48    // from another ecosystem must survive (e.g. excluding foo@npm must keep
49    // foo@pypi).
50    let mut removal = RemovalSet::default();
51
52    for comp in sbom.components.values() {
53        let mut keep = true;
54
55        // Filter by license family
56        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        // Filter by ecosystem
69        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        // Filter by component type: accept CycloneDX spec values
83        // ("cryptographic-asset", "machine-learning-model", ...) as well as
84        // the internal Debug spellings, case-insensitively.
85        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        // Filter by name pattern (glob when the pattern contains `*`,
97        // substring otherwise)
98        if let Some(pattern) = &config.include_name_pattern
99            && !name_matches_pattern(&comp.name, pattern)
100        {
101            keep = false;
102        }
103
104        // Filter by crypto asset type
105        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                // No crypto properties — exclude if we're filtering by crypto type
117                keep = false;
118            }
119        }
120
121        if !keep {
122            removal.add(comp);
123        }
124    }
125
126    // Prune from CycloneDX
127    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/// Identities of components selected for removal.
139///
140/// Matching is by canonical identity (format-native id via `format_id`, or
141/// purl) — never bare name alone. A name-based fallback exists ONLY for model
142/// components that carry no distinguishing identity at all, and it is applied
143/// only to raw components that also lack canonical identifiers, so a
144/// same-named component from another ecosystem is never removed collaterally.
145#[derive(Default)]
146struct RemovalSet {
147    /// bom-ref / SPDXID / spdxId (format_id) and purl values
148    ids: HashSet<String>,
149    /// Names of removed components with no identity beyond their name
150    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        // Parsers fall back to the bare name for format_id when the source
162        // has no native id; only such identity-poor components may use the
163        // name fallback.
164        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    /// Does this raw CycloneDX component match a removed identity?
172    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    /// Does this raw SPDX 2.x package match a removed identity?
183    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    /// Does this raw SPDX 3.0 element match a removed identity?
190    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    /// Does a dependency/relationship reference point at a removed identity?
197    fn matches_ref(&self, reference: &str) -> bool {
198        !reference.is_empty() && self.ids.contains(reference)
199    }
200}
201
202/// Match a component name against an `--include-name` pattern.
203///
204/// A pattern containing `*` uses glob semantics (each `*` matches any run of
205/// characters, anchored at both ends); without `*` it is a plain substring
206/// match. Both are case-insensitive. The literal-`*` substring matching this
207/// replaces made the documented `--include-name "my-org/*"` example keep 0
208/// components.
209fn 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            // No leading `*`: segment is anchored at the start.
226            if !name.starts_with(seg) {
227                return false;
228            }
229            pos = seg.len();
230        } else if i == last {
231            // No trailing `*`: segment is anchored at the end (and must not
232            // overlap already-consumed input).
233            if !name.ends_with(seg) || name.len() - seg.len() < pos {
234                return false;
235            }
236            pos = name.len();
237        } else {
238            // Interior segment: first occurrence at or after `pos`.
239            match name[pos..].find(seg) {
240                Some(idx) => pos = pos + idx + seg.len(),
241                None => return false,
242            }
243        }
244    }
245    true
246}
247
248/// Normalize a component-type token so CycloneDX spec values
249/// ("machine-learning-model"), internal Debug spellings
250/// ("MachineLearningModel"), and the model's Display strings all compare
251/// equal. `cryptographic-asset` (the CycloneDX 1.6 spec value) maps to the
252/// internal `cryptographic`.
253fn 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    // Remove components
268    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    // Remove corresponding dependency entries
273    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        // Also remove from dependsOn arrays
277        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    // Strip vulnerabilities if requested
285    if config.strip_vulns {
286        doc.as_object_mut().map(|o| o.remove("vulnerabilities"));
287    }
288
289    // Strip extensions/properties if requested
290    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            // Only filter software packages, keep relationships and other elements
312            if !elem_type.contains("Package") && !elem_type.contains("package") {
313                // If stripping vulns, also remove vulnerability elements
314                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    // Remove packages
327    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    // Remove relationships referencing removed packages
332    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    // SPDX 2.x has no native vulnerability field; this tool's enricher
347    // records vulnerabilities as annotations shaped
348    // `annotator: "Tool: sbom-tools"` + `comment: "Vulnerability <id>: ..."`.
349    // Strip exactly that class — deleting the whole `annotations` array
350    // destroyed unrelated data (e.g. human REVIEW notes).
351    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    /// Excluding an ecosystem must remove only that ecosystem's component,
405    /// never a same-named component from another ecosystem.
406    #[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    /// The documented `--include-name "my-org/*"` example must keep matching
448    /// components (previously the `*` was matched literally, keeping 0).
449    #[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        // Overlap guard: prefix and suffix may not share characters.
459        assert!(!name_matches_pattern("foob", "foo*ob"));
460        // No `*` keeps plain substring semantics.
461        assert!(name_matches_pattern("my-org/pkg-a", "org/pkg"));
462        assert!(!name_matches_pattern("my-org/pkg-a", "other"));
463    }
464
465    /// `--include-types` must accept CycloneDX spec values case-insensitively
466    /// while the internal Debug spellings keep working.
467    #[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        // cryptographic-asset (spec) maps to the internal cryptographic
500        assert_eq!(normalize_type_token("cryptographic-asset"), "cryptographic");
501        assert_eq!(normalize_type_token("Cryptographic"), "cryptographic");
502    }
503
504    /// `--strip-vulns` on SPDX 2.x must remove only this tool's
505    /// vulnerability-shaped annotations, keeping human REVIEW notes.
506    #[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}