diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 7bd09740..e89abf8c 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 DefinitionRepository, _DefinitionRepositoryHelper from .dependency import Dependable, Dependency from .license import License, LicenseExpression, LicenseRepository from .service import Service @@ -72,6 +73,7 @@ def __init__( manufacturer: Optional[OrganizationalEntity] = None, # Deprecated as of v1.6 manufacture: Optional[OrganizationalEntity] = None, + definitions: Optional[DefinitionRepository] = None, ) -> None: self.timestamp = timestamp or _get_now_utc() self.tools = tools or [] # type:ignore[assignment] @@ -88,6 +90,7 @@ def __init__( '`bom.metadata.manufacture` is deprecated from CycloneDX v1.6 onwards. ' 'Please use `bom.metadata.component.manufacturer` instead.', DeprecationWarning) + self.definitions = definitions @property @serializable.type_mapping(serializable.helpers.XsdDateTime) @@ -282,6 +285,23 @@ def properties(self) -> 'SortedSet[Property]': def properties(self, properties: Iterable[Property]) -> None: self._properties = SortedSet(properties) + @property + @serializable.type_mapping(_DefinitionRepositoryHelper) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(90) + def definitions(self) -> Optional[DefinitionRepository]: + """ + The repository for definitions + + Returns: + `DefinitionRepository` + """ + return self._definitions + + @definitions.setter + def definitions(self, definitions: DefinitionRepository) -> None: + self._definitions = definitions + def __eq__(self, other: object) -> bool: if isinstance(other, BomMetaData): return hash(other) == hash(self) diff --git a/cyclonedx/model/definition.py b/cyclonedx/model/definition.py new file mode 100644 index 00000000..b3893ad9 --- /dev/null +++ b/cyclonedx/model/definition.py @@ -0,0 +1,508 @@ +# 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. + +import re +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Type, Union +from xml.etree.ElementTree import Element # nosec B405 + +import serializable +from serializable.helpers import BaseHelper +from sortedcontainers import SortedSet + +from ..exception.model import CycloneDxModelException +from ..exception.serialization import CycloneDxDeserializationException, SerializationOfUnexpectedValueException +from ..serialization import BomRefHelper +from . import ExternalReference, Property +from .bom_ref import BomRef + +if TYPE_CHECKING: # pragma: no cover + from serializable import ObjectMetadataLibrary, ViewType + + +def bom_ref_or_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) + + +class InvalidCreIdException(CycloneDxModelException): + """ + Raised when a supplied value for an CRE ID does not meet the format requirements + as defined at https://opencre.org/ + """ + pass + + +@serializable.serializable_class +class CreId(serializable.helpers.BaseHelper): + """ + Helper class that allows us to perform validation on data strings that must conform to + Common Requirements Enumeration (CRE) identifier(s). + + """ + + _VALID_CRE_REGEX = re.compile(r'^CRE:[0-9]+-[0-9]+$') + + def __init__(self, id: str) -> None: + if CreId._VALID_CRE_REGEX.match(id) is None: + raise InvalidCreIdException( + f'Supplied value "{id} does not meet format specification.' + ) + self._id = id + + @property + @serializable.json_name('.') + @serializable.xml_name('.') + def id(self) -> str: + return self._id + + @classmethod + def serialize(cls, o: Any) -> str: + if isinstance(o, CreId): + return str(o) + raise SerializationOfUnexpectedValueException( + f'Attempt to serialize a non-CreId: {o!r}') + + @classmethod + def deserialize(cls, o: Any) -> 'CreId': + try: + return CreId(id=str(o)) + except ValueError as err: + raise CycloneDxDeserializationException( + f'CreId string supplied does not parse: {o!r}' + ) from err + + def __eq__(self, other: Any) -> bool: + if isinstance(other, CreId): + return hash(other) == hash(self) + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, CreId): + return self._id < other._id + return NotImplemented + + def __hash__(self) -> int: + return hash(self._id) + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self._id + + +@serializable.serializable_class +class Requirement: + """ + A requirement comprising a standard. + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + identifier: Optional[str] = None, + title: Optional[str] = None, + text: Optional[str] = None, + descriptions: Optional[Iterable[str]] = None, + open_cre: Optional[Iterable[CreId]] = None, + parent: Optional[Union[str, BomRef]] = None, + properties: Optional[Iterable[Property]] = None, + external_references: Optional[Iterable[ExternalReference]] = None, + ) -> None: + self._bom_ref = bom_ref_or_str(bom_ref) + self.identifier = identifier + self.title = title + self.text = text + self.descriptions = descriptions or [] # type:ignore[assignment] + self.open_cre = open_cre or [] # type:ignore[assignment] + self._parent = bom_ref_or_str(parent) + self.properties = properties or [] # type:ignore[assignment] + self.external_references = external_references or [] # type:ignore[assignment] + + @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 component 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(2) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def identifier(self) -> Optional[str]: + """ + Returns: + The identifier of the requirement. + """ + return self._identifier + + @identifier.setter + def identifier(self, identifier: Optional[str]) -> None: + self._identifier = identifier + + @property + @serializable.xml_sequence(3) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def title(self) -> Optional[str]: + """ + Returns: + The title of the requirement. + """ + return self._title + + @title.setter + def title(self, title: Optional[str]) -> None: + self._title = title + + @property + @serializable.xml_sequence(4) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def text(self) -> Optional[str]: + """ + Returns: + The text of the requirement. + """ + return self._text + + @text.setter + def text(self, text: Optional[str]) -> None: + self._text = text + + @property + @serializable.xml_sequence(5) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'description') + def descriptions(self) -> 'SortedSet[str]': + """ + Returns: + A SortedSet of descriptions of the requirement. + """ + return self._descriptions + + @descriptions.setter + def descriptions(self, descriptions: Iterable[str]) -> None: + self._descriptions = SortedSet(descriptions) + + @property + @serializable.json_name('openCre') + @serializable.xml_sequence(6) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'openCre') + def open_cre(self) -> 'SortedSet[CreId]': + """ + CRE is a structured and standardized framework for uniting security standards and guidelines. CRE links each + section of a resource to a shared topic identifier (a Common Requirement). Through this shared topic link, all + resources map to each other. Use of CRE promotes clear and unambiguous communication among stakeholders. + + Returns: + The Common Requirements Enumeration (CRE) identifier(s). + CREs must match regular expression: ^CRE:[0-9]+-[0-9]+$ + """ + return self._open_cre + + @open_cre.setter + def open_cre(self, open_cre: Iterable[CreId]) -> None: + self._open_cre = SortedSet(open_cre) + + @property + @serializable.type_mapping(BomRefHelper) + @serializable.xml_attribute() + def parent(self) -> Optional[BomRef]: + """ + Returns: + The optional bom-ref to a parent requirement. This establishes a hierarchy of requirements. Top-level + requirements must not define a parent. Only child requirements should define parents. + """ + return self._parent + + @parent.setter + def parent(self, parent: Optional[Union[str, BomRef]]) -> None: + self._parent = bom_ref_or_str(parent) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property') + @serializable.xml_sequence(22) + def properties(self) -> 'SortedSet[Property]': + """ + Provides the ability to document properties in a key/value store. This provides flexibility to include data not + officially supported in the standard without having to use additional namespaces or create extensions. + + Return: + Set of `Property` + """ + return self._properties + + @properties.setter + def properties(self, properties: Iterable[Property]) -> None: + self._properties = SortedSet(properties) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference') + @serializable.xml_sequence(21) + def external_references(self) -> 'SortedSet[ExternalReference]': + """ + Provides the ability to document external references related to the component or to the project the component + describes. + + Returns: + Set of `ExternalReference` + """ + 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 +class Level: + """ + Level of compliance for a standard. + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + identifier: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + requirements: Optional[Iterable[Union[str, BomRef]]] = None, + ) -> None: + self._bom_ref = bom_ref_or_str(bom_ref) + self.identifier = identifier + self.title = title + self.description = description + self.requirements = requirements or [] + + +@serializable.serializable_class(serialization_types=[ + serializable.SerializationType.JSON, + serializable.SerializationType.XML] +) +class Standard: + """ + A standard of regulations, industry or organizational-specific standards, maturity models, best practices, + or any other requirements. + """ + + def __init__( + self, *, + name: Optional[str] = None, + version: Optional[str] = None, + description: Optional[str] = None, + owner: Optional[str] = None, + requirements: Optional[Iterable[Requirement]] = None, + levels: Optional[Iterable[Level]] = None, + external_references: Optional[Iterable['ExternalReference']] = None + ) -> None: + self.name = name + self.version = version + self.description = description + self.owner = owner + self.requirements = requirements or [] # type:ignore[assignment] + self.levels = levels or [] # type:ignore[assignment] + self.external_references = external_references or [] # type:ignore[assignment] + + @property + @serializable.xml_sequence(2) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + 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(3) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + 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(4) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + 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(5) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + 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_sequence(10) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'requirements') + 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_sequence(20) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'levels') + 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_sequence(30) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference') + 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) + + +class DefinitionRepository: + """ + The repository for definitions + """ + + def __init__( + self, *, + standards: Optional[Iterable[Standard]] = None + ) -> None: + self.standards = standards or () # type:ignore[assignment] + + @property + 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) + + +class _DefinitionRepositoryHelper(BaseHelper): + """ + Helper class for serializing and deserializing a Definitions. + """ + + @classmethod + def json_normalize(cls, o: DefinitionRepository, *, + view: Optional[Type['ViewType']], + **__: Any) -> Any: + elem: Dict[str, Any] = {} + if o.standards: + elem['standards'] = tuple(o.standards) + return elem or None + + @classmethod + def json_denormalize(cls, o: Union[List[Dict[str, Any]], Dict[str, Any]], + **__: Any) -> DefinitionRepository: + standards = None + if isinstance(o, Dict): + standards = map(lambda c: Standard.from_json(c), # type:ignore[attr-defined] + o.get('standards', ())) + return DefinitionRepository(standards=standards) + + @classmethod + def xml_normalize(cls, o: DefinitionRepository, *, + element_name: str, + view: Optional[Type['ViewType']], + xmlns: Optional[str], + **__: Any) -> Optional[Element]: + elem = Element(element_name) + if o.standards: + elem_s = Element(f'{{{xmlns}}}standards' if xmlns else 'standards') + elem_s.extend( + si.as_xml( # type:ignore[attr-defined] + view_=view, as_string=False, element_name='standard', xmlns=xmlns) + for si in o.standards) + elem.append(elem_s) + return elem \ + if len(elem) > 0 \ + else None + + @classmethod + def xml_denormalize(cls, o: Element, *, + default_ns: Optional[str], + prop_info: 'ObjectMetadataLibrary.SerializableProperty', + ctx: Type[Any], + **kwargs: Any) -> DefinitionRepository: + standards = None + for e in o: + tag = e.tag if default_ns is None else e.tag.replace(f'{{{default_ns}}}', '') + if tag == 'standards': + standards = map(lambda s: Standard.from_xml( # type:ignore[attr-defined] + s, default_ns), e) + else: + raise CycloneDxDeserializationException(f'unexpected: {e!r}') + return DefinitionRepository(standards=standards) diff --git a/tests/test_model_definition.py b/tests/test_model_definition.py new file mode 100644 index 00000000..a5478760 --- /dev/null +++ b/tests/test_model_definition.py @@ -0,0 +1,53 @@ +# 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 CreId, InvalidCreIdException + + +class TestModelCreId(TestCase): + + def test_different(self) -> None: + id1 = CreId('CRE:123-456') + id2 = CreId('CRE:987-654') + self.assertNotEqual(id(id1), id(id2)) + self.assertNotEqual(hash(id1), hash(id2)) + self.assertFalse(id1 == id2) + + def test_same(self) -> None: + id1 = CreId('CRE:123-456') + id2 = CreId('CRE:123-456') + self.assertNotEqual(id(id1), id(id2)) + self.assertEqual(hash(id1), hash(id2)) + self.assertTrue(id1 == id2) + + def test_invalid_id(self) -> None: + with self.assertRaises(TypeError): + CreId() + with self.assertRaises(InvalidCreIdException): + CreId('') + with self.assertRaises(InvalidCreIdException): + CreId('some string') + with self.assertRaises(InvalidCreIdException): + CreId('123-456') + with self.assertRaises(InvalidCreIdException): + CreId('CRE:123-456-789') + with self.assertRaises(InvalidCreIdException): + CreId('CRE:abc-def') + with self.assertRaises(InvalidCreIdException): + CreId('CRE:123456')