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 + Send + Sync>> {
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 — one component per distinct `name@version` (a workspace lock
58/// can pin the same version at several paths). Every entry under a `node_modules/` segment
59/// counts, including ones nested below a workspace path (`app/node_modules/…`); the root `""`
60/// project entry, the workspace directories themselves, and `link: true` workspace/`file:`
61/// links are excluded — those are local paths, not distributable packages.
62pub fn components(lock: &Lockfile) -> Vec<Component> {
63    let mut out: Vec<Component> = lock
64        .packages
65        .iter()
66        .filter(|p| p.key.contains("node_modules/") && !p.link)
67        .map(|p| Component {
68            purl: npm_purl(&p.name, &p.version),
69            name: p.name.clone(),
70            version: p.version.clone(),
71            license: p.license.clone(),
72            resolved: p.resolved.clone(),
73            integrity: p.integrity.clone(),
74        })
75        .collect();
76    out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
77    out.dedup_by(|a, b| a.name == b.name && a.version == b.version);
78    out
79}
80
81/// Registry-resolved packages (an in-memory [`crate::registry`] resolution) as bill-of-materials
82/// components — the same shape [`components`] distills from a lockfile, with no lockfile ever
83/// existing. A nested resolution may carry several versions of one name; each becomes its own
84/// component. Sorted by name, then version.
85pub fn components_from_resolved(resolved: &[crate::registry::Resolved]) -> Vec<Component> {
86    let mut out: Vec<Component> = resolved
87        .iter()
88        .map(|r| Component {
89            purl: npm_purl(&r.name, &r.version.to_string()),
90            name: r.name.clone(),
91            version: r.version.to_string(),
92            license: r.license.clone(),
93            resolved: Some(r.tarball_url.clone()),
94            integrity: r.integrity.clone(),
95        })
96        .collect();
97    out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
98    out
99}
100
101/// Group component `name@version`s by their declared license. Components with no declared license
102/// fall under `NOASSERTION`. Keys (licenses) and values (packages) are both sorted.
103pub fn license_summary(components: &[Component]) -> BTreeMap<String, Vec<String>> {
104    let mut by_license: BTreeMap<String, Vec<String>> = BTreeMap::new();
105    for c in components {
106        let key = c.license.clone().unwrap_or_else(|| NOASSERTION.to_string());
107        by_license
108            .entry(key)
109            .or_default()
110            .push(format!("{}@{}", c.name, c.version));
111    }
112    for pkgs in by_license.values_mut() {
113        pkgs.sort();
114    }
115    by_license
116}
117
118/// A plain-text license overview: a header, then each license with the packages under it.
119pub fn render_summary(components: &[Component]) -> String {
120    use std::fmt::Write as _;
121    let by = license_summary(components);
122    let mut s = String::new();
123    let _ = writeln!(
124        s,
125        "{} package(s) across {} license(s)",
126        components.len(),
127        by.len()
128    );
129    for (license, pkgs) in &by {
130        let _ = write!(s, "\n{license} ({})\n", pkgs.len());
131        for p in pkgs {
132            let _ = writeln!(s, "  {p}");
133        }
134    }
135    s
136}
137
138/// Render a CycloneDX 1.6 JSON SBOM. `app_name`/`app_version` describe the root component;
139/// `timestamp` is an RFC 3339 string for `metadata.timestamp` (or `None` to omit it — useful for
140/// reproducible output and tests).
141pub fn to_cyclonedx(
142    components: &[Component],
143    app_name: &str,
144    app_version: &str,
145    timestamp: Option<&str>,
146) -> String {
147    let mut metadata = Map::new();
148    if let Some(ts) = timestamp {
149        metadata.insert("timestamp".into(), json!(ts));
150    }
151    metadata.insert(
152        "tools".into(),
153        json!({ "components": [{
154            "type": "application",
155            "name": env!("CARGO_PKG_NAME"),
156            "version": env!("CARGO_PKG_VERSION"),
157        }] }),
158    );
159    metadata.insert(
160        "component".into(),
161        json!({
162            "type": "application",
163            "bom-ref": format!("{app_name}@{app_version}"),
164            "name": app_name,
165            "version": app_version,
166        }),
167    );
168
169    let comps: Vec<Value> = components
170        .iter()
171        .map(|c| {
172            let mut m = Map::new();
173            m.insert("type".into(), json!("library"));
174            m.insert("bom-ref".into(), json!(c.purl));
175            m.insert("name".into(), json!(c.name));
176            m.insert("version".into(), json!(c.version));
177            if let Some(lic) = &c.license {
178                m.insert("licenses".into(), cyclonedx_licenses(lic));
179            }
180            m.insert("purl".into(), json!(c.purl));
181            if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
182                m.insert(
183                    "hashes".into(),
184                    json!([{ "alg": "SHA-512", "content": hex }]),
185                );
186            }
187            Value::Object(m)
188        })
189        .collect();
190
191    let doc = json!({
192        "bomFormat": "CycloneDX",
193        "specVersion": "1.6",
194        "version": 1,
195        "metadata": Value::Object(metadata),
196        "components": comps,
197    });
198    let mut s = serde_json::to_string_pretty(&doc).expect("serialize CycloneDX");
199    s.push('\n');
200    s
201}
202
203/// Render an SPDX 2.3 JSON SBOM. `name`/`namespace` are the document name and its unique
204/// `documentNamespace` URI; `created` is the RFC 3339 creation time (SPDX requires it).
205pub fn to_spdx(components: &[Component], name: &str, namespace: &str, created: &str) -> String {
206    let packages: Vec<Value> = components
207        .iter()
208        .map(|c| {
209            let mut m = Map::new();
210            m.insert(
211                "SPDXID".into(),
212                json!(format!(
213                    "SPDXRef-Package-{}-{}",
214                    spdx_id_fragment(&c.name),
215                    spdx_id_fragment(&c.version)
216                )),
217            );
218            m.insert("name".into(), json!(c.name));
219            m.insert("versionInfo".into(), json!(c.version));
220            m.insert(
221                "downloadLocation".into(),
222                json!(c.resolved.as_deref().unwrap_or(NOASSERTION)),
223            );
224            m.insert("filesAnalyzed".into(), json!(false));
225            m.insert("licenseConcluded".into(), json!(NOASSERTION));
226            m.insert(
227                "licenseDeclared".into(),
228                json!(c.license.as_deref().unwrap_or(NOASSERTION)),
229            );
230            m.insert("copyrightText".into(), json!(NOASSERTION));
231            m.insert(
232                "externalRefs".into(),
233                json!([{
234                    "referenceCategory": "PACKAGE-MANAGER",
235                    "referenceType": "purl",
236                    "referenceLocator": c.purl,
237                }]),
238            );
239            if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
240                m.insert(
241                    "checksums".into(),
242                    json!([{ "algorithm": "SHA512", "checksumValue": hex }]),
243                );
244            }
245            Value::Object(m)
246        })
247        .collect();
248
249    let doc = json!({
250        "spdxVersion": "SPDX-2.3",
251        "dataLicense": "CC0-1.0",
252        "SPDXID": "SPDXRef-DOCUMENT",
253        "name": name,
254        "documentNamespace": namespace,
255        "creationInfo": {
256            "created": created,
257            "creators": [format!("Tool: {}-{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))],
258        },
259        "packages": packages,
260    });
261    let mut s = serde_json::to_string_pretty(&doc).expect("serialize SPDX");
262    s.push('\n');
263    s
264}
265
266/// The current UTC time as an RFC 3339 timestamp (second precision), for stamping a freshly
267/// generated SBOM — via the `time` crate.
268pub fn now_rfc3339() -> String {
269    let now = time::OffsetDateTime::now_utc();
270    now.replace_nanosecond(0)
271        .unwrap_or(now)
272        .format(&time::format_description::well_known::Rfc3339)
273        .expect("format current time as RFC 3339")
274}
275
276/// `pkg:npm/<name>@<version>` — a [Package URL]. A scoped `@scope/name` percent-encodes its
277/// leading `@` to `%40`, per the npm purl type.
278///
279/// [Package URL]: https://github.com/package-url/purl-spec
280fn npm_purl(name: &str, version: &str) -> String {
281    let path = match name.strip_prefix('@') {
282        Some(rest) => format!("%40{rest}"),
283        None => name.to_string(),
284    };
285    format!("pkg:npm/{path}@{version}")
286}
287
288/// CycloneDX `licenses` for one declared license string: a single SPDX id becomes
289/// `[{ license: { id } }]`; anything that looks like an SPDX *expression* (contains `OR`/`AND`/
290/// `WITH`/parens) becomes `[{ expression }]`.
291fn cyclonedx_licenses(license: &str) -> Value {
292    if is_spdx_expression(license) {
293        json!([{ "expression": license }])
294    } else {
295        json!([{ "license": { "id": license } }])
296    }
297}
298
299fn is_spdx_expression(license: &str) -> bool {
300    license.contains(" OR ")
301        || license.contains(" AND ")
302        || license.contains(" WITH ")
303        || license.contains('(')
304}
305
306/// Decode a `sha512-<base64>` Subresource-Integrity into lowercase hex (the form CycloneDX
307/// `hashes`/SPDX `checksums` want). `None` for a missing, non-sha512, or malformed value.
308fn sri_to_sha512_hex(integrity: Option<&str>) -> Option<String> {
309    let b64 = integrity?.strip_prefix("sha512-")?;
310    let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
311    Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
312}
313
314/// Sanitize a string into the `SPDXRef-` id charset (`[a-zA-Z0-9.-]`): every other character
315/// becomes `-`. Keeps SPDXIDs valid for scoped names (`@lit/context` → `-lit-context`).
316fn spdx_id_fragment(s: &str) -> String {
317    s.chars()
318        .map(|c| {
319            if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
320                c
321            } else {
322                '-'
323            }
324        })
325        .collect()
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    /// A small v3 lockfile: a scoped + an unscoped package (one licensed with integrity, one
333    /// license-less), plus the root and a `link` that must be excluded.
334    const SAMPLE: &str = r#"{
335        "name": "demo", "version": "1.0.0", "lockfileVersion": 3,
336        "packages": {
337            "": { "name": "demo", "version": "1.0.0" },
338            "node_modules/lit": {
339                "version": "3.3.3",
340                "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
341                "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
342                "license": "BSD-3-Clause"
343            },
344            "node_modules/@lit/context": {
345                "version": "1.1.6",
346                "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz",
347                "license": "BSD-3-Clause"
348            },
349            "node_modules/mystery": { "version": "0.0.1" },
350            "node_modules/local-link": { "version": "1.0.0", "link": true }
351        }
352    }"#;
353
354    fn sample_components() -> Vec<Component> {
355        components(&Lockfile::parse(SAMPLE).unwrap())
356    }
357
358    #[test]
359    fn components_skip_root_and_links_and_build_purls() {
360        let c = sample_components();
361        let names: Vec<&str> = c.iter().map(|x| x.name.as_str()).collect();
362        // Root "" and the `link: true` entry are excluded; sorted by name.
363        assert_eq!(names, ["@lit/context", "lit", "mystery"]);
364        // Scoped purl percent-encodes the leading `@`.
365        assert_eq!(c[0].purl, "pkg:npm/%40lit/context@1.1.6");
366        assert_eq!(c[1].purl, "pkg:npm/lit@3.3.3");
367    }
368
369    #[test]
370    fn components_cover_nested_and_aliased_entries_once() {
371        // A workspace lock: `app` itself is a local path (excluded like the root and links),
372        // its nested conflict copy counts, and an aliased entry counts under its real name —
373        // identical name@version pairs collapse into one component.
374        let lock = Lockfile::parse(
375            r#"{
376            "name": "ws", "version": "1.0.0", "lockfileVersion": 3,
377            "packages": {
378                "": { "name": "ws", "version": "1.0.0" },
379                "app": { "name": "@ws/app", "version": "1.0.0" },
380                "node_modules/lodash": { "version": "4.17.21" },
381                "app/node_modules/lodash": { "version": "4.17.11" },
382                "node_modules/dash": { "name": "lodash", "version": "4.17.11" }
383            }
384        }"#,
385        )
386        .unwrap();
387        let c = components(&lock);
388        let ids: Vec<String> = c
389            .iter()
390            .map(|x| format!("{}@{}", x.name, x.version))
391            .collect();
392        assert_eq!(ids, ["lodash@4.17.11", "lodash@4.17.21"]);
393        assert_eq!(c[0].purl, "pkg:npm/lodash@4.17.11");
394    }
395
396    #[test]
397    fn license_summary_groups_and_marks_unknown_as_noassertion() {
398        let summary = license_summary(&sample_components());
399        assert_eq!(
400            summary.get("BSD-3-Clause").unwrap(),
401            &["@lit/context@1.1.6", "lit@3.3.3"]
402        );
403        // A package with no declared license is grouped under NOASSERTION.
404        assert_eq!(summary.get("NOASSERTION").unwrap(), &["mystery@0.0.1"]);
405    }
406
407    #[test]
408    fn cyclonedx_has_required_shape_purls_licenses_and_sha512_hash() {
409        let json: Value =
410            serde_json::from_str(&to_cyclonedx(&sample_components(), "demo", "1.0.0", None))
411                .unwrap();
412        assert_eq!(json["bomFormat"], "CycloneDX");
413        assert_eq!(json["specVersion"], "1.6");
414        // No timestamp requested → key omitted (reproducible).
415        assert!(json["metadata"].get("timestamp").is_none());
416
417        let lit = &json["components"][1];
418        assert_eq!(lit["name"], "lit");
419        assert_eq!(lit["purl"], "pkg:npm/lit@3.3.3");
420        assert_eq!(lit["licenses"][0]["license"]["id"], "BSD-3-Clause");
421        // sha512-<base64> decoded to 64 bytes → 128 lowercase hex chars.
422        let hex = lit["hashes"][0]["content"].as_str().unwrap();
423        assert_eq!(lit["hashes"][0]["alg"], "SHA-512");
424        assert_eq!(hex.len(), 128);
425        assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()));
426        // The license-less package carries neither a licenses nor a hashes key.
427        let mystery = &json["components"][2];
428        assert_eq!(mystery["name"], "mystery");
429        assert!(mystery.get("licenses").is_none());
430        assert!(mystery.get("hashes").is_none());
431    }
432
433    #[test]
434    fn cyclonedx_uses_expression_for_compound_licenses() {
435        let comps = vec![Component {
436            name: "dual".into(),
437            version: "1.0.0".into(),
438            purl: "pkg:npm/dual@1.0.0".into(),
439            license: Some("MIT OR Apache-2.0".into()),
440            resolved: None,
441            integrity: None,
442        }];
443        let json: Value = serde_json::from_str(&to_cyclonedx(&comps, "x", "1", None)).unwrap();
444        assert_eq!(
445            json["components"][0]["licenses"][0]["expression"],
446            "MIT OR Apache-2.0"
447        );
448    }
449
450    #[test]
451    fn spdx_has_required_shape_ids_purls_and_license() {
452        let json: Value = serde_json::from_str(&to_spdx(
453            &sample_components(),
454            "demo-sbom",
455            "https://spdx.example/demo",
456            "2026-01-01T00:00:00Z",
457        ))
458        .unwrap();
459        assert_eq!(json["spdxVersion"], "SPDX-2.3");
460        assert_eq!(json["SPDXID"], "SPDXRef-DOCUMENT");
461        assert_eq!(json["creationInfo"]["created"], "2026-01-01T00:00:00Z");
462
463        // Scoped name sanitized into a valid SPDXID.
464        let scoped = &json["packages"][0];
465        assert_eq!(scoped["SPDXID"], "SPDXRef-Package--lit-context-1.1.6");
466        assert_eq!(scoped["licenseDeclared"], "BSD-3-Clause");
467        assert_eq!(
468            scoped["externalRefs"][0]["referenceLocator"],
469            "pkg:npm/%40lit/context@1.1.6"
470        );
471        // License-less package → NOASSERTION.
472        assert_eq!(json["packages"][2]["licenseDeclared"], "NOASSERTION");
473    }
474
475    #[test]
476    fn sri_decodes_to_64_byte_sha512_hex() {
477        let hex = sri_to_sha512_hex(Some(
478            "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
479        ))
480        .unwrap();
481        assert_eq!(hex.len(), 128); // 64 bytes
482        assert!(sri_to_sha512_hex(Some("sha1-deadbeef")).is_none()); // not sha512
483        assert!(sri_to_sha512_hex(None).is_none());
484    }
485}