use crate::model::{LicenseFamily, NormalizedSbom};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
use super::ValueExt;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TailorConfig {
pub include_license_families: Vec<LicenseFamily>,
pub exclude_ecosystems: Vec<String>,
pub include_types: Vec<String>,
pub include_name_pattern: Option<String>,
pub include_crypto_types: Vec<String>,
pub strip_vulns: bool,
pub strip_extensions: bool,
}
pub fn tailor_sbom_json(
raw_json: &str,
sbom: &NormalizedSbom,
config: &TailorConfig,
) -> anyhow::Result<String> {
let mut doc: Value = serde_json::from_str(raw_json)?;
let mut removal = RemovalSet::default();
for comp in sbom.components.values() {
let mut keep = true;
if !config.include_license_families.is_empty() {
let family = comp
.licenses
.declared
.first()
.map(|l| l.family())
.unwrap_or(LicenseFamily::Other);
if !config.include_license_families.contains(&family) {
keep = false;
}
}
if !config.exclude_ecosystems.is_empty()
&& let Some(eco) = &comp.ecosystem
{
let eco_str = format!("{eco:?}").to_lowercase();
if config
.exclude_ecosystems
.iter()
.any(|e| e.to_lowercase() == eco_str)
{
keep = false;
}
}
if !config.include_types.is_empty() {
let comp_type = normalize_type_token(&comp.component_type.to_string());
if !config
.include_types
.iter()
.any(|t| normalize_type_token(t) == comp_type)
{
keep = false;
}
}
if let Some(pattern) = &config.include_name_pattern
&& !name_matches_pattern(&comp.name, pattern)
{
keep = false;
}
if !config.include_crypto_types.is_empty() {
if let Some(cp) = &comp.crypto_properties {
let asset_str = cp.asset_type.to_string().to_lowercase();
if !config
.include_crypto_types
.iter()
.any(|t| t.to_lowercase() == asset_str)
{
keep = false;
}
} else {
keep = false;
}
}
if !keep {
removal.add(comp);
}
}
if doc.get("bomFormat").is_some() {
prune_cyclonedx(&mut doc, &removal, config);
} else if doc.get("@context").is_some() {
prune_spdx3(&mut doc, &removal, config);
} else {
prune_spdx2(&mut doc, &removal, config);
}
Ok(serde_json::to_string_pretty(&doc)?)
}
#[derive(Default)]
struct RemovalSet {
ids: HashSet<String>,
name_fallback: HashSet<String>,
}
impl RemovalSet {
fn add(&mut self, comp: &crate::model::Component) {
if !comp.identifiers.format_id.is_empty() {
self.ids.insert(comp.identifiers.format_id.clone());
}
if let Some(purl) = &comp.identifiers.purl {
self.ids.insert(purl.clone());
}
if comp.identifiers.purl.is_none()
&& (comp.identifiers.format_id.is_empty() || comp.identifiers.format_id == comp.name)
{
self.name_fallback.insert(comp.name.clone());
}
}
fn matches_cyclonedx(&self, comp: &Value) -> bool {
let bom_ref = comp.str_field("bom-ref");
let purl = comp.str_field("purl");
(!bom_ref.is_empty() && self.ids.contains(bom_ref))
|| (!purl.is_empty() && self.ids.contains(purl))
|| (bom_ref.is_empty()
&& purl.is_empty()
&& self.name_fallback.contains(comp.str_field("name")))
}
fn matches_spdx2(&self, pkg: &Value) -> bool {
let spdx_id = pkg.str_field("SPDXID");
(!spdx_id.is_empty() && self.ids.contains(spdx_id))
|| (spdx_id.is_empty() && self.name_fallback.contains(pkg.str_field("name")))
}
fn matches_spdx3(&self, elem: &Value) -> bool {
let spdx_id = elem.str_field("spdxId");
(!spdx_id.is_empty() && self.ids.contains(spdx_id))
|| (spdx_id.is_empty() && self.name_fallback.contains(elem.str_field("name")))
}
fn matches_ref(&self, reference: &str) -> bool {
!reference.is_empty() && self.ids.contains(reference)
}
}
fn name_matches_pattern(name: &str, pattern: &str) -> bool {
let name = name.to_lowercase();
let pattern = pattern.to_lowercase();
if !pattern.contains('*') {
return name.contains(&pattern);
}
let segments: Vec<&str> = pattern.split('*').collect();
let last = segments.len() - 1;
let mut pos = 0usize;
for (i, seg) in segments.iter().enumerate() {
if seg.is_empty() {
continue;
}
if i == 0 {
if !name.starts_with(seg) {
return false;
}
pos = seg.len();
} else if i == last {
if !name.ends_with(seg) || name.len() - seg.len() < pos {
return false;
}
pos = name.len();
} else {
match name[pos..].find(seg) {
Some(idx) => pos = pos + idx + seg.len(),
None => return false,
}
}
}
true
}
fn normalize_type_token(token: &str) -> String {
let normalized: String = token
.chars()
.filter(|c| *c != '-' && *c != '_')
.collect::<String>()
.to_lowercase();
if normalized == "cryptographicasset" {
"cryptographic".to_string()
} else {
normalized
}
}
fn prune_cyclonedx(doc: &mut Value, removal: &RemovalSet, config: &TailorConfig) {
if let Some(components) = doc.get_mut("components").and_then(Value::as_array_mut) {
components.retain(|comp| !removal.matches_cyclonedx(comp));
}
if let Some(deps) = doc.get_mut("dependencies").and_then(Value::as_array_mut) {
deps.retain(|dep| !removal.matches_ref(dep.str_field("ref")));
for dep in deps.iter_mut() {
if let Some(depends_on) = dep.get_mut("dependsOn").and_then(Value::as_array_mut) {
depends_on.retain(|d| !removal.matches_ref(d.as_str().unwrap_or("")));
}
}
}
if config.strip_vulns {
doc.as_object_mut().map(|o| o.remove("vulnerabilities"));
}
if config.strip_extensions
&& let Some(components) = doc.get_mut("components").and_then(Value::as_array_mut)
{
for comp in components {
comp.as_object_mut().map(|o| o.remove("properties"));
}
}
}
fn prune_spdx3(doc: &mut Value, removal: &RemovalSet, config: &TailorConfig) {
let key = if doc.get("element").is_some() {
"element"
} else {
"@graph"
};
let elements = doc.get_mut(key).and_then(Value::as_array_mut);
if let Some(elems) = elements {
elems.retain(|elem| {
let elem_type = elem.str_field("type");
if !elem_type.contains("Package") && !elem_type.contains("package") {
if config.strip_vulns && elem_type.contains("Vulnerability") {
return false;
}
return true;
}
!removal.matches_spdx3(elem)
});
}
}
fn prune_spdx2(doc: &mut Value, removal: &RemovalSet, config: &TailorConfig) {
if let Some(packages) = doc.get_mut("packages").and_then(Value::as_array_mut) {
packages.retain(|pkg| !removal.matches_spdx2(pkg));
}
if let Some(rels) = doc.get_mut("relationships").and_then(Value::as_array_mut) {
rels.retain(|rel| {
let elem = rel
.get("spdxElementId")
.and_then(Value::as_str)
.unwrap_or("");
let related = rel
.get("relatedSpdxElement")
.and_then(Value::as_str)
.unwrap_or("");
!removal.matches_ref(elem) && !removal.matches_ref(related)
});
}
if config.strip_vulns
&& let Some(annots) = doc.get_mut("annotations").and_then(Value::as_array_mut)
{
annots.retain(|annotation| {
!(annotation.str_field("annotator") == "Tool: sbom-tools"
&& annotation
.str_field("comment")
.starts_with("Vulnerability "))
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Component;
#[test]
fn tailor_by_name_pattern() {
let raw = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"bom-ref":"id-keep","name":"keep-me","version":"1.0"},
{"bom-ref":"id-remove","name":"remove-me","version":"2.0"}
]}"#;
let mut sbom = NormalizedSbom::default();
let keep = Component::new("keep-me".to_string(), "id-keep".to_string());
let remove = Component::new("remove-me".to_string(), "id-remove".to_string());
sbom.components.insert(keep.canonical_id.clone(), keep);
sbom.components.insert(remove.canonical_id.clone(), remove);
let config = TailorConfig {
include_name_pattern: Some("keep".to_string()),
..Default::default()
};
let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
assert!(result.contains("keep-me"));
assert!(!result.contains("remove-me"));
}
#[test]
fn strip_vulns() {
let raw = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[],"vulnerabilities":[{"id":"CVE-1"}]}"#;
let sbom = NormalizedSbom::default();
let config = TailorConfig {
strip_vulns: true,
..Default::default()
};
let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
assert!(!result.contains("vulnerabilities"));
}
#[test]
fn exclude_ecosystem_keeps_same_name_other_ecosystem() {
use crate::model::Ecosystem;
let raw = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"bom-ref":"foo-npm","name":"foo","version":"1.0","purl":"pkg:npm/foo@1.0"},
{"bom-ref":"foo-pypi","name":"foo","version":"2.0","purl":"pkg:pypi/foo@2.0"}
]}"#;
let mut sbom = NormalizedSbom::default();
let mut foo_npm = Component::new("foo".to_string(), "foo-npm".to_string())
.with_purl("pkg:npm/foo@1.0".to_string());
foo_npm.ecosystem = Some(Ecosystem::Npm);
let mut foo_pypi = Component::new("foo".to_string(), "foo-pypi".to_string())
.with_purl("pkg:pypi/foo@2.0".to_string());
foo_pypi.ecosystem = Some(Ecosystem::PyPi);
sbom.components
.insert(foo_npm.canonical_id.clone(), foo_npm);
sbom.components
.insert(foo_pypi.canonical_id.clone(), foo_pypi);
let config = TailorConfig {
exclude_ecosystems: vec!["npm".to_string()],
..Default::default()
};
let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
let kept: Vec<&str> = doc["components"]
.as_array()
.unwrap()
.iter()
.filter_map(|c| c["purl"].as_str())
.collect();
assert_eq!(
kept,
vec!["pkg:pypi/foo@2.0"],
"foo@pypi must survive excluding npm"
);
}
#[test]
fn include_name_glob_pattern() {
assert!(name_matches_pattern("my-org/pkg-a", "my-org/*"));
assert!(name_matches_pattern("my-org/pkg-b", "MY-ORG/*"));
assert!(!name_matches_pattern("other/pkg-c", "my-org/*"));
assert!(name_matches_pattern("libfoo-core", "*foo*"));
assert!(name_matches_pattern("foo-middle-bar", "foo*bar"));
assert!(!name_matches_pattern("foo-middle-baz", "foo*bar"));
assert!(!name_matches_pattern("xfoobar", "foo*bar"));
assert!(!name_matches_pattern("foob", "foo*ob"));
assert!(name_matches_pattern("my-org/pkg-a", "org/pkg"));
assert!(!name_matches_pattern("my-org/pkg-a", "other"));
}
#[test]
fn include_types_accepts_spec_and_debug_spellings() {
use crate::model::ComponentType;
let raw = r#"{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
{"bom-ref":"lib1","name":"libfoo","version":"1.0"},
{"bom-ref":"mlm","name":"bert-base","version":"1.0"}
]}"#;
let mut sbom = NormalizedSbom::default();
let lib = Component::new("libfoo".to_string(), "lib1".to_string());
let mut mlm = Component::new("bert-base".to_string(), "mlm".to_string());
mlm.component_type = ComponentType::MachineLearningModel;
sbom.components.insert(lib.canonical_id.clone(), lib);
sbom.components.insert(mlm.canonical_id.clone(), mlm);
for spelling in ["machine-learning-model", "MachineLearningModel"] {
let config = TailorConfig {
include_types: vec![spelling.to_string()],
..Default::default()
};
let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
let kept: Vec<&str> = doc["components"]
.as_array()
.unwrap()
.iter()
.filter_map(|c| c["name"].as_str())
.collect();
assert_eq!(kept, vec!["bert-base"], "spelling {spelling} must match");
}
assert_eq!(normalize_type_token("cryptographic-asset"), "cryptographic");
assert_eq!(normalize_type_token("Cryptographic"), "cryptographic");
}
#[test]
fn strip_vulns_spdx2_keeps_non_vuln_annotations() {
let raw = r#"{
"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT",
"packages":[{"SPDXID":"SPDXRef-a","name":"a"}],
"annotations":[
{"annotator":"Person: Jane Reviewer","annotationType":"REVIEW",
"comment":"Manually reviewed licensing"},
{"annotator":"Tool: sbom-tools","annotationType":"REVIEW",
"comment":"Vulnerability CVE-2024-0001: A bad bug"}
]
}"#;
let sbom = NormalizedSbom::default();
let config = TailorConfig {
strip_vulns: true,
..Default::default()
};
let result = tailor_sbom_json(raw, &sbom, &config).unwrap();
let doc: Value = serde_json::from_str(&result).unwrap();
let annots = doc["annotations"].as_array().unwrap();
assert_eq!(annots.len(), 1, "only the vuln annotation is removed");
assert_eq!(annots[0]["annotator"], "Person: Jane Reviewer");
}
}