src/sbom.rs
//! CycloneDX 1.5 SBOM **emit** (feature `scan`).
//!
//! modgunn READS SBOMs from the warehouse (`warehouse::read_sbom_components`); this
//! module is the other direction — serializing a resolved component + vuln set into
//! a standards CycloneDX 1.5 document. It owns a small input shape ([`SbomInput`])
//! rather than reaching for a build-side type, so the emit is **multi-ecosystem**
//! from day one: the purl carries the ecosystem (`pkg:cargo/…`, `pkg:npm/…`).
//!
//! nornir's `SecurityReport::to_cyclonedx` reduces to a mapper into [`SbomInput`] +
//! a call here, so the verdict/SBOM serialization lives in exactly one place. For
//! the `cargo` ecosystem the output is byte-compatible with nornir's prior literal.
use serde_json::{json, Value};
/// One component in an SBOM: an ecosystem-qualified package with an optional
/// declared license. `license == None` ⇒ unknown (the `licenses` array is omitted).
pub struct SbomComponent {
pub ecosystem: String,
pub name: String,
pub version: String,
pub license: Option<String>,
}
/// One matched vulnerability against a component: the advisory ids plus a summary.
pub struct SbomVuln {
pub ecosystem: String,
pub name: String,
pub version: String,
pub ids: Vec<String>,
pub summary: String,
}
/// The input to [`to_cyclonedx`]: the SBOM subject (repo / archive name) plus its
/// resolved components and matched vulns.
pub struct SbomInput {
pub subject: String,
pub components: Vec<SbomComponent>,
pub vulns: Vec<SbomVuln>,
}
/// Package URL (purl) for a component: `pkg:{ecosystem}/{name}@{version}`, in the
/// **canonical** percent-encoded form the purl spec requires. The name/version path
/// segments are percent-encoded (RFC 3986 unreserved kept), so a scoped npm package
/// emits `pkg:npm/%40scope/name@…` — NOT the raw `@scope/name`, which a downstream
/// CycloneDX consumer (Grype / Dependency-Track normalize to `%40`) would fail to
/// join to its advisory, a silent false negative on every scoped npm package. The
/// `/` that separates a namespace from the name is preserved (it is a purl
/// separator, not a data character); for `maven` the stored `group:artifact` name's
/// `:` becomes that `/` separator, matching the canonical maven purl.
pub fn purl(ecosystem: &str, name: &str, version: &str) -> String {
// maven stores `group:artifact`; the canonical purl uses `/` as the
// namespace/name separator, not `:`.
let path = if ecosystem == "maven" {
name.replacen(':', "/", 1)
} else {
name.to_string()
};
// Encode each `/`-separated segment; the `/` (npm scope, maven group, go module
// path) is a purl separator and stays.
let encoded: Vec<String> = path.split('/').map(|seg| purl_encode(seg)).collect();
format!(
"pkg:{ecosystem}/{}@{}",
encoded.join("/"),
purl_encode(version)
)
}
/// The **inverse** of [`purl`]: parse `pkg:{type}/{namespace/}{name}@{version}` back
/// into `(ecosystem, name, version)`, undoing the canonical percent-encoding and the
/// maven `group/artifact` → `group:artifact` convention — so the purl grammar lives
/// in exactly ONE module in both directions and a round-trip cannot drift (L5).
///
/// This is what lets a purl-keyed feed join modgunn's `(ecosystem, package)` advisory
/// plane: OSS-Index reports (`coordinates`), CycloneDX SBOMs and the `vuln_findings`
/// key are all purls.
///
/// Handles the full purl shape: the optional `?qualifiers` and `#subpath` suffixes are
/// stripped, `%XX` escapes are decoded (so `pkg:npm/%40scope/name` reads back as the
/// raw `@scope/name`), and a namespace `/` is preserved as part of the name. Returns
/// `None` for anything that is not a `pkg:` URL with a type and a name; the version is
/// `""` when the purl carries none (a version-less purl is a *package*-level coord,
/// which the caller may still want).
pub fn parse_purl(purl: &str) -> Option<(String, String, String)> {
let rest = purl.trim().strip_prefix("pkg:")?;
// Drop the qualifiers / subpath — neither participates in the join key.
let rest = rest.split(['?', '#']).next()?;
let (ptype, path) = rest.split_once('/')?;
let ptype = ptype.trim();
if ptype.is_empty() || path.is_empty() {
return None;
}
// The version is after the LAST `@` — an npm scope's leading `@` lives in the
// first segment (and is normally `%40`-encoded, but be tolerant of a raw one).
let (name_path, version) = match path.rfind('@') {
// A leading `@` at position 0 is a raw npm scope, not a version separator.
Some(i) if i > 0 => (&path[..i], &path[i + 1..]),
_ => (path, ""),
};
let decoded: Vec<String> = name_path.split('/').map(percent_decode).collect();
if decoded.iter().all(|s| s.is_empty()) {
return None;
}
let mut name = decoded.join("/");
// maven's canonical purl uses `/` between group and artifact; modgunn stores
// `group:artifact` (the coord OSV uses).
if ptype == "maven" {
name = name.replacen('/', ":", 1);
}
Some((ptype.to_string(), name, percent_decode(version)))
}
/// Decode `%XX` escapes in one purl path segment. A malformed escape is kept
/// verbatim rather than dropped — losing bytes would silently corrupt a package name
/// into one that joins to nothing.
fn percent_decode(seg: &str) -> String {
let b = seg.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let hex = std::str::from_utf8(&b[i + 1..i + 3])
.ok()
.and_then(|h| u8::from_str_radix(h, 16).ok());
if let Some(byte) = hex {
out.push(byte);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Percent-encode one purl path segment per RFC 3986: the unreserved set
/// (`A-Za-z0-9-._~`) is kept, every other byte becomes `%XX` (upper-hex). Operates
/// on bytes so UTF-8 is encoded correctly.
fn purl_encode(seg: &str) -> String {
let mut out = String::with_capacity(seg.len());
for b in seg.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// Emit a CycloneDX 1.5 SBOM (components + vulnerabilities) for `input`.
pub fn to_cyclonedx(input: &SbomInput) -> Value {
json!({
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": { "component": { "type": "application", "name": input.subject } },
"components": input.components.iter().map(component_json).collect::<Vec<_>>(),
"vulnerabilities": input.vulns.iter().flat_map(vuln_json).collect::<Vec<_>>(),
})
}
fn component_json(c: &SbomComponent) -> Value {
let mut obj = json!({
"type": "library",
"name": c.name,
"version": c.version,
"purl": purl(&c.ecosystem, &c.name, &c.version),
});
// Append licenses last (mirrors nornir's literal key order) when known.
if let Some(lic) = &c.license {
obj["licenses"] = json!([{ "license": { "name": lic } }]);
}
obj
}
fn vuln_json(v: &SbomVuln) -> Vec<Value> {
// A matched vuln with no advisory id must NOT be structurally erased (an empty
// `ids` would make `flat_map` drop the whole vuln) — that is a fail-open. Fall
// back to a single placeholder id so the finding still appears in the SBOM. The
// key order (id, source, affects, description) is preserved for nornir byte-compat.
let ids: Vec<&str> = if v.ids.is_empty() {
vec!["UNKNOWN"]
} else {
v.ids.iter().map(String::as_str).collect()
};
ids.iter()
.map(|id| {
json!({
"id": id,
"source": { "name": "OSV", "url": format!("https://osv.dev/vulnerability/{id}") },
"affects": [{ "ref": purl(&v.ecosystem, &v.name, &v.version) }],
"description": v.summary,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// `parse_purl` is the inverse of `purl` — asserted by ROUND-TRIPPING every
/// awkward shape through both. Reverting either side's percent-encoding or the
/// maven `group:artifact` ↔ `group/artifact` convention breaks the identity, so
/// the two can never drift apart into a silent join miss.
#[test]
fn purl_parse_is_the_exact_inverse_of_purl_build() {
let cases = [
("cargo", "openssl", "0.10.55"),
("npm", "@scope/name", "1.2.3"), // scoped npm ⇒ %40 in the purl
("maven", "org.apache.logging.log4j:log4j-core", "2.14.1"), // group:artifact
("go", "github.com/foo/bar", "v1.2.3"), // multi-segment module path
("pypi", "zope.interface", "5.4.0"),
("gem", "rack", "2.2.3"),
("npm", "pkg with space", "1.0.0+build.1"), // encoded space AND `+`
];
for (eco, name, ver) in cases {
let built = purl(eco, name, ver);
assert_eq!(
parse_purl(&built),
Some((eco.to_string(), name.to_string(), ver.to_string())),
"round-trip failed for {built}"
);
}
// The scoped-npm case really does go through the encoded form.
assert_eq!(
purl("npm", "@scope/name", "1.2.3"),
"pkg:npm/%40scope/name@1.2.3"
);
// …and a raw (unencoded) scope from a sloppy producer still parses — the
// leading `@` is a scope, NOT a version separator.
assert_eq!(
parse_purl("pkg:npm/@scope/name@1.2.3"),
Some(("npm".into(), "@scope/name".into(), "1.2.3".into()))
);
// Qualifiers + subpath are stripped; they are not part of the join key.
assert_eq!(
parse_purl("pkg:cargo/openssl@0.10.55?repository_url=x#sub/dir"),
Some(("cargo".into(), "openssl".into(), "0.10.55".into()))
);
// A version-less purl is a package-level coord, not a parse failure.
assert_eq!(
parse_purl("pkg:npm/lodash"),
Some(("npm".into(), "lodash".into(), String::new()))
);
// Garbage is None — never a bogus coord that joins to the wrong package.
assert_eq!(parse_purl("openssl@0.10.55"), None);
assert_eq!(parse_purl("pkg:cargo"), None);
assert_eq!(parse_purl("pkg:/openssl@1.0"), None);
assert_eq!(parse_purl(""), None);
// A malformed escape is preserved verbatim rather than silently dropped.
assert_eq!(
parse_purl("pkg:npm/a%ZZb@1.0"),
Some(("npm".into(), "a%ZZb".into(), "1.0".into()))
);
}
/// The cargo emit is byte-compatible with nornir's prior `to_cyclonedx`
/// literal: `pkg:cargo/…` purls, a `licenses` array carrying the raw name
/// (including `NOASSERTION`), and one `vulnerabilities` entry per advisory id.
#[test]
fn cyclonedx_cargo_is_byte_compatible() {
let input = SbomInput {
subject: "myrepo".into(),
components: vec![
SbomComponent {
ecosystem: "cargo".into(),
name: "serde".into(),
version: "1.0.0".into(),
license: Some("MIT OR Apache-2.0".into()),
},
SbomComponent {
ecosystem: "cargo".into(),
name: "openssl".into(),
version: "0.10.55".into(),
license: Some("NOASSERTION".into()),
},
],
vulns: vec![SbomVuln {
ecosystem: "cargo".into(),
name: "openssl".into(),
version: "0.10.55".into(),
ids: vec!["RUSTSEC-2023-0044".into()],
summary: "use-after-free".into(),
}],
};
let want = json!({
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": { "component": { "type": "application", "name": "myrepo" } },
"components": [
{
"type": "library",
"name": "serde",
"version": "1.0.0",
"purl": "pkg:cargo/serde@1.0.0",
"licenses": [{ "license": { "name": "MIT OR Apache-2.0" } }],
},
{
"type": "library",
"name": "openssl",
"version": "0.10.55",
"purl": "pkg:cargo/openssl@0.10.55",
"licenses": [{ "license": { "name": "NOASSERTION" } }],
},
],
"vulnerabilities": [
{
"id": "RUSTSEC-2023-0044",
"source": { "name": "OSV", "url": "https://osv.dev/vulnerability/RUSTSEC-2023-0044" },
"affects": [{ "ref": "pkg:cargo/openssl@0.10.55" }],
"description": "use-after-free",
},
],
});
assert_eq!(to_cyclonedx(&input), want);
}
/// The purl carries the ecosystem, so a non-cargo component emits `pkg:npm/…`,
/// and an unknown license omits the `licenses` array entirely.
#[test]
fn cyclonedx_multi_ecosystem_purl_and_unknown_license() {
let input = SbomInput {
subject: "app".into(),
components: vec![SbomComponent {
ecosystem: "npm".into(),
name: "left-pad".into(),
version: "1.3.0".into(),
license: None,
}],
vulns: vec![],
};
let out = to_cyclonedx(&input);
let comp = &out["components"][0];
assert_eq!(comp["purl"], "pkg:npm/left-pad@1.3.0");
assert!(
comp.get("licenses").is_none(),
"unknown license ⇒ no licenses array"
);
}
/// RED-when-reverted: a scoped npm package's purl MUST percent-encode the scope
/// `@` → `%40` (canonical purl), else a normalizing consumer won't join the
/// advisory. The namespace `/` is preserved. Maven's stored `group:artifact`
/// becomes the canonical `group/artifact` namespace form.
#[test]
fn purl_is_canonical_percent_encoded() {
assert_eq!(
purl("npm", "@angular/core", "17.0.0"),
"pkg:npm/%40angular/core@17.0.0"
);
assert_eq!(
purl("maven", "org.apache.logging.log4j:log4j-core", "2.14.1"),
"pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
);
// A go module path keeps its `/` separators (they are purl separators).
assert_eq!(
purl("go", "github.com/pkg/errors", "v0.9.1"),
"pkg:go/github.com/pkg/errors@v0.9.1"
);
// Plain names/versions with only unreserved chars are unchanged (cargo compat).
assert_eq!(purl("cargo", "serde", "1.0.0"), "pkg:cargo/serde@1.0.0");
// The scoped ref reaches the vuln `affects` too, not just the component.
let input = SbomInput {
subject: "app".into(),
components: vec![],
vulns: vec![SbomVuln {
ecosystem: "npm".into(),
name: "@babel/traverse".into(),
version: "7.23.0".into(),
ids: vec!["CVE-2023-45133".into()],
summary: "arbitrary code execution".into(),
}],
};
let out = to_cyclonedx(&input);
assert_eq!(
out["vulnerabilities"][0]["affects"][0]["ref"],
"pkg:npm/%40babel/traverse@7.23.0"
);
}
/// RED-when-reverted: a matched vuln that carries no advisory id must still emit
/// an SBOM entry (was silently dropped by `flat_map` over an empty `ids`).
#[test]
fn idless_vuln_is_not_dropped() {
let input = SbomInput {
subject: "app".into(),
components: vec![],
vulns: vec![SbomVuln {
ecosystem: "npm".into(),
name: "left-pad".into(),
version: "1.3.0".into(),
ids: vec![],
summary: "matched, id pending".into(),
}],
};
let out = to_cyclonedx(&input);
assert_eq!(
out["vulnerabilities"].as_array().unwrap().len(),
1,
"id-less vuln preserved"
);
assert_eq!(
out["vulnerabilities"][0]["description"],
"matched, id pending"
);
}
}