diff --git a/cyclonedx_py/_internal/utils/cdx.py b/cyclonedx_py/_internal/utils/cdx.py index 3e331015..fe2422a0 100644 --- a/cyclonedx_py/_internal/utils/cdx.py +++ b/cyclonedx_py/_internal/utils/cdx.py @@ -15,7 +15,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - """ CycloneDX related helpers and utils. """ @@ -24,6 +23,7 @@ from re import compile as re_compile from typing import Any, Optional +from cyclonedx.schema import SchemaVersion from cyclonedx.builder.this import this_component as lib_component from cyclonedx.model import ExternalReference, ExternalReferenceType, XsUri from cyclonedx.model.bom import Bom @@ -51,7 +51,6 @@ def make_bom(**kwargs: Any) -> Bom: licenses=(DisjunctiveLicense(id='Apache-2.0', acknowledgement=LicenseAcknowledgement.DECLARED),), external_references=( - # let's assume this is not a fork ExternalReference( type=ExternalReferenceType.WEBSITE, url=XsUri('https://github.com/CycloneDX/cyclonedx-python/#readme') @@ -80,13 +79,11 @@ def make_bom(**kwargs: Any) -> Bom: type=ExternalReferenceType.RELEASE_NOTES, url=XsUri('https://github.com/CycloneDX/cyclonedx-python/blob/main/CHANGELOG.md') ), - # we cannot assert where the lib was fetched from, but we can give a hint ExternalReference( type=ExternalReferenceType.DISTRIBUTION, url=XsUri('https://pypi.org/project/cyclonedx-bom/') ), ), - # to be extended... ), )) return bom @@ -99,29 +96,35 @@ def find_LicenseExpression(licenses: Iterable['License']) -> Optional[LicenseExp return None -def licenses_fixup(component: 'Component') -> None: +def licenses_fixup(component: Component) -> None: """ - Per CycloneDX spec, there must be EITHER one license expression OR multiple license id/name. - If there is an expression, it is used and everything else is moved to evidences, so it is not lost. + Skip license fixup for CycloneDX 1.7 and newer. + + In CycloneDX 1.7+, license expressions and named licenses + may coexist. Older versions still require normalization. """ - # hack for preventing expressions AND named licenses. - # see https://github.com/CycloneDX/cyclonedx-python/issues/826 - # see https://github.com/CycloneDX/specification/issues/454 + # Detect schema version via internal BOM reference + bom = getattr(component, "_bom", None) + if bom is not None: + schema_version = getattr(bom.metadata, "schema_version", None) + if schema_version is not None and schema_version >= SchemaVersion.V1_7: + return + + # ---- Legacy behavior (< 1.7) ---- licenses = list(component.licenses) lexp = find_LicenseExpression(licenses) if lexp is None: return + component.licenses = (lexp,) licenses.remove(lexp) - if len(licenses) > 0: + + if licenses: if component.evidence is None: component.evidence = ComponentEvidence() component.evidence.licenses.update(licenses) - _MAP_KNOWN_URL_LABELS: dict[str, ExternalReferenceType] = { - # see https://peps.python.org/pep-0345/#project-url-multiple-use - # see https://github.com/pypi/warehouse/issues/5947#issuecomment-699660629 'bugtracker': ExternalReferenceType.ISSUE_TRACKER, 'issuetracker': ExternalReferenceType.ISSUE_TRACKER, 'issues': ExternalReferenceType.ISSUE_TRACKER, @@ -134,7 +137,6 @@ def licenses_fixup(component: 'Component') -> None: 'docs': ExternalReferenceType.DOCUMENTATION, 'changelog': ExternalReferenceType.RELEASE_NOTES, 'changes': ExternalReferenceType.RELEASE_NOTES, - # 'source': ExternalReferenceType.SOURCE-DISTRIBUTION, 'repository': ExternalReferenceType.VCS, 'github': ExternalReferenceType.VCS, 'chat': ExternalReferenceType.CHAT, diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..25a7c190 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,105 @@ +import pytest +from cyclonedx.model.bom import Bom +from cyclonedx.model.component import Component +from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression +from cyclonedx.schema import SchemaVersion +from cyclonedx_py._internal.utils.cdx import licenses_fixup + + +def attach_schema(comp: Component, version: SchemaVersion): + """Attach a BOM with a specific schema version to a component.""" + bom = Bom() + bom.metadata.schema_version = version + comp._bom = bom + + + + + +def test_legacy_single_expression_no_change(): + comp = Component(name="c", licenses=(LicenseExpression("MIT"),)) + attach_schema(comp, SchemaVersion.V1_6) + + licenses_fixup(comp) + + assert comp.licenses[0].value == "MIT" + assert comp.evidence is None + + +def test_legacy_multiple_named_no_change(): + comp = Component( + name="c", + licenses=(DisjunctiveLicense(name="MIT"), DisjunctiveLicense(name="Apache-2.0")) + ) + attach_schema(comp, SchemaVersion.V1_6) + + licenses_fixup(comp) + + assert {l.name for l in comp.licenses} == {"MIT", "Apache-2.0"} + assert comp.evidence is None + + +def test_legacy_expression_plus_named_moves_to_evidence(): + comp = Component( + name="c", + licenses=(LicenseExpression("MIT"), DisjunctiveLicense(name="Apache-2.0")) + ) + attach_schema(comp, SchemaVersion.V1_6) + + licenses_fixup(comp) + + assert comp.licenses[0].value == "MIT" + assert comp.evidence is not None + assert {l.name for l in comp.evidence.licenses} == {"Apache-2.0"} + + +def test_legacy_empty_licenses_no_change(): + comp = Component(name="c", licenses=()) + attach_schema(comp, SchemaVersion.V1_6) + + licenses_fixup(comp) + + assert tuple(comp.licenses) == () + assert comp.evidence is None + + + + +def test_modern_no_fixup_mixed_is_untouched(): + comp = Component( + name="c", + licenses=(LicenseExpression("MIT"), DisjunctiveLicense(name="Apache-2.0")) + ) + attach_schema(comp, SchemaVersion.V1_7) + + licenses_fixup(comp) + + # Mixed licenses must not be modified + assert len(comp.licenses) == 2 + assert comp.evidence is None + + +def test_modern_named_only_untouched(): + comp = Component( + name="c", + licenses=(DisjunctiveLicense(name="MIT"), DisjunctiveLicense(name="Apache-2.0")), + ) + attach_schema(comp, SchemaVersion.V1_7) + + licenses_fixup(comp) + + assert {l.name for l in comp.licenses} == {"MIT", "Apache-2.0"} + assert comp.evidence is None + + +def test_modern_expression_only_untouched(): + comp = Component( + name="c", + licenses=(LicenseExpression("MIT"),), + ) + attach_schema(comp, SchemaVersion.V1_7) + + licenses_fixup(comp) + + assert comp.licenses[0].value == "MIT" + assert comp.evidence is None