diff --git a/docs/adr/0012-sbom-resolved-dependency-components.md b/docs/adr/0012-sbom-resolved-dependency-components.md index ce89c4495..91e9f9e65 100644 --- a/docs/adr/0012-sbom-resolved-dependency-components.md +++ b/docs/adr/0012-sbom-resolved-dependency-components.md @@ -178,6 +178,40 @@ already extracted. When neither a detected package nor a same-owner resolved version exists for an identity, the component keeps its unversioned purl and unset license. +## Amendment: license acknowledgement in CycloneDX + +The CycloneDX renderer carries the declared-vs-concluded distinction — the same +one SPDX renders as `PackageLicenseDeclared` / `PackageLicenseConcluded` +(`src/output/spdx.rs`) — via `licenses[].acknowledgement`. Scope is the +CycloneDX renderer only; SPDX and the native ScanCode-compatible output are +unchanged (ADR 0008). + +- **Spec version.** The renderer emits a single current CycloneDX schema + version — recent enough to define `licenses[].acknowledgement` — via + `specVersion`, the JSON `$schema`, and the XML `bom` namespace. The exact + version string, schema URL, and namespace are owned by `src/output/cyclonedx.rs` + and the validator config, not this record. Feature areas outside Provenant's + use (crypto/CBOM/formulation) are not emitted. +- **Acknowledgement is honest, not stamped.** It comes from the same package + fields SPDX uses: a parser-declared expression + (`declared_license_expression_spdx`, then raw `declared_license_expression`) + is `declared`; a license concluded from source/file detection + (`license_detections`) is `concluded`. The field tags provenance without + altering the expression a component reports. In JSON it sits beside + `expression`; in XML it is the `acknowledgement` attribute on ``. +- **License evidence is source-faithful.** Each component carries + `component.evidence` built from the file-content license detections of the + files it owns, using the same file→package ownership assembly computes + (`file.for_packages`): the distinct observed SPDX expressions as + `evidence.licenses`, and the file+line of each detection as + `evidence.occurrences`. Only detected packages own scanned files; a promoted + resolved dependency owns none, so it carries no evidence. This is + render-time reuse of data already in the document — nothing is re-detected. +- **Conformance is externally gated.** Goldens prove stability, not conformance; + all CycloneDX goldens and the four `examples/sbom` validate against the + official CycloneDX schema + XSD for the emitted version via + `scripts/validate_output_format_fixtures.py` (`cyclonedx-python-lib`). + ## Consequences ### Benefits @@ -222,6 +256,7 @@ unset license. - Issue #1319 - Issue #1320 (versioned purls + vendored-license join amendment) +- Issue #1328 (CycloneDX 1.7 + license acknowledgement/evidence amendment) - Promotion/dedup: `src/output/sbom.rs` - CycloneDX renderer: `src/output/cyclonedx.rs` - SPDX renderer: `src/output/spdx.rs` diff --git a/examples/sbom/README.md b/examples/sbom/README.md index f794c3bd7..3364fa9fd 100644 --- a/examples/sbom/README.md +++ b/examples/sbom/README.md @@ -2,7 +2,7 @@ Real software bills of materials produced by [Provenant](https://github.com/getprovenant/provenant), one directory per target. Each target directory contains an SPDX tag-value document ([`sbom.spdx`](ripgrep/sbom.spdx)) and a CycloneDX JSON document ([`sbom.cdx.json`](ripgrep/sbom.cdx.json)), plus a short provenance README. -These examples are generated by Provenant 1.0.2 (the current build) scanning each project at a pinned commit with license, package, and copyright detection enabled. Every SBOM is a complete, closed inventory: each resolved dependency is promoted to a component with a package URL, and every dependency relationship resolves within the document. +These examples are generated by Provenant 1.0.3 (the current build) scanning each project at a pinned commit with license, package, and copyright detection enabled. Every SBOM is a complete, closed inventory: each resolved dependency is promoted to a component with a package URL, and every dependency relationship resolves within the document. ## Targets diff --git a/examples/sbom/express/README.md b/examples/sbom/express/README.md index 0a9360f34..8aeb1ee60 100644 --- a/examples/sbom/express/README.md +++ b/examples/sbom/express/README.md @@ -6,7 +6,7 @@ SPDX tag-value ([`sbom.spdx`](sbom.spdx)) and CycloneDX JSON ([`sbom.cdx.json`]( - Source: `https://github.com/expressjs/express.git` at tag `v5.2.1` - Pinned commit: `dbac741a49a5a64336b70c06e85c2e2706e36336` -- Generated by: Provenant 1.0.2 +- Generated by: Provenant 1.0.3 - Command: `provenant scan --license --package --copyright --strip-root --spdx-tv sbom.spdx --cyclonedx sbom.cdx.json` ## What's inside diff --git a/examples/sbom/express/sbom.cdx.json b/examples/sbom/express/sbom.cdx.json index 6340f8ec9..0d15b23cd 100644 --- a/examples/sbom/express/sbom.cdx.json +++ b/examples/sbom/express/sbom.cdx.json @@ -1,14 +1,15 @@ { + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.3", - "serialNumber": "urn:uuid:88b5de63-2310-40c2-9151-0bc05ffaba28", + "specVersion": "1.7", + "serialNumber": "urn:uuid:9a30ff04-67e1-468e-aa86-b8e480c4b564", "version": 1, "metadata": { - "timestamp": "2026-07-24T13:05:02Z", + "timestamp": "2026-07-24T14:34:28Z", "tools": [ { "name": "Provenant", - "version": "1.0.2" + "version": "1.0.3" } ], "component": { @@ -20,7 +21,8 @@ "purl": "pkg:npm/express@5.2.1", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -44,7 +46,60 @@ "type": "vcs", "url": "https://github.com/expressjs/express" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 7 + }, + { + "location": "Readme.md", + "line": 261 + }, + { + "location": "index.js", + "line": 6 + }, + { + "location": "lib/application.js", + "line": 6 + }, + { + "location": "lib/express.js", + "line": 6 + }, + { + "location": "lib/request.js", + "line": 6 + }, + { + "location": "lib/response.js", + "line": 5 + }, + { + "location": "lib/utils.js", + "line": 5 + }, + { + "location": "lib/view.js", + "line": 6 + }, + { + "location": "package.json", + "line": 15 + } + ], + "licenses": [ + { + "expression": "MIT" + } + ] + } } }, "components": [ @@ -59,7 +114,8 @@ "purl": "pkg:npm/express@5.2.1", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -83,7 +139,60 @@ "type": "vcs", "url": "https://github.com/expressjs/express" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 7 + }, + { + "location": "Readme.md", + "line": 261 + }, + { + "location": "index.js", + "line": 6 + }, + { + "location": "lib/application.js", + "line": 6 + }, + { + "location": "lib/express.js", + "line": 6 + }, + { + "location": "lib/request.js", + "line": 6 + }, + { + "location": "lib/response.js", + "line": 5 + }, + { + "location": "lib/utils.js", + "line": 5 + }, + { + "location": "lib/view.js", + "line": 6 + }, + { + "location": "package.json", + "line": 15 + } + ], + "licenses": [ + { + "expression": "MIT" + } + ] + } }, { "type": "library", diff --git a/examples/sbom/express/sbom.spdx b/examples/sbom/express/sbom.spdx index ecdc97f02..c7f3910da 100644 --- a/examples/sbom/express/sbom.spdx +++ b/examples/sbom/express/sbom.spdx @@ -12,8 +12,8 @@ Provenant is a free software code scanning tool. Visit https://github.com/getprovenant/provenant/ for support and download. SPDX License List: 3.27 ## Creation Information -Creator: Tool: Provenant-1.0.2 -Created: 2026-07-24T13:05:01Z +Creator: Tool: Provenant-1.0.3 +Created: 2026-07-24T14:34:28Z ## Package Information PackageName: express SPDXID: SPDXRef-Package-1 diff --git a/examples/sbom/flask/README.md b/examples/sbom/flask/README.md index 644bf4980..b989871d2 100644 --- a/examples/sbom/flask/README.md +++ b/examples/sbom/flask/README.md @@ -6,7 +6,7 @@ SPDX tag-value ([`sbom.spdx`](sbom.spdx)) and CycloneDX JSON ([`sbom.cdx.json`]( - Source: `https://github.com/pallets/flask.git` at tag `3.1.3` - Pinned commit: `22d924701a6ae2e4cd01e9a15bbaf3946094af65` -- Generated by: Provenant 1.0.2 +- Generated by: Provenant 1.0.3 - Command: `provenant scan --license --package --copyright --strip-root --spdx-tv sbom.spdx --cyclonedx sbom.cdx.json` ## What's inside diff --git a/examples/sbom/flask/sbom.cdx.json b/examples/sbom/flask/sbom.cdx.json index 7980b8735..cb3096a18 100644 --- a/examples/sbom/flask/sbom.cdx.json +++ b/examples/sbom/flask/sbom.cdx.json @@ -1,14 +1,15 @@ { + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.3", - "serialNumber": "urn:uuid:79885f4f-877f-440d-8d66-832ee74c9f54", + "specVersion": "1.7", + "serialNumber": "urn:uuid:96b8310d-ea47-490f-8934-9194fa93de20", "version": 1, "metadata": { - "timestamp": "2026-07-24T13:05:04Z", + "timestamp": "2026-07-24T14:34:31Z", "tools": [ { "name": "Provenant", - "version": "1.0.2" + "version": "1.0.3" } ], "component": { @@ -20,7 +21,8 @@ "purl": "pkg:pypi/flask@3.1.3", "licenses": [ { - "expression": "BSD-3-Clause" + "expression": "BSD-3-Clause", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -36,7 +38,24 @@ "type": "website", "url": "https://pypi.org/project/Flask" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "pyproject.toml", + "line": 6 + }, + { + "location": "pyproject.toml", + "line": 7 + } + ], + "licenses": [ + { + "expression": "BSD-3-Clause" + } + ] + } } }, "components": [ @@ -74,7 +93,8 @@ "purl": "pkg:pypi/flask@3.1.3", "licenses": [ { - "expression": "BSD-3-Clause" + "expression": "BSD-3-Clause", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -90,7 +110,24 @@ "type": "website", "url": "https://pypi.org/project/Flask" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "pyproject.toml", + "line": 6 + }, + { + "location": "pyproject.toml", + "line": 7 + } + ], + "licenses": [ + { + "expression": "BSD-3-Clause" + } + ] + } }, { "type": "library", @@ -103,7 +140,8 @@ "purl": "pkg:pypi/flaskr@1.0.0", "licenses": [ { - "expression": "BSD-3-Clause" + "expression": "BSD-3-Clause", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -119,7 +157,24 @@ "type": "website", "url": "https://pypi.org/project/flaskr" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "examples/tutorial/LICENSE.txt", + "line": 3 + }, + { + "location": "examples/tutorial/pyproject.toml", + "line": 6 + } + ], + "licenses": [ + { + "expression": "BSD-3-Clause" + } + ] + } }, { "type": "library", @@ -132,7 +187,8 @@ "purl": "pkg:pypi/js-example@1.1.0", "licenses": [ { - "expression": "BSD-3-Clause" + "expression": "BSD-3-Clause", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -148,7 +204,24 @@ "type": "website", "url": "https://pypi.org/project/js_example" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "examples/javascript/LICENSE.txt", + "line": 3 + }, + { + "location": "examples/javascript/pyproject.toml", + "line": 6 + } + ], + "licenses": [ + { + "expression": "BSD-3-Clause" + } + ] + } }, { "type": "library", diff --git a/examples/sbom/flask/sbom.spdx b/examples/sbom/flask/sbom.spdx index 73b7f713c..0840948a0 100644 --- a/examples/sbom/flask/sbom.spdx +++ b/examples/sbom/flask/sbom.spdx @@ -12,8 +12,8 @@ Provenant is a free software code scanning tool. Visit https://github.com/getprovenant/provenant/ for support and download. SPDX License List: 3.27 ## Creation Information -Creator: Tool: Provenant-1.0.2 -Created: 2026-07-24T13:05:03Z +Creator: Tool: Provenant-1.0.3 +Created: 2026-07-24T14:34:30Z ## Package Information PackageName: flask-example-celery SPDXID: SPDXRef-Package-1 diff --git a/examples/sbom/ripgrep/README.md b/examples/sbom/ripgrep/README.md index 423e0643c..76635c12c 100644 --- a/examples/sbom/ripgrep/README.md +++ b/examples/sbom/ripgrep/README.md @@ -6,7 +6,7 @@ SPDX tag-value ([`sbom.spdx`](sbom.spdx)) and CycloneDX JSON ([`sbom.cdx.json`]( - Source: `https://github.com/BurntSushi/ripgrep.git` at tag `15.2.0` - Pinned commit: `e89fff89ac9af12e8d4ce9d5fd07beb408ca730f` -- Generated by: Provenant 1.0.2 +- Generated by: Provenant 1.0.3 - Command: `provenant scan --license --package --copyright --strip-root --spdx-tv sbom.spdx --cyclonedx sbom.cdx.json` ## What's inside diff --git a/examples/sbom/ripgrep/sbom.cdx.json b/examples/sbom/ripgrep/sbom.cdx.json index 1ac0dcbe8..363650eaf 100644 --- a/examples/sbom/ripgrep/sbom.cdx.json +++ b/examples/sbom/ripgrep/sbom.cdx.json @@ -1,14 +1,15 @@ { + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.3", - "serialNumber": "urn:uuid:2c9df26a-01e0-43f5-8646-3d3dc6dc87ca", + "specVersion": "1.7", + "serialNumber": "urn:uuid:bb50d7b5-c5e7-4611-8543-e53bcd2122bb", "version": 1, "metadata": { - "timestamp": "2026-07-24T13:04:59Z", + "timestamp": "2026-07-24T14:34:27Z", "tools": [ { "name": "Provenant", - "version": "1.0.2" + "version": "1.0.3" } ], "component": { @@ -20,7 +21,8 @@ "purl": "pkg:cargo/ripgrep@15.2.0", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -44,7 +46,392 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": ".github/workflows/release.yml", + "line": 219 + }, + { + "location": "COPYING", + "line": 1 + }, + { + "location": "Cargo.toml", + "line": 15 + }, + { + "location": "Cargo.toml", + "line": 98 + }, + { + "location": "Cargo.toml", + "line": 99 + }, + { + "location": "FAQ.md", + "line": 928 + }, + { + "location": "FAQ.md", + "line": 929 + }, + { + "location": "FAQ.md", + "line": 930 + }, + { + "location": "FAQ.md", + "line": 933 + }, + { + "location": "FAQ.md", + "line": 936 + }, + { + "location": "FAQ.md", + "line": 939 + }, + { + "location": "FAQ.md", + "line": 940 + }, + { + "location": "FAQ.md", + "line": 947 + }, + { + "location": "FAQ.md", + "line": 948 + }, + { + "location": "GUIDE.md", + "line": 44 + }, + { + "location": "GUIDE.md", + "line": 47 + }, + { + "location": "GUIDE.md", + "line": 638 + }, + { + "location": "LICENSE-MIT", + "line": 1 + }, + { + "location": "LICENSE-MIT", + "line": 5 + }, + { + "location": "README.md", + "line": 15 + }, + { + "location": "UNLICENSE", + "line": 1 + }, + { + "location": "benchsuite/runs/2020-10-14-archlinux-frink/README.md", + "line": 32 + }, + { + "location": "benchsuite/runs/2022-12-16-archlinux-duff/README.md", + "line": 31 + }, + { + "location": "crates/cli/Cargo.toml", + "line": 13 + }, + { + "location": "crates/cli/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/cli/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/cli/README.md", + "line": 11 + }, + { + "location": "crates/cli/UNLICENSE", + "line": 1 + }, + { + "location": "crates/core/flags/complete/rg.zsh", + "line": 662 + }, + { + "location": "crates/globset/COPYING", + "line": 1 + }, + { + "location": "crates/globset/Cargo.toml", + "line": 15 + }, + { + "location": "crates/globset/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/globset/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/globset/README.md", + "line": 10 + }, + { + "location": "crates/globset/UNLICENSE", + "line": 1 + }, + { + "location": "crates/grep/COPYING", + "line": 1 + }, + { + "location": "crates/grep/Cargo.toml", + "line": 13 + }, + { + "location": "crates/grep/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/grep/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/grep/README.md", + "line": 8 + }, + { + "location": "crates/grep/UNLICENSE", + "line": 1 + }, + { + "location": "crates/ignore/COPYING", + "line": 1 + }, + { + "location": "crates/ignore/Cargo.toml", + "line": 14 + }, + { + "location": "crates/ignore/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/ignore/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/ignore/README.md", + "line": 10 + }, + { + "location": "crates/ignore/UNLICENSE", + "line": 1 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 129 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 143 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 144 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 148 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 152 + }, + { + "location": "crates/matcher/Cargo.toml", + "line": 13 + }, + { + "location": "crates/matcher/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/matcher/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/matcher/README.md", + "line": 10 + }, + { + "location": "crates/matcher/UNLICENSE", + "line": 1 + }, + { + "location": "crates/pcre2/Cargo.toml", + "line": 13 + }, + { + "location": "crates/pcre2/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/pcre2/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/pcre2/README.md", + "line": 10 + }, + { + "location": "crates/pcre2/UNLICENSE", + "line": 1 + }, + { + "location": "crates/printer/Cargo.toml", + "line": 14 + }, + { + "location": "crates/printer/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/printer/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/printer/README.md", + "line": 9 + }, + { + "location": "crates/printer/UNLICENSE", + "line": 1 + }, + { + "location": "crates/regex/Cargo.toml", + "line": 13 + }, + { + "location": "crates/regex/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/regex/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/regex/README.md", + "line": 10 + }, + { + "location": "crates/regex/UNLICENSE", + "line": 1 + }, + { + "location": "crates/searcher/Cargo.toml", + "line": 13 + }, + { + "location": "crates/searcher/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/searcher/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/searcher/README.md", + "line": 11 + }, + { + "location": "crates/searcher/UNLICENSE", + "line": 1 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 3 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 236 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 469 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 702 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 935 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 1168 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 1401 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 1634 + } + ], + "licenses": [ + { + "expression": "BSD-2-Clause" + }, + { + "expression": "BSD-3-Clause" + }, + { + "expression": "CC-BY-4.0 AND LGPL-2.0-or-later" + }, + { + "expression": "LGPL-2.0-or-later AND Apache-2.0" + }, + { + "expression": "LGPL-2.0-or-later AND LicenseRef-scancode-other-copyleft" + }, + { + "expression": "LicenseRef-scancode-generic-exception" + }, + { + "expression": "LicenseRef-scancode-gutenberg-2020" + }, + { + "expression": "MIT" + }, + { + "expression": "MIT AND Unlicense" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + }, + { + "expression": "Unlicense AND MIT" + }, + { + "expression": "Unlicense OR MIT OR LicenseRef-scancode-unknown-license-reference" + } + ] + } } }, "components": [ @@ -81,7 +468,8 @@ "purl": "pkg:cargo/globset@0.4.19", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -105,7 +493,46 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep/tree/master/crates/globset" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "crates/globset/COPYING", + "line": 1 + }, + { + "location": "crates/globset/Cargo.toml", + "line": 15 + }, + { + "location": "crates/globset/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/globset/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/globset/README.md", + "line": 10 + }, + { + "location": "crates/globset/UNLICENSE", + "line": 1 + } + ], + "licenses": [ + { + "expression": "MIT" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + } + ] + } }, { "type": "library", @@ -118,7 +545,8 @@ "purl": "pkg:cargo/grep-cli@0.1.12", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -142,7 +570,42 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep/tree/master/crates/cli" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "crates/cli/Cargo.toml", + "line": 13 + }, + { + "location": "crates/cli/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/cli/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/cli/README.md", + "line": 11 + }, + { + "location": "crates/cli/UNLICENSE", + "line": 1 + } + ], + "licenses": [ + { + "expression": "MIT" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + } + ] + } }, { "type": "library", @@ -155,7 +618,8 @@ "purl": "pkg:cargo/grep-matcher@0.1.9", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -179,7 +643,42 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep/tree/master/crates/matcher" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "crates/matcher/Cargo.toml", + "line": 13 + }, + { + "location": "crates/matcher/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/matcher/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/matcher/README.md", + "line": 10 + }, + { + "location": "crates/matcher/UNLICENSE", + "line": 1 + } + ], + "licenses": [ + { + "expression": "MIT" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + } + ] + } }, { "type": "library", @@ -192,7 +691,8 @@ "purl": "pkg:cargo/grep-pcre2@0.1.10", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -216,7 +716,42 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep/tree/master/crates/pcre2" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "crates/pcre2/Cargo.toml", + "line": 13 + }, + { + "location": "crates/pcre2/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/pcre2/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/pcre2/README.md", + "line": 10 + }, + { + "location": "crates/pcre2/UNLICENSE", + "line": 1 + } + ], + "licenses": [ + { + "expression": "MIT" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + } + ] + } }, { "type": "library", @@ -229,7 +764,8 @@ "purl": "pkg:cargo/grep-printer@0.3.1", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -253,7 +789,42 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep/tree/master/crates/printer" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "crates/printer/Cargo.toml", + "line": 14 + }, + { + "location": "crates/printer/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/printer/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/printer/README.md", + "line": 9 + }, + { + "location": "crates/printer/UNLICENSE", + "line": 1 + } + ], + "licenses": [ + { + "expression": "MIT" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + } + ] + } }, { "type": "library", @@ -266,7 +837,8 @@ "purl": "pkg:cargo/grep-regex@0.1.14", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -290,7 +862,42 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep/tree/master/crates/regex" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "crates/regex/Cargo.toml", + "line": 13 + }, + { + "location": "crates/regex/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/regex/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/regex/README.md", + "line": 10 + }, + { + "location": "crates/regex/UNLICENSE", + "line": 1 + } + ], + "licenses": [ + { + "expression": "MIT" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + } + ] + } }, { "type": "library", @@ -303,7 +910,8 @@ "purl": "pkg:cargo/grep-searcher@0.1.17", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -327,7 +935,42 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep/tree/master/crates/searcher" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "crates/searcher/Cargo.toml", + "line": 13 + }, + { + "location": "crates/searcher/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/searcher/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/searcher/README.md", + "line": 11 + }, + { + "location": "crates/searcher/UNLICENSE", + "line": 1 + } + ], + "licenses": [ + { + "expression": "MIT" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + } + ] + } }, { "type": "library", @@ -340,7 +983,8 @@ "purl": "pkg:cargo/grep@0.4.1", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -364,7 +1008,46 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep/tree/master/crates/grep" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "crates/grep/COPYING", + "line": 1 + }, + { + "location": "crates/grep/Cargo.toml", + "line": 13 + }, + { + "location": "crates/grep/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/grep/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/grep/README.md", + "line": 8 + }, + { + "location": "crates/grep/UNLICENSE", + "line": 1 + } + ], + "licenses": [ + { + "expression": "MIT" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + } + ] + } }, { "type": "library", @@ -377,7 +1060,8 @@ "purl": "pkg:cargo/ignore@0.4.29", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -401,7 +1085,75 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "crates/ignore/COPYING", + "line": 1 + }, + { + "location": "crates/ignore/Cargo.toml", + "line": 14 + }, + { + "location": "crates/ignore/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/ignore/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/ignore/README.md", + "line": 10 + }, + { + "location": "crates/ignore/UNLICENSE", + "line": 1 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 129 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 143 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 144 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 148 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 152 + } + ], + "licenses": [ + { + "expression": "BSD-2-Clause" + }, + { + "expression": "CC-BY-4.0 AND LGPL-2.0-or-later" + }, + { + "expression": "LGPL-2.0-or-later AND Apache-2.0" + }, + { + "expression": "MIT" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + } + ] + } }, { "type": "library", @@ -414,7 +1166,8 @@ "purl": "pkg:cargo/ripgrep@15.2.0", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -438,7 +1191,392 @@ "type": "vcs", "url": "https://github.com/BurntSushi/ripgrep" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": ".github/workflows/release.yml", + "line": 219 + }, + { + "location": "COPYING", + "line": 1 + }, + { + "location": "Cargo.toml", + "line": 15 + }, + { + "location": "Cargo.toml", + "line": 98 + }, + { + "location": "Cargo.toml", + "line": 99 + }, + { + "location": "FAQ.md", + "line": 928 + }, + { + "location": "FAQ.md", + "line": 929 + }, + { + "location": "FAQ.md", + "line": 930 + }, + { + "location": "FAQ.md", + "line": 933 + }, + { + "location": "FAQ.md", + "line": 936 + }, + { + "location": "FAQ.md", + "line": 939 + }, + { + "location": "FAQ.md", + "line": 940 + }, + { + "location": "FAQ.md", + "line": 947 + }, + { + "location": "FAQ.md", + "line": 948 + }, + { + "location": "GUIDE.md", + "line": 44 + }, + { + "location": "GUIDE.md", + "line": 47 + }, + { + "location": "GUIDE.md", + "line": 638 + }, + { + "location": "LICENSE-MIT", + "line": 1 + }, + { + "location": "LICENSE-MIT", + "line": 5 + }, + { + "location": "README.md", + "line": 15 + }, + { + "location": "UNLICENSE", + "line": 1 + }, + { + "location": "benchsuite/runs/2020-10-14-archlinux-frink/README.md", + "line": 32 + }, + { + "location": "benchsuite/runs/2022-12-16-archlinux-duff/README.md", + "line": 31 + }, + { + "location": "crates/cli/Cargo.toml", + "line": 13 + }, + { + "location": "crates/cli/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/cli/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/cli/README.md", + "line": 11 + }, + { + "location": "crates/cli/UNLICENSE", + "line": 1 + }, + { + "location": "crates/core/flags/complete/rg.zsh", + "line": 662 + }, + { + "location": "crates/globset/COPYING", + "line": 1 + }, + { + "location": "crates/globset/Cargo.toml", + "line": 15 + }, + { + "location": "crates/globset/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/globset/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/globset/README.md", + "line": 10 + }, + { + "location": "crates/globset/UNLICENSE", + "line": 1 + }, + { + "location": "crates/grep/COPYING", + "line": 1 + }, + { + "location": "crates/grep/Cargo.toml", + "line": 13 + }, + { + "location": "crates/grep/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/grep/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/grep/README.md", + "line": 8 + }, + { + "location": "crates/grep/UNLICENSE", + "line": 1 + }, + { + "location": "crates/ignore/COPYING", + "line": 1 + }, + { + "location": "crates/ignore/Cargo.toml", + "line": 14 + }, + { + "location": "crates/ignore/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/ignore/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/ignore/README.md", + "line": 10 + }, + { + "location": "crates/ignore/UNLICENSE", + "line": 1 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 129 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 143 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 144 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 148 + }, + { + "location": "crates/ignore/src/default_types.rs", + "line": 152 + }, + { + "location": "crates/matcher/Cargo.toml", + "line": 13 + }, + { + "location": "crates/matcher/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/matcher/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/matcher/README.md", + "line": 10 + }, + { + "location": "crates/matcher/UNLICENSE", + "line": 1 + }, + { + "location": "crates/pcre2/Cargo.toml", + "line": 13 + }, + { + "location": "crates/pcre2/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/pcre2/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/pcre2/README.md", + "line": 10 + }, + { + "location": "crates/pcre2/UNLICENSE", + "line": 1 + }, + { + "location": "crates/printer/Cargo.toml", + "line": 14 + }, + { + "location": "crates/printer/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/printer/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/printer/README.md", + "line": 9 + }, + { + "location": "crates/printer/UNLICENSE", + "line": 1 + }, + { + "location": "crates/regex/Cargo.toml", + "line": 13 + }, + { + "location": "crates/regex/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/regex/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/regex/README.md", + "line": 10 + }, + { + "location": "crates/regex/UNLICENSE", + "line": 1 + }, + { + "location": "crates/searcher/Cargo.toml", + "line": 13 + }, + { + "location": "crates/searcher/LICENSE-MIT", + "line": 1 + }, + { + "location": "crates/searcher/LICENSE-MIT", + "line": 5 + }, + { + "location": "crates/searcher/README.md", + "line": 11 + }, + { + "location": "crates/searcher/UNLICENSE", + "line": 1 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 3 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 236 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 469 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 702 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 935 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 1168 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 1401 + }, + { + "location": "tests/data/sherlock-nul.txt", + "line": 1634 + } + ], + "licenses": [ + { + "expression": "BSD-2-Clause" + }, + { + "expression": "BSD-3-Clause" + }, + { + "expression": "CC-BY-4.0 AND LGPL-2.0-or-later" + }, + { + "expression": "LGPL-2.0-or-later AND Apache-2.0" + }, + { + "expression": "LGPL-2.0-or-later AND LicenseRef-scancode-other-copyleft" + }, + { + "expression": "LicenseRef-scancode-generic-exception" + }, + { + "expression": "LicenseRef-scancode-gutenberg-2020" + }, + { + "expression": "MIT" + }, + { + "expression": "MIT AND Unlicense" + }, + { + "expression": "MIT OR Unlicense" + }, + { + "expression": "Unlicense" + }, + { + "expression": "Unlicense AND MIT" + }, + { + "expression": "Unlicense OR MIT OR LicenseRef-scancode-unknown-license-reference" + } + ] + } }, { "type": "library", diff --git a/examples/sbom/ripgrep/sbom.spdx b/examples/sbom/ripgrep/sbom.spdx index d1bf0eb86..09fb21b42 100644 --- a/examples/sbom/ripgrep/sbom.spdx +++ b/examples/sbom/ripgrep/sbom.spdx @@ -12,8 +12,8 @@ Provenant is a free software code scanning tool. Visit https://github.com/getprovenant/provenant/ for support and download. SPDX License List: 3.27 ## Creation Information -Creator: Tool: Provenant-1.0.2 -Created: 2026-07-24T13:04:58Z +Creator: Tool: Provenant-1.0.3 +Created: 2026-07-24T14:34:26Z ## Package Information PackageName: fuzz SPDXID: SPDXRef-Package-1 diff --git a/examples/sbom/tokio/README.md b/examples/sbom/tokio/README.md index 62c6b48e3..bee8c3d3d 100644 --- a/examples/sbom/tokio/README.md +++ b/examples/sbom/tokio/README.md @@ -6,7 +6,7 @@ SPDX tag-value ([`sbom.spdx`](sbom.spdx)) and CycloneDX JSON ([`sbom.cdx.json`]( - Source: `https://github.com/tokio-rs/tokio.git` at tag `tokio-1.53.1` - Pinned commit: `75fef53d0a8590c2d1dbb63672aa7b7d1ef51155` -- Generated by: Provenant 1.0.2 +- Generated by: Provenant 1.0.3 - Command: `provenant scan --license --package --copyright --strip-root --spdx-tv sbom.spdx --cyclonedx sbom.cdx.json` ## What's inside diff --git a/examples/sbom/tokio/sbom.cdx.json b/examples/sbom/tokio/sbom.cdx.json index b97d06e14..10d643bdb 100644 --- a/examples/sbom/tokio/sbom.cdx.json +++ b/examples/sbom/tokio/sbom.cdx.json @@ -1,14 +1,15 @@ { + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.3", - "serialNumber": "urn:uuid:9e809ac0-41cb-489c-a74a-44abdebe4146", + "specVersion": "1.7", + "serialNumber": "urn:uuid:c93cfeb2-5956-49cb-89bf-5513c66125cd", "version": 1, "metadata": { - "timestamp": "2026-07-24T13:05:06Z", + "timestamp": "2026-07-24T14:34:34Z", "tools": [ { "name": "Provenant", - "version": "1.0.2" + "version": "1.0.3" } ] }, @@ -22,7 +23,8 @@ "purl": "pkg:cargo/benches@0.0.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -38,7 +40,55 @@ "type": "website", "url": "https://crates.io/crates/benches" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "benches/Cargo.toml", + "line": 6 + }, + { + "location": "deny.toml", + "line": 8 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", @@ -49,7 +99,8 @@ "purl": "pkg:cargo/examples@0.0.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -65,7 +116,55 @@ "type": "website", "url": "https://crates.io/crates/examples" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "deny.toml", + "line": 8 + }, + { + "location": "examples/Cargo.toml", + "line": 6 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", @@ -77,7 +176,8 @@ "purl": "pkg:cargo/stress-test@0.1.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -93,7 +193,55 @@ "type": "website", "url": "https://crates.io/crates/stress-test" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "deny.toml", + "line": 8 + }, + { + "location": "stress-test/Cargo.toml", + "line": 6 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", @@ -105,7 +253,8 @@ "purl": "pkg:cargo/tests-build@0.1.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -121,7 +270,55 @@ "type": "website", "url": "https://crates.io/crates/tests-build" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "deny.toml", + "line": 8 + }, + { + "location": "tests-build/Cargo.toml", + "line": 6 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", @@ -133,7 +330,8 @@ "purl": "pkg:cargo/tests-integration@0.1.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -149,7 +347,55 @@ "type": "website", "url": "https://crates.io/crates/tests-integration" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "deny.toml", + "line": 8 + }, + { + "location": "tests-integration/Cargo.toml", + "line": 6 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", @@ -184,7 +430,8 @@ "purl": "pkg:cargo/tokio-macros@2.7.1", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -208,7 +455,67 @@ "type": "vcs", "url": "https://github.com/tokio-rs/tokio" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "deny.toml", + "line": 8 + }, + { + "location": "tokio-macros/Cargo.toml", + "line": 11 + }, + { + "location": "tokio-macros/LICENSE", + "line": 1 + }, + { + "location": "tokio-macros/LICENSE", + "line": 6 + }, + { + "location": "tokio-macros/README.md", + "line": 5 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", @@ -243,7 +550,8 @@ "purl": "pkg:cargo/tokio-stream@0.1.18", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -267,7 +575,67 @@ "type": "vcs", "url": "https://github.com/tokio-rs/tokio" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "deny.toml", + "line": 8 + }, + { + "location": "tokio-stream/Cargo.toml", + "line": 11 + }, + { + "location": "tokio-stream/LICENSE", + "line": 1 + }, + { + "location": "tokio-stream/LICENSE", + "line": 5 + }, + { + "location": "tokio-stream/README.md", + "line": 5 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", @@ -280,7 +648,8 @@ "purl": "pkg:cargo/tokio-test@0.4.5", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -304,7 +673,67 @@ "type": "vcs", "url": "https://github.com/tokio-rs/tokio" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "deny.toml", + "line": 8 + }, + { + "location": "tokio-test/Cargo.toml", + "line": 11 + }, + { + "location": "tokio-test/LICENSE", + "line": 1 + }, + { + "location": "tokio-test/LICENSE", + "line": 5 + }, + { + "location": "tokio-test/README.md", + "line": 5 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", @@ -317,7 +746,8 @@ "purl": "pkg:cargo/tokio-util@0.7.18", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -341,7 +771,67 @@ "type": "vcs", "url": "https://github.com/tokio-rs/tokio" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "deny.toml", + "line": 8 + }, + { + "location": "tokio-util/Cargo.toml", + "line": 11 + }, + { + "location": "tokio-util/LICENSE", + "line": 1 + }, + { + "location": "tokio-util/LICENSE", + "line": 5 + }, + { + "location": "tokio-util/README.md", + "line": 5 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", @@ -354,7 +844,8 @@ "purl": "pkg:cargo/tokio@1.53.1", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -378,7 +869,83 @@ "type": "vcs", "url": "https://github.com/tokio-rs/tokio" } - ] + ], + "evidence": { + "occurrences": [ + { + "location": "LICENSE", + "line": 1 + }, + { + "location": "LICENSE", + "line": 5 + }, + { + "location": "README.md", + "line": 20 + }, + { + "location": "README.md", + "line": 26 + }, + { + "location": "README.md", + "line": 250 + }, + { + "location": "README.md", + "line": 252 + }, + { + "location": "README.md", + "line": 263 + }, + { + "location": "deny.toml", + "line": 8 + }, + { + "location": "tokio/Cargo.toml", + "line": 13 + }, + { + "location": "tokio/LICENSE", + "line": 1 + }, + { + "location": "tokio/LICENSE", + "line": 5 + }, + { + "location": "tokio/README.md", + "line": 20 + }, + { + "location": "tokio/README.md", + "line": 26 + }, + { + "location": "tokio/README.md", + "line": 250 + }, + { + "location": "tokio/README.md", + "line": 252 + }, + { + "location": "tokio/README.md", + "line": 263 + } + ], + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + }, + { + "expression": "MIT" + } + ] + } }, { "type": "library", diff --git a/examples/sbom/tokio/sbom.spdx b/examples/sbom/tokio/sbom.spdx index e9d0a639a..ce41b8a45 100644 --- a/examples/sbom/tokio/sbom.spdx +++ b/examples/sbom/tokio/sbom.spdx @@ -12,8 +12,8 @@ Provenant is a free software code scanning tool. Visit https://github.com/getprovenant/provenant/ for support and download. SPDX License List: 3.27 ## Creation Information -Creator: Tool: Provenant-1.0.2 -Created: 2026-07-24T13:05:06Z +Creator: Tool: Provenant-1.0.3 +Created: 2026-07-24T14:34:33Z ## Package Information PackageName: benches SPDXID: SPDXRef-Package-1 diff --git a/scripts/validate_output_format_fixtures.py b/scripts/validate_output_format_fixtures.py index dfcaf7f84..ffa4a8148 100644 --- a/scripts/validate_output_format_fixtures.py +++ b/scripts/validate_output_format_fixtures.py @@ -13,7 +13,7 @@ This script instead runs the actual reference tooling from each spec: - CycloneDX JSON/XML fixtures are validated against the official CycloneDX - 1.3 JSON Schema / XSD, via the `cyclonedx-python-lib` validators. + 1.7 JSON Schema / XSD, via the `cyclonedx-python-lib` validators. - SPDX tag-value fixtures are validated with `pyspdxtools` (spdx-tools), which parses and runs full SPDX-2.2/2.3 document validation. @@ -48,10 +48,13 @@ CYCLONEDX_JSON_FIXTURES = [ "cyclonedx-expected.json", "cyclonedx-dependencies-expected.json", + # Exercises component license evidence (evidence.licenses + occurrences). + "cyclonedx-evidence-expected.json", ] CYCLONEDX_XML_FIXTURES = [ "cyclonedx-expected.xml", "cyclonedx-dependencies-expected.xml", + "cyclonedx-evidence-expected.xml", ] # `spdx-empty-expected.tv` is intentionally excluded: Provenant emits a @@ -66,7 +69,7 @@ "spdx-dependencies-expected.tv", ] -CYCLONEDX_SPEC_VERSION = "1.3" +CYCLONEDX_SPEC_VERSION = "1.7" def report_ok(label: str) -> None: @@ -87,7 +90,7 @@ def validate_cyclonedx_json(path: Path, errors: list[str], label: str) -> None: from cyclonedx.schema import OutputFormat, SchemaVersion from cyclonedx.validation import make_schemabased_validator - validator = make_schemabased_validator(OutputFormat.JSON, SchemaVersion.V1_3) + validator = make_schemabased_validator(OutputFormat.JSON, SchemaVersion.V1_7) error = validator.validate_str(path.read_text()) if error is None: report_ok(label) @@ -99,7 +102,7 @@ def validate_cyclonedx_xml(path: Path, errors: list[str], label: str) -> None: from cyclonedx.schema import OutputFormat, SchemaVersion from cyclonedx.validation import make_schemabased_validator - validator = make_schemabased_validator(OutputFormat.XML, SchemaVersion.V1_3) + validator = make_schemabased_validator(OutputFormat.XML, SchemaVersion.V1_7) error = validator.validate_str(path.read_text()) if error is None: report_ok(label) diff --git a/src/output/cyclonedx.rs b/src/output/cyclonedx.rs index 541f25ab3..177bc92bb 100644 --- a/src/output/cyclonedx.rs +++ b/src/output/cyclonedx.rs @@ -10,13 +10,128 @@ use std::io::{self, Write}; use uuid::Uuid; use crate::output_schema::{ - Output, OutputPackage as Package, OutputTopLevelDependency as TopLevelDependency, + Output, OutputFileInfo, OutputFileType, OutputPackage as Package, + OutputTopLevelDependency as TopLevelDependency, }; use crate::utils::time::{convert_header_timestamp_to_iso_utc, fallback_iso_utc_timestamp}; use super::sbom::{self, InventoryEntry, SbomInventory}; use super::shared::{io_other, xml_escape}; +/// CycloneDX specification version this renderer emits. +const CYCLONEDX_SPEC_VERSION: &str = "1.7"; +/// Canonical `$schema` URL for the emitted JSON spec version. +const CYCLONEDX_JSON_SCHEMA: &str = "http://cyclonedx.org/schema/bom-1.7.schema.json"; +/// XML namespace for the emitted spec version. +const CYCLONEDX_XML_NAMESPACE: &str = "http://cyclonedx.org/schema/bom/1.7"; + +/// How a component's license expression was established, mapped onto the +/// CycloneDX `licenses[].acknowledgement` field. A parser-declared expression +/// (the same source SPDX renders as `PackageLicenseDeclared`) is `declared`; a +/// file/source-detected expression is `concluded`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum LicenseAcknowledgement { + Declared, + Concluded, +} + +impl LicenseAcknowledgement { + fn as_str(self) -> &'static str { + match self { + Self::Declared => "declared", + Self::Concluded => "concluded", + } + } +} + +/// A single file+line where a license was observed, for CycloneDX +/// `component.evidence.occurrences`. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +struct LicenseOccurrence { + location: String, + line: Option, +} + +/// Observed license evidence for one component: the distinct license +/// expressions detected in the files the component owns, and the file+line +/// occurrences that substantiate them (CycloneDX `component.evidence`). +struct LicenseEvidence { + licenses: Vec, + occurrences: Vec, +} + +/// Map each package `package_uid` to the license evidence observed in the +/// files it owns. Only file-content detections (`file.license_detections`) are +/// treated as evidence — the source-faithful "where a license was detected" +/// record — using the file→package ownership assembly already computed +/// (`file.for_packages`). A package that owns no scanned files with detections +/// (e.g. a promoted resolved dependency) has no entry, so it gets no evidence. +fn build_license_evidence(files: &[OutputFileInfo]) -> HashMap { + let mut licenses: HashMap<&str, BTreeSet> = HashMap::new(); + let mut occurrences: HashMap<&str, BTreeSet> = HashMap::new(); + + for file in files { + if file.file_type != OutputFileType::File + || file.for_packages.is_empty() + || file.license_detections.is_empty() + { + continue; + } + for detection in &file.license_detections { + let expression = detection.license_expression_spdx.trim(); + if expression.is_empty() { + continue; + } + // Each match records where the license text was found; a detection + // without matches still evidences the license at the file itself. + let detection_occurrences: Vec = if detection.matches.is_empty() { + vec![LicenseOccurrence { + location: file.path.clone(), + line: None, + }] + } else { + detection + .matches + .iter() + .map(|m| LicenseOccurrence { + location: m + .from_file + .as_deref() + .filter(|from| !from.is_empty()) + .unwrap_or(file.path.as_str()) + .to_string(), + line: Some(m.start_line), + }) + .collect() + }; + for uid in &file.for_packages { + licenses + .entry(uid.as_str()) + .or_default() + .insert(expression.to_string()); + occurrences + .entry(uid.as_str()) + .or_default() + .extend(detection_occurrences.iter().cloned()); + } + } + } + + licenses + .into_iter() + .map(|(uid, license_set)| { + let occurrence_set = occurrences.remove(uid).unwrap_or_default(); + ( + uid.to_string(), + LicenseEvidence { + licenses: license_set.into_iter().collect(), + occurrences: occurrence_set.into_iter().collect(), + }, + ) + }) + .collect() +} + /// CycloneDX `scope` from shared inventory intent. Detected packages are /// `required`; promoted dependencies map proven `is_optional` only. fn cyclonedx_scope(entry: &InventoryEntry<'_>) -> Option<&'static str> { @@ -42,13 +157,15 @@ pub(crate) fn write_cyclonedx_xml(output: &Output, writer: &mut dyn Write) -> io .unwrap_or_else(|| fallback_iso_utc_timestamp().to_string()); let inventory = sbom::build_inventory(output); let component_refs = component_bom_refs_for(&inventory); + let evidence_by_uid = build_license_evidence(&output.files); let dependency_graph = build_dependency_graph(&inventory, &output.dependencies, &component_refs); let mut xml = String::new(); xml.push_str("\n"); xml.push_str(&format!( - "\n", + "\n", + CYCLONEDX_XML_NAMESPACE, xml_escape(&serial) )); xml.push_str(" \n"); @@ -61,14 +178,16 @@ pub(crate) fn write_cyclonedx_xml(output: &Output, writer: &mut dyn Write) -> io xml.push_str("\n"); xml.push_str(" \n"); // `component` (the BOM subject) must follow `tools` per the CycloneDX - // 1.3 XSD `metadata` element order. + // 1.7 XSD `metadata` element order. if let Some(root_idx) = select_root_package_index(&output.packages) { + let root_pkg = &output.packages[root_idx]; write_component_xml( &mut xml, - &output.packages[root_idx], + root_pkg, None, "application", None, + evidence_by_uid.get(root_pkg.package_uid.as_str()), ); } xml.push_str(" \n"); @@ -81,6 +200,7 @@ pub(crate) fn write_cyclonedx_xml(output: &Output, writer: &mut dyn Write) -> io Some(&component_refs[idx]), "library", cyclonedx_scope(entry), + evidence_by_uid.get(entry.package().package_uid.as_str()), ); } xml.push_str(" \n"); @@ -108,11 +228,11 @@ pub(crate) fn write_cyclonedx_xml(output: &Output, writer: &mut dyn Write) -> io } /// Write a single `` element (used for both `metadata.component` -/// and entries in ``), in the CycloneDX 1.3 XSD element order: +/// and entries in ``), in the CycloneDX 1.7 XSD element order: /// `author` precedes `name`/`version`, which precede `description`, which /// precedes `scope`, `hashes`, `licenses`, `purl`, `externalReferences`. /// -/// `bom_ref` is `None` for `metadata.component`: CycloneDX 1.3 requires +/// `bom_ref` is `None` for `metadata.component`: CycloneDX requires /// every `bom-ref` in a document to be unique, so the root package's /// existing `components` entry (which already carries that `bom-ref`) is /// the single source of truth for it and `metadata.component` does not @@ -123,6 +243,7 @@ fn write_component_xml( bom_ref: Option<&str>, component_type: &str, scope: Option<&str>, + evidence: Option<&LicenseEvidence>, ) { let name = pkg.name.as_deref().unwrap_or("unknown"); let version = pkg.version.as_deref().unwrap_or("unknown"); @@ -166,12 +287,15 @@ fn write_component_xml( } xml.push_str(" \n"); } - if let Some(license_expression) = cyclonedx_license_expression(pkg) { - xml.push_str(" "); + if let Some((license_expression, acknowledgement)) = cyclonedx_license_expression(pkg) { + xml.push_str(&format!( + " ", + acknowledgement.as_str() + )); xml.push_str(&xml_escape(&license_expression)); xml.push_str("\n"); } - // `purl` must follow `licenses`/`copyright`/`cpe` per the CycloneDX 1.3 XSD. + // `purl` must follow `licenses`/`copyright`/`cpe` per the CycloneDX 1.7 XSD. if let Some(purl) = &pkg.purl { xml.push_str(" "); xml.push_str(&xml_escape(purl)); @@ -189,6 +313,36 @@ fn write_component_xml( } xml.push_str(" \n"); } + // `evidence` is the last `component` child per the CycloneDX 1.7 XSD, and + // within it `occurrences` precedes `licenses`. + if let Some(evidence) = evidence + && (!evidence.occurrences.is_empty() || !evidence.licenses.is_empty()) + { + xml.push_str(" \n"); + if !evidence.occurrences.is_empty() { + xml.push_str(" \n"); + for occurrence in &evidence.occurrences { + xml.push_str(" "); + xml.push_str(&xml_escape(&occurrence.location)); + xml.push_str(""); + if let Some(line) = occurrence.line { + xml.push_str(&format!("{line}")); + } + xml.push_str("\n"); + } + xml.push_str(" \n"); + } + if !evidence.licenses.is_empty() { + xml.push_str(" "); + for expression in &evidence.licenses { + xml.push_str(""); + xml.push_str(&xml_escape(expression)); + xml.push_str(""); + } + xml.push_str("\n"); + } + xml.push_str(" \n"); + } xml.push_str(" \n"); } @@ -200,6 +354,7 @@ fn build_cyclonedx_json(output: &Output) -> Value { .unwrap_or_else(|| fallback_iso_utc_timestamp().to_string()); let inventory = sbom::build_inventory(output); let component_refs = component_bom_refs_for(&inventory); + let evidence_by_uid = build_license_evidence(&output.files); let components = inventory .entries @@ -213,6 +368,7 @@ fn build_cyclonedx_json(output: &Output) -> Value { entry.package(), Some(&component_refs[idx]), cyclonedx_scope(entry), + evidence_by_uid.get(entry.package().package_uid.as_str()), ); Value::Object(obj) }) @@ -230,8 +386,9 @@ fn build_cyclonedx_json(output: &Output) -> Value { if components.is_empty() && dependencies.is_empty() { json!({ + "$schema": CYCLONEDX_JSON_SCHEMA, "bomFormat": "CycloneDX", - "specVersion": "1.3", + "specVersion": CYCLONEDX_SPEC_VERSION, "version": 1, "components": [], "dependencies": [], @@ -246,16 +403,24 @@ fn build_cyclonedx_json(output: &Output) -> Value { if let Some(root_idx) = select_root_package_index(&output.packages) { let mut root_obj = Map::new(); root_obj.insert("type".to_string(), Value::String("application".to_string())); - // No `bom-ref`: CycloneDX 1.3 requires every `bom-ref` in a + // No `bom-ref`: CycloneDX requires every `bom-ref` in a // document to be unique, and the root package's existing // `components` entry already carries that `bom-ref`. - component_json_fields(&mut root_obj, &output.packages[root_idx], None, None); + let root_pkg = &output.packages[root_idx]; + component_json_fields( + &mut root_obj, + root_pkg, + None, + None, + evidence_by_uid.get(root_pkg.package_uid.as_str()), + ); metadata.insert("component".to_string(), Value::Object(root_obj)); } json!({ + "$schema": CYCLONEDX_JSON_SCHEMA, "bomFormat": "CycloneDX", - "specVersion": "1.3", + "specVersion": CYCLONEDX_SPEC_VERSION, "serialNumber": format!("urn:uuid:{}", Uuid::new_v4()), "version": 1, "metadata": metadata, @@ -269,13 +434,14 @@ fn build_cyclonedx_json(output: &Output) -> Value { /// an optional `bom-ref` (omitted for `metadata.component`, see /// [`build_cyclonedx_json`]), `name`, `version`, `description`, `author`, /// an optional `scope` (regular components only; `metadata.component` has -/// no scope of its own), `purl`, `hashes`, `licenses`, -/// `externalReferences`. Callers insert `type` before calling this. +/// no scope of its own), `purl`, `hashes`, `licenses`, `externalReferences`, +/// and observed license `evidence`. Callers insert `type` before calling this. fn component_json_fields( obj: &mut Map, pkg: &Package, bom_ref: Option<&str>, scope: Option<&str>, + evidence: Option<&LicenseEvidence>, ) { if let Some(bom_ref) = bom_ref { obj.insert("bom-ref".to_string(), Value::String(bom_ref.to_string())); @@ -310,10 +476,13 @@ fn component_json_fields( if !hashes.is_empty() { obj.insert("hashes".to_string(), Value::Array(hashes)); } - if let Some(license_expression) = cyclonedx_license_expression(pkg) { + if let Some((license_expression, acknowledgement)) = cyclonedx_license_expression(pkg) { obj.insert( "licenses".to_string(), - Value::Array(vec![json!({ "expression": license_expression })]), + Value::Array(vec![json!({ + "expression": license_expression, + "acknowledgement": acknowledgement.as_str(), + })]), ); } let external_refs = component_external_references(pkg) @@ -326,6 +495,38 @@ fn component_json_fields( Value::Array(external_refs), ); } + if let Some(evidence) = evidence { + let mut evidence_obj = Map::new(); + if !evidence.occurrences.is_empty() { + let occurrences = evidence + .occurrences + .iter() + .map(|occurrence| { + let mut entry = Map::new(); + entry.insert( + "location".to_string(), + Value::String(occurrence.location.clone()), + ); + if let Some(line) = occurrence.line { + entry.insert("line".to_string(), json!(line)); + } + Value::Object(entry) + }) + .collect(); + evidence_obj.insert("occurrences".to_string(), Value::Array(occurrences)); + } + if !evidence.licenses.is_empty() { + let licenses = evidence + .licenses + .iter() + .map(|expression| json!({ "expression": expression })) + .collect(); + evidence_obj.insert("licenses".to_string(), Value::Array(licenses)); + } + if !evidence_obj.is_empty() { + obj.insert("evidence".to_string(), Value::Object(evidence_obj)); + } + } } /// Assign a unique `bom-ref` per package. Prefer purl when it does not collide; @@ -427,14 +628,22 @@ fn build_dependency_graph( graph } -fn cyclonedx_license_expression(pkg: &Package) -> Option { +/// The single license expression to emit for a component, paired with how it +/// was established. Preference order: declared SPDX expression, then declared +/// raw expression (both parser-`declared`), then the first source/file +/// detection (`concluded`). +fn cyclonedx_license_expression(pkg: &Package) -> Option<(String, LicenseAcknowledgement)> { pkg.declared_license_expression_spdx .clone() .or_else(|| pkg.declared_license_expression.clone()) + .map(|expression| (expression, LicenseAcknowledgement::Declared)) .or_else(|| { - pkg.license_detections - .first() - .map(|d| d.license_expression_spdx.clone()) + pkg.license_detections.first().map(|d| { + ( + d.license_expression_spdx.clone(), + LicenseAcknowledgement::Concluded, + ) + }) }) } @@ -740,9 +949,218 @@ mod tests { assert_eq!(select_root_package_index(&packages), Some(1)); } + fn package_from_data(data: crate::models::PackageData) -> Package { + Package::from(&crate::models::Package::from_package_data( + &data, + "package.json".to_string(), + )) + } + + #[test] + fn license_expression_tags_parser_declared_as_declared() { + let pkg = package_from_data(crate::models::PackageData { + package_type: Some(crate::models::PackageType::Npm), + declared_license_expression: Some("mit".to_string()), + declared_license_expression_spdx: Some("MIT".to_string()), + ..Default::default() + }); + assert_eq!( + cyclonedx_license_expression(&pkg), + Some(("MIT".to_string(), LicenseAcknowledgement::Declared)) + ); + } + + #[test] + fn license_expression_tags_source_detection_as_concluded() { + let pkg = package_from_data(crate::models::PackageData { + package_type: Some(crate::models::PackageType::Npm), + // No declared expression: the only evidence is a source/file + // detection, which CycloneDX marks `concluded`. + license_detections: vec![crate::models::LicenseDetection { + license_expression: "apache-2.0".to_string(), + license_expression_spdx: "Apache-2.0".to_string(), + matches: vec![], + detection_log: vec![], + identifier: String::new(), + }], + ..Default::default() + }); + assert_eq!( + cyclonedx_license_expression(&pkg), + Some(("Apache-2.0".to_string(), LicenseAcknowledgement::Concluded)) + ); + } + + #[test] + fn license_expression_prefers_declared_over_detection() { + // A package that both declares and has a detection reports the declared + // expression tagged `declared` (unchanged selection, matching SPDX). + let pkg = package_from_data(crate::models::PackageData { + package_type: Some(crate::models::PackageType::Npm), + declared_license_expression_spdx: Some("MIT".to_string()), + license_detections: vec![crate::models::LicenseDetection { + license_expression: "apache-2.0".to_string(), + license_expression_spdx: "Apache-2.0".to_string(), + matches: vec![], + detection_log: vec![], + identifier: String::new(), + }], + ..Default::default() + }); + assert_eq!( + cyclonedx_license_expression(&pkg), + Some(("MIT".to_string(), LicenseAcknowledgement::Declared)) + ); + } + + #[test] + fn json_emits_spec_version_schema_and_license_acknowledgement() { + let mut declared = package_from_data(crate::models::PackageData { + package_type: Some(crate::models::PackageType::Npm), + name: Some("declared-pkg".to_string()), + version: Some("1.0.0".to_string()), + purl: Some("pkg:npm/declared-pkg@1.0.0".to_string()), + declared_license_expression_spdx: Some("MIT".to_string()), + ..Default::default() + }); + declared.package_uid = "uid-declared".to_string(); + let output = Output { + summary: None, + tallies: None, + tallies_of_key_files: None, + tallies_by_facet: None, + headers: vec![], + packages: vec![declared], + dependencies: vec![], + license_detections: vec![], + files: vec![], + license_references: vec![], + license_rule_references: vec![], + }; + + let bom = build_cyclonedx_json(&output); + assert_eq!(bom["specVersion"], "1.7"); + assert_eq!(bom["$schema"], CYCLONEDX_JSON_SCHEMA); + assert_eq!(bom["bomFormat"], "CycloneDX"); + let license = &bom["components"][0]["licenses"][0]; + assert_eq!(license["expression"], "MIT"); + assert_eq!(license["acknowledgement"], "declared"); + } + + #[test] + fn license_evidence_maps_file_detections_to_owning_package() { + use crate::models::{FileInfo, FileType, LicenseDetection, LineNumber, Match, MatchScore}; + let detection = |spdx: &str, path: &str, line: usize| LicenseDetection { + license_expression: spdx.to_lowercase(), + license_expression_spdx: spdx.to_string(), + matches: vec![Match { + license_expression: spdx.to_lowercase(), + license_expression_spdx: spdx.to_string(), + from_file: Some(path.to_string()), + start_line: LineNumber::new(line).unwrap(), + end_line: LineNumber::new(line).unwrap(), + matcher: crate::license_detection::MatcherKind::Hash, + score: MatchScore::MAX, + matched_length: Some(1), + match_coverage: Some(100.0), + rule_relevance: Some(100), + rule_identifier: String::new(), + rule_url: None, + matched_text: None, + referenced_filenames: None, + matched_text_diagnostics: None, + }], + detection_log: vec![], + identifier: String::new(), + }; + let make_file = |path: &str, det: LicenseDetection| { + let mut file = FileInfo::new( + path.to_string(), + path.to_string(), + String::new(), + path.to_string(), + FileType::File, + None, + None, + 1, + None, + None, + None, + None, + None, + vec![], + None, + vec![det], + vec![], + vec![], + vec![], + vec![], + vec![], + vec![], + vec![], + vec![], + ); + file.for_packages = vec![crate::models::PackageUid::from_raw( + "uid-widget".to_string(), + )]; + crate::output_schema::OutputFileInfo::from(&file) + }; + + let files = vec![ + make_file("scan/LICENSE", detection("MIT", "scan/LICENSE", 1)), + make_file( + "scan/src/main.js", + detection("Apache-2.0", "scan/src/main.js", 5), + ), + ]; + let evidence = build_license_evidence(&files); + let widget = evidence.get("uid-widget").expect("owning package evidence"); + // Licenses are deduped + sorted; occurrences sorted by (location, line). + assert_eq!( + widget.licenses, + vec!["Apache-2.0".to_string(), "MIT".to_string()] + ); + assert_eq!( + widget.occurrences, + vec![ + LicenseOccurrence { + location: "scan/LICENSE".to_string(), + line: Some(1) + }, + LicenseOccurrence { + location: "scan/src/main.js".to_string(), + line: Some(5) + }, + ] + ); + + // A package that owns no files with detections gets no evidence entry. + assert!(build_license_evidence(&[]).is_empty()); + } + + #[test] + fn empty_document_carries_spec_version_and_schema() { + let output = Output { + summary: None, + tallies: None, + tallies_of_key_files: None, + tallies_by_facet: None, + headers: vec![], + packages: vec![], + dependencies: vec![], + license_detections: vec![], + files: vec![], + license_references: vec![], + license_rule_references: vec![], + }; + let bom = build_cyclonedx_json(&output); + assert_eq!(bom["specVersion"], "1.7"); + assert_eq!(bom["$schema"], CYCLONEDX_JSON_SCHEMA); + } + #[test] fn metadata_component_has_no_bom_ref_to_avoid_duplicating_the_unique_key() { - // CycloneDX 1.3 requires every `bom-ref` in a document to be unique. + // CycloneDX requires every `bom-ref` in a document to be unique. // The root package already gets a `bom-ref` in `components`, so // `metadata.component` must not repeat it (schema-guard against // reintroducing the duplicate-key regression). diff --git a/src/output/mod.rs b/src/output/mod.rs index 2ffe75602..fc767f694 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -977,7 +977,11 @@ mod tests { let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8"); let value: Value = serde_json::from_str(&rendered).expect("valid json"); assert_eq!(value["bomFormat"], "CycloneDX"); - assert_eq!(value["specVersion"], "1.3"); + assert_eq!(value["specVersion"], "1.7"); + assert_eq!( + value["$schema"], + "http://cyclonedx.org/schema/bom-1.7.schema.json" + ); } #[test] @@ -1456,7 +1460,7 @@ mod tests { .expect("cyclonedx xml write should succeed"); let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8"); - assert!(rendered.contains("")); } diff --git a/testdata/output-formats/cyclonedx-dependencies-expected.json b/testdata/output-formats/cyclonedx-dependencies-expected.json index fc3bc9b55..ee8155dee 100644 --- a/testdata/output-formats/cyclonedx-dependencies-expected.json +++ b/testdata/output-formats/cyclonedx-dependencies-expected.json @@ -1,6 +1,7 @@ { + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.3", + "specVersion": "1.7", "serialNumber": "urn:uuid:4504b45a-f05a-47e3-81c8-5080f4eecd2b", "version": 1, "metadata": { @@ -52,7 +53,8 @@ "purl": "pkg:npm/left-pad@1.3.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ] } diff --git a/testdata/output-formats/cyclonedx-dependencies-expected.xml b/testdata/output-formats/cyclonedx-dependencies-expected.xml index 43be6f1d6..e8a167363 100644 --- a/testdata/output-formats/cyclonedx-dependencies-expected.xml +++ b/testdata/output-formats/cyclonedx-dependencies-expected.xml @@ -1,5 +1,5 @@ - + 2026-01-01T00:00:01Z @@ -34,7 +34,7 @@ left-pad 1.3.0 required - MIT + MIT pkg:npm/left-pad@1.3.0 diff --git a/testdata/output-formats/cyclonedx-evidence-expected.json b/testdata/output-formats/cyclonedx-evidence-expected.json new file mode 100644 index 000000000..3140ce925 --- /dev/null +++ b/testdata/output-formats/cyclonedx-evidence-expected.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "serialNumber": "urn:uuid:de659fb0-e048-459f-a959-88e2bab03856", + "version": 1, + "metadata": { + "timestamp": "2026-01-01T00:00:01Z", + "tools": [ + { + "name": "Provenant", + "version": "1.0.2-2-g480e2d6b-dirty" + } + ], + "component": { + "type": "application", + "name": "widget", + "version": "1.0.0", + "purl": "pkg:npm/widget@1.0.0", + "licenses": [ + { + "expression": "MIT", + "acknowledgement": "declared" + } + ], + "evidence": { + "occurrences": [ + { + "location": "scan/LICENSE", + "line": 1 + }, + { + "location": "scan/src/main.js", + "line": 5 + } + ], + "licenses": [ + { + "expression": "Apache-2.0" + }, + { + "expression": "MIT" + } + ] + } + } + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:npm/widget@1.0.0", + "name": "widget", + "version": "1.0.0", + "scope": "required", + "purl": "pkg:npm/widget@1.0.0", + "licenses": [ + { + "expression": "MIT", + "acknowledgement": "declared" + } + ], + "evidence": { + "occurrences": [ + { + "location": "scan/LICENSE", + "line": 1 + }, + { + "location": "scan/src/main.js", + "line": 5 + } + ], + "licenses": [ + { + "expression": "Apache-2.0" + }, + { + "expression": "MIT" + } + ] + } + } + ], + "dependencies": [ + { + "ref": "pkg:npm/widget@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/testdata/output-formats/cyclonedx-evidence-expected.xml b/testdata/output-formats/cyclonedx-evidence-expected.xml new file mode 100644 index 000000000..d7b025c70 --- /dev/null +++ b/testdata/output-formats/cyclonedx-evidence-expected.xml @@ -0,0 +1,42 @@ + + + + 2026-01-01T00:00:01Z + + ProvenantProvenant1.0.2-2-g480e2d6b-dirty + + + widget + 1.0.0 + MIT + pkg:npm/widget@1.0.0 + + + scan/LICENSE1 + scan/src/main.js5 + + Apache-2.0MIT + + + + + + widget + 1.0.0 + required + MIT + pkg:npm/widget@1.0.0 + + + scan/LICENSE1 + scan/src/main.js5 + + Apache-2.0MIT + + + + + + + + diff --git a/testdata/output-formats/cyclonedx-expected-without-packages.json b/testdata/output-formats/cyclonedx-expected-without-packages.json index 2ffc20243..5dc0e458d 100644 --- a/testdata/output-formats/cyclonedx-expected-without-packages.json +++ b/testdata/output-formats/cyclonedx-expected-without-packages.json @@ -1,6 +1,7 @@ { + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.3", + "specVersion": "1.7", "version": 1, "components": [], "dependencies": [] diff --git a/testdata/output-formats/cyclonedx-expected.json b/testdata/output-formats/cyclonedx-expected.json index 46b7539e9..a7e54e713 100644 --- a/testdata/output-formats/cyclonedx-expected.json +++ b/testdata/output-formats/cyclonedx-expected.json @@ -1,6 +1,7 @@ { + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.3", + "specVersion": "1.7", "serialNumber": "urn:uuid:ce548f93-c5aa-499a-834c-a0233495928f", "version": 1, "metadata": { @@ -26,7 +27,8 @@ ], "licenses": [ { - "expression": "Artistic-2.0" + "expression": "Artistic-2.0", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -75,7 +77,8 @@ ], "licenses": [ { - "expression": "Artistic-2.0" + "expression": "Artistic-2.0", + "acknowledgement": "declared" } ], "externalReferences": [ diff --git a/testdata/output-formats/cyclonedx-expected.xml b/testdata/output-formats/cyclonedx-expected.xml index bc5c209b3..8c2346e08 100644 --- a/testdata/output-formats/cyclonedx-expected.xml +++ b/testdata/output-formats/cyclonedx-expected.xml @@ -1,5 +1,5 @@ - + 2023-03-31T10:36:22Z @@ -18,7 +18,7 @@ a124386bce4a90506f28ad4b1d1a804a17baaf32 - Artistic-2.0 + Artistic-2.0 pkg:npm/npm@2.13.5 @@ -39,7 +39,7 @@ a124386bce4a90506f28ad4b1d1a804a17baaf32 - Artistic-2.0 + Artistic-2.0 pkg:npm/npm@2.13.5 diff --git a/tests/output_format_golden.rs b/tests/output_format_golden.rs index b474028c4..79132ea24 100644 --- a/tests/output_format_golden.rs +++ b/tests/output_format_golden.rs @@ -1093,7 +1093,11 @@ fn test_cyclonedx_rich_output_contains_enriched_fields_json_and_xml() { .expect("cyclonedx json output should be generated"); let json_value: Value = serde_json::from_slice(&json_bytes).expect("json must parse"); assert_eq!(json_value["bomFormat"], "CycloneDX"); - assert_eq!(json_value["specVersion"], "1.3"); + assert_eq!(json_value["specVersion"], "1.7"); + assert_eq!( + json_value["$schema"], + "http://cyclonedx.org/schema/bom-1.7.schema.json" + ); assert_eq!(json_value["version"], 1); assert!( json_value["serialNumber"] @@ -1108,6 +1112,9 @@ fn test_cyclonedx_rich_output_contains_enriched_fields_json_and_xml() { assert_eq!(component["scope"], "required"); assert!(component["hashes"].is_array()); assert!(component["externalReferences"].is_array()); + // The npm package declares its license, so CycloneDX marks it `declared`. + assert_eq!(component["licenses"][0]["expression"], "Artistic-2.0"); + assert_eq!(component["licenses"][0]["acknowledgement"], "declared"); let mut xml_bytes = Vec::new(); writer_for_format(OutputFormat::CycloneDxXml) @@ -1122,12 +1129,16 @@ fn test_cyclonedx_rich_output_contains_enriched_fields_json_and_xml() { ) .expect("cyclonedx xml output should be generated"); let xml = String::from_utf8(xml_bytes).expect("xml must be utf-8"); + assert!(xml.contains("xmlns=\"http://cyclonedx.org/schema/bom/1.7\"")); assert!(xml.contains("Provenant")); assert!(xml.contains("a package manager for JavaScript")); assert!(xml.contains("Isaac Z. Schlueter")); assert!(xml.contains("required")); assert!(xml.contains("")); assert!(xml.contains("")); + assert!(xml.contains( + "Artistic-2.0" + )); } #[test] @@ -1278,6 +1289,83 @@ fn test_cyclonedx_xml_matches_local_fixture_after_normalization() { ); } +#[test] +fn test_cyclonedx_json_evidence_matches_local_fixture_after_normalization() { + let output = sample_cyclonedx_evidence_output(); + let mut bytes = Vec::new(); + let schema_output = OutputSchemaOutput::from(&output); + writer_for_format(OutputFormat::CycloneDxJson) + .write( + &schema_output, + &mut bytes, + &OutputWriteConfig { + format: OutputFormat::CycloneDxJson, + custom_template: None, + scanned_path: Some("scan".to_string()), + }, + ) + .expect("cyclonedx json output should be generated"); + + let actual: Value = serde_json::from_slice(&bytes).expect("cyclonedx json should be valid"); + let expected_text = + fs::read_to_string("testdata/output-formats/cyclonedx-evidence-expected.json") + .expect("cyclonedx evidence json fixture should be readable"); + let expected: Value = serde_json::from_str(&expected_text) + .expect("cyclonedx evidence json fixture should be valid"); + + assert_eq!( + normalize_cyclonedx(actual.clone()), + normalize_cyclonedx(expected) + ); + + // The declared license is `declared`; the source-observed detections become + // component evidence with file + line occurrences. + let component = &actual["components"][0]; + assert_eq!(component["licenses"][0]["acknowledgement"], "declared"); + assert_eq!( + component["evidence"]["licenses"][0]["expression"], + "Apache-2.0" + ); + assert_eq!(component["evidence"]["licenses"][1]["expression"], "MIT"); + assert_eq!( + component["evidence"]["occurrences"][0]["location"], + "scan/LICENSE" + ); + assert_eq!(component["evidence"]["occurrences"][0]["line"], 1); + assert_eq!( + component["evidence"]["occurrences"][1]["location"], + "scan/src/main.js" + ); + assert_eq!(component["evidence"]["occurrences"][1]["line"], 5); +} + +#[test] +fn test_cyclonedx_xml_evidence_matches_local_fixture_after_normalization() { + let output = sample_cyclonedx_evidence_output(); + let mut bytes = Vec::new(); + let schema_output = OutputSchemaOutput::from(&output); + writer_for_format(OutputFormat::CycloneDxXml) + .write( + &schema_output, + &mut bytes, + &OutputWriteConfig { + format: OutputFormat::CycloneDxXml, + custom_template: None, + scanned_path: Some("scan".to_string()), + }, + ) + .expect("cyclonedx xml output should be generated"); + + let actual = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8"); + let expected = fs::read_to_string("testdata/output-formats/cyclonedx-evidence-expected.xml") + .expect("cyclonedx evidence xml fixture should be readable"); + + assert_eq!( + normalize_cyclonedx_xml(&actual), + normalize_cyclonedx_xml(&expected) + ); +} + fn normalize_cyclonedx(mut value: Value) -> Value { if let Some(obj) = value.as_object_mut() { obj.remove("serialNumber"); @@ -1308,7 +1396,7 @@ fn normalize_cyclonedx_xml(xml: &str) -> String { let normalized = bom_open_re.replace_all( xml, - "", + "", ); let normalized = serial_re.replace_all(&normalized, "serialNumber=\"urn:uuid:NORMALIZED\""); let normalized = timestamp_re.replace_all(&normalized, "NORMALIZED"); @@ -2538,6 +2626,93 @@ fn sample_cyclonedx_dependency_output() -> Output { ) } +/// A single package that declares MIT and owns two files whose file-content +/// detections observe MIT (in `LICENSE`) and Apache-2.0 (in a source file). +/// Exercises CycloneDX `component.evidence`: the declared license is tagged +/// `declared`, while the observed detections become `evidence.licenses` + +/// `evidence.occurrences` (file + line) on the owning component. +fn sample_cyclonedx_evidence_output() -> Output { + let widget_uid = PackageUid::from_raw( + "pkg:npm/widget@1.0.0?uuid=00000000-0000-0000-0000-0000000000e1".to_string(), + ); + let widget = { + let mut pkg = Package::from_package_data( + &PackageData { + package_type: Some(PackageType::Npm), + name: Some("widget".to_string()), + version: Some("1.0.0".to_string()), + purl: Some("pkg:npm/widget@1.0.0".to_string()), + declared_license_expression: Some("mit".to_string()), + declared_license_expression_spdx: Some("MIT".to_string()), + ..Default::default() + }, + "scan/package.json".to_string(), + ); + pkg.package_uid = widget_uid.clone(); + pkg + }; + + let file_detection = |key: &str, spdx: &str, path: &str, line: usize| { + let n = LineNumber::new(line).expect("line >= 1"); + provenant::models::LicenseDetection { + license_expression: key.to_string(), + license_expression_spdx: spdx.to_string(), + matches: vec![provenant::models::Match { + license_expression: key.to_string(), + license_expression_spdx: spdx.to_string(), + from_file: Some(path.to_string()), + start_line: n, + end_line: n, + matcher: MatcherKind::Hash, + score: MatchScore::MAX, + matched_length: Some(1), + match_coverage: Some(100.0), + rule_relevance: Some(100), + rule_identifier: format!("{key}_1.RULE"), + rule_url: None, + matched_text: Some(spdx.to_string()), + referenced_filenames: None, + matched_text_diagnostics: None, + }], + detection_log: vec![], + identifier: String::new(), + } + }; + + let mut license_file = sample_plain_text_file( + "LICENSE", + "LICENSE", + "", + "scan/LICENSE", + 40, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + vec![], + ); + license_file.detected_license_expression = Some("MIT".to_string()); + license_file.license_detections = vec![file_detection("mit", "MIT", "scan/LICENSE", 1)]; + license_file.for_packages = vec![widget_uid.clone()]; + + let mut source_file = sample_plain_text_file( + "main.js", + "main", + ".js", + "scan/src/main.js", + 80, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + vec![], + ); + source_file.detected_license_expression = Some("Apache-2.0".to_string()); + source_file.license_detections = vec![file_detection( + "apache-2.0", + "Apache-2.0", + "scan/src/main.js", + 5, + )]; + source_file.for_packages = vec![widget_uid]; + + sample_output_with_sections(2, 0, vec![widget], vec![], vec![license_file, source_file]) +} + /// A scanned subject package that owns files, plus one promoted resolved /// dependency. Unlike [`sample_cyclonedx_dependency_output`], this carries real /// files so it exercises SPDX tag-value's positional file→package association.