From 480e2d6b24a67bced484b0e581165d3882ea6771 Mon Sep 17 00:00:00 2001 From: Maxim Stykow Date: Fri, 24 Jul 2026 16:13:19 +0200 Subject: [PATCH 1/2] feat(cyclonedx): emit CycloneDX 1.7 with license acknowledgement Bump the CycloneDX renderer from spec 1.3 to 1.7 and populate licenses[].acknowledgement (declared vs concluded), the distinction Provenant already expresses in SPDX. A parser-declared license expression is `declared`; a license concluded from source/file detection is `concluded`, mapped from the same package fields the SPDX writer uses. Only the CycloneDX renderer changes: JSON specVersion, $schema, and the XML bom namespace move to 1.7, and every licenses[] entry carries an acknowledgement. SPDX and the native ScanCode-compatible JSON are unchanged. Component license evidence (evidence.licenses / evidence.occurrences) is split to a documented follow-up: a clean component-level file->license mapping is more than a moderate change in a renderer that does not otherwise consume output.files. - Switch the external validator to CycloneDX SchemaVersion.V1_7 (the pinned cyclonedx-python-lib already exposes it) and CYCLONEDX_SPEC_VERSION to 1.7. - Regenerate CycloneDX goldens and the four examples/sbom; all validate clean against the official 1.7 JSON schema + XSD, and SPDX still validates. - Document the bump + acknowledgement as an amendment to ADR 0012. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Maxim Stykow --- ...012-sbom-resolved-dependency-components.md | 33 +++ examples/sbom/express/sbom.cdx.json | 13 +- examples/sbom/express/sbom.spdx | 2 +- examples/sbom/flask/sbom.cdx.json | 19 +- examples/sbom/flask/sbom.spdx | 2 +- examples/sbom/ripgrep/sbom.cdx.json | 40 ++-- examples/sbom/ripgrep/sbom.spdx | 2 +- examples/sbom/tokio/sbom.cdx.json | 37 ++-- examples/sbom/tokio/sbom.spdx | 2 +- scripts/validate_output_format_fixtures.py | 8 +- src/output/cyclonedx.rs | 195 ++++++++++++++++-- src/output/mod.rs | 8 +- .../cyclonedx-dependencies-expected.json | 6 +- .../cyclonedx-dependencies-expected.xml | 4 +- .../cyclonedx-expected-without-packages.json | 3 +- .../output-formats/cyclonedx-expected.json | 9 +- .../output-formats/cyclonedx-expected.xml | 6 +- tests/output_format_golden.rs | 15 +- 18 files changed, 325 insertions(+), 79 deletions(-) diff --git a/docs/adr/0012-sbom-resolved-dependency-components.md b/docs/adr/0012-sbom-resolved-dependency-components.md index ce89c4495..4565338f0 100644 --- a/docs/adr/0012-sbom-resolved-dependency-components.md +++ b/docs/adr/0012-sbom-resolved-dependency-components.md @@ -178,6 +178,37 @@ 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 ``. +- **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`). + +Component license **evidence** (`evidence.licenses` / `evidence.occurrences` +with file + line) is a documented follow-up, not part of this contract: a clean +component-level file→license mapping is more than a moderate change in a +renderer that does not otherwise consume `output.files`. + ## Consequences ### Benefits @@ -222,6 +253,8 @@ unset license. - Issue #1319 - Issue #1320 (versioned purls + vendored-license join amendment) +- Issue #1328 (CycloneDX 1.7 + license acknowledgement amendment) +- Issue #1330 (component license evidence follow-up) - Promotion/dedup: `src/output/sbom.rs` - CycloneDX renderer: `src/output/cyclonedx.rs` - SPDX renderer: `src/output/spdx.rs` diff --git a/examples/sbom/express/sbom.cdx.json b/examples/sbom/express/sbom.cdx.json index 6340f8ec9..1f627eff3 100644 --- a/examples/sbom/express/sbom.cdx.json +++ b/examples/sbom/express/sbom.cdx.json @@ -1,10 +1,11 @@ { + "$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:647b41f1-9c9e-43aa-b13d-8cfc458f218c", "version": 1, "metadata": { - "timestamp": "2026-07-24T13:05:02Z", + "timestamp": "2026-07-24T14:05:45Z", "tools": [ { "name": "Provenant", @@ -20,7 +21,8 @@ "purl": "pkg:npm/express@5.2.1", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -59,7 +61,8 @@ "purl": "pkg:npm/express@5.2.1", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ diff --git a/examples/sbom/express/sbom.spdx b/examples/sbom/express/sbom.spdx index ecdc97f02..765e001af 100644 --- a/examples/sbom/express/sbom.spdx +++ b/examples/sbom/express/sbom.spdx @@ -13,7 +13,7 @@ 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 +Created: 2026-07-24T14:05:44Z ## Package Information PackageName: express SPDXID: SPDXRef-Package-1 diff --git a/examples/sbom/flask/sbom.cdx.json b/examples/sbom/flask/sbom.cdx.json index 7980b8735..18eb38e69 100644 --- a/examples/sbom/flask/sbom.cdx.json +++ b/examples/sbom/flask/sbom.cdx.json @@ -1,10 +1,11 @@ { + "$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:258f8af3-9f4f-4016-94ad-5ef0ccb6390c", "version": 1, "metadata": { - "timestamp": "2026-07-24T13:05:04Z", + "timestamp": "2026-07-24T14:05:49Z", "tools": [ { "name": "Provenant", @@ -20,7 +21,8 @@ "purl": "pkg:pypi/flask@3.1.3", "licenses": [ { - "expression": "BSD-3-Clause" + "expression": "BSD-3-Clause", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -74,7 +76,8 @@ "purl": "pkg:pypi/flask@3.1.3", "licenses": [ { - "expression": "BSD-3-Clause" + "expression": "BSD-3-Clause", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -103,7 +106,8 @@ "purl": "pkg:pypi/flaskr@1.0.0", "licenses": [ { - "expression": "BSD-3-Clause" + "expression": "BSD-3-Clause", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -132,7 +136,8 @@ "purl": "pkg:pypi/js-example@1.1.0", "licenses": [ { - "expression": "BSD-3-Clause" + "expression": "BSD-3-Clause", + "acknowledgement": "declared" } ], "externalReferences": [ diff --git a/examples/sbom/flask/sbom.spdx b/examples/sbom/flask/sbom.spdx index 73b7f713c..a3390d1db 100644 --- a/examples/sbom/flask/sbom.spdx +++ b/examples/sbom/flask/sbom.spdx @@ -13,7 +13,7 @@ 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 +Created: 2026-07-24T14:05:48Z ## Package Information PackageName: flask-example-celery SPDXID: SPDXRef-Package-1 diff --git a/examples/sbom/ripgrep/sbom.cdx.json b/examples/sbom/ripgrep/sbom.cdx.json index 1ac0dcbe8..d66025c1c 100644 --- a/examples/sbom/ripgrep/sbom.cdx.json +++ b/examples/sbom/ripgrep/sbom.cdx.json @@ -1,10 +1,11 @@ { + "$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:c92e1b1c-6b2b-4ff6-ab6a-b5dbe1f2272d", "version": 1, "metadata": { - "timestamp": "2026-07-24T13:04:59Z", + "timestamp": "2026-07-24T14:05:43Z", "tools": [ { "name": "Provenant", @@ -20,7 +21,8 @@ "purl": "pkg:cargo/ripgrep@15.2.0", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -81,7 +83,8 @@ "purl": "pkg:cargo/globset@0.4.19", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -118,7 +121,8 @@ "purl": "pkg:cargo/grep-cli@0.1.12", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -155,7 +159,8 @@ "purl": "pkg:cargo/grep-matcher@0.1.9", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -192,7 +197,8 @@ "purl": "pkg:cargo/grep-pcre2@0.1.10", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -229,7 +235,8 @@ "purl": "pkg:cargo/grep-printer@0.3.1", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -266,7 +273,8 @@ "purl": "pkg:cargo/grep-regex@0.1.14", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -303,7 +311,8 @@ "purl": "pkg:cargo/grep-searcher@0.1.17", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -340,7 +349,8 @@ "purl": "pkg:cargo/grep@0.4.1", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -377,7 +387,8 @@ "purl": "pkg:cargo/ignore@0.4.29", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -414,7 +425,8 @@ "purl": "pkg:cargo/ripgrep@15.2.0", "licenses": [ { - "expression": "MIT OR Unlicense" + "expression": "MIT OR Unlicense", + "acknowledgement": "declared" } ], "externalReferences": [ diff --git a/examples/sbom/ripgrep/sbom.spdx b/examples/sbom/ripgrep/sbom.spdx index d1bf0eb86..3b8f4b2b6 100644 --- a/examples/sbom/ripgrep/sbom.spdx +++ b/examples/sbom/ripgrep/sbom.spdx @@ -13,7 +13,7 @@ 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 +Created: 2026-07-24T14:05:42Z ## Package Information PackageName: fuzz SPDXID: SPDXRef-Package-1 diff --git a/examples/sbom/tokio/sbom.cdx.json b/examples/sbom/tokio/sbom.cdx.json index b97d06e14..50680fb9e 100644 --- a/examples/sbom/tokio/sbom.cdx.json +++ b/examples/sbom/tokio/sbom.cdx.json @@ -1,10 +1,11 @@ { + "$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:c6e24877-6586-467d-92b7-4f00db357b0e", "version": 1, "metadata": { - "timestamp": "2026-07-24T13:05:06Z", + "timestamp": "2026-07-24T14:05:55Z", "tools": [ { "name": "Provenant", @@ -22,7 +23,8 @@ "purl": "pkg:cargo/benches@0.0.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -49,7 +51,8 @@ "purl": "pkg:cargo/examples@0.0.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -77,7 +80,8 @@ "purl": "pkg:cargo/stress-test@0.1.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -105,7 +109,8 @@ "purl": "pkg:cargo/tests-build@0.1.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -133,7 +138,8 @@ "purl": "pkg:cargo/tests-integration@0.1.0", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -184,7 +190,8 @@ "purl": "pkg:cargo/tokio-macros@2.7.1", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -243,7 +250,8 @@ "purl": "pkg:cargo/tokio-stream@0.1.18", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -280,7 +288,8 @@ "purl": "pkg:cargo/tokio-test@0.4.5", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -317,7 +326,8 @@ "purl": "pkg:cargo/tokio-util@0.7.18", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ @@ -354,7 +364,8 @@ "purl": "pkg:cargo/tokio@1.53.1", "licenses": [ { - "expression": "MIT" + "expression": "MIT", + "acknowledgement": "declared" } ], "externalReferences": [ diff --git a/examples/sbom/tokio/sbom.spdx b/examples/sbom/tokio/sbom.spdx index e9d0a639a..c5f5a5cb4 100644 --- a/examples/sbom/tokio/sbom.spdx +++ b/examples/sbom/tokio/sbom.spdx @@ -13,7 +13,7 @@ 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 +Created: 2026-07-24T14:05:54Z ## 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..9cd2e9c48 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. @@ -66,7 +66,7 @@ "spdx-dependencies-expected.tv", ] -CYCLONEDX_SPEC_VERSION = "1.3" +CYCLONEDX_SPEC_VERSION = "1.7" def report_ok(label: str) -> None: @@ -87,7 +87,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 +99,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..864f9ab6c 100644 --- a/src/output/cyclonedx.rs +++ b/src/output/cyclonedx.rs @@ -17,6 +17,32 @@ use crate::utils::time::{convert_header_timestamp_to_iso_utc, fallback_iso_utc_t 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", + } + } +} + /// 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> { @@ -48,7 +74,8 @@ pub(crate) fn write_cyclonedx_xml(output: &Output, writer: &mut dyn Write) -> io 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,7 +88,7 @@ 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) { write_component_xml( &mut xml, @@ -108,11 +135,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 @@ -166,12 +193,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)); @@ -230,8 +260,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,7 +277,7 @@ 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); @@ -254,8 +285,9 @@ fn build_cyclonedx_json(output: &Output) -> Value { } 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, @@ -310,10 +342,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) @@ -427,14 +462,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 +783,127 @@ 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 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-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..7d403d380 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] @@ -1308,7 +1319,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"); From 63de65828a2cb9d48cff3fea820ef19a91d4e0f4 Mon Sep 17 00:00:00 2001 From: Maxim Stykow Date: Fri, 24 Jul 2026 16:37:04 +0200 Subject: [PATCH 2/2] feat(cyclonedx): add component license evidence and re-stamp examples Populate CycloneDX component.evidence from the file-content license detections of the files each component owns (file->package ownership via 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, so promoted resolved dependencies carry no evidence. This completes the second license feature of the CycloneDX 1.7 work alongside licenses[].acknowledgement, rather than deferring it. SPDX and the native ScanCode-compatible JSON remain unchanged. - Add a dedicated cyclonedx-evidence golden (JSON + XML) and wire it into the external 1.7 schema/XSD validator; add unit coverage for the file->package evidence mapping. - Regenerate the four examples/sbom (now carrying evidence) stamped for the release that first ships this output; all validate clean against the official CycloneDX 1.7 schema + XSD, and SPDX still validates. - Fold the evidence contract into the ADR 0012 amendment. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Maxim Stykow --- ...012-sbom-resolved-dependency-components.md | 16 +- examples/sbom/README.md | 2 +- examples/sbom/express/README.md | 2 +- examples/sbom/express/sbom.cdx.json | 116 +- examples/sbom/express/sbom.spdx | 4 +- examples/sbom/flask/README.md | 2 +- examples/sbom/flask/sbom.cdx.json | 82 +- examples/sbom/flask/sbom.spdx | 4 +- examples/sbom/ripgrep/README.md | 2 +- examples/sbom/ripgrep/sbom.cdx.json | 1154 ++++++++++++++++- examples/sbom/ripgrep/sbom.spdx | 4 +- examples/sbom/tokio/README.md | 2 +- examples/sbom/tokio/sbom.cdx.json | 582 ++++++++- examples/sbom/tokio/sbom.spdx | 4 +- scripts/validate_output_format_fixtures.py | 3 + src/output/cyclonedx.rs | 267 +++- .../cyclonedx-evidence-expected.json | 90 ++ .../cyclonedx-evidence-expected.xml | 42 + tests/output_format_golden.rs | 164 +++ 19 files changed, 2478 insertions(+), 64 deletions(-) create mode 100644 testdata/output-formats/cyclonedx-evidence-expected.json create mode 100644 testdata/output-formats/cyclonedx-evidence-expected.xml diff --git a/docs/adr/0012-sbom-resolved-dependency-components.md b/docs/adr/0012-sbom-resolved-dependency-components.md index 4565338f0..91e9f9e65 100644 --- a/docs/adr/0012-sbom-resolved-dependency-components.md +++ b/docs/adr/0012-sbom-resolved-dependency-components.md @@ -199,16 +199,19 @@ unchanged (ADR 0008). (`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`). -Component license **evidence** (`evidence.licenses` / `evidence.occurrences` -with file + line) is a documented follow-up, not part of this contract: a clean -component-level file→license mapping is more than a moderate change in a -renderer that does not otherwise consume `output.files`. - ## Consequences ### Benefits @@ -253,8 +256,7 @@ renderer that does not otherwise consume `output.files`. - Issue #1319 - Issue #1320 (versioned purls + vendored-license join amendment) -- Issue #1328 (CycloneDX 1.7 + license acknowledgement amendment) -- Issue #1330 (component license evidence follow-up) +- 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 1f627eff3..0d15b23cd 100644 --- a/examples/sbom/express/sbom.cdx.json +++ b/examples/sbom/express/sbom.cdx.json @@ -2,14 +2,14 @@ "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", "specVersion": "1.7", - "serialNumber": "urn:uuid:647b41f1-9c9e-43aa-b13d-8cfc458f218c", + "serialNumber": "urn:uuid:9a30ff04-67e1-468e-aa86-b8e480c4b564", "version": 1, "metadata": { - "timestamp": "2026-07-24T14:05:45Z", + "timestamp": "2026-07-24T14:34:28Z", "tools": [ { "name": "Provenant", - "version": "1.0.2" + "version": "1.0.3" } ], "component": { @@ -46,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": [ @@ -86,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 765e001af..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-24T14:05:44Z +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 18eb38e69..cb3096a18 100644 --- a/examples/sbom/flask/sbom.cdx.json +++ b/examples/sbom/flask/sbom.cdx.json @@ -2,14 +2,14 @@ "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", "specVersion": "1.7", - "serialNumber": "urn:uuid:258f8af3-9f4f-4016-94ad-5ef0ccb6390c", + "serialNumber": "urn:uuid:96b8310d-ea47-490f-8934-9194fa93de20", "version": 1, "metadata": { - "timestamp": "2026-07-24T14:05:49Z", + "timestamp": "2026-07-24T14:34:31Z", "tools": [ { "name": "Provenant", - "version": "1.0.2" + "version": "1.0.3" } ], "component": { @@ -38,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": [ @@ -93,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", @@ -123,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", @@ -153,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 a3390d1db..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-24T14:05:48Z +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 d66025c1c..363650eaf 100644 --- a/examples/sbom/ripgrep/sbom.cdx.json +++ b/examples/sbom/ripgrep/sbom.cdx.json @@ -2,14 +2,14 @@ "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", "specVersion": "1.7", - "serialNumber": "urn:uuid:c92e1b1c-6b2b-4ff6-ab6a-b5dbe1f2272d", + "serialNumber": "urn:uuid:bb50d7b5-c5e7-4611-8543-e53bcd2122bb", "version": 1, "metadata": { - "timestamp": "2026-07-24T14:05:43Z", + "timestamp": "2026-07-24T14:34:27Z", "tools": [ { "name": "Provenant", - "version": "1.0.2" + "version": "1.0.3" } ], "component": { @@ -46,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": [ @@ -108,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", @@ -146,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", @@ -184,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", @@ -222,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", @@ -260,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", @@ -298,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", @@ -336,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", @@ -374,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", @@ -412,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", @@ -450,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 3b8f4b2b6..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-24T14:05:42Z +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 50680fb9e..10d643bdb 100644 --- a/examples/sbom/tokio/sbom.cdx.json +++ b/examples/sbom/tokio/sbom.cdx.json @@ -2,14 +2,14 @@ "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", "bomFormat": "CycloneDX", "specVersion": "1.7", - "serialNumber": "urn:uuid:c6e24877-6586-467d-92b7-4f00db357b0e", + "serialNumber": "urn:uuid:c93cfeb2-5956-49cb-89bf-5513c66125cd", "version": 1, "metadata": { - "timestamp": "2026-07-24T14:05:55Z", + "timestamp": "2026-07-24T14:34:34Z", "tools": [ { "name": "Provenant", - "version": "1.0.2" + "version": "1.0.3" } ] }, @@ -40,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", @@ -68,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", @@ -97,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", @@ -126,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", @@ -155,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", @@ -215,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", @@ -275,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", @@ -313,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", @@ -351,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", @@ -389,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 c5f5a5cb4..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-24T14:05:54Z +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 9cd2e9c48..ffa4a8148 100644 --- a/scripts/validate_output_format_fixtures.py +++ b/scripts/validate_output_format_fixtures.py @@ -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 diff --git a/src/output/cyclonedx.rs b/src/output/cyclonedx.rs index 864f9ab6c..177bc92bb 100644 --- a/src/output/cyclonedx.rs +++ b/src/output/cyclonedx.rs @@ -10,7 +10,8 @@ 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}; @@ -43,6 +44,94 @@ impl LicenseAcknowledgement { } } +/// 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> { @@ -68,6 +157,7 @@ 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); @@ -90,12 +180,14 @@ pub(crate) fn write_cyclonedx_xml(output: &Output, writer: &mut dyn Write) -> io // `component` (the BOM subject) must follow `tools` per the CycloneDX // 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"); @@ -108,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"); @@ -150,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"); @@ -219,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"); } @@ -230,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 @@ -243,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) }) @@ -280,7 +406,14 @@ fn build_cyclonedx_json(output: &Output) -> Value { // 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)); } @@ -301,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())); @@ -361,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; @@ -881,6 +1047,97 @@ mod tests { 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 { 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/tests/output_format_golden.rs b/tests/output_format_golden.rs index 7d403d380..79132ea24 100644 --- a/tests/output_format_golden.rs +++ b/tests/output_format_golden.rs @@ -1289,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"); @@ -2549,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.