diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py
index 7cb0081e..687afaf4 100644
--- a/cyclonedx/model/bom.py
+++ b/cyclonedx/model/bom.py
@@ -44,7 +44,7 @@
from ..serialization import UrnUuidHelper
from . import _BOM_LINK_PREFIX, ExternalReference, Property
from .bom_ref import BomRef
-from .component import Component
+from .component import Component, _ComponentValidationHelper
from .contact import OrganizationalContact, OrganizationalEntity
from .definition import Definitions
from .dependency import Dependable, Dependency
@@ -863,6 +863,18 @@ def validate(self) -> bool:
raise LicenseExpressionAlongWithOthersException(
f'Found LicenseExpression along with others licenses in: {elem!r}')
+ # 4. Validates that each component conforms to CycloneDX 1.7 constraints:
+ # - root component may not have is_external=true
+ # - version and version_range are mutually exclusive
+ # - version_range requires is_external=true
+ if self.metadata.component and self.metadata.component.is_external:
+ warn(
+ f'The Component this BOM is describing {self.metadata.component.purl} must not have is_external=true.',
+ category=UserWarning, stacklevel=1
+ )
+ for _c in self._get_all_components():
+ _ComponentValidationHelper.validate(_c)
+
return True
def __comparable_tuple(self) -> _ComparableTuple:
diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py
index bc7152ae..97704210 100644
--- a/cyclonedx/model/component.py
+++ b/cyclonedx/model/component.py
@@ -34,7 +34,7 @@
from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str
from .._internal.compare import ComparablePackageURL as _ComparablePackageURL, ComparableTuple as _ComparableTuple
-from ..exception.model import InvalidOmniBorIdException, InvalidSwhidException
+from ..exception.model import InvalidOmniBorIdException, InvalidSwhidException, MutuallyExclusivePropertiesException
from ..exception.serialization import (
CycloneDxDeserializationException,
SerializationOfUnexpectedValueException,
@@ -991,6 +991,7 @@ def __init__(
publisher: Optional[str] = None,
group: Optional[str] = None,
version: Optional[str] = None,
+ version_range: Optional[str] = None,
description: Optional[str] = None,
scope: Optional[ComponentScope] = None,
is_external: Optional[bool] = None,
@@ -1025,6 +1026,8 @@ def __init__(
self.publisher = publisher
self.group = group
self.name = name
+ self.version = version
+ self.version_range = version_range
self.description = description
self.scope = scope
self.is_external = is_external
@@ -1047,7 +1050,6 @@ def __init__(
# spec-deprecated properties below
self.author = author
self.modified = modified
- self.version = version
@property
@serializable.type_mapping(_ComponentTypeSerializationHelper)
@@ -1272,6 +1274,34 @@ def version(self, version: Optional[str]) -> None:
warn('`@.version`has a maximum length of 1024 from CycloneDX v1.6 onwards.', UserWarning)
self._version = version
+ @property
+ @serializable.view(SchemaVersion1Dot7)
+ @serializable.json_name('versionRange')
+ @serializable.xml_name('versionRange')
+ @serializable.xml_sequence(8)
+ @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
+ def version_range(self) -> Optional[str]:
+ """
+ For an external component, this specifies the accepted version range.
+
+ The value must adhere to the Package URL Version Range syntax (vers), as defined at
+ https://github.com/package-url/vers-spec
+
+ May only be used if .isExternal is set to true.
+
+ Must be used exclusively, either 'version' or 'version_range', but not both.
+
+ Returns:
+ `str` if set, else `None`
+ """
+ return self._version_range
+
+ @version_range.setter
+ def version_range(self, version_range: Optional[str]) -> None:
+ if version_range is not None and not 1 <= len(version_range) <= 4096:
+ warn('`@.version_range`has a minimum length of 1 and a maximum length of 4096 characters.', UserWarning)
+ self._version_range = version_range
+
@property
@serializable.xml_sequence(9)
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
@@ -1715,7 +1745,7 @@ def __comparable_tuple(self) -> _ComparableTuple:
_ComparableTuple(self.components), self.evidence, self.release_notes, self.modified,
_ComparableTuple(self.authors), _ComparableTuple(self.omnibor_ids), self.manufacturer,
self.crypto_properties, _ComparableTuple(self.tags),
- self.is_external,
+ self.is_external, self.version_range,
))
def __eq__(self, other: object) -> bool:
@@ -1732,5 +1762,38 @@ def __hash__(self) -> int:
return hash(self.__comparable_tuple())
def __repr__(self) -> str:
+ version = f'versionRange={self.version_range}' \
+ if self.version_range is not None \
+ else f'version={self.version}'
return f''
+ f'{version}, type={self.type}>'
+
+
+class _ComponentValidationHelper:
+ """ THIS CLASS IS NON-PUBLIC API """
+
+ @staticmethod
+ def validate_version_choice(component: Component) -> None:
+ """ Validates that version and version_range are not both set. """
+ if component.version is not None and component.version_range is not None:
+ raise MutuallyExclusivePropertiesException(
+ f'Component cannot have both `version` and `version_range` set. Component: {component.name}'
+ )
+
+ @staticmethod
+ def validate_version_range_requirements(component: Component) -> None:
+ """ Validates that version_range is used correctly with is_external. """
+ if component.version_range is not None and not component.is_external:
+ raise MutuallyExclusivePropertiesException(
+ f'Component cannot have `version_range` set unless `is_external=true`. Component: {component.name}'
+ )
+
+ @staticmethod
+ def validate(component: Component) -> None:
+ """
+ Validates that the component conforms to CycloneDX 1.7 constraints:
+ - version and version_range are mutually exclusive
+ - version_range requires is_external=true
+ """
+ _ComponentValidationHelper.validate_version_choice(component)
+ _ComponentValidationHelper.validate_version_range_requirements(component)
diff --git a/tests/_data/models.py b/tests/_data/models.py
index e2052878..722a3b88 100644
--- a/tests/_data/models.py
+++ b/tests/_data/models.py
@@ -600,7 +600,26 @@ def get_bom_with_external_references() -> Bom:
def get_bom_with_external_component_1_7() -> Bom:
- bom = _make_bom(components=[get_component_external()])
+ bom = _make_bom(components=[
+ get_component_external_without_version(),
+ get_component_external_with_version(),
+ get_component_external_with_version_range(),
+ ])
+ return bom
+
+
+def get_bom_with_external_component_with_version_and_version_range_invalid() -> Bom:
+ bom = _make_bom(components=[get_component_external_with_version_and_version_range_invalid()])
+ return bom
+
+
+def get_bom_with_non_external_explicit_component_with_version_range_invalid() -> Bom:
+ bom = _make_bom(components=[get_component_non_external_explicit_with_version_range_invalid()])
+ return bom
+
+
+def get_bom_with_non_external_implicit_component_with_version_range_invalid() -> Bom:
+ bom = _make_bom(components=[get_component_non_external_implicit_with_version_range_invalid()])
return bom
@@ -864,9 +883,20 @@ def get_component_setuptools_simple(
)
-def get_component_external() -> Component:
+def get_component_external_without_version() -> Component:
return Component(
- name='external-lib', version='1.0.0',
+ name='external-lib',
+ type=ComponentType.LIBRARY,
+ is_external=True,
+ scope=ComponentScope.REQUIRED,
+ bom_ref='external-lib',
+ )
+
+
+def get_component_external_with_version() -> Component:
+ return Component(
+ name='external-lib',
+ version='1.0.0',
type=ComponentType.LIBRARY,
is_external=True,
scope=ComponentScope.REQUIRED,
@@ -874,6 +904,47 @@ def get_component_external() -> Component:
)
+def get_component_external_with_version_range() -> Component:
+ return Component(
+ name='external-lib',
+ version_range='vers:all/*',
+ type=ComponentType.LIBRARY,
+ is_external=True,
+ scope=ComponentScope.REQUIRED,
+ bom_ref='external-lib-with-range',
+ )
+
+
+def get_component_external_with_version_and_version_range_invalid() -> Component:
+ return Component(
+ name='external-lib',
+ version='1.0.0',
+ version_range='vers:all/*',
+ type=ComponentType.LIBRARY,
+ is_external=True,
+ bom_ref='external-lib-with-version-and-version-range',
+ )
+
+
+def get_component_non_external_explicit_with_version_range_invalid() -> Component:
+ return Component(
+ name='internal-lib',
+ version_range='vers:all/*',
+ type=ComponentType.LIBRARY,
+ is_external=False,
+ bom_ref='internal-lib-with-range',
+ )
+
+
+def get_component_non_external_implicit_with_version_range_invalid() -> Component:
+ return Component(
+ name='internal-lib',
+ version_range='vers:all/*',
+ type=ComponentType.LIBRARY,
+ bom_ref='internal-lib-with-range',
+ )
+
+
def get_component_setuptools_simple_no_version(bom_ref: Optional[str] = None) -> Component:
return Component(
name='setuptools', bom_ref=bom_ref or 'pkg:pypi/setuptools?extension=tar.gz',
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin
index aaae8337..d7c68502 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin
@@ -7,5 +7,17 @@
required
false
+
+ external-lib
+
+ required
+ false
+
+
+ external-lib
+
+ required
+ false
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin
index 06e044d3..729fb705 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin
@@ -6,5 +6,15 @@
1.0.0
required
+
+ external-lib
+
+ required
+
+
+ external-lib
+
+ required
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin
index fe5a4e0a..fe516737 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin
@@ -6,11 +6,31 @@
"scope": "required",
"type": "library",
"version": "1.0.0"
+ },
+ {
+ "bom-ref": "external-lib",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": ""
+ },
+ {
+ "bom-ref": "external-lib-with-range",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": ""
}
],
"dependencies": [
+ {
+ "ref": "external-lib"
+ },
{
"ref": "external-lib-1.0.0"
+ },
+ {
+ "ref": "external-lib-with-range"
}
],
"metadata": {
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin
index 266020af..f3201d9d 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin
@@ -9,8 +9,20 @@
1.0.0
required
+
+ external-lib
+
+ required
+
+
+ external-lib
+
+ required
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin
index 8500a9f7..fd2b3578 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin
@@ -6,11 +6,31 @@
"scope": "required",
"type": "library",
"version": "1.0.0"
+ },
+ {
+ "bom-ref": "external-lib",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": ""
+ },
+ {
+ "bom-ref": "external-lib-with-range",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": ""
}
],
"dependencies": [
+ {
+ "ref": "external-lib"
+ },
{
"ref": "external-lib-1.0.0"
+ },
+ {
+ "ref": "external-lib-with-range"
}
],
"metadata": {
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin
index 120d5d28..87012c02 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin
@@ -9,8 +9,20 @@
1.0.0
required
+
+ external-lib
+
+ required
+
+
+ external-lib
+
+ required
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin
index c2c3bbea..be5c2271 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin
@@ -6,11 +6,29 @@
"scope": "required",
"type": "library",
"version": "1.0.0"
+ },
+ {
+ "bom-ref": "external-lib",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library"
+ },
+ {
+ "bom-ref": "external-lib-with-range",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library"
}
],
"dependencies": [
+ {
+ "ref": "external-lib"
+ },
{
"ref": "external-lib-1.0.0"
+ },
+ {
+ "ref": "external-lib-with-range"
}
],
"metadata": {
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin
index 2d85c7de..7b4dea53 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin
@@ -9,8 +9,18 @@
1.0.0
required
+
+ external-lib
+ required
+
+
+ external-lib
+ required
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin
index f3d16896..17875278 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin
@@ -6,11 +6,29 @@
"scope": "required",
"type": "library",
"version": "1.0.0"
+ },
+ {
+ "bom-ref": "external-lib",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library"
+ },
+ {
+ "bom-ref": "external-lib-with-range",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library"
}
],
"dependencies": [
+ {
+ "ref": "external-lib"
+ },
{
"ref": "external-lib-1.0.0"
+ },
+ {
+ "ref": "external-lib-with-range"
}
],
"metadata": {
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin
index f06f8a0b..56036b2a 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin
@@ -9,9 +9,19 @@
1.0.0
required
+
+ external-lib
+ required
+
+
+ external-lib
+ required
+
+
+
val1
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin
index bf66e848..3ac794bb 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin
@@ -6,11 +6,29 @@
"scope": "required",
"type": "library",
"version": "1.0.0"
+ },
+ {
+ "bom-ref": "external-lib",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library"
+ },
+ {
+ "bom-ref": "external-lib-with-range",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library"
}
],
"dependencies": [
+ {
+ "ref": "external-lib"
+ },
{
"ref": "external-lib-1.0.0"
+ },
+ {
+ "ref": "external-lib-with-range"
}
],
"metadata": {
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin
index c81104a7..c3a5bea0 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin
@@ -9,9 +9,19 @@
1.0.0
required
+
+ external-lib
+ required
+
+
+ external-lib
+ required
+
+
+
val1
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin
index c3c2f85b..3756d89f 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin
@@ -7,11 +7,32 @@
"scope": "required",
"type": "library",
"version": "1.0.0"
+ },
+ {
+ "bom-ref": "external-lib",
+ "isExternal": true,
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library"
+ },
+ {
+ "bom-ref": "external-lib-with-range",
+ "isExternal": true,
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "versionRange": "vers:all/*"
}
],
"dependencies": [
+ {
+ "ref": "external-lib"
+ },
{
"ref": "external-lib-1.0.0"
+ },
+ {
+ "ref": "external-lib-with-range"
}
],
"metadata": {
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin
index b16d837f..e516611e 100644
--- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin
@@ -9,9 +9,20 @@
1.0.0
required
+
+ external-lib
+ required
+
+
+ external-lib
+ vers:all/*
+ required
+
+
+
val1
diff --git a/tests/test_model_bom.py b/tests/test_model_bom.py
index 030ee4a5..fe319345 100644
--- a/tests/test_model_bom.py
+++ b/tests/test_model_bom.py
@@ -217,6 +217,13 @@ def test_warning_missing_dependency(self) -> None:
self.assertEqual(len(w.warnings), 1)
self.assertIn('has no defined dependencies ', str(w.warnings[0]))
+ def test_warning_root_component_is_external(self) -> None:
+ with self.assertWarns(expected_warning=UserWarning) as w:
+ bom = Bom(metadata=BomMetaData(component=Component(name='root_component', is_external=True)))
+ _ = JsonV1Dot7(bom).output_as_string()
+ self.assertEqual(len(w.warnings), 1)
+ self.assertIn('must not have is_external=true', str(w.warnings[0]))
+
def test_empty_bom_defined_serial(self) -> None:
serial_number = uuid4()
bom = Bom(serial_number=serial_number)
diff --git a/tests/test_model_component.py b/tests/test_model_component.py
index f7b8fc80..b3d10236 100644
--- a/tests/test_model_component.py
+++ b/tests/test_model_component.py
@@ -304,6 +304,27 @@ def test_is_external_sorting(self) -> None:
expected_components = reorder(components, expected_order)
self.assertListEqual(sorted_components, expected_components)
+ def test_version_range_comparison(self) -> None:
+ component_all = Component(name='test-component', is_external=True, version_range='vers:all/*')
+ component_none = Component(name='test-component', is_external=True, version_range='vers:none/*')
+ self.assertEqual(component_all, component_all)
+ self.assertEqual(component_none, component_none)
+ self.assertNotEqual(component_all, component_none)
+ self.assertLess(component_all, component_none)
+ self.assertNotEqual(hash(component_all), hash(component_none))
+
+ def test_version_range_sorting(self) -> None:
+ # version is compared before version_range, None sorts as greater than any string
+ expected_order = [1, 2, 0]
+ components = [
+ Component(name='test_component', is_external=True),
+ Component(name='test_component', is_external=True, version='1.0.0'),
+ Component(name='test_component', is_external=True, version_range='vers:all/*'),
+ ]
+ sorted_components = sorted(components)
+ expected_components = reorder(components, expected_order)
+ self.assertListEqual(sorted_components, expected_components)
+
def test_nested_components_1(self) -> None:
comp_b = Component(name='comp_b')
comp_c = Component(name='comp_c')
diff --git a/tests/test_output_json.py b/tests/test_output_json.py
index b9340a4e..062f38cf 100644
--- a/tests/test_output_json.py
+++ b/tests/test_output_json.py
@@ -30,6 +30,7 @@
InvalidOmniBorIdException,
InvalidSwhidException,
LicenseExpressionAlongWithOthersException,
+ MutuallyExclusivePropertiesException,
UnknownComponentDependencyException,
)
from cyclonedx.exception.output import FormatNotSupportedException
@@ -93,6 +94,7 @@ def test_invalid(self, get_bom: Callable[[], Bom], sv: SchemaVersion) -> None:
LicenseExpressionAlongWithOthersException,
InvalidOmniBorIdException,
InvalidSwhidException,
+ MutuallyExclusivePropertiesException,
UnknownComponentDependencyException,
)):
return None # expected
diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py
index 6e887ded..2cb68585 100644
--- a/tests/test_output_xml.py
+++ b/tests/test_output_xml.py
@@ -29,6 +29,7 @@
InvalidOmniBorIdException,
InvalidSwhidException,
LicenseExpressionAlongWithOthersException,
+ MutuallyExclusivePropertiesException,
UnknownComponentDependencyException,
)
from cyclonedx.model.bom import Bom
@@ -81,6 +82,7 @@ def test_invalid(self, get_bom: Callable[[], Bom], sv: SchemaVersion) -> None:
LicenseExpressionAlongWithOthersException,
InvalidOmniBorIdException,
InvalidSwhidException,
+ MutuallyExclusivePropertiesException,
UnknownComponentDependencyException,
)):
return None # expected