diff --git a/tools/sbom-diff-and-risk/docs/component-identity-canonicalization.md b/tools/sbom-diff-and-risk/docs/component-identity-canonicalization.md index 69933de..95cdb88 100644 --- a/tools/sbom-diff-and-risk/docs/component-identity-canonicalization.md +++ b/tools/sbom-diff-and-risk/docs/component-identity-canonicalization.md @@ -31,6 +31,11 @@ Identity authority remains: 2. `bom_ref`, when no purl is present; 3. normalized `(ecosystem, package_name)` coordinate. +When no purl is present, `bom_ref` is an opaque local identifier: trim +surrounding whitespace only, preserve case, and do not infer aliases. A record +with a purl uses the purl identity even when `bom_ref` is also populated; a +bom_ref-only record never aliases a purl string. + When a purl is present, its type, package name, and version must agree with explicit component fields. Disagreement fails closed as `conflicting_metadata`. Within a single input, repeated identical canonical identities fail as diff --git a/tools/sbom-diff-and-risk/docs/v1.1-input-and-policy-semantics.md b/tools/sbom-diff-and-risk/docs/v1.1-input-and-policy-semantics.md index 3935f36..230f924 100644 --- a/tools/sbom-diff-and-risk/docs/v1.1-input-and-policy-semantics.md +++ b/tools/sbom-diff-and-risk/docs/v1.1-input-and-policy-semantics.md @@ -38,6 +38,11 @@ Identity authority remains `purl`, then `bom_ref`, then the normalized its ecosystem and package coordinate. Explicit metadata that disagrees with that coordinate is a conflict, not an alternative identity. +When no purl is present, `bom_ref` is an opaque local identifier: only +surrounding whitespace is trimmed, case is preserved, and aliases are not +inferred. A purl-bearing record keeps purl authority even when `bom_ref` is +also populated. + Canonical identity also drives change comparison: lexical PyPI variants that normalize to the same identity do not create a metadata change, and a version change carried only by the purl is still classified as `version_changed`. diff --git a/tools/sbom-diff-and-risk/src/sbom_diff_risk/component_identity.py b/tools/sbom-diff-and-risk/src/sbom_diff_risk/component_identity.py index 371690a..74fccc5 100644 --- a/tools/sbom-diff-and-risk/src/sbom_diff_risk/component_identity.py +++ b/tools/sbom-diff-and-risk/src/sbom_diff_risk/component_identity.py @@ -83,7 +83,7 @@ def canonicalize_component_identity(component: Component) -> CanonicalComponentI if component.purl is None: if component.bom_ref: - component_key = f"bom-ref:{component.bom_ref.strip().lower()}" + component_key = f"bom-ref:{component.bom_ref.strip()}" else: component_key = f"coord:{explicit_ecosystem}:{explicit_name}" return CanonicalComponentIdentity( diff --git a/tools/sbom-diff-and-risk/src/sbom_diff_risk/diffing.py b/tools/sbom-diff-and-risk/src/sbom_diff_risk/diffing.py index d83a583..f7d275c 100644 --- a/tools/sbom-diff-and-risk/src/sbom_diff_risk/diffing.py +++ b/tools/sbom-diff-and-risk/src/sbom_diff_risk/diffing.py @@ -19,7 +19,7 @@ def _component_signature(component: Component) -> tuple[object, ...]: _normalized_metadata(component.license_id), _normalized_metadata(component.supplier), _normalized_metadata(component.source_url), - _normalized_metadata(component.bom_ref, lower=True), + _normalized_metadata(component.bom_ref), _normalized_metadata(component.raw_type, lower=True), ) diff --git a/tools/sbom-diff-and-risk/tests/fixtures/cdx_bom_ref_case_variants.json b/tools/sbom-diff-and-risk/tests/fixtures/cdx_bom_ref_case_variants.json new file mode 100644 index 0000000..730685d --- /dev/null +++ b/tools/sbom-diff-and-risk/tests/fixtures/cdx_bom_ref_case_variants.json @@ -0,0 +1,19 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "components": [ + { + "bom-ref": "Component-A", + "type": "library", + "name": "opaque-lib", + "version": "1.0.0" + }, + { + "bom-ref": "component-a", + "type": "library", + "name": "opaque-lib", + "version": "1.0.0" + } + ] +} diff --git a/tools/sbom-diff-and-risk/tests/test_diffing.py b/tools/sbom-diff-and-risk/tests/test_diffing.py index a09c2a2..3ddac14 100644 --- a/tools/sbom-diff-and-risk/tests/test_diffing.py +++ b/tools/sbom-diff-and-risk/tests/test_diffing.py @@ -21,6 +21,77 @@ def test_component_key_prefers_purl() -> None: assert component_key(component) == "purl:pkg:pypi/requests" +def test_component_key_does_not_alias_bom_ref_only_identity_to_purl() -> None: + purl_component = Component( + name="requests", + version="2.31.0", + ecosystem="pypi", + purl="pkg:pypi/requests@2.31.0", + bom_ref="legacy-ref", + ) + bom_ref_only_component = Component( + name="requests", + version="2.31.0", + ecosystem="pypi", + bom_ref="legacy-ref", + ) + + assert component_key(purl_component) == "purl:pkg:pypi/requests" + assert component_key(bom_ref_only_component) == "bom-ref:legacy-ref" + + +def test_diff_components_preserves_case_in_opaque_bom_ref_fixture() -> None: + fixture = Path(__file__).parent / "fixtures" / "cdx_bom_ref_case_variants.json" + _, components, _ = normalize_input(fixture) + + added, removed, changed = diff_components([], components) + + assert {component_key(component) for component in added} == { + "bom-ref:Component-A", + "bom-ref:component-a", + } + assert removed == [] + assert changed == [] + + +def test_diff_components_fails_on_duplicate_exact_bom_ref() -> None: + duplicate_before = [ + Component(name="opaque-lib", version="1.0.0", ecosystem="generic", bom_ref="Component-A"), + Component(name="opaque-lib", version="1.0.0", ecosystem="generic", bom_ref="Component-A"), + ] + + with pytest.raises(ComponentIdentityError, match="duplicate_component") as exc_info: + diff_components(duplicate_before, []) + + assert exc_info.value.code is ComponentIdentityDiagnosticCode.DUPLICATE_COMPONENT + assert exc_info.value.component_key == "bom-ref:Component-A" + + +def test_diff_components_fails_on_same_bom_ref_with_conflicting_metadata() -> None: + conflicting_before = [ + Component( + name="opaque-lib", + version="1.0.0", + ecosystem="generic", + bom_ref="Component-A", + supplier="Supplier A", + ), + Component( + name="opaque-lib", + version="1.0.0", + ecosystem="generic", + bom_ref="Component-A", + supplier="Supplier B", + ), + ] + + with pytest.raises(ComponentIdentityError, match="conflicting_metadata") as exc_info: + diff_components(conflicting_before, []) + + assert exc_info.value.code is ComponentIdentityDiagnosticCode.CONFLICTING_METADATA + assert exc_info.value.component_key == "bom-ref:Component-A" + + def test_diff_components_empty_inputs() -> None: added, removed, changed = diff_components([], []) assert added == []