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