Skip to main content

npm_utils/
sbom.rs

1//! Software bill of materials + license output for a parsed `package-lock.json`.
2//!
3//! The packages a lockfile pins ([`crate::package_json::lock`]) become a vendor-neutral bill of
4//! materials — a set of [`Component`]s — that this module renders three ways:
5//!
6//! - [`render_summary`] — a plain-text license overview (which packages are under which license).
7//! - [`to_cyclonedx`] — a [CycloneDX] 1.6 JSON document.
8//! - [`to_spdx`] — an [SPDX] 2.3 JSON document.
9//!
10//! All three are pure (no IO, no network): a CLI or build script turns a *committed*
11//! `package-lock.json` into compliance artifacts with no Node, no npm. The license + integrity a
12//! component carries are exactly what the lockfile records (npm writes both per package, and so
13//! does this crate's [`crate::package_json::lock::render_v3`]).
14//!
15//! [CycloneDX]: https://cyclonedx.org
16//! [SPDX]: https://spdx.dev
17//!
18//! ```no_run
19//! use npm_utils::{package_json::lock::Lockfile, sbom};
20//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
21//! let lock = Lockfile::parse(&std::fs::read_to_string("package-lock.json")?)?;
22//! let bom = sbom::components(&lock);
23//! print!("{}", sbom::render_summary(&bom));
24//! std::fs::write("sbom.cdx.json", sbom::to_cyclonedx(&bom, "my-app", "1.0.0", None))?;
25//! # Ok(()) }
26//! ```
27
28use std::collections::BTreeMap;
29
30use base64::Engine as _;
31use serde_json::{json, Map, Value};
32
33use crate::package_json::lock::Lockfile;
34
35/// The SPDX/compliance sentinel for "no license/value asserted".
36const NOASSERTION: &str = "NOASSERTION";
37
38/// One package in the bill of materials, distilled from a
39/// [`LockedPackage`](crate::package_json::lock::LockedPackage).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Component {
42    /// Package name (scoped names keep their `@scope/` prefix).
43    pub name: String,
44    /// Exact version.
45    pub version: String,
46    /// Package URL, e.g. `pkg:npm/lit@3.3.3` or `pkg:npm/%40lit/context@1.1.6`.
47    pub purl: String,
48    /// Declared SPDX license string, when the lockfile records one.
49    pub license: Option<String>,
50    /// Resolved download location (the registry tarball URL), when present.
51    pub resolved: Option<String>,
52    /// `sha512-<base64>` Subresource-Integrity, when present.
53    pub integrity: Option<String>,
54}
55
56/// The real (non-root, non-link) packages a lockfile pins, as bill-of-materials components,
57/// sorted by name then version. The root `""` project entry and `link: true` workspace/`file:`
58/// links are excluded — a link is a local path, not a distributable package.
59pub fn components(lock: &Lockfile) -> Vec<Component> {
60    let mut out: Vec<Component> = lock
61        .packages
62        .iter()
63        .filter(|p| p.key.starts_with("node_modules/") && !p.link)
64        .map(|p| Component {
65            purl: npm_purl(&p.name, &p.version),
66            name: p.name.clone(),
67            version: p.version.clone(),
68            license: p.license.clone(),
69            resolved: p.resolved.clone(),
70            integrity: p.integrity.clone(),
71        })
72        .collect();
73    out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
74    out
75}
76
77/// Group component `name@version`s by their declared license. Components with no declared license
78/// fall under [`NOASSERTION`]. Keys (licenses) and values (packages) are both sorted.
79pub fn license_summary(components: &[Component]) -> BTreeMap<String, Vec<String>> {
80    let mut by_license: BTreeMap<String, Vec<String>> = BTreeMap::new();
81    for c in components {
82        let key = c.license.clone().unwrap_or_else(|| NOASSERTION.to_string());
83        by_license
84            .entry(key)
85            .or_default()
86            .push(format!("{}@{}", c.name, c.version));
87    }
88    for pkgs in by_license.values_mut() {
89        pkgs.sort();
90    }
91    by_license
92}
93
94/// A plain-text license overview: a header, then each license with the packages under it.
95pub fn render_summary(components: &[Component]) -> String {
96    use std::fmt::Write as _;
97    let by = license_summary(components);
98    let mut s = String::new();
99    let _ = writeln!(
100        s,
101        "{} package(s) across {} license(s)",
102        components.len(),
103        by.len()
104    );
105    for (license, pkgs) in &by {
106        let _ = write!(s, "\n{license} ({})\n", pkgs.len());
107        for p in pkgs {
108            let _ = writeln!(s, "  {p}");
109        }
110    }
111    s
112}
113
114/// Render a [CycloneDX] 1.6 JSON SBOM. `app_name`/`app_version` describe the root component;
115/// `timestamp` is an RFC 3339 string for `metadata.timestamp` (or `None` to omit it — useful for
116/// reproducible output and tests).
117pub fn to_cyclonedx(
118    components: &[Component],
119    app_name: &str,
120    app_version: &str,
121    timestamp: Option<&str>,
122) -> String {
123    let mut metadata = Map::new();
124    if let Some(ts) = timestamp {
125        metadata.insert("timestamp".into(), json!(ts));
126    }
127    metadata.insert(
128        "tools".into(),
129        json!({ "components": [{
130            "type": "application",
131            "name": env!("CARGO_PKG_NAME"),
132            "version": env!("CARGO_PKG_VERSION"),
133        }] }),
134    );
135    metadata.insert(
136        "component".into(),
137        json!({
138            "type": "application",
139            "bom-ref": format!("{app_name}@{app_version}"),
140            "name": app_name,
141            "version": app_version,
142        }),
143    );
144
145    let comps: Vec<Value> = components
146        .iter()
147        .map(|c| {
148            let mut m = Map::new();
149            m.insert("type".into(), json!("library"));
150            m.insert("bom-ref".into(), json!(c.purl));
151            m.insert("name".into(), json!(c.name));
152            m.insert("version".into(), json!(c.version));
153            if let Some(lic) = &c.license {
154                m.insert("licenses".into(), cyclonedx_licenses(lic));
155            }
156            m.insert("purl".into(), json!(c.purl));
157            if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
158                m.insert(
159                    "hashes".into(),
160                    json!([{ "alg": "SHA-512", "content": hex }]),
161                );
162            }
163            Value::Object(m)
164        })
165        .collect();
166
167    let doc = json!({
168        "bomFormat": "CycloneDX",
169        "specVersion": "1.6",
170        "version": 1,
171        "metadata": Value::Object(metadata),
172        "components": comps,
173    });
174    let mut s = serde_json::to_string_pretty(&doc).expect("serialize CycloneDX");
175    s.push('\n');
176    s
177}
178
179/// Render an [SPDX] 2.3 JSON SBOM. `name`/`namespace` are the document name and its unique
180/// `documentNamespace` URI; `created` is the RFC 3339 creation time (SPDX requires it).
181pub fn to_spdx(components: &[Component], name: &str, namespace: &str, created: &str) -> String {
182    let packages: Vec<Value> = components
183        .iter()
184        .map(|c| {
185            let mut m = Map::new();
186            m.insert(
187                "SPDXID".into(),
188                json!(format!(
189                    "SPDXRef-Package-{}-{}",
190                    spdx_id_fragment(&c.name),
191                    spdx_id_fragment(&c.version)
192                )),
193            );
194            m.insert("name".into(), json!(c.name));
195            m.insert("versionInfo".into(), json!(c.version));
196            m.insert(
197                "downloadLocation".into(),
198                json!(c.resolved.as_deref().unwrap_or(NOASSERTION)),
199            );
200            m.insert("filesAnalyzed".into(), json!(false));
201            m.insert("licenseConcluded".into(), json!(NOASSERTION));
202            m.insert(
203                "licenseDeclared".into(),
204                json!(c.license.as_deref().unwrap_or(NOASSERTION)),
205            );
206            m.insert("copyrightText".into(), json!(NOASSERTION));
207            m.insert(
208                "externalRefs".into(),
209                json!([{
210                    "referenceCategory": "PACKAGE-MANAGER",
211                    "referenceType": "purl",
212                    "referenceLocator": c.purl,
213                }]),
214            );
215            if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
216                m.insert(
217                    "checksums".into(),
218                    json!([{ "algorithm": "SHA512", "checksumValue": hex }]),
219                );
220            }
221            Value::Object(m)
222        })
223        .collect();
224
225    let doc = json!({
226        "spdxVersion": "SPDX-2.3",
227        "dataLicense": "CC0-1.0",
228        "SPDXID": "SPDXRef-DOCUMENT",
229        "name": name,
230        "documentNamespace": namespace,
231        "creationInfo": {
232            "created": created,
233            "creators": [format!("Tool: {}-{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))],
234        },
235        "packages": packages,
236    });
237    let mut s = serde_json::to_string_pretty(&doc).expect("serialize SPDX");
238    s.push('\n');
239    s
240}
241
242/// The current UTC time as an RFC 3339 timestamp (second precision), for stamping a freshly
243/// generated SBOM — via the `time` crate.
244pub fn now_rfc3339() -> String {
245    let now = time::OffsetDateTime::now_utc();
246    now.replace_nanosecond(0)
247        .unwrap_or(now)
248        .format(&time::format_description::well_known::Rfc3339)
249        .expect("format current time as RFC 3339")
250}
251
252/// `pkg:npm/<name>@<version>` — a [Package URL]. A scoped `@scope/name` percent-encodes its
253/// leading `@` to `%40`, per the npm purl type.
254///
255/// [Package URL]: https://github.com/package-url/purl-spec
256fn npm_purl(name: &str, version: &str) -> String {
257    let path = match name.strip_prefix('@') {
258        Some(rest) => format!("%40{rest}"),
259        None => name.to_string(),
260    };
261    format!("pkg:npm/{path}@{version}")
262}
263
264/// CycloneDX `licenses` for one declared license string: a single SPDX id becomes
265/// `[{ license: { id } }]`; anything that looks like an SPDX *expression* (contains `OR`/`AND`/
266/// `WITH`/parens) becomes `[{ expression }]`.
267fn cyclonedx_licenses(license: &str) -> Value {
268    if is_spdx_expression(license) {
269        json!([{ "expression": license }])
270    } else {
271        json!([{ "license": { "id": license } }])
272    }
273}
274
275fn is_spdx_expression(license: &str) -> bool {
276    license.contains(" OR ")
277        || license.contains(" AND ")
278        || license.contains(" WITH ")
279        || license.contains('(')
280}
281
282/// Decode a `sha512-<base64>` Subresource-Integrity into lowercase hex (the form CycloneDX
283/// `hashes`/SPDX `checksums` want). `None` for a missing, non-sha512, or malformed value.
284fn sri_to_sha512_hex(integrity: Option<&str>) -> Option<String> {
285    let b64 = integrity?.strip_prefix("sha512-")?;
286    let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
287    Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
288}
289
290/// Sanitize a string into the `SPDXRef-` id charset (`[a-zA-Z0-9.-]`): every other character
291/// becomes `-`. Keeps SPDXIDs valid for scoped names (`@lit/context` → `-lit-context`).
292fn spdx_id_fragment(s: &str) -> String {
293    s.chars()
294        .map(|c| {
295            if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
296                c
297            } else {
298                '-'
299            }
300        })
301        .collect()
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    /// A small v3 lockfile: a scoped + an unscoped package (one licensed with integrity, one
309    /// license-less), plus the root and a `link` that must be excluded.
310    const SAMPLE: &str = r#"{
311        "name": "demo", "version": "1.0.0", "lockfileVersion": 3,
312        "packages": {
313            "": { "name": "demo", "version": "1.0.0" },
314            "node_modules/lit": {
315                "version": "3.3.3",
316                "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
317                "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
318                "license": "BSD-3-Clause"
319            },
320            "node_modules/@lit/context": {
321                "version": "1.1.6",
322                "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz",
323                "license": "BSD-3-Clause"
324            },
325            "node_modules/mystery": { "version": "0.0.1" },
326            "node_modules/local-link": { "version": "1.0.0", "link": true }
327        }
328    }"#;
329
330    fn sample_components() -> Vec<Component> {
331        components(&Lockfile::parse(SAMPLE).unwrap())
332    }
333
334    #[test]
335    fn components_skip_root_and_links_and_build_purls() {
336        let c = sample_components();
337        let names: Vec<&str> = c.iter().map(|x| x.name.as_str()).collect();
338        // Root "" and the `link: true` entry are excluded; sorted by name.
339        assert_eq!(names, ["@lit/context", "lit", "mystery"]);
340        // Scoped purl percent-encodes the leading `@`.
341        assert_eq!(c[0].purl, "pkg:npm/%40lit/context@1.1.6");
342        assert_eq!(c[1].purl, "pkg:npm/lit@3.3.3");
343    }
344
345    #[test]
346    fn license_summary_groups_and_marks_unknown_as_noassertion() {
347        let summary = license_summary(&sample_components());
348        assert_eq!(
349            summary.get("BSD-3-Clause").unwrap(),
350            &["@lit/context@1.1.6", "lit@3.3.3"]
351        );
352        // A package with no declared license is grouped under NOASSERTION.
353        assert_eq!(summary.get("NOASSERTION").unwrap(), &["mystery@0.0.1"]);
354    }
355
356    #[test]
357    fn cyclonedx_has_required_shape_purls_licenses_and_sha512_hash() {
358        let json: Value =
359            serde_json::from_str(&to_cyclonedx(&sample_components(), "demo", "1.0.0", None))
360                .unwrap();
361        assert_eq!(json["bomFormat"], "CycloneDX");
362        assert_eq!(json["specVersion"], "1.6");
363        // No timestamp requested → key omitted (reproducible).
364        assert!(json["metadata"].get("timestamp").is_none());
365
366        let lit = &json["components"][1];
367        assert_eq!(lit["name"], "lit");
368        assert_eq!(lit["purl"], "pkg:npm/lit@3.3.3");
369        assert_eq!(lit["licenses"][0]["license"]["id"], "BSD-3-Clause");
370        // sha512-<base64> decoded to 64 bytes → 128 lowercase hex chars.
371        let hex = lit["hashes"][0]["content"].as_str().unwrap();
372        assert_eq!(lit["hashes"][0]["alg"], "SHA-512");
373        assert_eq!(hex.len(), 128);
374        assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()));
375        // The license-less package carries neither a licenses nor a hashes key.
376        let mystery = &json["components"][2];
377        assert_eq!(mystery["name"], "mystery");
378        assert!(mystery.get("licenses").is_none());
379        assert!(mystery.get("hashes").is_none());
380    }
381
382    #[test]
383    fn cyclonedx_uses_expression_for_compound_licenses() {
384        let comps = vec![Component {
385            name: "dual".into(),
386            version: "1.0.0".into(),
387            purl: "pkg:npm/dual@1.0.0".into(),
388            license: Some("MIT OR Apache-2.0".into()),
389            resolved: None,
390            integrity: None,
391        }];
392        let json: Value = serde_json::from_str(&to_cyclonedx(&comps, "x", "1", None)).unwrap();
393        assert_eq!(
394            json["components"][0]["licenses"][0]["expression"],
395            "MIT OR Apache-2.0"
396        );
397    }
398
399    #[test]
400    fn spdx_has_required_shape_ids_purls_and_license() {
401        let json: Value = serde_json::from_str(&to_spdx(
402            &sample_components(),
403            "demo-sbom",
404            "https://spdx.example/demo",
405            "2026-01-01T00:00:00Z",
406        ))
407        .unwrap();
408        assert_eq!(json["spdxVersion"], "SPDX-2.3");
409        assert_eq!(json["SPDXID"], "SPDXRef-DOCUMENT");
410        assert_eq!(json["creationInfo"]["created"], "2026-01-01T00:00:00Z");
411
412        // Scoped name sanitized into a valid SPDXID.
413        let scoped = &json["packages"][0];
414        assert_eq!(scoped["SPDXID"], "SPDXRef-Package--lit-context-1.1.6");
415        assert_eq!(scoped["licenseDeclared"], "BSD-3-Clause");
416        assert_eq!(
417            scoped["externalRefs"][0]["referenceLocator"],
418            "pkg:npm/%40lit/context@1.1.6"
419        );
420        // License-less package → NOASSERTION.
421        assert_eq!(json["packages"][2]["licenseDeclared"], "NOASSERTION");
422    }
423
424    #[test]
425    fn sri_decodes_to_64_byte_sha512_hex() {
426        let hex = sri_to_sha512_hex(Some(
427            "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
428        ))
429        .unwrap();
430        assert_eq!(hex.len(), 128); // 64 bytes
431        assert!(sri_to_sha512_hex(Some("sha1-deadbeef")).is_none()); // not sha512
432        assert!(sri_to_sha512_hex(None).is_none());
433    }
434}