diff --git a/cyclonedx/_internal/bom_ref.py b/cyclonedx/_internal/bom_ref.py new file mode 100644 index 00000000..c0943da5 --- /dev/null +++ b/cyclonedx/_internal/bom_ref.py @@ -0,0 +1,33 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + + +""" +!!! ALL SYMBOLS IN HERE ARE INTERNAL. +Everything might change without any notice. +""" + +from typing import Optional, Union + +from ..model.bom_ref import BomRef + + +def bom_ref_from_str(bom_ref: Optional[Union[str, BomRef]]) -> BomRef: + if isinstance(bom_ref, BomRef): + return bom_ref + else: + return BomRef(value=str(bom_ref) if bom_ref else None) diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 94cdd83e..03809f2d 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -41,6 +41,7 @@ from .bom_ref import BomRef from .component import Component from .contact import OrganizationalContact, OrganizationalEntity +from .definition import Definitions from .dependency import Dependable, Dependency from .license import License, LicenseExpression, LicenseRepository from .lifecycle import Lifecycle, LifecycleRepository, _LifecycleRepositoryHelper @@ -327,6 +328,7 @@ def __init__( dependencies: Optional[Iterable[Dependency]] = None, vulnerabilities: Optional[Iterable[Vulnerability]] = None, properties: Optional[Iterable[Property]] = None, + definitions: Optional[Definitions] = None, ) -> None: """ Create a new Bom that you can manually/programmatically add data to later. @@ -343,6 +345,7 @@ def __init__( self.vulnerabilities = vulnerabilities or [] # type:ignore[assignment] self.dependencies = dependencies or [] # type:ignore[assignment] self.properties = properties or [] # type:ignore[assignment] + self.definitions = definitions or Definitions() @property @serializable.type_mapping(UrnUuidHelper) @@ -552,6 +555,22 @@ def vulnerabilities(self, vulnerabilities: Iterable[Vulnerability]) -> None: # def formulation(self, ...) -> None: # ... # TODO Since CDX 1.5 + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(110) + def definitions(self) -> Optional[Definitions]: + """ + The repository for definitions + + Returns: + `DefinitionRepository` + """ + return self._definitions if len(self._definitions.standards) > 0 else None + + @definitions.setter + def definitions(self, definitions: Definitions) -> None: + self._definitions = definitions + def get_component_by_purl(self, purl: Optional['PackageURL']) -> Optional[Component]: """ Get a Component already in the Bom by its PURL diff --git a/cyclonedx/model/definition.py b/cyclonedx/model/definition.py new file mode 100644 index 00000000..0bde33c3 --- /dev/null +++ b/cyclonedx/model/definition.py @@ -0,0 +1,230 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + +from typing import TYPE_CHECKING, Any, Iterable, Optional, Union + +import serializable +from sortedcontainers import SortedSet + +from .._internal.bom_ref import bom_ref_from_str +from .._internal.compare import ComparableTuple as _ComparableTuple +from ..serialization import BomRefHelper +from . import ExternalReference +from .bom_ref import BomRef + +if TYPE_CHECKING: # pragma: no cover + pass + + +@serializable.serializable_class +class Standard: + """ + A standard of regulations, industry or organizational-specific standards, maturity models, best practices, + or any other requirements. + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + name: Optional[str] = None, + version: Optional[str] = None, + description: Optional[str] = None, + owner: Optional[str] = None, + external_references: Optional[Iterable['ExternalReference']] = None + ) -> None: + self._bom_ref = bom_ref_from_str(bom_ref) + self.name = name + self.version = version + self.description = description + self.owner = owner + self.external_references = external_references or [] # type:ignore[assignment] + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Standard): + return (_ComparableTuple((self.bom_ref, self.name, self.version)) + < _ComparableTuple((other.bom_ref, other.name, other.version))) + return NotImplemented + + def __eq__(self, other: object) -> bool: + if isinstance(other, Standard): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.bom_ref, self.name, self.version, self.description, self.owner, tuple(self.external_references) + )) + + def __repr__(self) -> str: + return f'' + + @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRefHelper) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + def bom_ref(self) -> BomRef: + """ + An optional identifier which can be used to reference the standard elsewhere in the BOM. Every bom-ref MUST be + unique within the BOM. If a value was not provided in the constructor, a UUIDv4 will have been assigned. + Returns: + `BomRef` + """ + return self._bom_ref + + @property + @serializable.xml_sequence(1) + def name(self) -> Optional[str]: + """ + Returns: + The name of the standard + """ + return self._name + + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name + + @property + @serializable.xml_sequence(2) + def version(self) -> Optional[str]: + """ + Returns: + The version of the standard + """ + return self._version + + @version.setter + def version(self, version: Optional[str]) -> None: + self._version = version + + @property + @serializable.xml_sequence(3) + def description(self) -> Optional[str]: + """ + Returns: + The description of the standard + """ + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + @property + @serializable.xml_sequence(4) + def owner(self) -> Optional[str]: + """ + Returns: + The owner of the standard, often the entity responsible for its release. + """ + return self._owner + + @owner.setter + def owner(self, owner: Optional[str]) -> None: + self._owner = owner + + # @property + # @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'requirement') + # @serializable.xml_sequence(5) + # def requirements(self) -> 'SortedSet[Requirement]': + # """ + # Returns: + # A SortedSet of requirements comprising the standard. + # """ + # return self._requirements + # + # @requirements.setter + # def requirements(self, requirements: Iterable[Requirement]) -> None: + # self._requirements = SortedSet(requirements) + # + # @property + # @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'level') + # @serializable.xml_sequence(6) + # def levels(self) -> 'SortedSet[Level]': + # """ + # Returns: + # A SortedSet of levels associated with the standard. Some standards have different levels of compliance. + # """ + # return self._levels + # + # @levels.setter + # def levels(self, levels: Iterable[Level]) -> None: + # self._levels = SortedSet(levels) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference') + @serializable.xml_sequence(7) + def external_references(self) -> 'SortedSet[ExternalReference]': + """ + Returns: + A SortedSet of external references associated with the standard. + """ + return self._external_references + + @external_references.setter + def external_references(self, external_references: Iterable[ExternalReference]) -> None: + self._external_references = SortedSet(external_references) + + +@serializable.serializable_class(name='definitions') +class Definitions: + """ + The repository for definitions + """ + + def __init__( + self, *, + standards: Optional[Iterable[Standard]] = None + ) -> None: + self.standards = standards or () # type:ignore[assignment] + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'standard') + @serializable.xml_sequence(1) + def standards(self) -> 'SortedSet[Standard]': + """ + Returns: + A SortedSet of Standards + """ + return self._standards + + @standards.setter + def standards(self, standards: Iterable[Standard]) -> None: + self._standards = SortedSet(standards) + + def __bool__(self) -> bool: + return len(self._standards) > 0 + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Definitions): + return False + + return self._standards == other._standards + + def __hash__(self) -> int: + return hash((tuple(self._standards))) + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Definitions): + return (_ComparableTuple(self._standards) + < _ComparableTuple(other.standards)) + return NotImplemented + + def __repr__(self) -> str: + return '' diff --git a/tests/_data/models.py b/tests/_data/models.py index 963b1743..ffbf7d4a 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -78,6 +78,7 @@ RelatedCryptoMaterialState, RelatedCryptoMaterialType, ) +from cyclonedx.model.definition import Definitions, Standard from cyclonedx.model.dependency import Dependency from cyclonedx.model.impact_analysis import ( ImpactAnalysisAffectedStatus, @@ -1292,7 +1293,20 @@ def get_bom_with_lifecycles() -> Bom: description='Integration testing specific to the runtime platform'), ], component=Component(name='app', type=ComponentType.APPLICATION, bom_ref='my-app'), - ), + ) + ) + + +def get_bom_with_definitions_standards() -> Bom: + """ + Returns a BOM with definitions and standards only. + """ + return _make_bom( + definitions=Definitions(standards=[ + Standard(name='Some Standard', version='1.2.3', description='Some description', bom_ref='some-standard', + owner='Some Owner', external_references=[get_external_reference_2()] + ) + ]) ) @@ -1342,4 +1356,5 @@ def get_bom_with_lifecycles() -> Bom: get_bom_with_component_setuptools_with_v16_fields, get_bom_for_issue_630_empty_property, get_bom_with_lifecycles, + get_bom_with_definitions_standards, } diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.0.xml.bin new file mode 100644 index 00000000..acb06612 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.0.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.1.xml.bin new file mode 100644 index 00000000..55ef5cda --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.1.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.2.json.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.2.json.bin new file mode 100644 index 00000000..8f473bd3 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.2.json.bin @@ -0,0 +1,10 @@ +{ + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.2b.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.2.xml.bin new file mode 100644 index 00000000..df1938ec --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.2.xml.bin @@ -0,0 +1,6 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.3.json.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.3.json.bin new file mode 100644 index 00000000..02943890 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.3.json.bin @@ -0,0 +1,10 @@ +{ + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.3a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.3.xml.bin new file mode 100644 index 00000000..8341ff60 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.3.xml.bin @@ -0,0 +1,6 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.4.json.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.4.json.bin new file mode 100644 index 00000000..48f1745d --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.4.json.bin @@ -0,0 +1,10 @@ +{ + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.4.xml.bin new file mode 100644 index 00000000..d0a7d4c9 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.4.xml.bin @@ -0,0 +1,6 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.5.json.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.5.json.bin new file mode 100644 index 00000000..57b5e590 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.5.json.bin @@ -0,0 +1,20 @@ +{ + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.5.xml.bin new file mode 100644 index 00000000..f952637c --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.5.xml.bin @@ -0,0 +1,10 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.6.json.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.6.json.bin new file mode 100644 index 00000000..9fba8848 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.6.json.bin @@ -0,0 +1,37 @@ +{ + "definitions": { + "standards": [ + { + "bom-ref": "some-standard", + "description": "Some description", + "externalReferences": [ + { + "type": "website", + "url": "https://cyclonedx.org" + } + ], + "name": "Some Standard", + "owner": "Some Owner", + "version": "1.2.3" + } + ] + }, + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_standards-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_standards-1.6.xml.bin new file mode 100644 index 00000000..b983bdf6 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_standards-1.6.xml.bin @@ -0,0 +1,25 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + val1 + val2 + + + + + Some Standard + 1.2.3 + Some description + Some Owner + + + https://cyclonedx.org + + + + + + diff --git a/tests/test_model_definition.py b/tests/test_model_definition.py new file mode 100644 index 00000000..5a1b80f3 --- /dev/null +++ b/tests/test_model_definition.py @@ -0,0 +1,67 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + + +from unittest import TestCase + +from cyclonedx.model.definition import Definitions, Standard + + +class TestModelDefinitionRepository(TestCase): + + def test_init(self) -> Definitions: + s = Standard(name='test-standard') + dr = Definitions( + standards=(s, ), + ) + self.assertIs(s, tuple(dr.standards)[0]) + return dr + + def test_filled(self) -> None: + dr = self.test_init() + self.assertIsNotNone(dr.standards) + self.assertEqual(1, len(dr.standards)) + self.assertTrue(dr) + + def test_empty(self) -> None: + dr = Definitions() + self.assertIsNotNone(dr.standards) + self.assertEqual(0, len(dr.standards)) + self.assertFalse(dr) + + def test_unequal_different_type(self) -> None: + dr = Definitions() + self.assertFalse(dr == 'other') + + def test_equal_self(self) -> None: + dr = Definitions() + dr.standards.add(Standard(name='my-standard')) + self.assertTrue(dr == dr) + + def test_unequal(self) -> None: + dr1 = Definitions() + dr1.standards.add(Standard(name='my-standard')) + tr2 = Definitions() + self.assertFalse(dr1 == tr2) + + def test_equal(self) -> None: + s = Standard(name='my-standard') + dr1 = Definitions() + dr1.standards.add(s) + tr2 = Definitions() + tr2.standards.add(s) + self.assertTrue(dr1 == tr2)