From 1879caa615602f26ef2795fd46c46e1ac2cdb748 Mon Sep 17 00:00:00 2001 From: rohahann-tech Date: Tue, 18 Nov 2025 05:57:21 +0530 Subject: [PATCH 1/5] feat!: don't "fix" licenses if not needed #995 --- cyclonedx_py/_internal/utils/cdx.py | 37 +++++++++++++---------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/cyclonedx_py/_internal/utils/cdx.py b/cyclonedx_py/_internal/utils/cdx.py index 3e331015..ac707070 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. """ @@ -51,7 +50,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 +78,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 @@ -101,27 +97,29 @@ def find_LicenseExpression(licenses: Iterable['License']) -> Optional[LicenseExp 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. + CycloneDX 1.7 compliant license handling. + + Rules: + - A component may have: + 1. One license expression + 2. One or more named licenses + 3. A mix of expression + named licenses (allowed by spec) + + Behavior: + - Single license expression → leave as-is. + - Only named licenses → leave as-is. + - Mixed expression + named → leave as-is (spec allows this). + - No licenses are moved to evidence unless explicitly desired. """ - # hack for preventing expressions AND named licenses. - # see https://github.com/CycloneDX/cyclonedx-python/issues/826 - # see https://github.com/CycloneDX/specification/issues/454 licenses = list(component.licenses) - lexp = find_LicenseExpression(licenses) - if lexp is None: + if not licenses: return - component.licenses = (lexp,) - licenses.remove(lexp) - if len(licenses) > 0: - if component.evidence is None: - component.evidence = ComponentEvidence() - component.evidence.licenses.update(licenses) + + # No forced "fixing" for mixed license states + return _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 +132,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, From 00d15db7ebde608625d61fe4028b39139a267965 Mon Sep 17 00:00:00 2001 From: rohahann-tech Date: Tue, 18 Nov 2025 06:02:53 +0530 Subject: [PATCH 2/5] feat!: don't "fix" licenses if not needed #995 --- tests/test_utils.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/test_utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..76eade0c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,47 @@ +import pytest +from cyclonedx.model.component import Component +from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression +from cyclonedx_py._internal.utils.cdx import licenses_fixup + +def test_single_expression_no_change(): + comp = Component( + name="test-component", + licenses=(LicenseExpression("MIT"),) + ) + licenses_fixup(comp) + assert comp.licenses[0].value == "MIT" + assert comp.evidence is None + +def test_multiple_named_no_change(): + comp = Component( + name="test-component", + licenses=(DisjunctiveLicense(name="MIT"), + DisjunctiveLicense(name="Apache-2.0")) + ) + licenses_fixup(comp) + names = {l.name for l in comp.licenses} + assert names == {"MIT", "Apache-2.0"} + assert comp.evidence is None + +def test_expression_plus_named_moves_named_to_evidence(): + comp = Component( + name="test-component", + licenses=(LicenseExpression("MIT"), + DisjunctiveLicense(name="Apache-2.0")) + ) + licenses_fixup(comp) + # Check expression stays + assert comp.licenses[0].value == "MIT" + # Check named moved to evidence + assert comp.evidence is not None + moved = {l.name for l in comp.evidence.licenses} + assert moved == {"Apache-2.0"} + +def test_empty_licenses_no_change(): + comp = Component( + name="test-component", + licenses=() + ) + licenses_fixup(comp) + assert tuple(comp.licenses) == () + assert comp.evidence is None From 6a239343963e2b1a41b0fa8aea1bf9d8a69af222 Mon Sep 17 00:00:00 2001 From: rohahann-tech Date: Tue, 18 Nov 2025 15:59:52 +0530 Subject: [PATCH 3/5] feat: skip license fixup for CycloneDX schema 1.7+ (fixes #995) --- cyclonedx_py/_internal/utils/cdx.py | 39 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/cyclonedx_py/_internal/utils/cdx.py b/cyclonedx_py/_internal/utils/cdx.py index ac707070..fe2422a0 100644 --- a/cyclonedx_py/_internal/utils/cdx.py +++ b/cyclonedx_py/_internal/utils/cdx.py @@ -23,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 @@ -95,29 +96,33 @@ def find_LicenseExpression(licenses: Iterable['License']) -> Optional[LicenseExp return None -def licenses_fixup(component: 'Component') -> None: +def licenses_fixup(component: Component) -> None: """ - CycloneDX 1.7 compliant license handling. - - Rules: - - A component may have: - 1. One license expression - 2. One or more named licenses - 3. A mix of expression + named licenses (allowed by spec) - - Behavior: - - Single license expression → leave as-is. - - Only named licenses → leave as-is. - - Mixed expression + named → leave as-is (spec allows this). - - No licenses are moved to evidence unless explicitly desired. + 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. """ + # 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) - if not licenses: + lexp = find_LicenseExpression(licenses) + if lexp is None: return - # No forced "fixing" for mixed license states - return + component.licenses = (lexp,) + licenses.remove(lexp) + if licenses: + if component.evidence is None: + component.evidence = ComponentEvidence() + component.evidence.licenses.update(licenses) _MAP_KNOWN_URL_LABELS: dict[str, ExternalReferenceType] = { 'bugtracker': ExternalReferenceType.ISSUE_TRACKER, From 40455cb4c5758d018a1d572ab0a50d845e9430d1 Mon Sep 17 00:00:00 2001 From: rohankaf <123352427+Rohankaf@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:07:19 +0530 Subject: [PATCH 4/5] Update test_utils.py Signed-off-by: rohankaf <123352427+Rohankaf@users.noreply.github.com> --- tests/test_utils.py | 107 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 84 insertions(+), 23 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 76eade0c..4bdedabb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,47 +1,108 @@ 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 test_single_expression_no_change(): - comp = Component( - name="test-component", - licenses=(LicenseExpression("MIT"),) - ) + +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 + + +# ------------------------------- +# Legacy behavior (< 1.7) +# ------------------------------- + +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_multiple_named_no_change(): + +def test_legacy_multiple_named_no_change(): comp = Component( - name="test-component", - licenses=(DisjunctiveLicense(name="MIT"), - DisjunctiveLicense(name="Apache-2.0")) + name="c", + licenses=(DisjunctiveLicense(name="MIT"), DisjunctiveLicense(name="Apache-2.0")) ) + attach_schema(comp, SchemaVersion.V1_6) + licenses_fixup(comp) - names = {l.name for l in comp.licenses} - assert names == {"MIT", "Apache-2.0"} + + assert {l.name for l in comp.licenses} == {"MIT", "Apache-2.0"} assert comp.evidence is None -def test_expression_plus_named_moves_named_to_evidence(): + +def test_legacy_expression_plus_named_moves_to_evidence(): comp = Component( - name="test-component", - licenses=(LicenseExpression("MIT"), - DisjunctiveLicense(name="Apache-2.0")) + name="c", + licenses=(LicenseExpression("MIT"), DisjunctiveLicense(name="Apache-2.0")) ) + attach_schema(comp, SchemaVersion.V1_6) + licenses_fixup(comp) - # Check expression stays + assert comp.licenses[0].value == "MIT" - # Check named moved to evidence assert comp.evidence is not None - moved = {l.name for l in comp.evidence.licenses} - assert moved == {"Apache-2.0"} + 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_empty_licenses_no_change(): +# ------------------------------- +# Modern behavior (>= 1.7) +# ------------------------------- + +def test_modern_no_fixup_mixed_is_untouched(): comp = Component( - name="test-component", - licenses=() + name="c", + licenses=(LicenseExpression("MIT"), DisjunctiveLicense(name="Apache-2.0")) ) + attach_schema(comp, SchemaVersion.V1_7) + licenses_fixup(comp) - assert tuple(comp.licenses) == () + + # 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 From 2ea8ab0358da1ca34e71eed7eb1768ffd63677b8 Mon Sep 17 00:00:00 2001 From: rohankaf <123352427+Rohankaf@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:54:17 +0530 Subject: [PATCH 5/5] Update test_utils.py Signed-off-by: rohankaf <123352427+Rohankaf@users.noreply.github.com> --- tests/test_utils.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 4bdedabb..25a7c190 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -13,9 +13,8 @@ def attach_schema(comp: Component, version: SchemaVersion): comp._bom = bom -# ------------------------------- -# Legacy behavior (< 1.7) -# ------------------------------- + + def test_legacy_single_expression_no_change(): comp = Component(name="c", licenses=(LicenseExpression("MIT"),)) @@ -64,9 +63,7 @@ def test_legacy_empty_licenses_no_change(): assert comp.evidence is None -# ------------------------------- -# Modern behavior (>= 1.7) -# ------------------------------- + def test_modern_no_fixup_mixed_is_untouched(): comp = Component(