Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions cyclonedx_py/_internal/utils/cdx.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.


"""
CycloneDX related helpers and utils.
"""
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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
Loading