diff --git a/cyclonedx/model/annotation.py b/cyclonedx/model/annotation.py new file mode 100644 index 00000000..d88bddbc --- /dev/null +++ b/cyclonedx/model/annotation.py @@ -0,0 +1,295 @@ +# 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 collections.abc import Iterable +from datetime import datetime +from typing import Any, Optional, Union + +import py_serializable as serializable +from py_serializable.helpers import XsdDateTime +from sortedcontainers import SortedSet + +from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str +from .._internal.compare import ComparableTuple as _ComparableTuple +from ..exception.serialization import SerializationOfUnexpectedValueException +from .bom_ref import BomRef +from .component import Component +from .contact import OrganizationalContact, OrganizationalEntity +from .service import Service + + +class _SubjectsSerializationHelper(serializable.helpers.BaseHelper): + """THIS CLASS IS NON-PUBLIC API + + JSON: subjects is a plain list of bom-ref strings. + XML: subjects wrapper element containing children. + """ + + @classmethod + def serialize(cls, o: Any) -> list[str]: + if isinstance(o, (SortedSet, set, list)): + return [str(i) for i in o] + raise SerializationOfUnexpectedValueException( + f'Attempt to serialize a non-subjects collection: {o!r}') + + @classmethod + def deserialize(cls, o: Any) -> 'set[_AnnotationSubject]': + subjects: set[_AnnotationSubject] = set() + if isinstance(o, list): + for v in o: + subjects.add(_AnnotationSubject(ref=BomRef(value=str(v)))) + return subjects + + @classmethod + def xml_denormalize(cls, o: Any, *, + default_ns: Any, + prop_info: Any, + ctx: Any, + **kwargs: Any) -> set['_AnnotationSubject']: + subjects: set[_AnnotationSubject] = set() + if o is None: + return subjects + for child in o: + ref_val = child.get('ref') + if ref_val: + subjects.add(_AnnotationSubject(ref=BomRef(value=ref_val))) + return subjects + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class _AnnotationSubject: + """THIS CLASS IS NON-PUBLIC API + + Wrapper that renders as ```` in XML. + """ + + def __init__(self, ref: BomRef) -> None: + self._ref = ref + + @property + @serializable.type_mapping(BomRef) + @serializable.xml_attribute() + def ref(self) -> BomRef: + return self._ref + + def __eq__(self, other: object) -> bool: + if isinstance(other, _AnnotationSubject): + return self._ref == other._ref + return False + + def __hash__(self) -> int: + return hash(self._ref) + + def __lt__(self, other: object) -> bool: + if isinstance(other, _AnnotationSubject): + return str(self._ref) < str(other._ref) + return NotImplemented + + def __str__(self) -> str: + return str(self._ref) + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class Annotator: + """ + The organization, person, component, or service which created the textual content of the annotation. + """ + + def __init__( + self, *, + organization: Optional[OrganizationalEntity] = None, + individual: Optional[OrganizationalContact] = None, + component: Optional[Component] = None, + service: Optional[Service] = None, + ) -> None: + if sum(x is not None for x in (organization, individual, component, service)) != 1: + raise ValueError('Exactly one of organization, individual, component, or service must be provided.') + + self.organization = organization + self.individual = individual + self.component = component + self.service = service + + @property + @serializable.xml_sequence(1) + def organization(self) -> Optional[OrganizationalEntity]: + return self._organization + + @organization.setter + def organization(self, organization: Optional[OrganizationalEntity]) -> None: + self._organization = organization + + @property + @serializable.xml_sequence(2) + def individual(self) -> Optional[OrganizationalContact]: + return self._individual + + @individual.setter + def individual(self, individual: Optional[OrganizationalContact]) -> None: + self._individual = individual + + @property + @serializable.xml_sequence(3) + def component(self) -> Optional[Component]: + return self._component + + @component.setter + def component(self, component: Optional[Component]) -> None: + self._component = component + + @property + @serializable.xml_sequence(4) + def service(self) -> Optional[Service]: + return self._service + + @service.setter + def service(self, service: Optional[Service]) -> None: + self._service = service + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.organization, self.individual, self.component, self.service + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Annotator): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Annotator): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class Annotation: + """ + A comment, note, explanation, or similar textual content which provides additional context + to the object(s) being annotated. + """ + + def __init__( + self, *, + subjects: Iterable[BomRef], + annotator: Annotator, + timestamp: datetime, + text: str, + bom_ref: Optional[Union[str, BomRef]] = None, + ) -> None: + self._bom_ref = _bom_ref_from_str(bom_ref) + self.subjects = subjects + self.annotator = annotator + self.timestamp = timestamp + self.text = text + + @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRef) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + def bom_ref(self) -> BomRef: + """ + An optional identifier which can be used to reference the annotation elsewhere in the BOM. + """ + return self._bom_ref + + @bom_ref.setter + def bom_ref(self, bom_ref: BomRef) -> None: + self._bom_ref = bom_ref + + @property + @serializable.type_mapping(_SubjectsSerializationHelper) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'subject') + @serializable.xml_sequence(1) + def subjects(self) -> 'SortedSet[_AnnotationSubject]': + """ + The object in the BOM identified by its bom-ref. + """ + return self._subjects + + @subjects.setter + def subjects(self, subjects: Iterable[Union[BomRef, '_AnnotationSubject']]) -> None: + self._subjects = SortedSet( + s if isinstance(s, _AnnotationSubject) else _AnnotationSubject(ref=s) + for s in subjects + ) + + @property + @serializable.xml_sequence(2) + def annotator(self) -> Annotator: + """ + The organization, person, component, or service which created the textual content of the annotation. + """ + return self._annotator + + @annotator.setter + def annotator(self, annotator: Annotator) -> None: + self._annotator = annotator + + @property + @serializable.type_mapping(XsdDateTime) + @serializable.xml_sequence(3) + def timestamp(self) -> datetime: + """ + The date and time (timestamp) when the annotation was created. + """ + return self._timestamp + + @timestamp.setter + def timestamp(self, timestamp: datetime) -> None: + self._timestamp = timestamp + + @property + @serializable.xml_sequence(4) + def text(self) -> str: + """ + The textual content of the annotation. + """ + return self._text + + @text.setter + def text(self, text: str) -> None: + self._text = text + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.bom_ref.value, _ComparableTuple(self.subjects), + self.annotator, self.timestamp, self.text + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Annotation): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Annotation): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 7cb0081e..44475629 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -43,6 +43,7 @@ ) from ..serialization import UrnUuidHelper from . import _BOM_LINK_PREFIX, ExternalReference, Property +from .annotation import Annotation from .bom_ref import BomRef from .component import Component from .contact import OrganizationalContact, OrganizationalEntity @@ -444,6 +445,7 @@ def __init__( vulnerabilities: Optional[Iterable[Vulnerability]] = None, properties: Optional[Iterable[Property]] = None, definitions: Optional[Definitions] = None, + annotations: Optional[Iterable[Annotation]] = None, ) -> None: """ Create a new Bom that you can manually/programmatically add data to later. @@ -461,6 +463,7 @@ def __init__( self.dependencies = dependencies or [] self.properties = properties or [] self.definitions = definitions or Definitions() + self.annotations = annotations or [] @property @serializable.type_mapping(UrnUuidHelper) @@ -655,16 +658,25 @@ def vulnerabilities(self) -> 'SortedSet[Vulnerability]': def vulnerabilities(self, vulnerabilities: Iterable[Vulnerability]) -> None: self._vulnerabilities = SortedSet(vulnerabilities) - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(9) - # def annotations(self) -> ...: - # ... # TODO Since CDX 1.5 - # - # @annotations.setter - # def annotations(self, ...) -> None: - # ... # TODO Since CDX 1.5 + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'annotation') + @serializable.xml_sequence(90) + def annotations(self) -> 'SortedSet[Annotation]': + """ + Comments, notes, explanations, or similar textual content which provides additional context + to the object(s) being annotated. + + Returns: + Set of `Annotation` + """ + return self._annotations + + @annotations.setter + def annotations(self, annotations: Iterable[Annotation]) -> None: + self._annotations = SortedSet(annotations) # @property # ... diff --git a/tests/_data/models.py b/tests/_data/models.py index e2052878..f52ee727 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -42,6 +42,7 @@ Property, XsUri, ) +from cyclonedx.model.annotation import Annotation, Annotator from cyclonedx.model.bom import Bom, BomMetaData, DistributionConstraints, TlpClassification from cyclonedx.model.bom_ref import BomRef from cyclonedx.model.component import ( @@ -1679,6 +1680,27 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom: # --- +def get_bom_with_annotations() -> Bom: + annotator = Annotator( + organization=OrganizationalEntity( + name='Acme, Inc.', + urls=[XsUri('https://example.com')] + ) + ) + + annotation = Annotation( + bom_ref='annotation-1', + subjects=[BomRef('subject-1')], + annotator=annotator, + timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + text='This is an annotation.' + ) + + return _make_bom(annotations=[annotation]) + + +# --- + all_get_bom_funct_valid = tuple( (n, f) for n, f in getmembers(sys.modules[__name__], isfunction) if n.startswith('get_bom_') and not n.endswith('_invalid') @@ -1726,4 +1748,5 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom: get_bom_with_distribution_constraints, get_bom_with_definitions_standards, get_bom_with_definitions_and_detailed_standards, + get_bom_with_annotations, } diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.0.xml.bin new file mode 100644 index 00000000..acb06612 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-1.0.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.1.xml.bin new file mode 100644 index 00000000..55ef5cda --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-1.1.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.2.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.2.json.bin new file mode 100644 index 00000000..8f473bd3 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-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_annotations-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.2.xml.bin new file mode 100644 index 00000000..df1938ec --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-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_annotations-1.3.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.3.json.bin new file mode 100644 index 00000000..02943890 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-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_annotations-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.3.xml.bin new file mode 100644 index 00000000..8341ff60 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-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_annotations-1.4.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.4.json.bin new file mode 100644 index 00000000..48f1745d --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-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_annotations-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.4.xml.bin new file mode 100644 index 00000000..d0a7d4c9 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-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_annotations-1.5.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.5.json.bin new file mode 100644 index 00000000..32115b8a --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-1.5.json.bin @@ -0,0 +1,38 @@ +{ + "annotations": [ + { + "annotator": { + "organization": { + "name": "Acme, Inc.", + "url": [ + "https://example.com" + ] + } + }, + "bom-ref": "annotation-1", + "subjects": [ + "subject-1" + ], + "text": "This is an annotation.", + "timestamp": "2024-01-01T12:00:00+00:00" + } + ], + "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_annotations-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.5.xml.bin new file mode 100644 index 00000000..3a00ca8a --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-1.5.xml.bin @@ -0,0 +1,25 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + val1 + val2 + + + + + + + + + Acme, Inc. + https://example.com + + + 2024-01-01T12:00:00+00:00 + This is an annotation. + + + diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.6.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.6.json.bin new file mode 100644 index 00000000..383c6ff9 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-1.6.json.bin @@ -0,0 +1,38 @@ +{ + "annotations": [ + { + "annotator": { + "organization": { + "name": "Acme, Inc.", + "url": [ + "https://example.com" + ] + } + }, + "bom-ref": "annotation-1", + "subjects": [ + "subject-1" + ], + "text": "This is an annotation.", + "timestamp": "2024-01-01T12:00:00+00:00" + } + ], + "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_annotations-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.6.xml.bin new file mode 100644 index 00000000..6aa0d772 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-1.6.xml.bin @@ -0,0 +1,25 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + val1 + val2 + + + + + + + + + Acme, Inc. + https://example.com + + + 2024-01-01T12:00:00+00:00 + This is an annotation. + + + diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.7.json.bin b/tests/_data/snapshots/get_bom_with_annotations-1.7.json.bin new file mode 100644 index 00000000..8d1b988c --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-1.7.json.bin @@ -0,0 +1,38 @@ +{ + "annotations": [ + { + "annotator": { + "organization": { + "name": "Acme, Inc.", + "url": [ + "https://example.com" + ] + } + }, + "bom-ref": "annotation-1", + "subjects": [ + "subject-1" + ], + "text": "This is an annotation.", + "timestamp": "2024-01-01T12:00:00+00:00" + } + ], + "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.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_annotations-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_annotations-1.7.xml.bin new file mode 100644 index 00000000..726e3b92 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_annotations-1.7.xml.bin @@ -0,0 +1,25 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + val1 + val2 + + + + + + + + + Acme, Inc. + https://example.com + + + 2024-01-01T12:00:00+00:00 + This is an annotation. + + + diff --git a/tests/test_model_annotation.py b/tests/test_model_annotation.py new file mode 100644 index 00000000..7e357014 --- /dev/null +++ b/tests/test_model_annotation.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 datetime import datetime, timezone +from unittest import TestCase + +from cyclonedx.model.annotation import Annotation, Annotator +from cyclonedx.model.bom_ref import BomRef +from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity +from tests import reorder + +_TIMESTAMP = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + +class TestModelAnnotator(TestCase): + + def test_invalid_no_args(self) -> None: + with self.assertRaises(ValueError): + Annotator() + + def test_invalid_multiple_args(self) -> None: + with self.assertRaises(ValueError): + Annotator( + organization=OrganizationalEntity(name='A'), + individual=OrganizationalContact(name='B'), + ) + + def test_sort(self) -> None: + # expected sort order: (organization, individual, component, service) + expected_order = [0, 2, 1] + annotators = [ + Annotator(organization=OrganizationalEntity(name='Acme')), + Annotator(individual=OrganizationalContact(name='Zoe')), + Annotator(individual=OrganizationalContact(name='Alice')), + ] + self.assertListEqual(sorted(annotators), reorder(annotators, expected_order)) + + +class TestModelAnnotation(TestCase): + + def test_sort(self) -> None: + # expected sort order: (bom_ref.value, subjects, annotator, timestamp, text) + annotator = Annotator(organization=OrganizationalEntity(name='Acme')) + expected_order = [0, 2, 1] + annotations = [ + Annotation(bom_ref='a', subjects=[BomRef('s')], annotator=annotator, + timestamp=_TIMESTAMP, text='alpha'), + Annotation(bom_ref='c', subjects=[BomRef('s')], annotator=annotator, + timestamp=_TIMESTAMP, text='gamma'), + Annotation(bom_ref='b', subjects=[BomRef('s')], annotator=annotator, + timestamp=_TIMESTAMP, text='beta'), + ] + self.assertListEqual(sorted(annotations), reorder(annotations, expected_order))