From b45ff187056893c5fb294cbf9de854fd130bb7be Mon Sep 17 00:00:00 2001 From: Jeffry Hesse <5544326+DarthHater@users.noreply.github.com> Date: Thu, 27 Jan 2022 00:44:40 -0900 Subject: [PATCH 01/29] WIP on `bom.services` * WIP but a lil hand up for @madpah Signed-off-by: Jeffry Hesse <5544326+DarthHater@users.noreply.github.com> * chore: added missing license header Signed-off-by: Paul Horton * No default values for required fields * Add Services to BOM * Typo fix * aligned classes with standards, commented out Signature work for now, added first tests for Services Signed-off-by: Paul Horton * addressed standards Signed-off-by: Paul Horton * 1.2.0 Automatically generated by python-semantic-release Signed-off-by: Paul Horton * feat: `bom-ref` for Component and Vulnerability default to a UUID (#142) * feat: `bom-ref` for Component and Vulnerability default to a UUID if not supplied ensuring they have a unique value #141 Signed-off-by: Paul Horton * doc: updated documentation to reflect change Signed-off-by: Paul Horton * patched other tests to support UUID for bom-ref Signed-off-by: Paul Horton * better syntax Signed-off-by: Paul Horton * 1.3.0 Automatically generated by python-semantic-release Signed-off-by: Paul Horton * WIP but a lil hand up for @madpah Signed-off-by: Jeffry Hesse <5544326+DarthHater@users.noreply.github.com> Signed-off-by: Paul Horton * chore: added missing license header Signed-off-by: Paul Horton * aligned classes with standards, commented out Signature work for now, added first tests for Services Signed-off-by: Paul Horton * removed signature from this branch Signed-off-by: Paul Horton * Add Services to BOM * Typo fix * addressed standards Signed-off-by: Paul Horton * resolved typing issues from merge Signed-off-by: Paul Horton * added a bunch more tests for JSON output Signed-off-by: Paul Horton Co-authored-by: Paul Horton Co-authored-by: github-actions --- cyclonedx/model/__init__.py | 71 ++++- cyclonedx/model/bom.py | 111 ++++++- cyclonedx/model/issue.py | 2 +- cyclonedx/model/service.py | 294 ++++++++++++++++++ cyclonedx/output/json.py | 23 +- cyclonedx/output/schema.py | 36 +++ cyclonedx/output/serializer/json.py | 4 +- cyclonedx/output/xml.py | 55 ++-- tests/fixtures/bom_v1.2_services_complex.json | 84 +++++ tests/fixtures/bom_v1.2_services_simple.json | 34 ++ tests/fixtures/bom_v1.3_services_complex.json | 94 ++++++ tests/fixtures/bom_v1.3_services_simple.json | 34 ++ tests/fixtures/bom_v1.4_services_complex.json | 187 +++++++++++ tests/fixtures/bom_v1.4_services_simple.json | 68 ++++ ..._v1.4_setuptools_with_vulnerabilities.json | 2 +- tests/test_model.py | 18 ++ tests/test_model_component.py | 18 ++ tests/test_model_release_note.py | 18 ++ tests/test_model_service.py | 44 +++ tests/test_model_vulnerability.py | 19 ++ tests/test_output_json.py | 278 +++++++++++++---- 21 files changed, 1374 insertions(+), 120 deletions(-) create mode 100644 cyclonedx/model/service.py create mode 100644 tests/fixtures/bom_v1.2_services_complex.json create mode 100644 tests/fixtures/bom_v1.2_services_simple.json create mode 100644 tests/fixtures/bom_v1.3_services_complex.json create mode 100644 tests/fixtures/bom_v1.3_services_simple.json create mode 100644 tests/fixtures/bom_v1.4_services_complex.json create mode 100644 tests/fixtures/bom_v1.4_services_simple.json create mode 100644 tests/test_model_service.py diff --git a/cyclonedx/model/__init__.py b/cyclonedx/model/__init__.py index 113ed928..6fa11761 100644 --- a/cyclonedx/model/__init__.py +++ b/cyclonedx/model/__init__.py @@ -51,9 +51,78 @@ def sha1sum(filename: str) -> str: return h.hexdigest() +class DataFlow(Enum): + """ + This is our internal representation of the dataFlowType simple type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema: https://cyclonedx.org/docs/1.4/xml/#type_dataFlowType + """ + INBOUND = "inbound" + OUTBOUND = "outbound" + BI_DIRECTIONAL = "bi-directional" + UNKNOWN = "unknown" + + +class DataClassification: + """ + This is our internal representation of the `dataClassificationType` complex type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema for dataClassificationType: + https://cyclonedx.org/docs/1.4/xml/#type_dataClassificationType + """ + + def __init__(self, flow: DataFlow, classification: str) -> None: + if not flow and not classification: + raise NoPropertiesProvidedException( + 'One of `flow` or `classification` must be supplied - neither supplied' + ) + + self.flow = flow + self.classification = classification + + @property + def flow(self) -> DataFlow: + """ + Specifies the flow direction of the data. + + Valid values are: inbound, outbound, bi-directional, and unknown. + + Direction is relative to the service. + + - Inbound flow states that data enters the service + - Outbound flow states that data leaves the service + - Bi-directional states that data flows both ways + - Unknown states that the direction is not known + + Returns: + `DataFlow` + """ + return self._flow + + @flow.setter + def flow(self, flow: DataFlow) -> None: + self._flow = flow + + @property + def classification(self) -> str: + """ + Data classification tags data according to its type, sensitivity, and value if altered, stolen, or destroyed. + + Returns: + `str` + """ + return self._classification + + @classification.setter + def classification(self, classification: str) -> None: + self._classification = classification + + class Encoding(Enum): """ - This is out internal representation of the encoding simple type within the CycloneDX standard. + This is our internal representation of the encoding simple type within the CycloneDX standard. .. note:: See the CycloneDX Schema: https://cyclonedx.org/docs/1.4/#type_encoding diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index d0aa0068..c15cad82 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -18,11 +18,12 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. from datetime import datetime, timezone -from typing import List, Optional +from typing import cast, List, Optional from uuid import uuid4, UUID from . import ThisTool, Tool from .component import Component +from .service import Service from ..parser import BaseParser @@ -133,7 +134,7 @@ def from_parser(parser: BaseParser) -> 'Bom': bom.add_components(parser.get_components()) return bom - def __init__(self) -> None: + def __init__(self, components: Optional[List[Component]] = None, services: Optional[List[Service]] = None) -> None: """ Create a new Bom that you can manually/programmatically add data to later. @@ -142,7 +143,8 @@ def __init__(self) -> None: """ self.uuid = uuid4() self.metadata = BomMetaData() - self._components: List[Component] = [] + self.components = components + self.services = services @property def uuid(self) -> UUID: @@ -176,17 +178,17 @@ def metadata(self, metadata: BomMetaData) -> None: self._metadata = metadata @property - def components(self) -> List[Component]: + def components(self) -> Optional[List[Component]]: """ Get all the Components currently in this Bom. Returns: - List of all Components in this Bom. + List of all Components in this Bom or `None` """ return self._components @components.setter - def components(self, components: List[Component]) -> None: + def components(self, components: Optional[List[Component]]) -> None: self._components = components def add_component(self, component: Component) -> None: @@ -200,8 +202,10 @@ def add_component(self, component: Component) -> None: Returns: None """ - if not self.has_component(component=component): - self._components.append(component) + if not self.components: + self.components = [component] + elif not self.has_component(component=component): + self.components.append(component) def add_components(self, components: List[Component]) -> None: """ @@ -214,7 +218,7 @@ def add_components(self, components: List[Component]) -> None: Returns: None """ - self.components = self._components + components + self.components = (self._components or []) + components def component_count(self) -> int: """ @@ -223,7 +227,7 @@ def component_count(self) -> int: Returns: The number of Components in this Bom as `int`. """ - return len(self._components) + return len(self._components) if self._components else 0 def get_component_by_purl(self, purl: Optional[str]) -> Optional[Component]: """ @@ -236,8 +240,11 @@ def get_component_by_purl(self, purl: Optional[str]) -> Optional[Component]: Returns: `Component` or `None` """ + if not self._components: + return None + if purl: - found = list(filter(lambda x: x.purl == purl, self.components)) + found = list(filter(lambda x: x.purl == purl, cast(List[Component], self.components))) if len(found) == 1: return found[0] @@ -263,7 +270,80 @@ def has_component(self, component: Component) -> bool: Returns: `bool` - `True` if the supplied Component is part of this Bom, `False` otherwise. """ - return component in self._components + if not self.components: + return False + return component in self.components + + @property + def services(self) -> Optional[List[Service]]: + """ + Get all the Services currently in this Bom. + + Returns: + List of `Service` in this Bom or `None` + """ + return self._services + + @services.setter + def services(self, services: Optional[List[Service]]) -> None: + self._services = services + + def add_service(self, service: Service) -> None: + """ + Add a Service to this Bom instance. + + Args: + service: + `cyclonedx.model.service.Service` instance to add to this Bom. + + Returns: + None + """ + if not self.services: + self.services = [service] + elif not self.has_service(service=service): + self.services.append(service) + + def add_services(self, services: List[Service]) -> None: + """ + Add multiple Services at once to this Bom instance. + + Args: + services: + List of `cyclonedx.model.service.Service` instances to add to this Bom. + + Returns: + None + """ + self.services = (self.services or []) + services + + def has_service(self, service: Service) -> bool: + """ + Check whether this Bom contains the provided Service. + + Args: + service: + The instance of `cyclonedx.model.service.Service` to check if this Bom contains. + + Returns: + `bool` - `True` if the supplied Service is part of this Bom, `False` otherwise. + """ + if not self.services: + return False + + return service in self.services + + def service_count(self) -> int: + """ + Returns the current count of Services within this Bom. + + Returns: + The number of Services in this Bom as `int`. + """ + if not self.services: + return 0 + + return len(self.services) def has_vulnerabilities(self) -> bool: """ @@ -273,8 +353,9 @@ def has_vulnerabilities(self) -> bool: `bool` - `True` if at least one `cyclonedx.model.component.Component` has at least one Vulnerability, `False` otherwise. """ - for c in self.components: - if c.has_vulnerabilities(): - return True + if self.components: + for c in self.components: + if c.has_vulnerabilities(): + return True return False diff --git a/cyclonedx/model/issue.py b/cyclonedx/model/issue.py index befd21d7..029f1540 100644 --- a/cyclonedx/model/issue.py +++ b/cyclonedx/model/issue.py @@ -26,7 +26,7 @@ class IssueClassification(Enum): This is out internal representation of the enum `issueClassification`. .. note:: - See the CycloneDX Schema definition: hhttps://cyclonedx.org/docs/1.4/xml/#type_issueClassification + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_issueClassification """ DEFECT = 'defect' ENHANCEMENT = 'enhancement' diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py new file mode 100644 index 00000000..674d0761 --- /dev/null +++ b/cyclonedx/model/service.py @@ -0,0 +1,294 @@ +# encoding: utf-8 + +# 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 + +from typing import List, Optional +from uuid import uuid4 + +from . import ExternalReference, DataClassification, LicenseChoice, OrganizationalEntity, Property, XsUri # , Signature +from .release_note import ReleaseNotes + +""" +This set of classes represents the data that is possible about known Services. + +.. note:: + See the CycloneDX Schema extension definition https://cyclonedx.org/docs/1.4/xml/#type_servicesType +""" + + +class Service: + """ + Class that models the `service` complex type in the CycloneDX schema. + + .. note:: + See the CycloneDX schema: https://cyclonedx.org/docs/1.4/xml/#type_service + """ + + def __init__(self, name: str, bom_ref: Optional[str] = None, provider: Optional[OrganizationalEntity] = None, + group: Optional[str] = None, version: Optional[str] = None, description: Optional[str] = None, + endpoints: Optional[List[XsUri]] = None, authenticated: Optional[bool] = None, + x_trust_boundary: Optional[bool] = None, data: Optional[List[DataClassification]] = None, + licenses: Optional[List[LicenseChoice]] = None, + external_references: Optional[List[ExternalReference]] = None, + properties: Optional[List[Property]] = None, + # services: Optional[List[Service]] = None, -- I have no clue how to do this, + # commenting out so someone else can + release_notes: Optional[ReleaseNotes] = None, + ) -> None: + self.bom_ref = bom_ref or str(uuid4()) + self.provider = provider + self.group = group + self.name = name + self.version = version + self.description = description + self.endpoints = endpoints + self.authenticated = authenticated + self.x_trust_boundary = x_trust_boundary + self.data = data + self.licenses = licenses or [] + self.external_references = external_references or [] + # self.services = services -- no clue + self.release_notes = release_notes + self.properties = properties + + @property + def bom_ref(self) -> Optional[str]: + """ + An optional identifier which can be used to reference the service elsewhere in the BOM. Uniqueness is enforced + within all elements and children of the root-level bom element. + + If a value was not provided in the constructor, a UUIDv4 will have been assigned. + + Returns: + `str` unique identifier for this Service + """ + return self._bom_ref + + @bom_ref.setter + def bom_ref(self, bom_ref: Optional[str]) -> None: + self._bom_ref = bom_ref + + @property + def provider(self) -> Optional[OrganizationalEntity]: + """ + Get the The organization that provides the service. + + Returns: + `OrganizationalEntity` if set else `None` + """ + return self._provider + + @provider.setter + def provider(self, provider: Optional[OrganizationalEntity]) -> None: + self._provider = provider + + @property + def group(self) -> Optional[str]: + """ + The grouping name, namespace, or identifier. This will often be a shortened, single name of the company or + project that produced the service or domain name. Whitespace and special characters should be avoided. + + Returns: + `str` if provided else `None` + """ + return self._group + + @group.setter + def group(self, group: Optional[str]) -> None: + self._group = group + + @property + def name(self) -> str: + """ + The name of the service. This will often be a shortened, single name of the service. + + Returns: + `str` + """ + return self._name + + @name.setter + def name(self, name: str) -> None: + self._name = name + + @property + def version(self) -> Optional[str]: + """ + The service version. + + Returns: + `str` if set else `None` + """ + return self._version + + @version.setter + def version(self, version: Optional[str]) -> None: + self._version = version + + @property + def description(self) -> Optional[str]: + """ + Specifies a description for the service. + + Returns: + `str` if set else `None` + """ + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + @property + def endpoints(self) -> Optional[List[XsUri]]: + """ + A list of endpoints URI's this service provides. + + Returns: + List of `XsUri` else `None` + """ + return self._endpoints + + @endpoints.setter + def endpoints(self, endpoints: Optional[List[XsUri]]) -> None: + self._endpoints = endpoints + + def add_endpoint(self, endpoint: XsUri) -> None: + """ + Add an endpoint URI for this Service. + + Args: + endpoint: + `XsUri` instance to add + + Returns: + None + """ + self.endpoints = (self._endpoints or []) + [endpoint] + + @property + def authenticated(self) -> Optional[bool]: + """ + A boolean value indicating if the service requires authentication. A value of true indicates the service + requires authentication prior to use. + + A value of false indicates the service does not require authentication. + + Returns: + `bool` if set else `None` + """ + return self._authenticated + + @authenticated.setter + def authenticated(self, authenticated: Optional[bool]) -> None: + self._authenticated = authenticated + + @property + def x_trust_boundary(self) -> Optional[bool]: + """ + A boolean value indicating if use of the service crosses a trust zone or boundary. A value of true indicates + that by using the service, a trust boundary is crossed. + + A value of false indicates that by using the service, a trust boundary is not crossed. + + Returns: + `bool` if set else `None` + """ + return self._x_trust_boundary + + @x_trust_boundary.setter + def x_trust_boundary(self, x_trust_boundary: Optional[bool]) -> None: + self._x_trust_boundary = x_trust_boundary + + @property + def data(self) -> Optional[List[DataClassification]]: + """ + Specifies the data classification. + + Returns: + List of `DataClassificiation` or `None` + """ + return self._data + + @data.setter + def data(self, data: Optional[List[DataClassification]]) -> None: + self._data = data + + @property + def licenses(self) -> List[LicenseChoice]: + """ + A optional list of statements about how this Service is licensed. + + Returns: + List of `LicenseChoice` else `None` + """ + return self._licenses + + @licenses.setter + def licenses(self, licenses: List[LicenseChoice]) -> None: + self._licenses = licenses + + @property + def external_references(self) -> List[ExternalReference]: + """ + Provides the ability to document external references related to the Service. + + Returns: + List of `ExternalReference`s + """ + return self._external_references + + @external_references.setter + def external_references(self, external_references: List[ExternalReference]) -> None: + self._external_references = external_references + + def add_external_reference(self, reference: ExternalReference) -> None: + """ + Add an `ExternalReference` to this `Service`. + + Args: + reference: + `ExternalReference` instance to add. + """ + self.external_references = self._external_references + [reference] + + @property + def release_notes(self) -> Optional[ReleaseNotes]: + """ + Specifies optional release notes. + + Returns: + `ReleaseNotes` or `None` + """ + return self._release_notes + + @release_notes.setter + def release_notes(self, release_notes: Optional[ReleaseNotes]) -> None: + self._release_notes = release_notes + + @property + def properties(self) -> Optional[List[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: + List of `Property` or `None` + """ + return self._properties + + @properties.setter + def properties(self, properties: Optional[List[Property]]) -> None: + self._properties = properties diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py index 1b7bcdec..e8460a20 100644 --- a/cyclonedx/output/json.py +++ b/cyclonedx/output/json.py @@ -19,13 +19,14 @@ import json from abc import abstractmethod -from typing import Any, Dict, List, Optional, Union +from typing import cast, Any, Dict, List, Optional, Union from . import BaseOutput from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3, \ SchemaVersion1Dot4 from .serializer.json import CycloneDxJSONEncoder from ..model.bom import Bom +from ..model.component import Component ComponentDict = Dict[str, Union[ @@ -51,11 +52,12 @@ def generate(self, force_regeneration: bool = False) -> None: return vulnerabilities: Dict[str, List[Dict[Any, Any]]] = {"vulnerabilities": []} - for component in self.get_bom().components: - for vulnerability in component.get_vulnerabilities(): - vulnerabilities['vulnerabilities'].append( - json.loads(json.dumps(vulnerability, cls=CycloneDxJSONEncoder)) - ) + if self.get_bom().components: + for component in cast(List[Component], self.get_bom().components): + for vulnerability in component.get_vulnerabilities(): + vulnerabilities['vulnerabilities'].append( + json.loads(json.dumps(vulnerability, cls=CycloneDxJSONEncoder)) + ) bom_json = json.loads(json.dumps(self.get_bom(), cls=CycloneDxJSONEncoder)) bom_json = json.loads(self._specialise_output_for_schema_version(bom_json=bom_json)) @@ -94,6 +96,15 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str else: bom_json['components'] = [] + # Iterate Services + if 'services' in bom_json.keys(): + for i in range(len(bom_json['services'])): + if not self.services_supports_properties() and 'properties' in bom_json['services'][i].keys(): + del bom_json['services'][i]['properties'] + + if not self.services_supports_release_notes() and 'releaseNotes' in bom_json['services'][i].keys(): + del bom_json['services'][i]['releaseNotes'] + # Iterate Vulnerabilities if 'vulnerabilities' in bom_json.keys(): for i in range(len(bom_json['vulnerabilities'])): diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py index 87e8ca18..138f613c 100644 --- a/cyclonedx/output/schema.py +++ b/cyclonedx/output/schema.py @@ -31,6 +31,15 @@ def bom_metadata_supports_tools(self) -> bool: def bom_metadata_supports_tools_external_references(self) -> bool: return True + def bom_supports_services(self) -> bool: + return True + + def services_supports_properties(self) -> bool: + return True + + def services_supports_release_notes(self) -> bool: + return True + def bom_supports_vulnerabilities(self) -> bool: return True @@ -77,6 +86,9 @@ class SchemaVersion1Dot3(BaseSchemaVersion): def bom_metadata_supports_tools_external_references(self) -> bool: return False + def services_supports_release_notes(self) -> bool: + return False + def bom_supports_vulnerabilities(self) -> bool: return False @@ -98,6 +110,12 @@ class SchemaVersion1Dot2(BaseSchemaVersion): def bom_metadata_supports_tools_external_references(self) -> bool: return False + def services_supports_properties(self) -> bool: + return False + + def services_supports_release_notes(self) -> bool: + return False + def bom_supports_vulnerabilities(self) -> bool: return False @@ -122,6 +140,15 @@ def bom_metadata_supports_tools(self) -> bool: def bom_metadata_supports_tools_external_references(self) -> bool: return False + def bom_supports_services(self) -> bool: + return False + + def services_supports_properties(self) -> bool: + return False + + def services_supports_release_notes(self) -> bool: + return False + def bom_supports_vulnerabilities(self) -> bool: return False @@ -152,6 +179,15 @@ def bom_metadata_supports_tools(self) -> bool: def bom_metadata_supports_tools_external_references(self) -> bool: return False + def bom_supports_services(self) -> bool: + return False + + def services_supports_properties(self) -> bool: + return False + + def services_supports_release_notes(self) -> bool: + return False + def bom_supports_vulnerabilities(self) -> bool: return False diff --git a/cyclonedx/output/serializer/json.py b/cyclonedx/output/serializer/json.py index e1a65267..5d0398c6 100644 --- a/cyclonedx/output/serializer/json.py +++ b/cyclonedx/output/serializer/json.py @@ -31,7 +31,7 @@ from cyclonedx.model import XsUri HYPHENATED_ATTRIBUTES = [ - 'bom_ref', 'mime_type' + 'bom_ref', 'mime_type', 'x_trust_boundary' ] PYTHON_TO_JSON_NAME = compile(r'_([a-z])') @@ -78,7 +78,7 @@ def default(self, o: Any) -> Any: new_key = PYTHON_TO_JSON_NAME.sub(lambda x: x.group(1).upper(), new_key) # Skip any None values - if v: + if v or v is False: if isinstance(v, PackageURL): # Special handling of PackageURL instances which JSON would otherwise automatically encode to # an Array diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index c4ba91fc..1282449e 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -18,7 +18,7 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. import warnings -from typing import List, Optional +from typing import cast, List, Optional from xml.etree import ElementTree from . import BaseOutput @@ -51,33 +51,34 @@ def generate(self, force_regeneration: bool = False) -> None: components_element = ElementTree.SubElement(self._root_bom_element, 'components') has_vulnerabilities: bool = False - for component in self.get_bom().components: - component_element = self._add_component_element(component=component) - components_element.append(component_element) - if self.bom_supports_vulnerabilities_via_extension() and component.has_vulnerabilities(): - # Vulnerabilities are only possible when bom-ref is supported by the main CycloneDX schema version - vulnerabilities = ElementTree.SubElement(component_element, 'v:vulnerabilities') - for vulnerability in component.get_vulnerabilities(): - if component.bom_ref: - vulnerabilities.append( - self._get_vulnerability_as_xml_element_pre_1_3(bom_ref=component.bom_ref, - vulnerability=vulnerability) + if self.get_bom().components: + for component in cast(List[Component], self.get_bom().components): + component_element = self._add_component_element(component=component) + components_element.append(component_element) + if self.bom_supports_vulnerabilities_via_extension() and component.has_vulnerabilities(): + # Vulnerabilities are only possible when bom-ref is supported by the main CycloneDX schema version + vulnerabilities = ElementTree.SubElement(component_element, 'v:vulnerabilities') + for vulnerability in component.get_vulnerabilities(): + if component.bom_ref: + vulnerabilities.append( + self._get_vulnerability_as_xml_element_pre_1_3(bom_ref=component.bom_ref, + vulnerability=vulnerability) + ) + else: + warnings.warn( + f'Unable to include Vulnerability {str(vulnerability)} in generated BOM as the ' + f'Component it relates to ({str(component)}) but it has no bom-ref.' + ) + elif component.has_vulnerabilities(): + has_vulnerabilities = True + + if self.bom_supports_vulnerabilities() and has_vulnerabilities: + vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities') + for component in cast(List[Component], self.get_bom().components): + for vulnerability in component.get_vulnerabilities(): + vulnerabilities_element.append( + self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability) ) - else: - warnings.warn( - f'Unable to include Vulnerability {str(vulnerability)} in generated BOM as the Component' - f'it relates to ({str(component)}) but it has no bom-ref.' - ) - elif component.has_vulnerabilities(): - has_vulnerabilities = True - - if self.bom_supports_vulnerabilities() and has_vulnerabilities: - vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities') - for component in self.get_bom().components: - for vulnerability in component.get_vulnerabilities(): - vulnerabilities_element.append( - self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability) - ) self.generated = True diff --git a/tests/fixtures/bom_v1.2_services_complex.json b/tests/fixtures/bom_v1.2_services_complex.json new file mode 100644 index 00000000..dfc82f66 --- /dev/null +++ b/tests/fixtures/bom_v1.2_services_complex.json @@ -0,0 +1,84 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ], + "component": { + "type": "library", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "name": "cyclonedx-python-lib", + "version": "1.0.0" + } + }, + "components": [], + "services": [ + { + "bom-ref": "my-specific-bom-ref-for-my-first-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "group": "a-group", + "name": "my-first-service", + "version": "1.2.3", + "description": "Description goes here", + "endpoints": [ + "/api/thing/1", + "/api/thing/2" + ], + "authenticated": false, + "x-trust-boundary": true, + "data": [ + { + "classification": "public", + "flow": "outbound" + } + ], + "licenses": [ + { + "expression": "Commercial" + } + ], + "externalReferences": [ + { + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "type": "distribution", + "url": "https://cyclonedx.org" + } + ] + }, + { + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "name": "my-second-service" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.2_services_simple.json b/tests/fixtures/bom_v1.2_services_simple.json new file mode 100644 index 00000000..a19dd04d --- /dev/null +++ b/tests/fixtures/bom_v1.2_services_simple.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ], + "component": { + "type": "library", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "name": "cyclonedx-python-lib", + "version": "1.0.0" + } + }, + "components": [], + "services": [ + { + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "name": "my-first-service" + }, + { + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "name": "my-second-service" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_services_complex.json b/tests/fixtures/bom_v1.3_services_complex.json new file mode 100644 index 00000000..d517c042 --- /dev/null +++ b/tests/fixtures/bom_v1.3_services_complex.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ], + "component": { + "type": "library", + "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "name": "cyclonedx-python-lib", + "version": "1.0.0" + } + }, + "components": [], + "services": [ + { + "bom-ref": "my-specific-bom-ref-for-my-first-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "group": "a-group", + "name": "my-first-service", + "version": "1.2.3", + "description": "Description goes here", + "endpoints": [ + "/api/thing/1", + "/api/thing/2" + ], + "authenticated": false, + "x-trust-boundary": true, + "data": [ + { + "classification": "public", + "flow": "outbound" + } + ], + "licenses": [ + { + "expression": "Commercial" + } + ], + "externalReferences": [ + { + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "type": "distribution", + "url": "https://cyclonedx.org" + } + ], + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ] + }, + { + "bom-ref": "e9c2e297-eee6-4f45-ac2d-6662b1db77bf", + "name": "my-second-service" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_services_simple.json b/tests/fixtures/bom_v1.3_services_simple.json new file mode 100644 index 00000000..514d2be4 --- /dev/null +++ b/tests/fixtures/bom_v1.3_services_simple.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ], + "component": { + "type": "library", + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "name": "cyclonedx-python-lib", + "version": "1.0.0" + } + }, + "components": [], + "services": [ + { + "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", + "name": "my-first-service" + }, + { + "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", + "name": "my-second-service" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_services_complex.json b/tests/fixtures/bom_v1.4_services_complex.json new file mode 100644 index 00000000..45c90ebb --- /dev/null +++ b/tests/fixtures/bom_v1.4_services_complex.json @@ -0,0 +1,187 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-python-lib/" + }, + { + "type": "documentation", + "url": "https://cyclonedx.github.io/cyclonedx-python-lib/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib" + }, + { + "type": "website", + "url": "https://cyclonedx.org" + } + ] + } + ], + "component": { + "type": "library", + "bom-ref": "2a4ec791-4846-4769-8332-06e6ee170395", + "name": "cyclonedx-python-lib", + "version": "1.0.0" + } + }, + "components": [], + "services": [ + { + "bom-ref": "my-specific-bom-ref-for-my-first-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "group": "a-group", + "name": "my-first-service", + "version": "1.2.3", + "description": "Description goes here", + "endpoints": [ + "/api/thing/1", + "/api/thing/2" + ], + "authenticated": false, + "x-trust-boundary": true, + "data": [ + { + "classification": "public", + "flow": "outbound" + } + ], + "licenses": [ + { + "expression": "Commercial" + } + ], + "externalReferences": [ + { + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "type": "distribution", + "url": "https://cyclonedx.org" + } + ], + "releaseNotes": { + "aliases": [ + "First Test Release" + ], + "description": "This release is a test release", + "featuredImage": "https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png", + "notes": [ + { + "locale": "en-GB", + "text": { + "content": "U29tZSBzaW1wbGUgcGxhaW4gdGV4dA==", + "contentType": "text/plain; charset=UTF-8", + "encoding": "base64" + } + }, + { + "locale": "en-US", + "text": { + "content": "U29tZSBzaW1wbGUgcGxhaW4gdGV4dA==", + "contentType": "text/plain; charset=UTF-8", + "encoding": "base64" + } + } + ], + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "resolves": [ + { + "description": "Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...", + "id": "CVE-2021-44228", + "name": "Apache Log3Shell", + "references": [ + "https://logging.apache.org/log4j/2.x/security.html", + "https://central.sonatype.org/news/20211213_log4shell_help" + ], + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-44228" + }, + "type": "security" + } + ], + "socialImage": "https://cyclonedx.org/cyclonedx-icon.png", + "tags": [ + "test", + "alpha" + ], + "timestamp": "2021-12-31T10:00:00+00:00", + "title": "Release Notes Title", + "type": "major" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ] + }, + { + "bom-ref": "b1650ab8-33fb-47e3-bc6e-07031054a946", + "name": "my-second-service" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_services_simple.json b/tests/fixtures/bom_v1.4_services_simple.json new file mode 100644 index 00000000..6a2ae027 --- /dev/null +++ b/tests/fixtures/bom_v1.4_services_simple.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-python-lib/" + }, + { + "type": "documentation", + "url": "https://cyclonedx.github.io/cyclonedx-python-lib/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib" + }, + { + "type": "website", + "url": "https://cyclonedx.org" + } + ] + } + ], + "component": { + "type": "library", + "bom-ref": "44eba9c8-ccfb-4868-90cb-deb27f72b358", + "name": "cyclonedx-python-lib", + "version": "1.0.0" + } + }, + "components": [], + "services": [ + { + "bom-ref": "86452881-cb1a-4296-9450-2eb6f3e55744", + "name": "my-first-service" + }, + { + "bom-ref": "86452881-cb1a-4296-9450-2eb6f3e55744", + "name": "my-second-service" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.json b/tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.json index 260af12e..ee9ba1b7 100644 --- a/tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.json +++ b/tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.json @@ -126,7 +126,7 @@ "contact": [ { "name": "Paul Horton", - "email": "simplyecommerce@googlemail.com" + "email": "paul.horton@owasp.org" }, { "name": "A N Other", diff --git a/tests/test_model.py b/tests/test_model.py index 6f1ae657..620cb661 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,3 +1,21 @@ +# encoding: utf-8 + +# This file is part of CycloneDX Python Lib +# +# 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 base64 from unittest import TestCase diff --git a/tests/test_model_component.py b/tests/test_model_component.py index a150b39a..070e2d3c 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -1,3 +1,21 @@ +# encoding: utf-8 + +# This file is part of CycloneDX Python Lib +# +# 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 unittest.mock import Mock, patch diff --git a/tests/test_model_release_note.py b/tests/test_model_release_note.py index 33af0c3c..a360bd70 100644 --- a/tests/test_model_release_note.py +++ b/tests/test_model_release_note.py @@ -1,3 +1,21 @@ +# encoding: utf-8 + +# This file is part of CycloneDX Python Lib +# +# 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 datetime from unittest import TestCase diff --git a/tests/test_model_service.py b/tests/test_model_service.py new file mode 100644 index 00000000..47571b76 --- /dev/null +++ b/tests/test_model_service.py @@ -0,0 +1,44 @@ +# encoding: utf-8 + +# This file is part of CycloneDX Python Lib +# +# 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 unittest.mock import Mock, patch + +from cyclonedx.model.service import Service + + +class TestModelService(TestCase): + + @patch('cyclonedx.model.service.uuid4', return_value='77d15ab9-5602-4cca-8ed2-59ae579aafd3') + def test_minimal_service(self, mock_uuid: Mock) -> None: + s = Service(name='my-test-service') + mock_uuid.assert_called() + self.assertEqual(s.name, 'my-test-service') + self.assertEqual(s.bom_ref, '77d15ab9-5602-4cca-8ed2-59ae579aafd3') + self.assertIsNone(s.provider) + self.assertIsNone(s.group) + self.assertIsNone(s.version) + self.assertIsNone(s.description) + self.assertIsNone(s.endpoints) + self.assertIsNone(s.authenticated) + self.assertIsNone(s.x_trust_boundary) + self.assertIsNone(s.data) + self.assertListEqual(s.licenses, []) + self.assertListEqual(s.external_references, []) + self.assertIsNone(s.release_notes) + self.assertIsNone(s.properties) diff --git a/tests/test_model_vulnerability.py b/tests/test_model_vulnerability.py index fe302c43..361b9f1d 100644 --- a/tests/test_model_vulnerability.py +++ b/tests/test_model_vulnerability.py @@ -1,3 +1,22 @@ +# encoding: utf-8 + +# This file is part of CycloneDX Python Lib +# +# 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 unittest from unittest import TestCase from unittest.mock import Mock, patch diff --git a/tests/test_output_json.py b/tests/test_output_json.py index c424e2f5..9d8e2d53 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -17,18 +17,21 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. import base64 -from decimal import Decimal from datetime import datetime, timezone +from decimal import Decimal from os.path import dirname, join -from packageurl import PackageURL +from typing import List from unittest.mock import Mock, patch +from packageurl import PackageURL + from cyclonedx.model import Encoding, ExternalReference, ExternalReferenceType, HashType, LicenseChoice, Note, \ - NoteText, OrganizationalContact, OrganizationalEntity, Property, Tool, XsUri + NoteText, OrganizationalContact, OrganizationalEntity, Property, Tool, XsUri, DataClassification, DataFlow from cyclonedx.model.bom import Bom from cyclonedx.model.component import Component, ComponentType from cyclonedx.model.issue import IssueClassification, IssueType from cyclonedx.model.release_note import ReleaseNotes +from cyclonedx.model.service import Service from cyclonedx.model.vulnerability import ImpactAnalysisState, ImpactAnalysisJustification, ImpactAnalysisResponse, \ ImpactAnalysisAffectedStatus, Vulnerability, VulnerabilityCredits, VulnerabilityRating, VulnerabilitySeverity, \ VulnerabilitySource, VulnerabilityScoreSource, VulnerabilityAdvisory, VulnerabilityReference, \ @@ -39,6 +42,7 @@ class TestOutputJson(BaseJsonTestCase): + timestamp: datetime = datetime(2021, 12, 31, 10, 0, 0, 0).replace(tzinfo=timezone.utc) def test_simple_bom_v1_4(self) -> None: bom = Bom() @@ -175,17 +179,7 @@ def test_bom_v1_3_with_component_external_references(self) -> None: c.add_hash( HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') ) - c.add_external_reference( - ExternalReference( - reference_type=ExternalReferenceType.DISTRIBUTION, - url='https://cyclonedx.org', - comment='No comment', - hashes=[ - HashType.from_composite_str( - 'sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') - ] - ) - ) + c.add_external_reference(TestOutputJson._get_external_reference_1()) bom.add_component(c) outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON) self.assertIsInstance(outputter, JsonV1Dot3) @@ -247,56 +241,12 @@ def test_with_component_release_notes_pre_1_4(self) -> None: def test_with_component_release_notes_post_1_4(self) -> None: bom = Bom() - timestamp: datetime = datetime(2021, 12, 31, 10, 0, 0, 0).replace(tzinfo=timezone.utc) - - text_content: str = base64.b64encode( - bytes('Some simple plain text', encoding='UTF-8') - ).decode(encoding='UTF-8') - c = Component( name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', purl=PackageURL( type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' ), - release_notes=ReleaseNotes( - type='major', title="Release Notes Title", - featured_image=XsUri('https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png'), - social_image=XsUri('https://cyclonedx.org/cyclonedx-icon.png'), - description="This release is a test release", timestamp=timestamp, - aliases=[ - "First Test Release" - ], - tags=['test', 'alpha'], - resolves=[ - IssueType( - classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', - description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', - source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), - references=[ - XsUri('https://logging.apache.org/log4j/2.x/security.html'), - XsUri('https://central.sonatype.org/news/20211213_log4shell_help') - ] - ) - ], - notes=[ - Note( - text=NoteText( - content=text_content, content_type='text/plain; charset=UTF-8', - content_encoding=Encoding.BASE_64 - ), locale='en-GB' - ), - Note( - text=NoteText( - content=text_content, content_type='text/plain; charset=UTF-8', - content_encoding=Encoding.BASE_64 - ), locale='en-US' - ) - ], - properties=[ - Property(name='key1', value='val1'), - Property(name='key2', value='val2') - ] - ) + release_notes=TestOutputJson._get_release_notes() ) bom.add_component(c) outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) @@ -350,13 +300,7 @@ def test_simple_bom_v1_4_with_vulnerabilities(self) -> None: tzinfo=timezone.utc), credits=VulnerabilityCredits( organizations=[ - OrganizationalEntity( - name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[ - OrganizationalContact(name='Paul Horton', email='simplyecommerce@googlemail.com'), - OrganizationalContact(name='A N Other', email='someone@somewhere.tld', - phone='+44 (0)1234 567890') - ] - ) + TestOutputJson._get_org_entity_1() ], individuals=[ OrganizationalContact(name='A N Other', email='someone@somewhere.tld', phone='+44 (0)1234 567890'), @@ -395,4 +339,204 @@ def test_bom_v1_3_with_metadata_component(self, mock_uuid: Mock) -> None: with open(join(dirname(__file__), 'fixtures/bom_v1.3_with_metadata_component.json')) as expected_json: self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) - maxDiff = None + @patch('cyclonedx.model.component.uuid4', return_value='17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda') + @patch('cyclonedx.model.service.uuid4', return_value='0b049d09-64c0-4490-a0f5-c84d9aacf857') + def test_bom_v1_2_services(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + bom = Bom(services=[ + Service(name='my-first-service'), + Service(name='my-second-service') + ]) + bom.metadata.component = Component( + name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_2) + self.assertIsInstance(outputter, JsonV1Dot2) + with open(join(dirname(__file__), 'fixtures/bom_v1.2_services_simple.json')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) + self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) + + @patch('cyclonedx.model.component.uuid4', return_value='17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda') + @patch('cyclonedx.model.service.uuid4', return_value='0b049d09-64c0-4490-a0f5-c84d9aacf857') + def test_bom_v1_2_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + bom = TestOutputJson._get_bom_with_services_simple() + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_2) + self.assertIsInstance(outputter, JsonV1Dot2) + with open(join(dirname(__file__), 'fixtures/bom_v1.2_services_simple.json')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) + self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) + + @patch('cyclonedx.model.component.uuid4', return_value='17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda') + @patch('cyclonedx.model.service.uuid4', return_value='0b049d09-64c0-4490-a0f5-c84d9aacf857') + def test_bom_v1_2_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + bom = TestOutputJson._get_bom_with_services_complex() + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_2) + self.assertIsInstance(outputter, JsonV1Dot2) + with open(join(dirname(__file__), 'fixtures/bom_v1.2_services_complex.json')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) + self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) + + @patch('cyclonedx.model.component.uuid4', return_value='cd3e9c95-9d41-49e7-9924-8cf0465ae789') + @patch('cyclonedx.model.service.uuid4', return_value='bb5911d6-1a1d-41c9-b6e0-46e848d16655') + def test_bom_v1_3_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + bom = TestOutputJson._get_bom_with_services_simple() + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_3) + self.assertIsInstance(outputter, JsonV1Dot3) + with open(join(dirname(__file__), 'fixtures/bom_v1.3_services_simple.json')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) + self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) + + @patch('cyclonedx.model.component.uuid4', return_value='df70b5f1-8f53-47a4-be48-669ae78795e6') + @patch('cyclonedx.model.service.uuid4', return_value='e9c2e297-eee6-4f45-ac2d-6662b1db77bf') + def test_bom_v1_3_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + bom = TestOutputJson._get_bom_with_services_complex() + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_3) + self.assertIsInstance(outputter, JsonV1Dot3) + with open(join(dirname(__file__), 'fixtures/bom_v1.3_services_complex.json')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) + self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) + + @patch('cyclonedx.model.component.uuid4', return_value='44eba9c8-ccfb-4868-90cb-deb27f72b358') + @patch('cyclonedx.model.service.uuid4', return_value='86452881-cb1a-4296-9450-2eb6f3e55744') + def test_bom_v1_4_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + bom = TestOutputJson._get_bom_with_services_simple() + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) + self.assertIsInstance(outputter, JsonV1Dot4) + with open(join(dirname(__file__), 'fixtures/bom_v1.4_services_simple.json')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) + self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) + + @patch('cyclonedx.model.component.uuid4', return_value='2a4ec791-4846-4769-8332-06e6ee170395') + @patch('cyclonedx.model.service.uuid4', return_value='b1650ab8-33fb-47e3-bc6e-07031054a946') + def test_bom_v1_4_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + bom = TestOutputJson._get_bom_with_services_complex() + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) + self.assertIsInstance(outputter, JsonV1Dot4) + with open(join(dirname(__file__), 'fixtures/bom_v1.4_services_complex.json')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) + self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) + + @staticmethod + def _get_bom_with_services_simple() -> Bom: + bom = Bom(services=[ + Service(name='my-first-service'), + Service(name='my-second-service') + ]) + bom.metadata.component = Component( + name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + ) + return bom + + @staticmethod + def _get_bom_with_services_complex() -> Bom: + bom = Bom(services=[ + Service( + name='my-first-service', bom_ref='my-specific-bom-ref-for-my-first-service', + provider=TestOutputJson._get_org_entity_1(), group='a-group', version='1.2.3', + description='Description goes here', endpoints=[ + XsUri('/api/thing/1'), + XsUri('/api/thing/2') + ], + authenticated=False, x_trust_boundary=True, data=[ + DataClassification(flow=DataFlow.OUTBOUND, classification='public') + ], + licenses=[ + LicenseChoice(license_expression='Commercial') + ], + external_references=[ + TestOutputJson._get_external_reference_1() + ], + properties=TestOutputJson._get_properties_1(), + release_notes=TestOutputJson._get_release_notes() + ), + Service(name='my-second-service') + ]) + bom.metadata.component = Component( + name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + ) + return bom + + @staticmethod + def _get_external_reference_1() -> ExternalReference: + return ExternalReference( + reference_type=ExternalReferenceType.DISTRIBUTION, + url='https://cyclonedx.org', + comment='No comment', + hashes=[ + HashType.from_composite_str( + 'sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') + ] + ) + + @staticmethod + def _get_org_entity_1() -> OrganizationalEntity: + return OrganizationalEntity( + name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[ + OrganizationalContact(name='Paul Horton', email='paul.horton@owasp.org'), + OrganizationalContact(name='A N Other', email='someone@somewhere.tld', + phone='+44 (0)1234 567890') + ] + ) + + @staticmethod + def _get_properties_1() -> List[Property]: + return [ + Property(name='key1', value='val1'), + Property(name='key2', value='val2') + ] + + @staticmethod + def _get_release_notes() -> ReleaseNotes: + text_content: str = base64.b64encode( + bytes('Some simple plain text', encoding='UTF-8') + ).decode(encoding='UTF-8') + + return ReleaseNotes( + type='major', title="Release Notes Title", + featured_image=XsUri('https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png'), + social_image=XsUri('https://cyclonedx.org/cyclonedx-icon.png'), + description="This release is a test release", timestamp=TestOutputJson.timestamp, + aliases=[ + "First Test Release" + ], + tags=['test', 'alpha'], + resolves=[ + IssueType( + classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', + description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', + source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), + references=[ + XsUri('https://logging.apache.org/log4j/2.x/security.html'), + XsUri('https://central.sonatype.org/news/20211213_log4shell_help') + ] + ) + ], + notes=[ + Note( + text=NoteText( + content=text_content, content_type='text/plain; charset=UTF-8', + content_encoding=Encoding.BASE_64 + ), locale='en-GB' + ), + Note( + text=NoteText( + content=text_content, content_type='text/plain; charset=UTF-8', + content_encoding=Encoding.BASE_64 + ), locale='en-US' + ) + ], + properties=TestOutputJson._get_properties_1() + ) From df43a9bff4b8360234bf50058ded82e44e2df082 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 27 Jan 2022 11:58:28 +0000 Subject: [PATCH 02/29] test: refactored fixtures for tests which has uncovered #150, #151 and #152 Signed-off-by: Paul Horton --- cyclonedx/exception/output.py | 9 + cyclonedx/output/__init__.py | 14 + cyclonedx/output/json.py | 13 +- cyclonedx/output/schema.py | 27 + cyclonedx/output/xml.py | 6 +- tests/data.py | 306 +++++++++ .../bom_v1.3_toml_with_component_license.json | 31 - .../1.2/bom_services_complex.json} | 4 +- .../1.2/bom_services_simple.json} | 4 +- .../1.2/bom_setuptools.json} | 5 + .../1.2/bom_setuptools_with_cpe.json} | 5 + .../1.2/bom_toml_1.json} | 17 +- .../json/1.2/bom_with_full_metadata.json | 24 + .../1.3/bom_services_complex.json} | 4 +- .../1.3/bom_services_simple.json} | 8 +- .../1.3/bom_setuptools.json} | 7 +- .../json/1.3/bom_setuptools_no_version.json | 32 + .../1.3/bom_setuptools_with_cpe.json} | 9 +- .../1.3/bom_toml_1.json} | 0 .../1.3/bom_with_full_metadata.json} | 2 +- .../1.4/bom_services_complex.json} | 4 +- .../1.4/bom_services_simple.json} | 6 +- .../1.4/bom_setuptools.json} | 10 +- .../1.4/bom_setuptools_no_version.json} | 12 +- .../json/1.4/bom_setuptools_with_cpe.json | 67 ++ .../bom_setuptools_with_release_notes.json} | 6 + .../bom_setuptools_with_vulnerabilities.json} | 6 + tests/fixtures/json/1.4/bom_toml_1.json | 79 +++ .../1.4/bom_with_full_metadata.json} | 15 +- tests/fixtures/xml/1.0/bom_empty.xml | 4 + .../1.0/bom_setuptools.xml} | 0 .../xml/1.0/bom_setuptools_no_version.xml | 11 + .../1.0/bom_setuptools_with_cpe.xml} | 0 .../1.0/bom_toml_hashes_and_references.xml | 14 + tests/fixtures/xml/1.1/bom_empty.xml | 4 + .../1.1/bom_setuptools.xml} | 3 + .../xml/1.1/bom_setuptools_no_version.xml | 13 + .../1.1/bom_setuptools_with_cpe.xml} | 3 + .../bom_setuptools_with_vulnerabilities.xml | 51 ++ .../1.1/bom_toml_hashes_and_references.xml | 19 + .../1.2/bom_setuptools.xml} | 4 + .../xml/1.2/bom_setuptools_no_version.xml | 24 + .../1.2/bom_setuptools_with_cpe.xml} | 4 + .../bom_setuptools_with_vulnerabilities.xml | 62 ++ .../1.2/bom_toml_hashes_and_references.xml | 29 + .../xml/1.2/bom_with_full_metadata.xml | 18 + .../1.3/bom_setuptools.xml} | 4 + .../xml/1.3/bom_setuptools_no_version.xml | 24 + .../1.3/bom_setuptools_with_cpe.xml} | 6 +- .../bom_setuptools_with_vulnerabilities.xml | 62 ++ .../1.3/bom_toml_hashes_and_references.xml | 32 + .../xml/1.3/bom_with_full_metadata.xml | 18 + .../1.4/bom_setuptools.xml} | 4 + .../1.4/bom_setuptools_no_version.xml} | 4 + .../1.4/bom_setuptools_with_cpe.xml} | 4 + .../1.4/bom_setuptools_with_release_notes.xml | 94 +++ .../bom_setuptools_with_vulnerabilities.xml} | 6 +- .../1.4/bom_toml_hashes_and_references.xml | 58 ++ .../xml/1.4/bom_with_full_metadata.xml | 44 ++ tests/test_component.py | 8 +- tests/test_output_json.py | 618 +++++------------ tests/test_output_xml.py | 637 +++++------------- 62 files changed, 1613 insertions(+), 1005 deletions(-) create mode 100644 tests/data.py delete mode 100644 tests/fixtures/bom_v1.3_toml_with_component_license.json rename tests/fixtures/{bom_v1.2_services_complex.json => json/1.2/bom_services_complex.json} (97%) rename tests/fixtures/{bom_v1.3_services_simple.json => json/1.2/bom_services_simple.json} (89%) rename tests/fixtures/{bom_v1.2_setuptools.json => json/1.2/bom_setuptools.json} (88%) rename tests/fixtures/{bom_v1.2_setuptools_with_cpe.json => json/1.2/bom_setuptools_with_cpe.json} (89%) rename tests/fixtures/{bom_v1.3_toml_with_component_hashes.json => json/1.2/bom_toml_1.json} (61%) create mode 100644 tests/fixtures/json/1.2/bom_with_full_metadata.json rename tests/fixtures/{bom_v1.3_services_complex.json => json/1.3/bom_services_complex.json} (94%) rename tests/fixtures/{bom_v1.2_services_simple.json => json/1.3/bom_services_simple.json} (75%) rename tests/fixtures/{bom_v1.3_setuptools.json => json/1.3/bom_setuptools.json} (86%) create mode 100644 tests/fixtures/json/1.3/bom_setuptools_no_version.json rename tests/fixtures/{bom_v1.3_setuptools_with_cpe.json => json/1.3/bom_setuptools_with_cpe.json} (88%) rename tests/fixtures/{bom_v1.3_toml_with_component_external_references.json => json/1.3/bom_toml_1.json} (100%) rename tests/fixtures/{bom_v1.3_with_metadata_component.json => json/1.3/bom_with_full_metadata.json} (90%) rename tests/fixtures/{bom_v1.4_services_complex.json => json/1.4/bom_services_complex.json} (97%) rename tests/fixtures/{bom_v1.4_services_simple.json => json/1.4/bom_services_simple.json} (91%) rename tests/fixtures/{bom_v1.4_setuptools.json => json/1.4/bom_setuptools.json} (87%) rename tests/fixtures/{bom_v1.4_setuptools_with_cpe.json => json/1.4/bom_setuptools_no_version.json} (87%) create mode 100644 tests/fixtures/json/1.4/bom_setuptools_with_cpe.json rename tests/fixtures/{bom_v1.4_setuptools_with_release_notes.json => json/1.4/bom_setuptools_with_release_notes.json} (96%) rename tests/fixtures/{bom_v1.4_setuptools_with_vulnerabilities.json => json/1.4/bom_setuptools_with_vulnerabilities.json} (97%) create mode 100644 tests/fixtures/json/1.4/bom_toml_1.json rename tests/fixtures/{bom_v1.4_setuptools_no_version.json => json/1.4/bom_with_full_metadata.json} (89%) create mode 100644 tests/fixtures/xml/1.0/bom_empty.xml rename tests/fixtures/{bom_v1.0_setuptools.xml => xml/1.0/bom_setuptools.xml} (100%) create mode 100644 tests/fixtures/xml/1.0/bom_setuptools_no_version.xml rename tests/fixtures/{bom_v1.0_setuptools_with_cpe.xml => xml/1.0/bom_setuptools_with_cpe.xml} (100%) create mode 100644 tests/fixtures/xml/1.0/bom_toml_hashes_and_references.xml create mode 100644 tests/fixtures/xml/1.1/bom_empty.xml rename tests/fixtures/{bom_v1.1_setuptools.xml => xml/1.1/bom_setuptools.xml} (82%) create mode 100644 tests/fixtures/xml/1.1/bom_setuptools_no_version.xml rename tests/fixtures/{bom_v1.1_setuptools_with_cpe.xml => xml/1.1/bom_setuptools_with_cpe.xml} (84%) create mode 100644 tests/fixtures/xml/1.1/bom_setuptools_with_vulnerabilities.xml create mode 100644 tests/fixtures/xml/1.1/bom_toml_hashes_and_references.xml rename tests/fixtures/{bom_v1.2_setuptools.xml => xml/1.2/bom_setuptools.xml} (83%) create mode 100644 tests/fixtures/xml/1.2/bom_setuptools_no_version.xml rename tests/fixtures/{bom_v1.2_setuptools_with_cpe.xml => xml/1.2/bom_setuptools_with_cpe.xml} (84%) create mode 100644 tests/fixtures/xml/1.2/bom_setuptools_with_vulnerabilities.xml create mode 100644 tests/fixtures/xml/1.2/bom_toml_hashes_and_references.xml create mode 100644 tests/fixtures/xml/1.2/bom_with_full_metadata.xml rename tests/fixtures/{bom_v1.3_setuptools.xml => xml/1.3/bom_setuptools.xml} (83%) create mode 100644 tests/fixtures/xml/1.3/bom_setuptools_no_version.xml rename tests/fixtures/{bom_v1.3_setuptools_with_cpe.xml => xml/1.3/bom_setuptools_with_cpe.xml} (83%) create mode 100644 tests/fixtures/xml/1.3/bom_setuptools_with_vulnerabilities.xml create mode 100644 tests/fixtures/xml/1.3/bom_toml_hashes_and_references.xml create mode 100644 tests/fixtures/xml/1.3/bom_with_full_metadata.xml rename tests/fixtures/{bom_v1.4_setuptools.xml => xml/1.4/bom_setuptools.xml} (93%) rename tests/fixtures/{bom_v1.4_setuptools_no_version.xml => xml/1.4/bom_setuptools_no_version.xml} (93%) rename tests/fixtures/{bom_v1.4_setuptools_with_cpe.xml => xml/1.4/bom_setuptools_with_cpe.xml} (94%) create mode 100644 tests/fixtures/xml/1.4/bom_setuptools_with_release_notes.xml rename tests/fixtures/{bom_v1.4_setuptools_with_vulnerabilities.xml => xml/1.4/bom_setuptools_with_vulnerabilities.xml} (96%) create mode 100644 tests/fixtures/xml/1.4/bom_toml_hashes_and_references.xml create mode 100644 tests/fixtures/xml/1.4/bom_with_full_metadata.xml diff --git a/cyclonedx/exception/output.py b/cyclonedx/exception/output.py index 57f8af12..76ed11e2 100644 --- a/cyclonedx/exception/output.py +++ b/cyclonedx/exception/output.py @@ -28,3 +28,12 @@ class ComponentVersionRequiredException(CycloneDxException): but one is not available/present. """ pass + + +class FormatNotSupportedException(CycloneDxException): + """ + Exception raised when attempting to output a BOM to a format not supported in the requested version. + + For example, JSON is not supported prior to 1.2. + """ + pass diff --git a/cyclonedx/output/__init__.py b/cyclonedx/output/__init__.py index aab09eae..37211e81 100644 --- a/cyclonedx/output/__init__.py +++ b/cyclonedx/output/__init__.py @@ -40,6 +40,15 @@ class SchemaVersion(Enum): V1_3: str = 'V1Dot3' V1_4: str = 'V1Dot4' + def to_version(self) -> str: + """ + Return as a version string - e.g. `1.4` + + Returns: + `str` version + """ + return f'{self.value[1]}.{self.value[5]}' + DEFAULT_SCHEMA_VERSION = SchemaVersion.V1_3 @@ -51,6 +60,11 @@ def __init__(self, bom: Bom, **kwargs: int) -> None: self._bom = bom self._generated: bool = False + @property + @abstractmethod + def schema_version(self) -> SchemaVersion: + pass + @property def generated(self) -> bool: return self._generated diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py index e8460a20..b1f937e6 100644 --- a/cyclonedx/output/json.py +++ b/cyclonedx/output/json.py @@ -21,14 +21,14 @@ from abc import abstractmethod from typing import cast, Any, Dict, List, Optional, Union -from . import BaseOutput +from . import BaseOutput, SchemaVersion from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3, \ SchemaVersion1Dot4 from .serializer.json import CycloneDxJSONEncoder +from ..exception.output import FormatNotSupportedException from ..model.bom import Bom from ..model.component import Component - ComponentDict = Dict[str, Union[ str, List[Dict[str, str]], @@ -42,14 +42,19 @@ def __init__(self, bom: Bom) -> None: super().__init__(bom=bom) self._json_output: str = '' + @property + def schema_version(self) -> SchemaVersion: + return self.schema_version_enum + def generate(self, force_regeneration: bool = False) -> None: if self.generated and not force_regeneration: return schema_uri: Optional[str] = self._get_schema_uri() if not schema_uri: - # JSON not supported! - return + raise FormatNotSupportedException( + f'JSON is not supported by CycloneDX in schema version {self.schema_version.to_version()}' + ) vulnerabilities: Dict[str, List[Dict[Any, Any]]] = {"vulnerabilities": []} if self.get_bom().components: diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py index 138f613c..f36dc336 100644 --- a/cyclonedx/output/schema.py +++ b/cyclonedx/output/schema.py @@ -19,9 +19,16 @@ from abc import ABC, abstractmethod +from . import SchemaVersion + class BaseSchemaVersion(ABC): + @property + @abstractmethod + def schema_version_enum(self) -> SchemaVersion: + pass + def bom_supports_metadata(self) -> bool: return True @@ -74,6 +81,10 @@ def get_schema_version(self) -> str: class SchemaVersion1Dot4(BaseSchemaVersion): + @property + def schema_version_enum(self) -> SchemaVersion: + return SchemaVersion.V1_4 + def get_schema_version(self) -> str: return '1.4' @@ -83,6 +94,10 @@ def component_version_optional(self) -> bool: class SchemaVersion1Dot3(BaseSchemaVersion): + @property + def schema_version_enum(self) -> SchemaVersion: + return SchemaVersion.V1_3 + def bom_metadata_supports_tools_external_references(self) -> bool: return False @@ -107,6 +122,10 @@ def get_schema_version(self) -> str: class SchemaVersion1Dot2(BaseSchemaVersion): + @property + def schema_version_enum(self) -> SchemaVersion: + return SchemaVersion.V1_2 + def bom_metadata_supports_tools_external_references(self) -> bool: return False @@ -134,6 +153,10 @@ def get_schema_version(self) -> str: class SchemaVersion1Dot1(BaseSchemaVersion): + @property + def schema_version_enum(self) -> SchemaVersion: + return SchemaVersion.V1_1 + def bom_metadata_supports_tools(self) -> bool: return False @@ -173,6 +196,10 @@ def get_schema_version(self) -> str: class SchemaVersion1Dot0(BaseSchemaVersion): + @property + def schema_version_enum(self) -> SchemaVersion: + return SchemaVersion.V1_0 + def bom_metadata_supports_tools(self) -> bool: return False diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 1282449e..5d784cb9 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -21,7 +21,7 @@ from typing import cast, List, Optional from xml.etree import ElementTree -from . import BaseOutput +from . import BaseOutput, SchemaVersion from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3, \ SchemaVersion1Dot4 from ..exception.output import ComponentVersionRequiredException @@ -39,6 +39,10 @@ def __init__(self, bom: Bom) -> None: super().__init__(bom=bom) self._root_bom_element: ElementTree.Element = self._create_bom_element() + @property + def schema_version(self) -> SchemaVersion: + return self.schema_version_enum + def generate(self, force_regeneration: bool = False) -> None: if self.generated and force_regeneration: self._root_bom_element = self._create_bom_element() diff --git a/tests/data.py b/tests/data.py new file mode 100644 index 00000000..e7bd987a --- /dev/null +++ b/tests/data.py @@ -0,0 +1,306 @@ +# encoding: utf-8 + +# This file is part of CycloneDX Python Lib +# +# 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 base64 +from datetime import datetime, timezone +from decimal import Decimal +from typing import List + +from packageurl import PackageURL + +from cyclonedx.model import Encoding, ExternalReference, ExternalReferenceType, HashType, LicenseChoice, Note, \ + NoteText, OrganizationalContact, OrganizationalEntity, Property, Tool, XsUri, DataClassification, DataFlow +from cyclonedx.model.bom import Bom +from cyclonedx.model.component import Component, ComponentType +from cyclonedx.model.issue import IssueClassification, IssueType +from cyclonedx.model.release_note import ReleaseNotes +from cyclonedx.model.service import Service +from cyclonedx.model.vulnerability import ImpactAnalysisState, ImpactAnalysisJustification, ImpactAnalysisResponse, \ + ImpactAnalysisAffectedStatus, Vulnerability, VulnerabilityCredits, VulnerabilityRating, VulnerabilitySeverity, \ + VulnerabilitySource, VulnerabilityScoreSource, VulnerabilityAdvisory, VulnerabilityReference, \ + VulnerabilityAnalysis, BomTarget, BomTargetVersionRange + +MOCK_TIMESTAMP: datetime = datetime(2021, 12, 31, 10, 0, 0, 0).replace(tzinfo=timezone.utc) +MOCK_UUID_1 = 'be2c6502-7e9a-47db-9a66-e34f729810a3' +MOCK_UUID_2 = '17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda' +MOCK_UUID_3 = '0b049d09-64c0-4490-a0f5-c84d9aacf857' +MOCK_UUID_4 = 'cd3e9c95-9d41-49e7-9924-8cf0465ae789' +MOCK_UUID_5 = 'bb5911d6-1a1d-41c9-b6e0-46e848d16655' +MOCK_UUID_6 = 'df70b5f1-8f53-47a4-be48-669ae78795e6' + + +def get_bom_with_component_setuptools_basic() -> Bom: + bom = Bom( + components=[Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ), license_str='MIT License', author='Test Author' + )] + ) + return bom + + +def get_bom_with_component_setuptools_with_cpe() -> Bom: + bom = Bom( + components=[Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ), license_str='MIT License', author='Test Author', + cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*' + )] + ) + return bom + + +def get_bom_with_component_setuptools_no_component_version() -> Bom: + bom = Bom( + components=[Component( + name='setuptools', bom_ref='pkg:pypi/setuptools?extension=tar.gz', + purl=PackageURL( + type='pypi', name='setuptools', qualifiers='extension=tar.gz' + ), license_str='MIT License', author='Test Author' + )] + ) + return bom + + +def get_bom_with_component_setuptools_with_release_notes() -> Bom: + bom = Bom( + components=[Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ), license_str='MIT License', author='Test Author', + release_notes=get_release_notes() + )] + ) + return bom + + +def get_bom_with_component_setuptools_with_vulnerability() -> Bom: + bom = Bom() + component = Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ), license_str='MIT License', author='Test Author' + ) + vulnerability = Vulnerability( + bom_ref='my-vuln-ref-1', id='CVE-2018-7489', source=get_vulnerability_source_nvd(), + references=[ + VulnerabilityReference(id='SOME-OTHER-ID', source=VulnerabilitySource( + name='OSS Index', url=XsUri('https://ossindex.sonatype.org/component/pkg:pypi/setuptools') + )) + ], + ratings=[ + VulnerabilityRating( + source=get_vulnerability_source_nvd(), score=Decimal(9.8), severity=VulnerabilitySeverity.CRITICAL, + method=VulnerabilityScoreSource.CVSS_V3, + vector='AN/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', justification='Some justification' + ), + VulnerabilityRating( + source=get_vulnerability_source_owasp(), score=Decimal(2.7), severity=VulnerabilitySeverity.LOW, + method=VulnerabilityScoreSource.CVSS_V3, + vector='AV:L/AC:H/PR:N/UI:R/S:C/C:L/I:N/A:N', justification='Some other justification' + ) + ], + cwes=[22, 33], description='A description here', detail='Some detail here', + recommendation='Upgrade', + advisories=[ + VulnerabilityAdvisory(url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2018-7489')), + VulnerabilityAdvisory(url=XsUri('http://www.securitytracker.com/id/1040693')) + ], + created=datetime(year=2021, month=9, day=1, hour=10, minute=50, second=42, microsecond=51979, + tzinfo=timezone.utc), + published=datetime(year=2021, month=9, day=2, hour=10, minute=50, second=42, microsecond=51979, + tzinfo=timezone.utc), + updated=datetime(year=2021, month=9, day=3, hour=10, minute=50, second=42, microsecond=51979, + tzinfo=timezone.utc), + credits=VulnerabilityCredits( + organizations=[ + get_org_entity_1() + ], + individuals=[ + OrganizationalContact(name='A N Other', email='someone@somewhere.tld', phone='+44 (0)1234 567890'), + ] + ), + tools=[ + Tool(vendor='CycloneDX', name='cyclonedx-python-lib') + ], + analysis=VulnerabilityAnalysis( + state=ImpactAnalysisState.EXPLOITABLE, justification=ImpactAnalysisJustification.REQUIRES_ENVIRONMENT, + responses=[ImpactAnalysisResponse.CAN_NOT_FIX], detail='Some extra detail' + ), + affects_targets=[ + BomTarget( + ref=component.purl.to_string() if component.purl else component.to_package_url().to_string(), + versions=[BomTargetVersionRange( + version_range='49.0.0 - 54.0.0', status=ImpactAnalysisAffectedStatus.AFFECTED + )] + ) + ] + ) + component.add_vulnerability(vulnerability=vulnerability) + bom.add_component(component=component) + return bom + + +def get_bom_with_component_toml_1() -> Bom: + bom = Bom(components=[ + Component( + name='toml', version='0.10.2', bom_ref='pkg:pypi/toml@0.10.2?extension=tar.gz', + purl=PackageURL( + type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' + ), hashes=[ + HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') + ], external_references=[ + get_external_reference_1() + ] + ) + ]) + return bom + + +def get_bom_just_complete_metadata() -> Bom: + bom = Bom() + bom.metadata.component = Component( + name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + ) + return bom + + +def get_bom_with_services_simple() -> Bom: + bom = Bom(services=[ + Service(name='my-first-service'), + Service(name='my-second-service') + ]) + bom.metadata.component = Component( + name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + ) + return bom + + +def get_bom_with_services_complex() -> Bom: + bom = Bom(services=[ + Service( + name='my-first-service', bom_ref='my-specific-bom-ref-for-my-first-service', + provider=get_org_entity_1(), group='a-group', version='1.2.3', + description='Description goes here', endpoints=[ + XsUri('/api/thing/1'), + XsUri('/api/thing/2') + ], + authenticated=False, x_trust_boundary=True, data=[ + DataClassification(flow=DataFlow.OUTBOUND, classification='public') + ], + licenses=[ + LicenseChoice(license_expression='Commercial') + ], + external_references=[ + get_external_reference_1() + ], + properties=get_properties_1(), + release_notes=get_release_notes() + ), + Service(name='my-second-service') + ]) + bom.metadata.component = Component( + name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + ) + return bom + + +def get_external_reference_1() -> ExternalReference: + return ExternalReference( + reference_type=ExternalReferenceType.DISTRIBUTION, + url='https://cyclonedx.org', + comment='No comment', + hashes=[ + HashType.from_composite_str( + 'sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') + ] + ) + + +def get_org_entity_1() -> OrganizationalEntity: + return OrganizationalEntity( + name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[ + OrganizationalContact(name='Paul Horton', email='paul.horton@owasp.org'), + OrganizationalContact(name='A N Other', email='someone@somewhere.tld', + phone='+44 (0)1234 567890') + ] + ) + + +def get_properties_1() -> List[Property]: + return [ + Property(name='key1', value='val1'), + Property(name='key2', value='val2') + ] + + +def get_release_notes() -> ReleaseNotes: + text_content: str = base64.b64encode( + bytes('Some simple plain text', encoding='UTF-8') + ).decode(encoding='UTF-8') + + return ReleaseNotes( + type='major', title="Release Notes Title", + featured_image=XsUri('https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png'), + social_image=XsUri('https://cyclonedx.org/cyclonedx-icon.png'), + description="This release is a test release", timestamp=MOCK_TIMESTAMP, + aliases=[ + "First Test Release" + ], + tags=['test', 'alpha'], + resolves=[ + IssueType( + classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', + description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', + source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), + references=[ + XsUri('https://logging.apache.org/log4j/2.x/security.html'), + XsUri('https://central.sonatype.org/news/20211213_log4shell_help') + ] + ) + ], + notes=[ + Note( + text=NoteText( + content=text_content, content_type='text/plain; charset=UTF-8', + content_encoding=Encoding.BASE_64 + ), locale='en-GB' + ), + Note( + text=NoteText( + content=text_content, content_type='text/plain; charset=UTF-8', + content_encoding=Encoding.BASE_64 + ), locale='en-US' + ) + ], + properties=get_properties_1() + ) + + +def get_vulnerability_source_nvd() -> VulnerabilitySource: + return VulnerabilitySource(name='NVD', url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2018-7489')) + + +def get_vulnerability_source_owasp() -> VulnerabilitySource: + return VulnerabilitySource(name='OWASP', url=XsUri('https://owasp.org')) diff --git a/tests/fixtures/bom_v1.3_toml_with_component_license.json b/tests/fixtures/bom_v1.3_toml_with_component_license.json deleted file mode 100644 index 3af1102a..00000000 --- a/tests/fixtures/bom_v1.3_toml_with_component_license.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", - "bomFormat": "CycloneDX", - "specVersion": "1.3", - "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", - "version": 1, - "metadata": { - "timestamp": "2021-09-01T10:50:42.051979+00:00", - "tools": [ - { - "vendor": "CycloneDX", - "name": "cyclonedx-python-lib", - "version": "VERSION" - } - ] - }, - "components": [ - { - "type": "library", - "bom-ref": "pkg:pypi/toml@0.10.2?extension=tar.gz", - "name": "toml", - "version": "0.10.2", - "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", - "licenses": [ - { - "expression": "MIT License" - } - ] - } - ] -} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.2_services_complex.json b/tests/fixtures/json/1.2/bom_services_complex.json similarity index 97% rename from tests/fixtures/bom_v1.2_services_complex.json rename to tests/fixtures/json/1.2/bom_services_complex.json index dfc82f66..693cf3dc 100644 --- a/tests/fixtures/bom_v1.2_services_complex.json +++ b/tests/fixtures/json/1.2/bom_services_complex.json @@ -15,7 +15,7 @@ ], "component": { "type": "library", - "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", "name": "cyclonedx-python-lib", "version": "1.0.0" } @@ -77,7 +77,7 @@ ] }, { - "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "name": "my-second-service" } ] diff --git a/tests/fixtures/bom_v1.3_services_simple.json b/tests/fixtures/json/1.2/bom_services_simple.json similarity index 89% rename from tests/fixtures/bom_v1.3_services_simple.json rename to tests/fixtures/json/1.2/bom_services_simple.json index 514d2be4..4695a368 100644 --- a/tests/fixtures/bom_v1.3_services_simple.json +++ b/tests/fixtures/json/1.2/bom_services_simple.json @@ -1,7 +1,7 @@ { - "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", + "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.3", + "specVersion": "1.2", "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", "version": 1, "metadata": { diff --git a/tests/fixtures/bom_v1.2_setuptools.json b/tests/fixtures/json/1.2/bom_setuptools.json similarity index 88% rename from tests/fixtures/bom_v1.2_setuptools.json rename to tests/fixtures/json/1.2/bom_setuptools.json index 5fa21c83..7f674f79 100644 --- a/tests/fixtures/bom_v1.2_setuptools.json +++ b/tests/fixtures/json/1.2/bom_setuptools.json @@ -21,6 +21,11 @@ "author": "Test Author", "name": "setuptools", "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" } ] diff --git a/tests/fixtures/bom_v1.2_setuptools_with_cpe.json b/tests/fixtures/json/1.2/bom_setuptools_with_cpe.json similarity index 89% rename from tests/fixtures/bom_v1.2_setuptools_with_cpe.json rename to tests/fixtures/json/1.2/bom_setuptools_with_cpe.json index 78d2c8fe..e4c0cf92 100644 --- a/tests/fixtures/bom_v1.2_setuptools_with_cpe.json +++ b/tests/fixtures/json/1.2/bom_setuptools_with_cpe.json @@ -21,6 +21,11 @@ "author": "Test Author", "name": "setuptools", "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" } diff --git a/tests/fixtures/bom_v1.3_toml_with_component_hashes.json b/tests/fixtures/json/1.2/bom_toml_1.json similarity index 61% rename from tests/fixtures/bom_v1.3_toml_with_component_hashes.json rename to tests/fixtures/json/1.2/bom_toml_1.json index a9f7ef64..9cd6ac54 100644 --- a/tests/fixtures/bom_v1.3_toml_with_component_hashes.json +++ b/tests/fixtures/json/1.2/bom_toml_1.json @@ -1,7 +1,7 @@ { - "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", + "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.3", + "specVersion": "1.2", "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", "version": 1, "metadata": { @@ -26,6 +26,19 @@ "alg": "SHA-256", "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } ] } ] diff --git a/tests/fixtures/json/1.2/bom_with_full_metadata.json b/tests/fixtures/json/1.2/bom_with_full_metadata.json new file mode 100644 index 00000000..0ae4d8c3 --- /dev/null +++ b/tests/fixtures/json/1.2/bom_with_full_metadata.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ], + "component": { + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "type": "library", + "name": "cyclonedx-python-lib", + "version": "1.0.0" + } + }, + "components": [] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_services_complex.json b/tests/fixtures/json/1.3/bom_services_complex.json similarity index 94% rename from tests/fixtures/bom_v1.3_services_complex.json rename to tests/fixtures/json/1.3/bom_services_complex.json index d517c042..ad262fdf 100644 --- a/tests/fixtures/bom_v1.3_services_complex.json +++ b/tests/fixtures/json/1.3/bom_services_complex.json @@ -15,7 +15,7 @@ ], "component": { "type": "library", - "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", "name": "cyclonedx-python-lib", "version": "1.0.0" } @@ -87,7 +87,7 @@ ] }, { - "bom-ref": "e9c2e297-eee6-4f45-ac2d-6662b1db77bf", + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "name": "my-second-service" } ] diff --git a/tests/fixtures/bom_v1.2_services_simple.json b/tests/fixtures/json/1.3/bom_services_simple.json similarity index 75% rename from tests/fixtures/bom_v1.2_services_simple.json rename to tests/fixtures/json/1.3/bom_services_simple.json index a19dd04d..4e26ad37 100644 --- a/tests/fixtures/bom_v1.2_services_simple.json +++ b/tests/fixtures/json/1.3/bom_services_simple.json @@ -1,7 +1,7 @@ { - "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", + "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", "bomFormat": "CycloneDX", - "specVersion": "1.2", + "specVersion": "1.3", "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", "version": 1, "metadata": { @@ -23,11 +23,11 @@ "components": [], "services": [ { - "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", "name": "my-first-service" }, { - "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", "name": "my-second-service" } ] diff --git a/tests/fixtures/bom_v1.3_setuptools.json b/tests/fixtures/json/1.3/bom_setuptools.json similarity index 86% rename from tests/fixtures/bom_v1.3_setuptools.json rename to tests/fixtures/json/1.3/bom_setuptools.json index 38cf3f7a..4de582cc 100644 --- a/tests/fixtures/bom_v1.3_setuptools.json +++ b/tests/fixtures/json/1.3/bom_setuptools.json @@ -17,15 +17,16 @@ "components": [ { "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", "name": "setuptools", "version": "50.3.2", - "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", - "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", "licenses": [ { "expression": "MIT License" } - ] + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" } ] } \ No newline at end of file diff --git a/tests/fixtures/json/1.3/bom_setuptools_no_version.json b/tests/fixtures/json/1.3/bom_setuptools_no_version.json new file mode 100644 index 00000000..4de582cc --- /dev/null +++ b/tests/fixtures/json/1.3/bom_setuptools_no_version.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_setuptools_with_cpe.json b/tests/fixtures/json/1.3/bom_setuptools_with_cpe.json similarity index 88% rename from tests/fixtures/bom_v1.3_setuptools_with_cpe.json rename to tests/fixtures/json/1.3/bom_setuptools_with_cpe.json index 14f551f7..c39b5fd4 100644 --- a/tests/fixtures/bom_v1.3_setuptools_with_cpe.json +++ b/tests/fixtures/json/1.3/bom_setuptools_with_cpe.json @@ -17,16 +17,17 @@ "components": [ { "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", "name": "setuptools", "version": "50.3.2", - "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", - "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", - "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", "licenses": [ { "expression": "MIT License" } - ] + ], + "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" } ] } \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_toml_with_component_external_references.json b/tests/fixtures/json/1.3/bom_toml_1.json similarity index 100% rename from tests/fixtures/bom_v1.3_toml_with_component_external_references.json rename to tests/fixtures/json/1.3/bom_toml_1.json diff --git a/tests/fixtures/bom_v1.3_with_metadata_component.json b/tests/fixtures/json/1.3/bom_with_full_metadata.json similarity index 90% rename from tests/fixtures/bom_v1.3_with_metadata_component.json rename to tests/fixtures/json/1.3/bom_with_full_metadata.json index 7290dfc7..1804190f 100644 --- a/tests/fixtures/bom_v1.3_with_metadata_component.json +++ b/tests/fixtures/json/1.3/bom_with_full_metadata.json @@ -14,7 +14,7 @@ } ], "component": { - "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "type": "library", "name": "cyclonedx-python-lib", "version": "1.0.0" diff --git a/tests/fixtures/bom_v1.4_services_complex.json b/tests/fixtures/json/1.4/bom_services_complex.json similarity index 97% rename from tests/fixtures/bom_v1.4_services_complex.json rename to tests/fixtures/json/1.4/bom_services_complex.json index 45c90ebb..c6747a6d 100644 --- a/tests/fixtures/bom_v1.4_services_complex.json +++ b/tests/fixtures/json/1.4/bom_services_complex.json @@ -49,7 +49,7 @@ ], "component": { "type": "library", - "bom-ref": "2a4ec791-4846-4769-8332-06e6ee170395", + "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", "name": "cyclonedx-python-lib", "version": "1.0.0" } @@ -180,7 +180,7 @@ ] }, { - "bom-ref": "b1650ab8-33fb-47e3-bc6e-07031054a946", + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", "name": "my-second-service" } ] diff --git a/tests/fixtures/bom_v1.4_services_simple.json b/tests/fixtures/json/1.4/bom_services_simple.json similarity index 91% rename from tests/fixtures/bom_v1.4_services_simple.json rename to tests/fixtures/json/1.4/bom_services_simple.json index 6a2ae027..37ce9475 100644 --- a/tests/fixtures/bom_v1.4_services_simple.json +++ b/tests/fixtures/json/1.4/bom_services_simple.json @@ -49,7 +49,7 @@ ], "component": { "type": "library", - "bom-ref": "44eba9c8-ccfb-4868-90cb-deb27f72b358", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "cyclonedx-python-lib", "version": "1.0.0" } @@ -57,11 +57,11 @@ "components": [], "services": [ { - "bom-ref": "86452881-cb1a-4296-9450-2eb6f3e55744", + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "name": "my-first-service" }, { - "bom-ref": "86452881-cb1a-4296-9450-2eb6f3e55744", + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "name": "my-second-service" } ] diff --git a/tests/fixtures/bom_v1.4_setuptools.json b/tests/fixtures/json/1.4/bom_setuptools.json similarity index 87% rename from tests/fixtures/bom_v1.4_setuptools.json rename to tests/fixtures/json/1.4/bom_setuptools.json index f93f4ca3..ea89697a 100644 --- a/tests/fixtures/bom_v1.4_setuptools.json +++ b/tests/fixtures/json/1.4/bom_setuptools.json @@ -51,10 +51,16 @@ "components": [ { "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", "name": "setuptools", "version": "50.3.2", - "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", - "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" } ] } \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_setuptools_with_cpe.json b/tests/fixtures/json/1.4/bom_setuptools_no_version.json similarity index 87% rename from tests/fixtures/bom_v1.4_setuptools_with_cpe.json rename to tests/fixtures/json/1.4/bom_setuptools_no_version.json index fe9995a8..7f1fdde4 100644 --- a/tests/fixtures/bom_v1.4_setuptools_with_cpe.json +++ b/tests/fixtures/json/1.4/bom_setuptools_no_version.json @@ -51,11 +51,15 @@ "components": [ { "type": "library", + "bom-ref": "pkg:pypi/setuptools?extension=tar.gz", + "author": "Test Author", "name": "setuptools", - "version": "50.3.2", - "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", - "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", - "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools?extension=tar.gz" } ] } \ No newline at end of file diff --git a/tests/fixtures/json/1.4/bom_setuptools_with_cpe.json b/tests/fixtures/json/1.4/bom_setuptools_with_cpe.json new file mode 100644 index 00000000..21a2c950 --- /dev/null +++ b/tests/fixtures/json/1.4/bom_setuptools_with_cpe.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-python-lib/" + }, + { + "type": "documentation", + "url": "https://cyclonedx.github.io/cyclonedx-python-lib/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib" + }, + { + "type": "website", + "url": "https://cyclonedx.org" + } + ] + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_setuptools_with_release_notes.json b/tests/fixtures/json/1.4/bom_setuptools_with_release_notes.json similarity index 96% rename from tests/fixtures/bom_v1.4_setuptools_with_release_notes.json rename to tests/fixtures/json/1.4/bom_setuptools_with_release_notes.json index 1cb10683..f2511fbd 100644 --- a/tests/fixtures/bom_v1.4_setuptools_with_release_notes.json +++ b/tests/fixtures/json/1.4/bom_setuptools_with_release_notes.json @@ -52,8 +52,14 @@ { "type": "library", "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", "name": "setuptools", "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", "releaseNotes": { "type": "major", diff --git a/tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.json b/tests/fixtures/json/1.4/bom_setuptools_with_vulnerabilities.json similarity index 97% rename from tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.json rename to tests/fixtures/json/1.4/bom_setuptools_with_vulnerabilities.json index ee9ba1b7..4b9a5db2 100644 --- a/tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.json +++ b/tests/fixtures/json/1.4/bom_setuptools_with_vulnerabilities.json @@ -52,8 +52,14 @@ { "type": "library", "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", "name": "setuptools", "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" } ], diff --git a/tests/fixtures/json/1.4/bom_toml_1.json b/tests/fixtures/json/1.4/bom_toml_1.json new file mode 100644 index 00000000..8c231dd2 --- /dev/null +++ b/tests/fixtures/json/1.4/bom_toml_1.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-python-lib/" + }, + { + "type": "documentation", + "url": "https://cyclonedx.github.io/cyclonedx-python-lib/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib" + }, + { + "type": "website", + "url": "https://cyclonedx.org" + } + ] + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_setuptools_no_version.json b/tests/fixtures/json/1.4/bom_with_full_metadata.json similarity index 89% rename from tests/fixtures/bom_v1.4_setuptools_no_version.json rename to tests/fixtures/json/1.4/bom_with_full_metadata.json index de51e77e..9d96c43c 100644 --- a/tests/fixtures/bom_v1.4_setuptools_no_version.json +++ b/tests/fixtures/json/1.4/bom_with_full_metadata.json @@ -46,14 +46,13 @@ } ] } - ] - }, - "components": [ - { + ], + "component": { + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "type": "library", - "name": "setuptools", - "purl": "pkg:pypi/setuptools?extension=tar.gz", - "bom-ref": "pkg:pypi/setuptools?extension=tar.gz" + "name": "cyclonedx-python-lib", + "version": "1.0.0" } - ] + }, + "components": [] } \ No newline at end of file diff --git a/tests/fixtures/xml/1.0/bom_empty.xml b/tests/fixtures/xml/1.0/bom_empty.xml new file mode 100644 index 00000000..b48899c8 --- /dev/null +++ b/tests/fixtures/xml/1.0/bom_empty.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.0_setuptools.xml b/tests/fixtures/xml/1.0/bom_setuptools.xml similarity index 100% rename from tests/fixtures/bom_v1.0_setuptools.xml rename to tests/fixtures/xml/1.0/bom_setuptools.xml diff --git a/tests/fixtures/xml/1.0/bom_setuptools_no_version.xml b/tests/fixtures/xml/1.0/bom_setuptools_no_version.xml new file mode 100644 index 00000000..10bd0836 --- /dev/null +++ b/tests/fixtures/xml/1.0/bom_setuptools_no_version.xml @@ -0,0 +1,11 @@ + + + + + setuptools + + pkg:pypi/setuptools?extension=tar.gz + false + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.0_setuptools_with_cpe.xml b/tests/fixtures/xml/1.0/bom_setuptools_with_cpe.xml similarity index 100% rename from tests/fixtures/bom_v1.0_setuptools_with_cpe.xml rename to tests/fixtures/xml/1.0/bom_setuptools_with_cpe.xml diff --git a/tests/fixtures/xml/1.0/bom_toml_hashes_and_references.xml b/tests/fixtures/xml/1.0/bom_toml_hashes_and_references.xml new file mode 100644 index 00000000..b9fd4a08 --- /dev/null +++ b/tests/fixtures/xml/1.0/bom_toml_hashes_and_references.xml @@ -0,0 +1,14 @@ + + + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + false + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.1/bom_empty.xml b/tests/fixtures/xml/1.1/bom_empty.xml new file mode 100644 index 00000000..09b752a1 --- /dev/null +++ b/tests/fixtures/xml/1.1/bom_empty.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.1_setuptools.xml b/tests/fixtures/xml/1.1/bom_setuptools.xml similarity index 82% rename from tests/fixtures/bom_v1.1_setuptools.xml rename to tests/fixtures/xml/1.1/bom_setuptools.xml index d051ce6b..1fb9f8db 100644 --- a/tests/fixtures/bom_v1.1_setuptools.xml +++ b/tests/fixtures/xml/1.1/bom_setuptools.xml @@ -5,6 +5,9 @@ setuptools 50.3.2 + + MIT License + pkg:pypi/setuptools@50.3.2?extension=tar.gz diff --git a/tests/fixtures/xml/1.1/bom_setuptools_no_version.xml b/tests/fixtures/xml/1.1/bom_setuptools_no_version.xml new file mode 100644 index 00000000..8880d3c7 --- /dev/null +++ b/tests/fixtures/xml/1.1/bom_setuptools_no_version.xml @@ -0,0 +1,13 @@ + + + + + setuptools + + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.1_setuptools_with_cpe.xml b/tests/fixtures/xml/1.1/bom_setuptools_with_cpe.xml similarity index 84% rename from tests/fixtures/bom_v1.1_setuptools_with_cpe.xml rename to tests/fixtures/xml/1.1/bom_setuptools_with_cpe.xml index d4657548..88d4b12c 100644 --- a/tests/fixtures/bom_v1.1_setuptools_with_cpe.xml +++ b/tests/fixtures/xml/1.1/bom_setuptools_with_cpe.xml @@ -5,6 +5,9 @@ setuptools 50.3.2 + + MIT License + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* pkg:pypi/setuptools@50.3.2?extension=tar.gz diff --git a/tests/fixtures/xml/1.1/bom_setuptools_with_vulnerabilities.xml b/tests/fixtures/xml/1.1/bom_setuptools_with_vulnerabilities.xml new file mode 100644 index 00000000..cb9305c4 --- /dev/null +++ b/tests/fixtures/xml/1.1/bom_setuptools_with_vulnerabilities.xml @@ -0,0 +1,51 @@ + + + + + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + CVE-2018-7489 + + https://nvd.nist.gov/vuln/detail/CVE-2018-7489 + + + + + 9.8 + + Critical + CVSSv3 + AN/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H + + + + 2.7 + + Low + CVSSv3 + AV:L/AC:H/PR:N/UI:R/S:C/C:L/I:N/A:N + + + + 22 + 33 + + A description here + + Upgrade + + + https://nvd.nist.gov/vuln/detail/CVE-2018-7489 + http://www.securitytracker.com/id/1040693 + + + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.1/bom_toml_hashes_and_references.xml b/tests/fixtures/xml/1.1/bom_toml_hashes_and_references.xml new file mode 100644 index 00000000..489fb300 --- /dev/null +++ b/tests/fixtures/xml/1.1/bom_toml_hashes_and_references.xml @@ -0,0 +1,19 @@ + + + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.2_setuptools.xml b/tests/fixtures/xml/1.2/bom_setuptools.xml similarity index 83% rename from tests/fixtures/bom_v1.2_setuptools.xml rename to tests/fixtures/xml/1.2/bom_setuptools.xml index 7deec2dc..85d33d1a 100644 --- a/tests/fixtures/bom_v1.2_setuptools.xml +++ b/tests/fixtures/xml/1.2/bom_setuptools.xml @@ -12,8 +12,12 @@ + Test Author setuptools 50.3.2 + + MIT License + pkg:pypi/setuptools@50.3.2?extension=tar.gz diff --git a/tests/fixtures/xml/1.2/bom_setuptools_no_version.xml b/tests/fixtures/xml/1.2/bom_setuptools_no_version.xml new file mode 100644 index 00000000..b8461ee9 --- /dev/null +++ b/tests/fixtures/xml/1.2/bom_setuptools_no_version.xml @@ -0,0 +1,24 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + Test Author + setuptools + + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.2_setuptools_with_cpe.xml b/tests/fixtures/xml/1.2/bom_setuptools_with_cpe.xml similarity index 84% rename from tests/fixtures/bom_v1.2_setuptools_with_cpe.xml rename to tests/fixtures/xml/1.2/bom_setuptools_with_cpe.xml index 6c19439c..02bf3e8e 100644 --- a/tests/fixtures/bom_v1.2_setuptools_with_cpe.xml +++ b/tests/fixtures/xml/1.2/bom_setuptools_with_cpe.xml @@ -12,8 +12,12 @@ + Test Author setuptools 50.3.2 + + MIT License + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* pkg:pypi/setuptools@50.3.2?extension=tar.gz diff --git a/tests/fixtures/xml/1.2/bom_setuptools_with_vulnerabilities.xml b/tests/fixtures/xml/1.2/bom_setuptools_with_vulnerabilities.xml new file mode 100644 index 00000000..e295e0a4 --- /dev/null +++ b/tests/fixtures/xml/1.2/bom_setuptools_with_vulnerabilities.xml @@ -0,0 +1,62 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + CVE-2018-7489 + + https://nvd.nist.gov/vuln/detail/CVE-2018-7489 + + + + + 9.8 + + Critical + CVSSv3 + AN/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H + + + + 2.7 + + Low + CVSSv3 + AV:L/AC:H/PR:N/UI:R/S:C/C:L/I:N/A:N + + + + 22 + 33 + + A description here + + Upgrade + + + https://nvd.nist.gov/vuln/detail/CVE-2018-7489 + http://www.securitytracker.com/id/1040693 + + + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.2/bom_toml_hashes_and_references.xml b/tests/fixtures/xml/1.2/bom_toml_hashes_and_references.xml new file mode 100644 index 00000000..638a77e5 --- /dev/null +++ b/tests/fixtures/xml/1.2/bom_toml_hashes_and_references.xml @@ -0,0 +1,29 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.2/bom_with_full_metadata.xml b/tests/fixtures/xml/1.2/bom_with_full_metadata.xml new file mode 100644 index 00000000..df4d6db7 --- /dev/null +++ b/tests/fixtures/xml/1.2/bom_with_full_metadata.xml @@ -0,0 +1,18 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + cyclonedx-python-lib + 1.0.0 + + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_setuptools.xml b/tests/fixtures/xml/1.3/bom_setuptools.xml similarity index 83% rename from tests/fixtures/bom_v1.3_setuptools.xml rename to tests/fixtures/xml/1.3/bom_setuptools.xml index de7e7a17..0b315196 100644 --- a/tests/fixtures/bom_v1.3_setuptools.xml +++ b/tests/fixtures/xml/1.3/bom_setuptools.xml @@ -12,8 +12,12 @@ + Test Author setuptools 50.3.2 + + MIT License + pkg:pypi/setuptools@50.3.2?extension=tar.gz diff --git a/tests/fixtures/xml/1.3/bom_setuptools_no_version.xml b/tests/fixtures/xml/1.3/bom_setuptools_no_version.xml new file mode 100644 index 00000000..2acc18b4 --- /dev/null +++ b/tests/fixtures/xml/1.3/bom_setuptools_no_version.xml @@ -0,0 +1,24 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + Test Author + setuptools + + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_setuptools_with_cpe.xml b/tests/fixtures/xml/1.3/bom_setuptools_with_cpe.xml similarity index 83% rename from tests/fixtures/bom_v1.3_setuptools_with_cpe.xml rename to tests/fixtures/xml/1.3/bom_setuptools_with_cpe.xml index 17ae9d66..402e45fc 100644 --- a/tests/fixtures/bom_v1.3_setuptools_with_cpe.xml +++ b/tests/fixtures/xml/1.3/bom_setuptools_with_cpe.xml @@ -12,9 +12,13 @@ + Test Author setuptools 50.3.2 - cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + + MIT License + + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* pkg:pypi/setuptools@50.3.2?extension=tar.gz diff --git a/tests/fixtures/xml/1.3/bom_setuptools_with_vulnerabilities.xml b/tests/fixtures/xml/1.3/bom_setuptools_with_vulnerabilities.xml new file mode 100644 index 00000000..56a5c326 --- /dev/null +++ b/tests/fixtures/xml/1.3/bom_setuptools_with_vulnerabilities.xml @@ -0,0 +1,62 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + CVE-2018-7489 + + https://nvd.nist.gov/vuln/detail/CVE-2018-7489 + + + + + 9.8 + + Critical + CVSSv3 + AN/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H + + + + 2.7 + + Low + CVSSv3 + AV:L/AC:H/PR:N/UI:R/S:C/C:L/I:N/A:N + + + + 22 + 33 + + A description here + + Upgrade + + + https://nvd.nist.gov/vuln/detail/CVE-2018-7489 + http://www.securitytracker.com/id/1040693 + + + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.3/bom_toml_hashes_and_references.xml b/tests/fixtures/xml/1.3/bom_toml_hashes_and_references.xml new file mode 100644 index 00000000..a6f8af4e --- /dev/null +++ b/tests/fixtures/xml/1.3/bom_toml_hashes_and_references.xml @@ -0,0 +1,32 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.3/bom_with_full_metadata.xml b/tests/fixtures/xml/1.3/bom_with_full_metadata.xml new file mode 100644 index 00000000..8d06d737 --- /dev/null +++ b/tests/fixtures/xml/1.3/bom_with_full_metadata.xml @@ -0,0 +1,18 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + cyclonedx-python-lib + 1.0.0 + + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_setuptools.xml b/tests/fixtures/xml/1.4/bom_setuptools.xml similarity index 93% rename from tests/fixtures/bom_v1.4_setuptools.xml rename to tests/fixtures/xml/1.4/bom_setuptools.xml index 4b225683..45f143b8 100644 --- a/tests/fixtures/bom_v1.4_setuptools.xml +++ b/tests/fixtures/xml/1.4/bom_setuptools.xml @@ -38,8 +38,12 @@ + Test Author setuptools 50.3.2 + + MIT License + pkg:pypi/setuptools@50.3.2?extension=tar.gz diff --git a/tests/fixtures/bom_v1.4_setuptools_no_version.xml b/tests/fixtures/xml/1.4/bom_setuptools_no_version.xml similarity index 93% rename from tests/fixtures/bom_v1.4_setuptools_no_version.xml rename to tests/fixtures/xml/1.4/bom_setuptools_no_version.xml index 1c9923c2..ad9edadd 100644 --- a/tests/fixtures/bom_v1.4_setuptools_no_version.xml +++ b/tests/fixtures/xml/1.4/bom_setuptools_no_version.xml @@ -38,7 +38,11 @@ + Test Author setuptools + + MIT License + pkg:pypi/setuptools?extension=tar.gz diff --git a/tests/fixtures/bom_v1.4_setuptools_with_cpe.xml b/tests/fixtures/xml/1.4/bom_setuptools_with_cpe.xml similarity index 94% rename from tests/fixtures/bom_v1.4_setuptools_with_cpe.xml rename to tests/fixtures/xml/1.4/bom_setuptools_with_cpe.xml index 113848b8..15d8e89a 100644 --- a/tests/fixtures/bom_v1.4_setuptools_with_cpe.xml +++ b/tests/fixtures/xml/1.4/bom_setuptools_with_cpe.xml @@ -38,8 +38,12 @@ + Test Author setuptools 50.3.2 + + MIT License + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* pkg:pypi/setuptools@50.3.2?extension=tar.gz diff --git a/tests/fixtures/xml/1.4/bom_setuptools_with_release_notes.xml b/tests/fixtures/xml/1.4/bom_setuptools_with_release_notes.xml new file mode 100644 index 00000000..d5640438 --- /dev/null +++ b/tests/fixtures/xml/1.4/bom_setuptools_with_release_notes.xml @@ -0,0 +1,94 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + https://github.com/CycloneDX/cyclonedx-python-lib/actions + + + https://pypi.org/project/cyclonedx-python-lib/ + + + https://cyclonedx.github.io/cyclonedx-python-lib/ + + + https://github.com/CycloneDX/cyclonedx-python-lib/issues + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md + + + https://github.com/CycloneDX/cyclonedx-python-lib + + + https://cyclonedx.org + + + + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + major + Release Notes Title + https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png + https://cyclonedx.org/cyclonedx-icon.png + This release is a test release + 2021-12-31T10:00:00+00:00 + + First Test Release + + + test + alpha + + + + CVE-2021-44228 + Apache Log3Shell + Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features... + + NVD + https://nvd.nist.gov/vuln/detail/CVE-2021-44228 + + + https://logging.apache.org/log4j/2.x/security.html + https://central.sonatype.org/news/20211213_log4shell_help + + + + + + en-GB + U29tZSBzaW1wbGUgcGxhaW4gdGV4dA== + + + en-US + U29tZSBzaW1wbGUgcGxhaW4gdGV4dA== + + + + val1 + val2 + + + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.xml b/tests/fixtures/xml/1.4/bom_setuptools_with_vulnerabilities.xml similarity index 96% rename from tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.xml rename to tests/fixtures/xml/1.4/bom_setuptools_with_vulnerabilities.xml index c2e9c051..c5c95537 100644 --- a/tests/fixtures/bom_v1.4_setuptools_with_vulnerabilities.xml +++ b/tests/fixtures/xml/1.4/bom_setuptools_with_vulnerabilities.xml @@ -38,8 +38,12 @@ + Test Author setuptools 50.3.2 + + MIT License + pkg:pypi/setuptools@50.3.2?extension=tar.gz @@ -108,7 +112,7 @@ https://cyclonedx.org Paul Horton - simplyecommerce@googlemail.com + paul.horton@owasp.org A N Other diff --git a/tests/fixtures/xml/1.4/bom_toml_hashes_and_references.xml b/tests/fixtures/xml/1.4/bom_toml_hashes_and_references.xml new file mode 100644 index 00000000..ada4ef4c --- /dev/null +++ b/tests/fixtures/xml/1.4/bom_toml_hashes_and_references.xml @@ -0,0 +1,58 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + https://github.com/CycloneDX/cyclonedx-python-lib/actions + + + https://pypi.org/project/cyclonedx-python-lib/ + + + https://cyclonedx.github.io/cyclonedx-python-lib/ + + + https://github.com/CycloneDX/cyclonedx-python-lib/issues + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md + + + https://github.com/CycloneDX/cyclonedx-python-lib + + + https://cyclonedx.org + + + + + + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.4/bom_with_full_metadata.xml b/tests/fixtures/xml/1.4/bom_with_full_metadata.xml new file mode 100644 index 00000000..a16f9b10 --- /dev/null +++ b/tests/fixtures/xml/1.4/bom_with_full_metadata.xml @@ -0,0 +1,44 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + https://github.com/CycloneDX/cyclonedx-python-lib/actions + + + https://pypi.org/project/cyclonedx-python-lib/ + + + https://cyclonedx.github.io/cyclonedx-python-lib/ + + + https://github.com/CycloneDX/cyclonedx-python-lib/issues + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md + + + https://github.com/CycloneDX/cyclonedx-python-lib + + + https://cyclonedx.org + + + + + + cyclonedx-python-lib + 1.0.0 + + + + \ No newline at end of file diff --git a/tests/test_component.py b/tests/test_component.py index bcf73d9f..c4072bd8 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -94,12 +94,12 @@ def test_purl_with_qualifiers(self) -> None: self.assertEqual(purl.qualifiers, {'extension': 'tar.gz'}) def test_from_file_with_path_for_bom(self) -> None: - test_file = join(dirname(__file__), 'fixtures/bom_v1.3_setuptools.xml') - c = Component.for_file(absolute_file_path=test_file, path_for_bom='fixtures/bom_v1.3_setuptools.xml') - self.assertEqual(c.name, 'fixtures/bom_v1.3_setuptools.xml') + test_file = join(dirname(__file__), 'fixtures/bom_setuptools.xml') + c = Component.for_file(absolute_file_path=test_file, path_for_bom='fixtures/bom_setuptools.xml') + self.assertEqual(c.name, 'fixtures/bom_setuptools.xml') self.assertEqual(c.version, '0.0.0-16932e52ed1e') purl = PackageURL( - type='generic', name='fixtures/bom_v1.3_setuptools.xml', version='0.0.0-16932e52ed1e' + type='generic', name='fixtures/bom_setuptools.xml', version='0.0.0-16932e52ed1e' ) self.assertEqual(c.purl, purl) self.assertEqual(len(c.hashes), 1) diff --git a/tests/test_output_json.py b/tests/test_output_json.py index 9d8e2d53..a16a178a 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -16,527 +16,225 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. -import base64 -from datetime import datetime, timezone -from decimal import Decimal from os.path import dirname, join -from typing import List from unittest.mock import Mock, patch -from packageurl import PackageURL - -from cyclonedx.model import Encoding, ExternalReference, ExternalReferenceType, HashType, LicenseChoice, Note, \ - NoteText, OrganizationalContact, OrganizationalEntity, Property, Tool, XsUri, DataClassification, DataFlow +from cyclonedx.exception.output import FormatNotSupportedException from cyclonedx.model.bom import Bom -from cyclonedx.model.component import Component, ComponentType -from cyclonedx.model.issue import IssueClassification, IssueType -from cyclonedx.model.release_note import ReleaseNotes -from cyclonedx.model.service import Service -from cyclonedx.model.vulnerability import ImpactAnalysisState, ImpactAnalysisJustification, ImpactAnalysisResponse, \ - ImpactAnalysisAffectedStatus, Vulnerability, VulnerabilityCredits, VulnerabilityRating, VulnerabilitySeverity, \ - VulnerabilitySource, VulnerabilityScoreSource, VulnerabilityAdvisory, VulnerabilityReference, \ - VulnerabilityAnalysis, BomTarget, BomTargetVersionRange from cyclonedx.output import get_instance, OutputFormat, SchemaVersion -from cyclonedx.output.json import Json, JsonV1Dot4, JsonV1Dot3, JsonV1Dot2 +from data import get_bom_with_component_setuptools_basic, get_bom_with_component_setuptools_with_cpe, \ + get_bom_with_services_simple, get_bom_with_component_toml_1, \ + get_bom_with_component_setuptools_no_component_version, \ + get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ + MOCK_UUID_1, get_bom_just_complete_metadata, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, \ + get_bom_with_services_complex, MOCK_UUID_6 from tests.base import BaseJsonTestCase class TestOutputJson(BaseJsonTestCase): - timestamp: datetime = datetime(2021, 12, 31, 10, 0, 0, 0).replace(tzinfo=timezone.utc) def test_simple_bom_v1_4(self) -> None: - bom = Bom() - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) + self._validate_json_bom( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools.json' ) - bom.add_component(c) - - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, JsonV1Dot4) - with open(join(dirname(__file__), 'fixtures/bom_v1.4_setuptools.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() def test_simple_bom_v1_3(self) -> None: - bom = Bom() - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), license_str='MIT License' + self._validate_json_bom( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools.json' ) - bom.add_component(c) - - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON) - self.assertIsInstance(outputter, JsonV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_setuptools.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() def test_simple_bom_v1_2(self) -> None: - bom = Bom() - bom.add_component( - Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), author='Test Author' - ) + self._validate_json_bom( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_2, + fixture='bom_setuptools.json' ) - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_2) - self.assertIsInstance(outputter, JsonV1Dot2) - with open(join(dirname(__file__), 'fixtures/bom_v1.2_setuptools.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() - def test_simple_bom_v1_4_with_cpe(self) -> None: - bom = Bom() - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) + def test_simple_bom_v1_1(self) -> None: + self._validate_json_bom_not_supported( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_1 ) - bom.add_component(c) - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, JsonV1Dot4) - with open(join(dirname(__file__), 'fixtures/bom_v1.4_setuptools_with_cpe.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() + def test_simple_bom_v1_0(self) -> None: + self._validate_json_bom_not_supported( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_0 + ) - def test_simple_bom_v1_3_with_cpe(self) -> None: - bom = Bom() - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), license_str='MIT License' + def test_simple_bom_v1_4_with_cpe(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_setuptools_with_cpe(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_with_cpe.json' ) - bom.add_component(c) - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON) - self.assertIsInstance(outputter, JsonV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_setuptools_with_cpe.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() + def test_simple_bom_v1_3_with_cpe(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_setuptools_with_cpe(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools_with_cpe.json' + ) def test_simple_bom_v1_2_with_cpe(self) -> None: - bom = Bom() - bom.add_component( - Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), author='Test Author' - ) + self._validate_json_bom( + bom=get_bom_with_component_setuptools_with_cpe(), schema_version=SchemaVersion.V1_2, + fixture='bom_setuptools_with_cpe.json' ) - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_2) - self.assertIsInstance(outputter, JsonV1Dot2) - with open(join(dirname(__file__), 'fixtures/bom_v1.2_setuptools_with_cpe.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() - def test_bom_v1_3_with_component_hashes(self) -> None: - bom = Bom() - c = Component( - name='toml', version='0.10.2', bom_ref='pkg:pypi/toml@0.10.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' - ) + def test_bom_v1_4_component_hashes_external_references(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_4, + fixture='bom_toml_1.json' ) - c.add_hash( - HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') + + def test_bom_v1_3_component_hashes_external_references(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_3, + fixture='bom_toml_1.json' ) - bom.add_component(c) - outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON) - self.assertIsInstance(outputter, JsonV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_toml_with_component_hashes.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualJsonBom(a=outputter.output_as_string(), b=expected_json.read()) - expected_json.close() - def test_bom_v1_3_with_component_external_references(self) -> None: - bom = Bom() - c = Component( - name='toml', version='0.10.2', bom_ref='pkg:pypi/toml@0.10.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' - ) + def test_bom_v1_2_component_hashes_external_references(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_2, + fixture='bom_toml_1.json' ) - c.add_hash( - HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') + + def test_bom_v1_1_component_hashes_external_references(self) -> None: + self._validate_json_bom_not_supported(bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_1) + + def test_bom_v1_0_component_hashes_external_references(self) -> None: + self._validate_json_bom_not_supported(bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_0) + + def test_bom_v1_4_no_component_version(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_setuptools_no_component_version(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_no_version.json' ) - c.add_external_reference(TestOutputJson._get_external_reference_1()) - bom.add_component(c) - outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON) - self.assertIsInstance(outputter, JsonV1Dot3) - with open(join(dirname(__file__), - 'fixtures/bom_v1.3_toml_with_component_external_references.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualJsonBom(a=outputter.output_as_string(), b=expected_json.read()) - expected_json.close() - def test_bom_v1_3_with_component_license(self) -> None: - bom = Bom() - c = Component( - name='toml', version='0.10.2', bom_ref='pkg:pypi/toml@0.10.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' - ), license_str='MIT License' + def test_bom_v1_3_no_component_version(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_setuptools_no_component_version(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools_no_version.json' ) - bom.add_component(c) - outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON) - self.assertIsInstance(outputter, JsonV1Dot3) - with open(join(dirname(__file__), - 'fixtures/bom_v1.3_toml_with_component_license.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualJsonBom(a=outputter.output_as_string(), b=expected_json.read()) - expected_json.close() - def test_bom_v1_4_no_component_version(self) -> None: - bom = Bom() - c = Component( - name='setuptools', bom_ref='pkg:pypi/setuptools?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', qualifiers='extension=tar.gz' - ) + def test_bom_v1_4_component_with_release_notes(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_setuptools_with_release_notes(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_with_release_notes.json' ) - bom.add_component(c) - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, JsonV1Dot4) - with open(join(dirname(__file__), 'fixtures/bom_v1.4_setuptools_no_version.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() + def test_bom_v1_3_component_with_release_notes(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_setuptools_with_release_notes(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools.json' + ) - def test_with_component_release_notes_pre_1_4(self) -> None: - bom = Bom() - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), release_notes=ReleaseNotes(type='major'), licenses=[LicenseChoice(license_expression='MIT License')] + def test_bom_v1_4_component_with_vulnerability(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_setuptools_with_vulnerability(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_with_vulnerabilities.json' ) - bom.add_component(c) - outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_3) - self.assertIsInstance(outputter, JsonV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_setuptools.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() - def test_with_component_release_notes_post_1_4(self) -> None: - bom = Bom() - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), - release_notes=TestOutputJson._get_release_notes() + def test_bom_v1_3_component_with_vulnerability(self) -> None: + # Vulnerabilities not support in JSON < 1.4 + self._validate_json_bom( + bom=get_bom_with_component_setuptools_with_vulnerability(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools.json' ) - bom.add_component(c) - outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, JsonV1Dot4) - with open(join(dirname(__file__), - 'fixtures/bom_v1.4_setuptools_with_release_notes.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() - def test_simple_bom_v1_4_with_vulnerabilities(self) -> None: - bom = Bom() - nvd = VulnerabilitySource(name='NVD', url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2018-7489')) - owasp = VulnerabilitySource(name='OWASP', url=XsUri('https://owasp.org')) - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + def test_bom_v1_4_with_metadata_component(self, mock_uuid: Mock) -> None: + self._validate_json_bom( + bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_4, + fixture='bom_with_full_metadata.json' ) - c.add_vulnerability(Vulnerability( - bom_ref='my-vuln-ref-1', id='CVE-2018-7489', source=nvd, - references=[ - VulnerabilityReference(id='SOME-OTHER-ID', source=VulnerabilitySource( - name='OSS Index', url=XsUri('https://ossindex.sonatype.org/component/pkg:pypi/setuptools') - )) - ], - ratings=[ - VulnerabilityRating( - source=nvd, score=Decimal(9.8), severity=VulnerabilitySeverity.CRITICAL, - method=VulnerabilityScoreSource.CVSS_V3, - vector='AN/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', justification='Some justification' - ), - VulnerabilityRating( - source=owasp, score=Decimal(2.7), severity=VulnerabilitySeverity.LOW, - method=VulnerabilityScoreSource.CVSS_V3, - vector='AV:L/AC:H/PR:N/UI:R/S:C/C:L/I:N/A:N', justification='Some other justification' - ) - ], - cwes=[22, 33], description='A description here', detail='Some detail here', - recommendation='Upgrade', - advisories=[ - VulnerabilityAdvisory(url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2018-7489')), - VulnerabilityAdvisory(url=XsUri('http://www.securitytracker.com/id/1040693')) - ], - created=datetime(year=2021, month=9, day=1, hour=10, minute=50, second=42, microsecond=51979, - tzinfo=timezone.utc), - published=datetime(year=2021, month=9, day=2, hour=10, minute=50, second=42, microsecond=51979, - tzinfo=timezone.utc), - updated=datetime(year=2021, month=9, day=3, hour=10, minute=50, second=42, microsecond=51979, - tzinfo=timezone.utc), - credits=VulnerabilityCredits( - organizations=[ - TestOutputJson._get_org_entity_1() - ], - individuals=[ - OrganizationalContact(name='A N Other', email='someone@somewhere.tld', phone='+44 (0)1234 567890'), - ] - ), - tools=[ - Tool(vendor='CycloneDX', name='cyclonedx-python-lib') - ], - analysis=VulnerabilityAnalysis( - state=ImpactAnalysisState.EXPLOITABLE, justification=ImpactAnalysisJustification.REQUIRES_ENVIRONMENT, - responses=[ImpactAnalysisResponse.CAN_NOT_FIX], detail='Some extra detail' - ), - affects_targets=[ - BomTarget(ref=c.purl or c.to_package_url().to_string(), versions=[ - BomTargetVersionRange(version_range='49.0.0 - 54.0.0', status=ImpactAnalysisAffectedStatus.AFFECTED) - ]) - ] - )) - bom.add_component(c) - outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, JsonV1Dot4) - with open(join(dirname(__file__), 'fixtures/bom_v1.4_setuptools_with_vulnerabilities.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) - expected_json.close() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value='be2c6502-7e9a-47db-9a66-e34f729810a3') + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_2) def test_bom_v1_3_with_metadata_component(self, mock_uuid: Mock) -> None: - bom = Bom() - bom.metadata.component = Component( - name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + self._validate_json_bom( + bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_3, + fixture='bom_with_full_metadata.json' ) mock_uuid.assert_called() - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON) - self.assertIsInstance(outputter, JsonV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_with_metadata_component.json')) as expected_json: - self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) - - @patch('cyclonedx.model.component.uuid4', return_value='17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda') - @patch('cyclonedx.model.service.uuid4', return_value='0b049d09-64c0-4490-a0f5-c84d9aacf857') - def test_bom_v1_2_services(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: - bom = Bom(services=[ - Service(name='my-first-service'), - Service(name='my-second-service') - ]) - bom.metadata.component = Component( - name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_3) + def test_bom_v1_2_with_metadata_component(self, mock_uuid: Mock) -> None: + self._validate_json_bom( + bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_2, + fixture='bom_with_full_metadata.json' + ) + mock_uuid.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_3) + def test_bom_v1_4_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_json_bom( + bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_4, + fixture='bom_services_simple.json' ) mock_uuid_c.assert_called() mock_uuid_s.assert_called() - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_2) - self.assertIsInstance(outputter, JsonV1Dot2) - with open(join(dirname(__file__), 'fixtures/bom_v1.2_services_simple.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) - self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) - - @patch('cyclonedx.model.component.uuid4', return_value='17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda') - @patch('cyclonedx.model.service.uuid4', return_value='0b049d09-64c0-4490-a0f5-c84d9aacf857') - def test_bom_v1_2_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: - bom = TestOutputJson._get_bom_with_services_simple() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_2) + @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_4) + def test_bom_v1_3_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_json_bom( + bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_3, + fixture='bom_services_simple.json' + ) mock_uuid_c.assert_called() mock_uuid_s.assert_called() - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_2) - self.assertIsInstance(outputter, JsonV1Dot2) - with open(join(dirname(__file__), 'fixtures/bom_v1.2_services_simple.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) - self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) - - @patch('cyclonedx.model.component.uuid4', return_value='17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda') - @patch('cyclonedx.model.service.uuid4', return_value='0b049d09-64c0-4490-a0f5-c84d9aacf857') - def test_bom_v1_2_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: - bom = TestOutputJson._get_bom_with_services_complex() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) + @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_5) + def test_bom_v1_2_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_json_bom( + bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_2, + fixture='bom_services_simple.json' + ) mock_uuid_c.assert_called() mock_uuid_s.assert_called() - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_2) - self.assertIsInstance(outputter, JsonV1Dot2) - with open(join(dirname(__file__), 'fixtures/bom_v1.2_services_complex.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) - self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) - - @patch('cyclonedx.model.component.uuid4', return_value='cd3e9c95-9d41-49e7-9924-8cf0465ae789') - @patch('cyclonedx.model.service.uuid4', return_value='bb5911d6-1a1d-41c9-b6e0-46e848d16655') - def test_bom_v1_3_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: - bom = TestOutputJson._get_bom_with_services_simple() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) + @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_4) + def test_bom_v1_4_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_json_bom( + bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_4, + fixture='bom_services_complex.json' + ) mock_uuid_c.assert_called() mock_uuid_s.assert_called() - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_3) - self.assertIsInstance(outputter, JsonV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_services_simple.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) - - @patch('cyclonedx.model.component.uuid4', return_value='df70b5f1-8f53-47a4-be48-669ae78795e6') - @patch('cyclonedx.model.service.uuid4', return_value='e9c2e297-eee6-4f45-ac2d-6662b1db77bf') + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) + @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_3) def test_bom_v1_3_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: - bom = TestOutputJson._get_bom_with_services_complex() - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_3) - self.assertIsInstance(outputter, JsonV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_services_complex.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) - - @patch('cyclonedx.model.component.uuid4', return_value='44eba9c8-ccfb-4868-90cb-deb27f72b358') - @patch('cyclonedx.model.service.uuid4', return_value='86452881-cb1a-4296-9450-2eb6f3e55744') - def test_bom_v1_4_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: - bom = TestOutputJson._get_bom_with_services_simple() + self._validate_json_bom( + bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_3, + fixture='bom_services_complex.json' + ) mock_uuid_c.assert_called() mock_uuid_s.assert_called() - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, JsonV1Dot4) - with open(join(dirname(__file__), 'fixtures/bom_v1.4_services_simple.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) - - @patch('cyclonedx.model.component.uuid4', return_value='2a4ec791-4846-4769-8332-06e6ee170395') - @patch('cyclonedx.model.service.uuid4', return_value='b1650ab8-33fb-47e3-bc6e-07031054a946') - def test_bom_v1_4_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: - bom = TestOutputJson._get_bom_with_services_complex() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) + @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_2) + def test_bom_v1_2_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_json_bom( + bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_2, + fixture='bom_services_complex.json' + ) mock_uuid_c.assert_called() mock_uuid_s.assert_called() - outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, JsonV1Dot4) - with open(join(dirname(__file__), 'fixtures/bom_v1.4_services_complex.json')) as expected_json: - self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) - - @staticmethod - def _get_bom_with_services_simple() -> Bom: - bom = Bom(services=[ - Service(name='my-first-service'), - Service(name='my-second-service') - ]) - bom.metadata.component = Component( - name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY - ) - return bom - - @staticmethod - def _get_bom_with_services_complex() -> Bom: - bom = Bom(services=[ - Service( - name='my-first-service', bom_ref='my-specific-bom-ref-for-my-first-service', - provider=TestOutputJson._get_org_entity_1(), group='a-group', version='1.2.3', - description='Description goes here', endpoints=[ - XsUri('/api/thing/1'), - XsUri('/api/thing/2') - ], - authenticated=False, x_trust_boundary=True, data=[ - DataClassification(flow=DataFlow.OUTBOUND, classification='public') - ], - licenses=[ - LicenseChoice(license_expression='Commercial') - ], - external_references=[ - TestOutputJson._get_external_reference_1() - ], - properties=TestOutputJson._get_properties_1(), - release_notes=TestOutputJson._get_release_notes() - ), - Service(name='my-second-service') - ]) - bom.metadata.component = Component( - name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY - ) - return bom - - @staticmethod - def _get_external_reference_1() -> ExternalReference: - return ExternalReference( - reference_type=ExternalReferenceType.DISTRIBUTION, - url='https://cyclonedx.org', - comment='No comment', - hashes=[ - HashType.from_composite_str( - 'sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') - ] - ) - @staticmethod - def _get_org_entity_1() -> OrganizationalEntity: - return OrganizationalEntity( - name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[ - OrganizationalContact(name='Paul Horton', email='paul.horton@owasp.org'), - OrganizationalContact(name='A N Other', email='someone@somewhere.tld', - phone='+44 (0)1234 567890') - ] - ) + # Helper methods + def _validate_json_bom(self, bom: Bom, schema_version: SchemaVersion, fixture: str) -> None: + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=schema_version) + self.assertEqual(outputter.schema_version, schema_version) + with open( + join(dirname(__file__), f'fixtures/json/{schema_version.to_version()}/{fixture}')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=schema_version) + self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) + expected_json.close() - @staticmethod - def _get_properties_1() -> List[Property]: - return [ - Property(name='key1', value='val1'), - Property(name='key2', value='val2') - ] - - @staticmethod - def _get_release_notes() -> ReleaseNotes: - text_content: str = base64.b64encode( - bytes('Some simple plain text', encoding='UTF-8') - ).decode(encoding='UTF-8') - - return ReleaseNotes( - type='major', title="Release Notes Title", - featured_image=XsUri('https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png'), - social_image=XsUri('https://cyclonedx.org/cyclonedx-icon.png'), - description="This release is a test release", timestamp=TestOutputJson.timestamp, - aliases=[ - "First Test Release" - ], - tags=['test', 'alpha'], - resolves=[ - IssueType( - classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', - description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', - source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), - references=[ - XsUri('https://logging.apache.org/log4j/2.x/security.html'), - XsUri('https://central.sonatype.org/news/20211213_log4shell_help') - ] - ) - ], - notes=[ - Note( - text=NoteText( - content=text_content, content_type='text/plain; charset=UTF-8', - content_encoding=Encoding.BASE_64 - ), locale='en-GB' - ), - Note( - text=NoteText( - content=text_content, content_type='text/plain; charset=UTF-8', - content_encoding=Encoding.BASE_64 - ), locale='en-US' - ) - ], - properties=TestOutputJson._get_properties_1() - ) + def _validate_json_bom_not_supported(self, bom: Bom, schema_version: SchemaVersion) -> None: + with self.assertRaises(FormatNotSupportedException): + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=schema_version) + outputter.output_as_string() diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index c659d425..2dee2440 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -16,521 +16,230 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. -import base64 -from datetime import datetime, timezone -from decimal import Decimal from os.path import dirname, join -from packageurl import PackageURL from unittest.mock import Mock, patch -from cyclonedx.model import Encoding, ExternalReference, ExternalReferenceType, HashType, Note, NoteText, \ - OrganizationalContact, OrganizationalEntity, Property, Tool, XsUri from cyclonedx.model.bom import Bom -from cyclonedx.model.component import Component, ComponentType -from cyclonedx.model.impact_analysis import ImpactAnalysisState, ImpactAnalysisJustification, ImpactAnalysisResponse, \ - ImpactAnalysisAffectedStatus -from cyclonedx.model.issue import IssueClassification, IssueType -from cyclonedx.model.release_note import ReleaseNotes -from cyclonedx.model.vulnerability import Vulnerability, VulnerabilityCredits, VulnerabilityRating, \ - VulnerabilitySeverity, VulnerabilitySource, VulnerabilityScoreSource, VulnerabilityAdvisory, \ - VulnerabilityReference, VulnerabilityAnalysis, BomTarget, BomTargetVersionRange from cyclonedx.output import get_instance, SchemaVersion -from cyclonedx.output.xml import XmlV1Dot4, XmlV1Dot3, XmlV1Dot2, XmlV1Dot1, XmlV1Dot0, Xml +from data import get_bom_with_component_setuptools_basic, get_bom_with_component_setuptools_with_cpe, \ + get_bom_with_component_toml_1, get_bom_with_component_setuptools_no_component_version, \ + get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ + MOCK_UUID_1, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6, get_bom_just_complete_metadata from tests.base import BaseXmlTestCase class TestOutputXml(BaseXmlTestCase): def test_simple_bom_v1_4(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - outputter: Xml = get_instance(bom=bom, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, XmlV1Dot4) - with open(join(dirname(__file__), 'fixtures/bom_v1.4_setuptools.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools.xml' + ) def test_simple_bom_v1_3(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - outputter: Xml = get_instance(bom=bom) - self.assertIsInstance(outputter, XmlV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_setuptools.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools.xml' + ) def test_simple_bom_v1_2(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_2) - self.assertIsInstance(outputter, XmlV1Dot2) - with open(join(dirname(__file__), 'fixtures/bom_v1.2_setuptools.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) - self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_2, + fixture='bom_setuptools.xml' + ) def test_simple_bom_v1_1(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_1) - self.assertIsInstance(outputter, XmlV1Dot1) - with open(join(dirname(__file__), 'fixtures/bom_v1.1_setuptools.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_1) - self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_1, + fixture='bom_setuptools.xml' + ) def test_simple_bom_v1_0(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - self.assertEqual(len(bom.components), 1) - outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_0) - self.assertIsInstance(outputter, XmlV1Dot0) - with open(join(dirname(__file__), 'fixtures/bom_v1.0_setuptools.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_0) - self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_0, + fixture='bom_setuptools.xml' + ) def test_simple_bom_v1_4_with_cpe(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - outputter: Xml = get_instance(bom=bom, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, XmlV1Dot4) - with open(join(dirname(__file__), 'fixtures/bom_v1.4_setuptools_with_cpe.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_cpe(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_with_cpe.xml' + ) def test_simple_bom_v1_3_with_cpe(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - outputter: Xml = get_instance(bom=bom) - self.assertIsInstance(outputter, XmlV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_setuptools_with_cpe.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_cpe(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools_with_cpe.xml' + ) def test_simple_bom_v1_2_with_cpe(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_2) - self.assertIsInstance(outputter, XmlV1Dot2) - with open(join(dirname(__file__), 'fixtures/bom_v1.2_setuptools_with_cpe.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) - self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_cpe(), schema_version=SchemaVersion.V1_2, + fixture='bom_setuptools_with_cpe.xml' + ) def test_simple_bom_v1_1_with_cpe(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_1) - self.assertIsInstance(outputter, XmlV1Dot1) - with open(join(dirname(__file__), 'fixtures/bom_v1.1_setuptools_with_cpe.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_1) - self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_cpe(), schema_version=SchemaVersion.V1_1, + fixture='bom_setuptools_with_cpe.xml' + ) def test_simple_bom_v1_0_with_cpe(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) - )) - self.assertEqual(len(bom.components), 1) - outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_0) - self.assertIsInstance(outputter, XmlV1Dot0) - with open(join(dirname(__file__), 'fixtures/bom_v1.0_setuptools_with_cpe.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_0) - self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_cpe(), schema_version=SchemaVersion.V1_0, + fixture='bom_setuptools_with_cpe.xml' + ) - def test_simple_bom_v1_4_with_vulnerabilities(self) -> None: - bom = Bom() - nvd = VulnerabilitySource(name='NVD', url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2018-7489')) - owasp = VulnerabilitySource(name='OWASP', url=XsUri('https://owasp.org')) - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) + def test_bom_v1_4_component_hashes_external_references(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_4, + fixture='bom_toml_hashes_and_references.xml' ) - c.add_vulnerability(Vulnerability( - bom_ref='my-vuln-ref-1', id='CVE-2018-7489', source=nvd, - references=[ - VulnerabilityReference(id='SOME-OTHER-ID', source=VulnerabilitySource( - name='OSS Index', url=XsUri('https://ossindex.sonatype.org/component/pkg:pypi/setuptools') - )) - ], - ratings=[ - VulnerabilityRating( - source=nvd, score=Decimal(9.8), severity=VulnerabilitySeverity.CRITICAL, - method=VulnerabilityScoreSource.CVSS_V3, - vector='AN/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', justification='Some justification' - ), - VulnerabilityRating( - source=owasp, score=Decimal(2.7), severity=VulnerabilitySeverity.LOW, - method=VulnerabilityScoreSource.CVSS_V3, - vector='AV:L/AC:H/PR:N/UI:R/S:C/C:L/I:N/A:N', justification='Some other justification' - ) - ], - cwes=[22, 33], description='A description here', detail='Some detail here', - recommendation='Upgrade', - advisories=[ - VulnerabilityAdvisory(url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2018-7489')), - VulnerabilityAdvisory(url=XsUri('http://www.securitytracker.com/id/1040693')) - ], - created=datetime(year=2021, month=9, day=1, hour=10, minute=50, second=42, microsecond=51979, - tzinfo=timezone.utc), - published=datetime(year=2021, month=9, day=2, hour=10, minute=50, second=42, microsecond=51979, - tzinfo=timezone.utc), - updated=datetime(year=2021, month=9, day=3, hour=10, minute=50, second=42, microsecond=51979, - tzinfo=timezone.utc), - credits=VulnerabilityCredits( - organizations=[ - OrganizationalEntity( - name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[ - OrganizationalContact(name='Paul Horton', email='simplyecommerce@googlemail.com'), - OrganizationalContact(name='A N Other', email='someone@somewhere.tld', - phone='+44 (0)1234 567890') - ] - ) - ], - individuals=[ - OrganizationalContact(name='A N Other', email='someone@somewhere.tld', phone='+44 (0)1234 567890'), - ] - ), - tools=[ - Tool(vendor='CycloneDX', name='cyclonedx-python-lib') - ], - analysis=VulnerabilityAnalysis( - state=ImpactAnalysisState.EXPLOITABLE, justification=ImpactAnalysisJustification.REQUIRES_ENVIRONMENT, - responses=[ImpactAnalysisResponse.CAN_NOT_FIX], detail='Some extra detail' - ), - affects_targets=[ - BomTarget(ref=c.bom_ref, versions=[ - BomTargetVersionRange(version_range='49.0.0 - 54.0.0', status=ImpactAnalysisAffectedStatus.AFFECTED) - ]) - ] - )) - bom.add_component(c) - outputter: Xml = get_instance(bom=bom, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, XmlV1Dot4) - with open(join(dirname(__file__), 'fixtures/bom_v1.4_setuptools_with_vulnerabilities.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualXmlBom(a=outputter.output_as_string(), - b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + def test_bom_v1_3_component_hashes_external_references(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_3, + fixture='bom_toml_hashes_and_references.xml' + ) - def test_simple_bom_v1_3_with_vulnerabilities(self) -> None: - bom = Bom() - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) + def test_bom_v1_2_component_hashes_external_references(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_2, + fixture='bom_toml_hashes_and_references.xml' ) - c.add_vulnerability(Vulnerability( - id='CVE-2018-7489', source_name='NVD', source_url='https://nvd.nist.gov/vuln/detail/CVE-2018-7489', - ratings=[ - VulnerabilityRating(score_base=9.8, - severity=VulnerabilitySeverity.CRITICAL, method=VulnerabilityScoreSource.CVSS_V3, - vector='AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'), - VulnerabilityRating(severity=VulnerabilitySeverity.LOW, method=VulnerabilityScoreSource.OWASP, - vector='OWASP/K9:M1:O0:Z2/D1:X1:W1:L3/C2:I1:A1:T1/F1:R1:S2:P3/50', ) - ], - cwes=[123, 456], description='A description here', recommendation='Upgrade', - advisories=[ - VulnerabilityAdvisory(url=XsUri('http://www.securityfocus.com/bid/103203')), - VulnerabilityAdvisory(url=XsUri('http://www.securitytracker.com/id/1040693')) - ] - )) - bom.add_component(c) - outputter: Xml = get_instance(bom=bom) - self.assertIsInstance(outputter, XmlV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_setuptools_with_vulnerabilities.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + def test_bom_v1_1_component_hashes_external_references(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_1, + fixture='bom_toml_hashes_and_references.xml' + ) - def test_simple_bom_v1_0_with_vulnerabilities(self) -> None: - bom = Bom() - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ) + def test_bom_v1_0_component_hashes_external_references(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_0, + fixture='bom_toml_hashes_and_references.xml' ) - c.add_vulnerability(Vulnerability( - id='CVE-2018-7489', source_name='NVD', source_url='https://nvd.nist.gov/vuln/detail/CVE-2018-7489', - ratings=[ - VulnerabilityRating(score_base=9.8, - severity=VulnerabilitySeverity.CRITICAL, method=VulnerabilityScoreSource.CVSS_V3, - vector='AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'), - VulnerabilityRating(severity=VulnerabilitySeverity.LOW, method=VulnerabilityScoreSource.OWASP, - vector='OWASP/K9:M1:O0:Z2/D1:X1:W1:L3/C2:I1:A1:T1/F1:R1:S2:P3/50', ) - ], - cwes=[123, 456], description='A description here', recommendations=['Upgrade'], - advisories=[ - 'http://www.securityfocus.com/bid/103203', - 'http://www.securitytracker.com/id/1040693' - ] - )) - bom.add_component(c) - outputter: Xml = get_instance(bom=bom, schema_version=SchemaVersion.V1_0) - self.assertIsInstance(outputter, XmlV1Dot0) - with open(join(dirname(__file__), 'fixtures/bom_v1.0_setuptools.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_0) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + def test_bom_v1_4_no_component_version(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_no_component_version(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_no_version.xml' + ) - def test_bom_v1_3_with_component_hashes(self) -> None: - bom = Bom() - c = Component( - name='toml', version='0.10.2', bom_ref='pkg:pypi/toml@0.10.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' - ) + def test_bom_v1_3_no_component_version(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_no_component_version(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools_no_version.xml' ) - c.add_hash( - HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') + + def test_bom_v1_2_no_component_version(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_no_component_version(), schema_version=SchemaVersion.V1_2, + fixture='bom_setuptools_no_version.xml' ) - bom.add_component(c) - outputter: Xml = get_instance(bom=bom) - self.assertIsInstance(outputter, XmlV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_toml_with_component_hashes.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() - def test_bom_v1_3_with_component_external_references(self) -> None: - bom = Bom() - c = Component( - name='toml', version='0.10.2', bom_ref='pkg:pypi/toml@0.10.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' - ) + def test_bom_v1_1_no_component_version(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_no_component_version(), schema_version=SchemaVersion.V1_1, + fixture='bom_setuptools_no_version.xml' ) - c.add_hash( - HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') - ) - c.add_external_reference( - ExternalReference( - reference_type=ExternalReferenceType.DISTRIBUTION, - url='https://cyclonedx.org', - comment='No comment', - hashes=[ - HashType.from_composite_str( - 'sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') - ] - ) + + def test_bom_v1_0_no_component_version(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_no_component_version(), schema_version=SchemaVersion.V1_0, + fixture='bom_setuptools_no_version.xml' ) - bom.add_component(c) - outputter: Xml = get_instance(bom=bom) - self.assertIsInstance(outputter, XmlV1Dot3) - with open(join(dirname(__file__), - 'fixtures/bom_v1.3_toml_with_component_external_references.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() - def test_with_component_license(self) -> None: - bom = Bom() - c = Component( - name='toml', version='0.10.2', bom_ref='pkg:pypi/toml@0.10.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' - ), license_str='MIT License' - ) - bom.add_component(c) - outputter: Xml = get_instance(bom=bom) - self.assertIsInstance(outputter, XmlV1Dot3) - with open(join(dirname(__file__), - 'fixtures/bom_v1.3_toml_with_component_license.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + def test_bom_v1_4_component_with_release_notes(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_release_notes(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_with_release_notes.xml' + ) - def test_with_no_component_version_1_4(self) -> None: - bom = Bom() - bom.add_component(Component( - name='setuptools', bom_ref='pkg:pypi/setuptools?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', qualifiers='extension=tar.gz' - ) - )) - outputter: Xml = get_instance(bom=bom, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, XmlV1Dot4) - with open(join(dirname(__file__), - 'fixtures/bom_v1.4_setuptools_no_version.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + def test_bom_v1_3_component_with_release_notes(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_release_notes(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools.xml' + ) - def test_with_component_release_notes_pre_1_4(self) -> None: - bom = Bom() - c = Component( - name='toml', version='0.10.2', bom_ref='pkg:pypi/toml@0.10.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' - ), release_notes=ReleaseNotes(type='major'), license_str='MIT License' - ) - bom.add_component(c) - outputter: Xml = get_instance(bom=bom, schema_version=SchemaVersion.V1_3) - self.assertIsInstance(outputter, XmlV1Dot3) - with open(join(dirname(__file__), - 'fixtures/bom_v1.3_toml_with_component_license.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() + def test_bom_v1_4_component_with_vulnerability(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_vulnerability(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_with_vulnerabilities.xml' + ) - def test_with_component_release_notes_post_1_4(self) -> None: - bom = Bom() - timestamp: datetime = datetime(2021, 12, 31, 10, 0, 0, 0).replace(tzinfo=timezone.utc) - - text_content: str = base64.b64encode( - bytes('Some simple plain text', encoding='UTF-8') - ).decode(encoding='UTF-8') - - c = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), - release_notes=ReleaseNotes( - type='major', title="Release Notes Title", - featured_image=XsUri('https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png'), - social_image=XsUri('https://cyclonedx.org/cyclonedx-icon.png'), - description="This release is a test release", timestamp=timestamp, - aliases=[ - "First Test Release" - ], - tags=['test', 'alpha'], - resolves=[ - IssueType( - classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', - description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', - source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), - references=[ - XsUri('https://logging.apache.org/log4j/2.x/security.html'), - XsUri('https://central.sonatype.org/news/20211213_log4shell_help') - ] - ) - ], - notes=[ - Note( - text=NoteText( - content=text_content, content_type='text/plain; charset=UTF-8', - content_encoding=Encoding.BASE_64 - ), locale='en-GB' - ), - Note( - text=NoteText( - content=text_content, content_type='text/plain; charset=UTF-8', - content_encoding=Encoding.BASE_64 - ), locale='en-US' - ) - ], - properties=[ - Property(name='key1', value='val1'), - Property(name='key2', value='val2') - ] - ) + def test_bom_v1_3_component_with_vulnerability(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_vulnerability(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools_with_vulnerabilities.xml' + ) + + def test_bom_v1_2_component_with_vulnerability(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_vulnerability(), schema_version=SchemaVersion.V1_2, + fixture='bom_setuptools_with_vulnerabilities.xml' ) - bom.add_component(c) - outputter: Xml = get_instance(bom=bom, schema_version=SchemaVersion.V1_4) - self.assertIsInstance(outputter, XmlV1Dot4) - with open(join(dirname(__file__), - 'fixtures/bom_v1.4_setuptools_with_release_notes.xml')) as expected_xml: - self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) - expected_xml.close() - @patch('cyclonedx.model.component.uuid4', return_value='5d82790b-3139-431d-855a-ab63d14a18bb') + def test_bom_v1_1_component_with_vulnerability(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_vulnerability(), schema_version=SchemaVersion.V1_1, + fixture='bom_setuptools_with_vulnerabilities.xml' + ) + + def test_bom_v1_0_component_with_vulnerability(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_with_vulnerability(), schema_version=SchemaVersion.V1_0, + fixture='bom_setuptools.xml' + ) + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) + def test_bom_v1_4_with_metadata_component(self, mock_uuid: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_4, + fixture='bom_with_full_metadata.xml' + ) + mock_uuid.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) def test_bom_v1_3_with_metadata_component(self, mock_uuid: Mock) -> None: - bom = Bom() - bom.metadata.component = Component( - name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + self._validate_xml_bom( + bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_3, + fixture='bom_with_full_metadata.xml' + ) + mock_uuid.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) + def test_bom_v1_2_with_metadata_component(self, mock_uuid: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_2, + fixture='bom_with_full_metadata.xml' ) mock_uuid.assert_called() - outputter: Xml = get_instance(bom=bom) - self.assertIsInstance(outputter, XmlV1Dot3) - with open(join(dirname(__file__), 'fixtures/bom_v1.3_with_metadata_component.xml')) as expected_xml: - self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), - namespace=outputter.get_target_namespace()) + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + def test_bom_v1_1_with_metadata_component(self, mock_uuid: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_1, + fixture='bom_empty.xml' + ) + mock_uuid.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + def test_bom_v1_0_with_metadata_component(self, mock_uuid: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_0, + fixture='bom_empty.xml' + ) + mock_uuid.assert_called() + + # Helper methods + def _validate_xml_bom(self, bom: Bom, schema_version: SchemaVersion, fixture: str) -> None: + outputter = get_instance(bom=bom, schema_version=schema_version) + self.assertEqual(outputter.schema_version, schema_version) + with open( + join(dirname(__file__), f'fixtures/xml/{schema_version.to_version()}/{fixture}')) as expected_xml: + self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=schema_version) + self.assertEqualXmlBom( + expected_xml.read(), outputter.output_as_string(), namespace=outputter.get_target_namespace() + ) expected_xml.close() From 15b081bd1891566dbe00e18a8b21d3be87154f72 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 27 Jan 2022 13:15:18 +0000 Subject: [PATCH 03/29] fix: `expression` not supported in Component Licsnes for version 1.0 Signed-off-by: Paul Horton --- cyclonedx/output/schema.py | 6 ++++++ cyclonedx/output/xml.py | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py index f36dc336..a72b15ec 100644 --- a/cyclonedx/output/schema.py +++ b/cyclonedx/output/schema.py @@ -65,6 +65,9 @@ def component_supports_bom_ref_attribute(self) -> bool: def component_supports_mime_type_attribute(self) -> bool: return True + def component_supports_licenses_expression(self) -> bool: + return True + def component_version_optional(self) -> bool: return False @@ -230,6 +233,9 @@ def component_supports_author(self) -> bool: def component_supports_bom_ref_attribute(self) -> bool: return False + def component_supports_licenses_expression(self) -> bool: + return False + def component_supports_mime_type_attribute(self) -> bool: return False diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 5d784cb9..79516850 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -159,6 +159,7 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: # licenses if component.licenses: + license_output: bool = False licenses_e = ElementTree.SubElement(component_element, 'licenses') for license in component.licenses: if license.license: @@ -177,8 +178,14 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: license_text_e_attrs).text = license.license.text.content ElementTree.SubElement(license_e, 'text').text = license.license.id + license_output = True else: - ElementTree.SubElement(licenses_e, 'expression').text = license.expression + if self.component_supports_licenses_expression(): + ElementTree.SubElement(licenses_e, 'expression').text = license.expression + license_output = True + + if not license_output: + component_element.remove(licenses_e) # cpe if component.cpe: From 70d25c8c162e05a5992761ccddbad617558346d1 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 27 Jan 2022 13:17:57 +0000 Subject: [PATCH 04/29] fix: Components with no version (optional since 1.4) produce invalid BOM output in XML #150 Signed-off-by: Paul Horton --- cyclonedx/exception/output.py | 8 -------- cyclonedx/output/xml.py | 9 +++------ 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/cyclonedx/exception/output.py b/cyclonedx/exception/output.py index 76ed11e2..58e83976 100644 --- a/cyclonedx/exception/output.py +++ b/cyclonedx/exception/output.py @@ -22,14 +22,6 @@ from . import CycloneDxException -class ComponentVersionRequiredException(CycloneDxException): - """ - Exception raised when attempting to output to an SBOM version that mandates a Component has a version, - but one is not available/present. - """ - pass - - class FormatNotSupportedException(CycloneDxException): """ Exception raised when attempting to output a BOM to a format not supported in the requested version. diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 79516850..77338e43 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -24,7 +24,6 @@ from . import BaseOutput, SchemaVersion from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3, \ SchemaVersion1Dot4 -from ..exception.output import ComponentVersionRequiredException from ..model import ExternalReference, HashType, OrganizationalEntity, OrganizationalContact, Tool from ..model.bom import Bom from ..model.component import Component @@ -147,11 +146,9 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: ElementTree.SubElement(component_element, 'version').text = component.version else: if not component.version: - raise ComponentVersionRequiredException( - f'Component "{str(component)}" has no version but the target schema version mandates ' - f'Components have a version specified' - ) - ElementTree.SubElement(component_element, 'version').text = component.version + ElementTree.SubElement(component_element, 'version') + else: + ElementTree.SubElement(component_element, 'version').text = component.version # hashes if component.hashes: From c09e396b98c484d1d3d509a5c41746133fe41276 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 27 Jan 2022 13:27:31 +0000 Subject: [PATCH 05/29] fix: regression introduced by first fix for #150 Signed-off-by: Paul Horton --- cyclonedx/output/schema.py | 12 ++++++++++++ cyclonedx/output/xml.py | 10 +++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py index a72b15ec..c795bc1d 100644 --- a/cyclonedx/output/schema.py +++ b/cyclonedx/output/schema.py @@ -77,6 +77,9 @@ def component_supports_external_references(self) -> bool: def component_supports_release_notes(self) -> bool: return True + def external_references_supports_hashes(self) -> bool: + return True + @abstractmethod def get_schema_version(self) -> str: raise NotImplementedError @@ -150,6 +153,9 @@ def component_supports_mime_type_attribute(self) -> bool: def component_supports_release_notes(self) -> bool: return False + def external_references_supports_hashes(self) -> bool: + return False + def get_schema_version(self) -> str: return '1.2' @@ -193,6 +199,9 @@ def component_supports_author(self) -> bool: def component_supports_release_notes(self) -> bool: return False + def external_references_supports_hashes(self) -> bool: + return False + def get_schema_version(self) -> str: return '1.1' @@ -245,5 +254,8 @@ def component_supports_external_references(self) -> bool: def component_supports_release_notes(self) -> bool: return False + def external_references_supports_hashes(self) -> bool: + return False + def get_schema_version(self) -> str: return '1.0' diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 77338e43..a29a1c04 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -208,7 +208,7 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: if ext_ref.get_comment(): ElementTree.SubElement(external_reference_e, 'comment').text = ext_ref.get_comment() - if len(ext_ref.get_hashes()) > 0: + if self.external_references_supports_hashes() and len(ext_ref.get_hashes()) > 0: Xml._add_hashes_to_element(hashes=ext_ref.get_hashes(), element=external_reference_e) # releaseNotes @@ -476,8 +476,8 @@ def _get_vulnerability_as_xml_element_pre_1_3(self, bom_ref: str, return vulnerability_element - @staticmethod - def _add_external_references_to_element(ext_refs: List[ExternalReference], element: ElementTree.Element) -> None: + def _add_external_references_to_element(self, ext_refs: List[ExternalReference], + element: ElementTree.Element) -> None: ext_refs_element = ElementTree.SubElement(element, 'externalReferences') for external_reference in ext_refs: ext_ref_element = ElementTree.SubElement( @@ -486,7 +486,7 @@ def _add_external_references_to_element(ext_refs: List[ExternalReference], eleme ElementTree.SubElement(ext_ref_element, 'url').text = external_reference.get_url() if external_reference.get_comment(): ElementTree.SubElement(ext_ref_element, 'comment').text = external_reference.get_comment() - if external_reference.get_hashes(): + if self.external_references_supports_hashes() and external_reference.get_hashes(): Xml._add_hashes_to_element(hashes=external_reference.get_hashes(), element=ext_ref_element) @staticmethod @@ -519,7 +519,7 @@ def _add_tool(self, parent_element: ElementTree.Element, tool: Tool, tag_name: s if tool.get_hashes(): Xml._add_hashes_to_element(hashes=tool.get_hashes(), element=tool_element) if self.bom_metadata_supports_tools_external_references() and tool.get_external_references(): - Xml._add_external_references_to_element( + self._add_external_references_to_element( ext_refs=tool.get_external_references(), element=tool_element ) From 1f55f3edfeacfc515ef0b5e493c27dd6e14861d6 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 27 Jan 2022 14:55:27 +0000 Subject: [PATCH 06/29] fix: further fix for #150 Signed-off-by: Paul Horton --- cyclonedx/output/json.py | 3 +++ cyclonedx/output/serializer/json.py | 1 + tests/fixtures/json/1.3/bom_setuptools_no_version.json | 6 +++--- tests/test_component.py | 8 +++++--- tests/test_output_generic.py | 6 ++---- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py index b1f937e6..7eab33de 100644 --- a/cyclonedx/output/json.py +++ b/cyclonedx/output/json.py @@ -89,6 +89,9 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str # Iterate Components if 'components' in bom_json.keys(): for i in range(len(bom_json['components'])): + if 'version' not in bom_json['components'][i].keys() and not self.component_version_optional(): + bom_json['components'][i]['version'] = '' + if not self.component_supports_author() and 'author' in bom_json['components'][i].keys(): del bom_json['components'][i]['author'] diff --git a/cyclonedx/output/serializer/json.py b/cyclonedx/output/serializer/json.py index 5d0398c6..eefb2bf6 100644 --- a/cyclonedx/output/serializer/json.py +++ b/cyclonedx/output/serializer/json.py @@ -29,6 +29,7 @@ from packageurl import PackageURL # type: ignore from cyclonedx.model import XsUri +from cyclonedx.model.component import Component HYPHENATED_ATTRIBUTES = [ 'bom_ref', 'mime_type', 'x_trust_boundary' diff --git a/tests/fixtures/json/1.3/bom_setuptools_no_version.json b/tests/fixtures/json/1.3/bom_setuptools_no_version.json index 4de582cc..971a6462 100644 --- a/tests/fixtures/json/1.3/bom_setuptools_no_version.json +++ b/tests/fixtures/json/1.3/bom_setuptools_no_version.json @@ -17,16 +17,16 @@ "components": [ { "type": "library", - "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "bom-ref": "pkg:pypi/setuptools?extension=tar.gz", "author": "Test Author", "name": "setuptools", - "version": "50.3.2", + "version": "", "licenses": [ { "expression": "MIT License" } ], - "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + "purl": "pkg:pypi/setuptools?extension=tar.gz" } ] } \ No newline at end of file diff --git a/tests/test_component.py b/tests/test_component.py index c4072bd8..c5c6056f 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -24,6 +24,8 @@ from cyclonedx.model.bom import Bom from cyclonedx.model.component import Component +FIXTURES_DIRECTORY = 'fixtures/xml/1.4' + class TestComponent(TestCase): @@ -94,12 +96,12 @@ def test_purl_with_qualifiers(self) -> None: self.assertEqual(purl.qualifiers, {'extension': 'tar.gz'}) def test_from_file_with_path_for_bom(self) -> None: - test_file = join(dirname(__file__), 'fixtures/bom_setuptools.xml') + test_file = join(dirname(__file__), FIXTURES_DIRECTORY, 'bom_setuptools.xml') c = Component.for_file(absolute_file_path=test_file, path_for_bom='fixtures/bom_setuptools.xml') self.assertEqual(c.name, 'fixtures/bom_setuptools.xml') - self.assertEqual(c.version, '0.0.0-16932e52ed1e') + self.assertEqual(c.version, '0.0.0-38165abddb68') purl = PackageURL( - type='generic', name='fixtures/bom_setuptools.xml', version='0.0.0-16932e52ed1e' + type='generic', name='fixtures/bom_setuptools.xml', version='0.0.0-38165abddb68' ) self.assertEqual(c.purl, purl) self.assertEqual(len(c.hashes), 1) diff --git a/tests/test_output_generic.py b/tests/test_output_generic.py index dc293d7c..ee8c58da 100644 --- a/tests/test_output_generic.py +++ b/tests/test_output_generic.py @@ -19,7 +19,6 @@ from unittest import TestCase -from cyclonedx.exception.output import ComponentVersionRequiredException from cyclonedx.model.bom import Bom from cyclonedx.model.component import Component from cyclonedx.output import get_instance, OutputFormat, SchemaVersion @@ -46,6 +45,5 @@ def test_get_instance_xml_v1_3(self) -> None: self.assertIsInstance(i, XmlV1Dot3) def test_component_no_version_v1_3(self) -> None: - with self.assertRaises(ComponentVersionRequiredException): - outputter: Xml = get_instance(bom=TestOutputGeneric._bom, schema_version=SchemaVersion.V1_3) - outputter.output_as_string() + i = get_instance(bom=TestOutputGeneric._bom, schema_version=SchemaVersion.V1_3) + self.assertIsInstance(i, XmlV1Dot3) From a35d540c97b898eb152f453003f46ce0e18b7ea6 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 27 Jan 2022 15:42:45 +0000 Subject: [PATCH 07/29] removed unused imports Signed-off-by: Paul Horton --- cyclonedx/output/serializer/json.py | 1 - tests/test_output_generic.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cyclonedx/output/serializer/json.py b/cyclonedx/output/serializer/json.py index eefb2bf6..5d0398c6 100644 --- a/cyclonedx/output/serializer/json.py +++ b/cyclonedx/output/serializer/json.py @@ -29,7 +29,6 @@ from packageurl import PackageURL # type: ignore from cyclonedx.model import XsUri -from cyclonedx.model.component import Component HYPHENATED_ATTRIBUTES = [ 'bom_ref', 'mime_type', 'x_trust_boundary' diff --git a/tests/test_output_generic.py b/tests/test_output_generic.py index ee8c58da..22c4896c 100644 --- a/tests/test_output_generic.py +++ b/tests/test_output_generic.py @@ -22,7 +22,7 @@ from cyclonedx.model.bom import Bom from cyclonedx.model.component import Component from cyclonedx.output import get_instance, OutputFormat, SchemaVersion -from cyclonedx.output.xml import XmlV1Dot3, Xml +from cyclonedx.output.xml import XmlV1Dot3 class TestOutputGeneric(TestCase): From 9edf6c940d20a44f5b99c557392a9fa4532b332e Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Mon, 31 Jan 2022 13:07:21 +0000 Subject: [PATCH 08/29] feat: support services in XML BOMs feat: support nested services in JSON and XML BOMs Signed-off-by: Paul Horton --- cyclonedx/model/service.py | 54 +++- cyclonedx/output/xml.py | 252 +++++++++++++----- tests/data.py | 50 ++++ ...m_v1.3_setuptools_with_vulnerabilities.xml | 55 ---- ...oml_with_component_external_references.xml | 32 --- .../bom_v1.3_toml_with_component_hashes.xml | 23 -- .../bom_v1.3_toml_with_component_license.xml | 23 -- .../json/1.2/bom_services_nested.json | 145 ++++++++++ .../json/1.3/bom_services_nested.json | 155 +++++++++++ .../json/1.4/bom_services_nested.json | 248 +++++++++++++++++ .../fixtures/xml/1.2/bom_services_complex.xml | 60 +++++ .../fixtures/xml/1.2/bom_services_nested.xml | 108 ++++++++ .../fixtures/xml/1.2/bom_services_simple.xml | 26 ++ .../fixtures/xml/1.3/bom_services_complex.xml | 67 +++++ .../fixtures/xml/1.3/bom_services_nested.xml | 115 ++++++++ .../1.3/bom_services_simple.xml} | 12 +- .../1.4/bom_services_complex.xml} | 61 ++++- .../fixtures/xml/1.4/bom_services_nested.xml | 185 +++++++++++++ .../fixtures/xml/1.4/bom_services_simple.xml | 52 ++++ tests/test_model_service.py | 26 ++ tests/test_output_json.py | 32 ++- tests/test_output_xml.py | 138 +++++++++- 22 files changed, 1699 insertions(+), 220 deletions(-) delete mode 100644 tests/fixtures/bom_v1.3_setuptools_with_vulnerabilities.xml delete mode 100644 tests/fixtures/bom_v1.3_toml_with_component_external_references.xml delete mode 100644 tests/fixtures/bom_v1.3_toml_with_component_hashes.xml delete mode 100644 tests/fixtures/bom_v1.3_toml_with_component_license.xml create mode 100644 tests/fixtures/json/1.2/bom_services_nested.json create mode 100644 tests/fixtures/json/1.3/bom_services_nested.json create mode 100644 tests/fixtures/json/1.4/bom_services_nested.json create mode 100644 tests/fixtures/xml/1.2/bom_services_complex.xml create mode 100644 tests/fixtures/xml/1.2/bom_services_nested.xml create mode 100644 tests/fixtures/xml/1.2/bom_services_simple.xml create mode 100644 tests/fixtures/xml/1.3/bom_services_complex.xml create mode 100644 tests/fixtures/xml/1.3/bom_services_nested.xml rename tests/fixtures/{bom_v1.3_with_metadata_component.xml => xml/1.3/bom_services_simple.xml} (57%) rename tests/fixtures/{bom_v1.4_setuptools_with_release_notes.xml => xml/1.4/bom_services_complex.xml} (65%) create mode 100644 tests/fixtures/xml/1.4/bom_services_nested.xml create mode 100644 tests/fixtures/xml/1.4/bom_services_simple.xml diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py index 674d0761..752b3452 100644 --- a/cyclonedx/model/service.py +++ b/cyclonedx/model/service.py @@ -43,8 +43,7 @@ def __init__(self, name: str, bom_ref: Optional[str] = None, provider: Optional[ licenses: Optional[List[LicenseChoice]] = None, external_references: Optional[List[ExternalReference]] = None, properties: Optional[List[Property]] = None, - # services: Optional[List[Service]] = None, -- I have no clue how to do this, - # commenting out so someone else can + services: Optional[List['Service']] = None, release_notes: Optional[ReleaseNotes] = None, ) -> None: self.bom_ref = bom_ref or str(uuid4()) @@ -59,7 +58,7 @@ def __init__(self, name: str, bom_ref: Optional[str] = None, provider: Optional[ self.data = data self.licenses = licenses or [] self.external_references = external_references or [] - # self.services = services -- no clue + self.services = services self.release_notes = release_notes self.properties = properties @@ -264,6 +263,40 @@ def add_external_reference(self, reference: ExternalReference) -> None: """ self.external_references = self._external_references + [reference] + @property + def services(self) -> Optional[List['Service']]: + """ + A list of services included or deployed behind the parent service. + + This is not a dependency tree. + + It provides a way to specify a hierarchical representation of service assemblies. + + Returns: + List of `Service`s or `None` + """ + return self._services + + @services.setter + def services(self, services: Optional[List['Service']]) -> None: + self._services = services + + def has_service(self, service: 'Service') -> bool: + """ + Check whether this Service contains the given Service. + + Args: + service: + The instance of `cyclonedx.model.service.Service` to check if this Service contains. + + Returns: + `bool` - `True` if the supplied Service is part of this Service, `False` otherwise. + """ + if not self.services: + return False + + return service in self.services + @property def release_notes(self) -> Optional[ReleaseNotes]: """ @@ -292,3 +325,18 @@ def properties(self) -> Optional[List[Property]]: @properties.setter def properties(self, properties: Optional[List[Property]]) -> None: self._properties = properties + + def __eq__(self, other: object) -> bool: + if isinstance(other, Service): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.authenticated, self.data, self.description, str(self.endpoints), + str(self.external_references), self.group, str(self.licenses), self.name, self.properties, self.provider, + self.release_notes, str(self.services), self.version, self.x_trust_boundary + )) + + def __repr__(self) -> str: + return f'' diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index a29a1c04..3c13c86f 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -24,9 +24,11 @@ from . import BaseOutput, SchemaVersion from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3, \ SchemaVersion1Dot4 -from ..model import ExternalReference, HashType, OrganizationalEntity, OrganizationalContact, Tool +from ..model import ExternalReference, HashType, OrganizationalEntity, OrganizationalContact, Property, Tool from ..model.bom import Bom from ..model.component import Component +from ..model.release_note import ReleaseNotes +from ..model.service import Service from ..model.vulnerability import Vulnerability, VulnerabilityRating, VulnerabilitySource, BomTargetVersionRange @@ -75,13 +77,19 @@ def generate(self, force_regeneration: bool = False) -> None: elif component.has_vulnerabilities(): has_vulnerabilities = True - if self.bom_supports_vulnerabilities() and has_vulnerabilities: - vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities') - for component in cast(List[Component], self.get_bom().components): - for vulnerability in component.get_vulnerabilities(): - vulnerabilities_element.append( - self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability) - ) + if self.bom_supports_services(): + if self.get_bom().services: + services_element = ElementTree.SubElement(self._root_bom_element, 'services') + for service in cast(List[Service], self.get_bom().services): + services_element.append(self._add_service_element(service=service)) + + if self.bom_supports_vulnerabilities() and has_vulnerabilities: + vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities') + for component in cast(List[Component], self.get_bom().components): + for vulnerability in component.get_vulnerabilities(): + vulnerabilities_element.append( + self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability) + ) self.generated = True @@ -213,74 +221,172 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: # releaseNotes if self.component_supports_release_notes() and component.release_notes: - release_notes_e = ElementTree.SubElement(component_element, 'releaseNotes') - release_notes = component.release_notes - - ElementTree.SubElement(release_notes_e, 'type').text = release_notes.type - if release_notes.title: - ElementTree.SubElement(release_notes_e, 'title').text = release_notes.title - if release_notes.featured_image: - ElementTree.SubElement(release_notes_e, - 'featuredImage').text = str(release_notes.featured_image) - if release_notes.social_image: - ElementTree.SubElement(release_notes_e, - 'socialImage').text = str(release_notes.social_image) - if release_notes.description: - ElementTree.SubElement(release_notes_e, - 'description').text = release_notes.description - if release_notes.timestamp: - ElementTree.SubElement(release_notes_e, 'timestamp').text = release_notes.timestamp.isoformat() - if release_notes.aliases: - release_notes_aliases_e = ElementTree.SubElement(release_notes_e, 'aliases') - for alias in release_notes.aliases: - ElementTree.SubElement(release_notes_aliases_e, 'alias').text = alias - if release_notes.tags: - release_notes_tags_e = ElementTree.SubElement(release_notes_e, 'tags') - for tag in release_notes.tags: - ElementTree.SubElement(release_notes_tags_e, 'tag').text = tag - if release_notes.resolves: - release_notes_resolves_e = ElementTree.SubElement(release_notes_e, 'resolves') - for issue in release_notes.resolves: - issue_e = ElementTree.SubElement( - release_notes_resolves_e, 'issue', {'type': issue.get_classification().value} - ) - if issue.get_id(): - ElementTree.SubElement(issue_e, 'id').text = issue.get_id() - if issue.get_name(): - ElementTree.SubElement(issue_e, 'name').text = issue.get_name() - if issue.get_description(): - ElementTree.SubElement(issue_e, 'description').text = issue.get_description() - if issue.source: - issue_source_e = ElementTree.SubElement(issue_e, 'source') - if issue.source.name: - ElementTree.SubElement(issue_source_e, 'name').text = issue.source.name - if issue.source.url: - ElementTree.SubElement(issue_source_e, 'url').text = str(issue.source.url) - if issue.get_references(): - issue_references_e = ElementTree.SubElement(issue_e, 'references') - for reference in issue.get_references(): - ElementTree.SubElement(issue_references_e, 'url').text = str(reference) - if release_notes.notes: - release_notes_notes_e = ElementTree.SubElement(release_notes_e, 'notes') - for note in release_notes.notes: - note_e = ElementTree.SubElement(release_notes_notes_e, 'note') - if note.locale: - ElementTree.SubElement(note_e, 'locale').text = note.locale - text_attrs = {} - if note.text.content_type: - text_attrs['content-type'] = note.text.content_type - if note.text.encoding: - text_attrs['encoding'] = note.text.encoding.value - ElementTree.SubElement(note_e, 'text', text_attrs).text = note.text.content - if release_notes.properties: - release_notes_properties_e = ElementTree.SubElement(release_notes_e, 'properties') - for prop in release_notes.properties: - ElementTree.SubElement( - release_notes_properties_e, 'property', {'name': prop.get_name()} - ).text = prop.get_value() + Xml._add_release_notes_element(release_notes=component.release_notes, parent_element=component_element) return component_element + @staticmethod + def _add_release_notes_element(release_notes: ReleaseNotes, parent_element: ElementTree.Element) -> None: + release_notes_e = ElementTree.SubElement(parent_element, 'releaseNotes') + + ElementTree.SubElement(release_notes_e, 'type').text = release_notes.type + if release_notes.title: + ElementTree.SubElement(release_notes_e, 'title').text = release_notes.title + if release_notes.featured_image: + ElementTree.SubElement(release_notes_e, + 'featuredImage').text = str(release_notes.featured_image) + if release_notes.social_image: + ElementTree.SubElement(release_notes_e, + 'socialImage').text = str(release_notes.social_image) + if release_notes.description: + ElementTree.SubElement(release_notes_e, + 'description').text = release_notes.description + if release_notes.timestamp: + ElementTree.SubElement(release_notes_e, 'timestamp').text = release_notes.timestamp.isoformat() + if release_notes.aliases: + release_notes_aliases_e = ElementTree.SubElement(release_notes_e, 'aliases') + for alias in release_notes.aliases: + ElementTree.SubElement(release_notes_aliases_e, 'alias').text = alias + if release_notes.tags: + release_notes_tags_e = ElementTree.SubElement(release_notes_e, 'tags') + for tag in release_notes.tags: + ElementTree.SubElement(release_notes_tags_e, 'tag').text = tag + if release_notes.resolves: + release_notes_resolves_e = ElementTree.SubElement(release_notes_e, 'resolves') + for issue in release_notes.resolves: + issue_e = ElementTree.SubElement( + release_notes_resolves_e, 'issue', {'type': issue.get_classification().value} + ) + if issue.get_id(): + ElementTree.SubElement(issue_e, 'id').text = issue.get_id() + if issue.get_name(): + ElementTree.SubElement(issue_e, 'name').text = issue.get_name() + if issue.get_description(): + ElementTree.SubElement(issue_e, 'description').text = issue.get_description() + if issue.source: + issue_source_e = ElementTree.SubElement(issue_e, 'source') + if issue.source.name: + ElementTree.SubElement(issue_source_e, 'name').text = issue.source.name + if issue.source.url: + ElementTree.SubElement(issue_source_e, 'url').text = str(issue.source.url) + if issue.get_references(): + issue_references_e = ElementTree.SubElement(issue_e, 'references') + for reference in issue.get_references(): + ElementTree.SubElement(issue_references_e, 'url').text = str(reference) + if release_notes.notes: + release_notes_notes_e = ElementTree.SubElement(release_notes_e, 'notes') + for note in release_notes.notes: + note_e = ElementTree.SubElement(release_notes_notes_e, 'note') + if note.locale: + ElementTree.SubElement(note_e, 'locale').text = note.locale + text_attrs = {} + if note.text.content_type: + text_attrs['content-type'] = note.text.content_type + if note.text.encoding: + text_attrs['encoding'] = note.text.encoding.value + ElementTree.SubElement(note_e, 'text', text_attrs).text = note.text.content + if release_notes.properties: + Xml._add_properties_element(properties=release_notes.properties, parent_element=release_notes_e) + + @staticmethod + def _add_properties_element(properties: List[Property], parent_element: ElementTree.Element) -> None: + properties_e = ElementTree.SubElement(parent_element, 'properties') + for property in properties: + ElementTree.SubElement( + properties_e, 'property', {'name': property.get_name()} + ).text = property.get_value() + + def _add_service_element(self, service: Service) -> ElementTree.Element: + element_attributes = {} + if service.bom_ref: + element_attributes['bom-ref'] = service.bom_ref + + service_element = ElementTree.Element('service', element_attributes) + + # provider + if service.provider: + self._add_organizational_entity( + parent_element=service_element, organization=service.provider, tag_name='provider' + ) + + # group + if service.group: + ElementTree.SubElement(service_element, 'group').text = service.group + + # name + ElementTree.SubElement(service_element, 'name').text = service.name + + # version + if service.version: + ElementTree.SubElement(service_element, 'version').text = service.version + + # description + if service.description: + ElementTree.SubElement(service_element, 'description').text = service.description + + # endpoints + if service.endpoints: + endpoints_e = ElementTree.SubElement(service_element, 'endpoints') + for endpoint in service.endpoints: + ElementTree.SubElement(endpoints_e, 'endpoint').text = str(endpoint) + + # authenticated + if isinstance(service.authenticated, bool): + ElementTree.SubElement(service_element, 'authenticated').text = str(service.authenticated).lower() + + # x-trust-boundary + if isinstance(service.x_trust_boundary, bool): + ElementTree.SubElement(service_element, 'x-trust-boundary').text = str(service.x_trust_boundary).lower() + + # data + if service.data: + data_e = ElementTree.SubElement(service_element, 'data') + for data in service.data: + ElementTree.SubElement(data_e, 'classification', {'flow': data.flow.value}).text = data.classification + + # licenses + if service.licenses: + licenses_e = ElementTree.SubElement(service_element, 'licenses') + for license in service.licenses: + if license.license: + license_e = ElementTree.SubElement(licenses_e, 'license') + if license.license.id: + ElementTree.SubElement(license_e, 'id').text = license.license.id + elif license.license.name: + ElementTree.SubElement(license_e, 'name').text = license.license.name + if license.license.text: + license_text_e_attrs = {} + if license.license.text.content_type: + license_text_e_attrs['content-type'] = license.license.text.content_type + if license.license.text.encoding: + license_text_e_attrs['encoding'] = license.license.text.encoding.value + ElementTree.SubElement(license_e, 'text', + license_text_e_attrs).text = license.license.text.content + + ElementTree.SubElement(license_e, 'text').text = license.license.id + else: + ElementTree.SubElement(licenses_e, 'expression').text = license.expression + + # externalReferences + if service.external_references: + self._add_external_references_to_element(ext_refs=service.external_references, element=service_element) + + # properties + if service.properties and self.services_supports_properties(): + Xml._add_properties_element(properties=service.properties, parent_element=service_element) + + # services + if service.services: + services_element = ElementTree.SubElement(service_element, 'services') + for sub_service in service.services: + services_element.append(self._add_service_element(service=sub_service)) + + # releaseNotes + if service.release_notes and self.services_supports_release_notes(): + Xml._add_release_notes_element(release_notes=service.release_notes, parent_element=service_element) + + return service_element + def _get_vulnerability_as_xml_element_post_1_4(self, vulnerability: Vulnerability) -> ElementTree.Element: vulnerability_element = ElementTree.Element( 'vulnerability', diff --git a/tests/data.py b/tests/data.py index e7bd987a..0ab2c262 100644 --- a/tests/data.py +++ b/tests/data.py @@ -226,6 +226,56 @@ def get_bom_with_services_complex() -> Bom: return bom +def get_bom_with_nested_services() -> Bom: + bom = Bom(services=[ + Service( + name='my-first-service', bom_ref='my-specific-bom-ref-for-my-first-service', + provider=get_org_entity_1(), group='a-group', version='1.2.3', + description='Description goes here', endpoints=[ + XsUri('/api/thing/1'), + XsUri('/api/thing/2') + ], + authenticated=False, x_trust_boundary=True, data=[ + DataClassification(flow=DataFlow.OUTBOUND, classification='public') + ], + licenses=[ + LicenseChoice(license_expression='Commercial') + ], + external_references=[ + get_external_reference_1() + ], + properties=get_properties_1(), + services=[ + Service( + name='first-nested-service' + ), + Service( + name='second-nested-service', bom_ref='my-specific-bom-ref-for-second-nested-service', + provider=get_org_entity_1(), group='no-group', version='3.2.1', + authenticated=True, x_trust_boundary=False, + ) + ], + release_notes=get_release_notes() + ), + Service( + name='my-second-service', + services=[ + Service( + name='yet-another-nested-service', provider=get_org_entity_1(), group='what-group', version='6.5.4' + ), + Service( + name='another-nested-service', + bom_ref='my-specific-bom-ref-for-another-nested-service', + ) + ], + ) + ]) + bom.metadata.component = Component( + name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY + ) + return bom + + def get_external_reference_1() -> ExternalReference: return ExternalReference( reference_type=ExternalReferenceType.DISTRIBUTION, diff --git a/tests/fixtures/bom_v1.3_setuptools_with_vulnerabilities.xml b/tests/fixtures/bom_v1.3_setuptools_with_vulnerabilities.xml deleted file mode 100644 index 6978c8af..00000000 --- a/tests/fixtures/bom_v1.3_setuptools_with_vulnerabilities.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - 2021-09-01T10:50:42.051979+00:00 - - - CycloneDX - cyclonedx-python-lib - VERSION - - - - - - setuptools - 50.3.2 - pkg:pypi/setuptools@50.3.2?extension=tar.gz - - - CVE-2018-7489 - - https://nvd.nist.gov/vuln/detail/CVE-2018-7489 - - - - - 9.8 - - Critical - CVSSv3 - AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H - - - Low - OWASP Risk - K9:M1:O0:Z2/D1:X1:W1:L3/C2:I1:A1:T1/F1:R1:S2:P3/50 - - - - 123 - 456 - - A description here - - Upgrade - - - http://www.securityfocus.com/bid/103203 - http://www.securitytracker.com/id/1040693 - - - - - - \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_toml_with_component_external_references.xml b/tests/fixtures/bom_v1.3_toml_with_component_external_references.xml deleted file mode 100644 index a6f8af4e..00000000 --- a/tests/fixtures/bom_v1.3_toml_with_component_external_references.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - 2021-09-01T10:50:42.051979+00:00 - - - CycloneDX - cyclonedx-python-lib - VERSION - - - - - - toml - 0.10.2 - - 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b - - pkg:pypi/toml@0.10.2?extension=tar.gz - - - https://cyclonedx.org - No comment - - 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b - - - - - - \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_toml_with_component_hashes.xml b/tests/fixtures/bom_v1.3_toml_with_component_hashes.xml deleted file mode 100644 index 5843c1ef..00000000 --- a/tests/fixtures/bom_v1.3_toml_with_component_hashes.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - 2021-09-01T10:50:42.051979+00:00 - - - CycloneDX - cyclonedx-python-lib - VERSION - - - - - - toml - 0.10.2 - - 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b - - pkg:pypi/toml@0.10.2?extension=tar.gz - - - \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_toml_with_component_license.xml b/tests/fixtures/bom_v1.3_toml_with_component_license.xml deleted file mode 100644 index 1e16f66b..00000000 --- a/tests/fixtures/bom_v1.3_toml_with_component_license.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - 2021-09-01T10:50:42.051979+00:00 - - - CycloneDX - cyclonedx-python-lib - VERSION - - - - - - toml - 0.10.2 - - MIT License - - pkg:pypi/toml@0.10.2?extension=tar.gz - - - \ No newline at end of file diff --git a/tests/fixtures/json/1.2/bom_services_nested.json b/tests/fixtures/json/1.2/bom_services_nested.json new file mode 100644 index 00000000..22032681 --- /dev/null +++ b/tests/fixtures/json/1.2/bom_services_nested.json @@ -0,0 +1,145 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", + "bomFormat": "CycloneDX", + "components": [ + ], + "metadata": { + "component": { + "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", + "name": "cyclonedx-python-lib", + "type": "library", + "version": "1.0.0" + }, + "timestamp": "2022-01-27T16:16:35.622354+00:00", + "tools": [ + { + "name": "cyclonedx-python-lib", + "vendor": "CycloneDX", + "version": "0.11.0" + } + ] + }, + "serialNumber": "urn:uuid:1d2c4529-8cf8-447d-b2a1-e4ebb610adb9", + "services": [ + { + "authenticated": false, + "bom-ref": "my-specific-bom-ref-for-my-first-service", + "data": [ + { + "classification": "public", + "flow": "outbound" + } + ], + "description": "Description goes here", + "endpoints": [ + "/api/thing/1", + "/api/thing/2" + ], + "externalReferences": [ + { + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "type": "distribution", + "url": "https://cyclonedx.org" + } + ], + "group": "a-group", + "licenses": [ + { + "expression": "Commercial" + } + ], + "name": "my-first-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "services": [ + { + "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "name": "first-nested-service" + }, + { + "authenticated": true, + "bom-ref": "my-specific-bom-ref-for-second-nested-service", + "group": "no-group", + "name": "second-nested-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "version": "3.2.1", + "x-trust-boundary": false + } + ], + "version": "1.2.3", + "x-trust-boundary": true + }, + { + "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "name": "my-second-service", + "services": [ + { + "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "group": "what-group", + "name": "yet-another-nested-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "version": "6.5.4" + }, + { + "bom-ref": "my-specific-bom-ref-for-another-nested-service", + "name": "another-nested-service" + } + ] + } + ], + "specVersion": "1.2", + "version": 1 +} diff --git a/tests/fixtures/json/1.3/bom_services_nested.json b/tests/fixtures/json/1.3/bom_services_nested.json new file mode 100644 index 00000000..106b5255 --- /dev/null +++ b/tests/fixtures/json/1.3/bom_services_nested.json @@ -0,0 +1,155 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", + "bomFormat": "CycloneDX", + "components": [ + ], + "metadata": { + "component": { + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "name": "cyclonedx-python-lib", + "type": "library", + "version": "1.0.0" + }, + "timestamp": "2022-01-27T16:16:35.622354+00:00", + "tools": [ + { + "name": "cyclonedx-python-lib", + "vendor": "CycloneDX", + "version": "0.11.0" + } + ] + }, + "serialNumber": "urn:uuid:1d2c4529-8cf8-447d-b2a1-e4ebb610adb9", + "services": [ + { + "authenticated": false, + "bom-ref": "my-specific-bom-ref-for-my-first-service", + "data": [ + { + "classification": "public", + "flow": "outbound" + } + ], + "description": "Description goes here", + "endpoints": [ + "/api/thing/1", + "/api/thing/2" + ], + "externalReferences": [ + { + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "type": "distribution", + "url": "https://cyclonedx.org" + } + ], + "group": "a-group", + "licenses": [ + { + "expression": "Commercial" + } + ], + "name": "my-first-service", + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "services": [ + { + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "name": "first-nested-service" + }, + { + "authenticated": true, + "bom-ref": "my-specific-bom-ref-for-second-nested-service", + "group": "no-group", + "name": "second-nested-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "version": "3.2.1", + "x-trust-boundary": false + } + ], + "version": "1.2.3", + "x-trust-boundary": true + }, + { + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "name": "my-second-service", + "services": [ + { + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "group": "what-group", + "name": "yet-another-nested-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "version": "6.5.4" + }, + { + "bom-ref": "my-specific-bom-ref-for-another-nested-service", + "name": "another-nested-service" + } + ] + } + ], + "specVersion": "1.3", + "version": 1 +} diff --git a/tests/fixtures/json/1.4/bom_services_nested.json b/tests/fixtures/json/1.4/bom_services_nested.json new file mode 100644 index 00000000..20c7ee12 --- /dev/null +++ b/tests/fixtures/json/1.4/bom_services_nested.json @@ -0,0 +1,248 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "components": [ + ], + "metadata": { + "component": { + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", + "name": "cyclonedx-python-lib", + "type": "library", + "version": "1.0.0" + }, + "timestamp": "2022-01-27T16:16:35.622354+00:00", + "tools": [ + { + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-python-lib/" + }, + { + "type": "documentation", + "url": "https://cyclonedx.github.io/cyclonedx-python-lib/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib" + }, + { + "type": "website", + "url": "https://cyclonedx.org" + } + ], + "name": "cyclonedx-python-lib", + "vendor": "CycloneDX", + "version": "0.11.0" + } + ] + }, + "serialNumber": "urn:uuid:1d2c4529-8cf8-447d-b2a1-e4ebb610adb9", + "services": [ + { + "authenticated": false, + "bom-ref": "my-specific-bom-ref-for-my-first-service", + "data": [ + { + "classification": "public", + "flow": "outbound" + } + ], + "description": "Description goes here", + "endpoints": [ + "/api/thing/1", + "/api/thing/2" + ], + "externalReferences": [ + { + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "type": "distribution", + "url": "https://cyclonedx.org" + } + ], + "group": "a-group", + "licenses": [ + { + "expression": "Commercial" + } + ], + "name": "my-first-service", + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "releaseNotes": { + "aliases": [ + "First Test Release" + ], + "description": "This release is a test release", + "featuredImage": "https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png", + "notes": [ + { + "locale": "en-GB", + "text": { + "content": "U29tZSBzaW1wbGUgcGxhaW4gdGV4dA==", + "contentType": "text/plain; charset=UTF-8", + "encoding": "base64" + } + }, + { + "locale": "en-US", + "text": { + "content": "U29tZSBzaW1wbGUgcGxhaW4gdGV4dA==", + "contentType": "text/plain; charset=UTF-8", + "encoding": "base64" + } + } + ], + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "resolves": [ + { + "description": "Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...", + "id": "CVE-2021-44228", + "name": "Apache Log3Shell", + "references": [ + "https://logging.apache.org/log4j/2.x/security.html", + "https://central.sonatype.org/news/20211213_log4shell_help" + ], + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-44228" + }, + "type": "security" + } + ], + "socialImage": "https://cyclonedx.org/cyclonedx-icon.png", + "tags": [ + "test", + "alpha" + ], + "timestamp": "2021-12-31T10:00:00+00:00", + "title": "Release Notes Title", + "type": "major" + }, + "services": [ + { + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "name": "first-nested-service" + }, + { + "authenticated": true, + "bom-ref": "my-specific-bom-ref-for-second-nested-service", + "group": "no-group", + "name": "second-nested-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "version": "3.2.1", + "x-trust-boundary": false + } + ], + "version": "1.2.3", + "x-trust-boundary": true + }, + { + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "name": "my-second-service", + "services": [ + { + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "group": "what-group", + "name": "yet-another-nested-service", + "provider": { + "contact": [ + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + }, + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ] + }, + "version": "6.5.4" + }, + { + "bom-ref": "my-specific-bom-ref-for-another-nested-service", + "name": "another-nested-service" + } + ] + } + ], + "specVersion": "1.4", + "version": 1 +} diff --git a/tests/fixtures/xml/1.2/bom_services_complex.xml b/tests/fixtures/xml/1.2/bom_services_complex.xml new file mode 100644 index 00000000..f330ee7a --- /dev/null +++ b/tests/fixtures/xml/1.2/bom_services_complex.xml @@ -0,0 +1,60 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + cyclonedx-python-lib + 1.0.0 + + + + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + a-group + my-first-service + 1.2.3 + Description goes here + + /api/thing/1 + /api/thing/2 + + false + true + + public + + + Commercial + + + + https://cyclonedx.org + No comment + + + + + my-second-service + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.2/bom_services_nested.xml b/tests/fixtures/xml/1.2/bom_services_nested.xml new file mode 100644 index 00000000..60439b6d --- /dev/null +++ b/tests/fixtures/xml/1.2/bom_services_nested.xml @@ -0,0 +1,108 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + cyclonedx-python-lib + 1.0.0 + + + + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + a-group + my-first-service + 1.2.3 + Description goes here + + /api/thing/1 + /api/thing/2 + + false + true + + public + + + Commercial + + + + https://cyclonedx.org + No comment + + + + + first-nested-service + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + no-group + second-nested-service + 3.2.1 + true + false + + + + + my-second-service + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + what-group + yet-another-nested-service + 6.5.4 + + + another-nested-service + + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.2/bom_services_simple.xml b/tests/fixtures/xml/1.2/bom_services_simple.xml new file mode 100644 index 00000000..e02ea1b7 --- /dev/null +++ b/tests/fixtures/xml/1.2/bom_services_simple.xml @@ -0,0 +1,26 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + cyclonedx-python-lib + 1.0.0 + + + + + + my-first-service + + + my-second-service + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.3/bom_services_complex.xml b/tests/fixtures/xml/1.3/bom_services_complex.xml new file mode 100644 index 00000000..56e2cce9 --- /dev/null +++ b/tests/fixtures/xml/1.3/bom_services_complex.xml @@ -0,0 +1,67 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + cyclonedx-python-lib + 1.0.0 + + + + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + a-group + my-first-service + 1.2.3 + Description goes here + + /api/thing/1 + /api/thing/2 + + false + true + + public + + + Commercial + + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + val1 + val2 + + + + my-second-service + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.3/bom_services_nested.xml b/tests/fixtures/xml/1.3/bom_services_nested.xml new file mode 100644 index 00000000..df05c422 --- /dev/null +++ b/tests/fixtures/xml/1.3/bom_services_nested.xml @@ -0,0 +1,115 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + cyclonedx-python-lib + 1.0.0 + + + + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + a-group + my-first-service + 1.2.3 + Description goes here + + /api/thing/1 + /api/thing/2 + + false + true + + public + + + Commercial + + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + val1 + val2 + + + + first-nested-service + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + no-group + second-nested-service + 3.2.1 + true + false + + + + + my-second-service + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + what-group + yet-another-nested-service + 6.5.4 + + + another-nested-service + + + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_with_metadata_component.xml b/tests/fixtures/xml/1.3/bom_services_simple.xml similarity index 57% rename from tests/fixtures/bom_v1.3_with_metadata_component.xml rename to tests/fixtures/xml/1.3/bom_services_simple.xml index 1bbe3362..c20a7c55 100644 --- a/tests/fixtures/bom_v1.3_with_metadata_component.xml +++ b/tests/fixtures/xml/1.3/bom_services_simple.xml @@ -9,10 +9,18 @@ VERSION - + cyclonedx-python-lib 1.0.0 - + + + + my-first-service + + + my-second-service + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_setuptools_with_release_notes.xml b/tests/fixtures/xml/1.4/bom_services_complex.xml similarity index 65% rename from tests/fixtures/bom_v1.4_setuptools_with_release_notes.xml rename to tests/fixtures/xml/1.4/bom_services_complex.xml index 8afcbf59..e8eb750c 100644 --- a/tests/fixtures/bom_v1.4_setuptools_with_release_notes.xml +++ b/tests/fixtures/xml/1.4/bom_services_complex.xml @@ -35,12 +35,56 @@ + + cyclonedx-python-lib + 1.0.0 + - - - setuptools - 50.3.2 - pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + a-group + my-first-service + 1.2.3 + Description goes here + + /api/thing/1 + /api/thing/2 + + false + true + + public + + + Commercial + + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + val1 + val2 + major Release Notes Title @@ -85,6 +129,9 @@ val2 - - + + + my-second-service + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.4/bom_services_nested.xml b/tests/fixtures/xml/1.4/bom_services_nested.xml new file mode 100644 index 00000000..0312b4d8 --- /dev/null +++ b/tests/fixtures/xml/1.4/bom_services_nested.xml @@ -0,0 +1,185 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + https://github.com/CycloneDX/cyclonedx-python-lib/actions + + + https://pypi.org/project/cyclonedx-python-lib/ + + + https://cyclonedx.github.io/cyclonedx-python-lib/ + + + https://github.com/CycloneDX/cyclonedx-python-lib/issues + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md + + + https://github.com/CycloneDX/cyclonedx-python-lib + + + https://cyclonedx.org + + + + + + cyclonedx-python-lib + 1.0.0 + + + + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + a-group + my-first-service + 1.2.3 + Description goes here + + /api/thing/1 + /api/thing/2 + + false + true + + public + + + Commercial + + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + val1 + val2 + + + + first-nested-service + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + no-group + second-nested-service + 3.2.1 + true + false + + + + major + Release Notes Title + https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png + https://cyclonedx.org/cyclonedx-icon.png + This release is a test release + 2021-12-31T10:00:00+00:00 + + First Test Release + + + test + alpha + + + + CVE-2021-44228 + Apache Log3Shell + Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features... + + NVD + https://nvd.nist.gov/vuln/detail/CVE-2021-44228 + + + https://logging.apache.org/log4j/2.x/security.html + https://central.sonatype.org/news/20211213_log4shell_help + + + + + + en-GB + U29tZSBzaW1wbGUgcGxhaW4gdGV4dA== + + + en-US + U29tZSBzaW1wbGUgcGxhaW4gdGV4dA== + + + + val1 + val2 + + + + + my-second-service + + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + what-group + yet-another-nested-service + 6.5.4 + + + another-nested-service + + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.4/bom_services_simple.xml b/tests/fixtures/xml/1.4/bom_services_simple.xml new file mode 100644 index 00000000..1dea0823 --- /dev/null +++ b/tests/fixtures/xml/1.4/bom_services_simple.xml @@ -0,0 +1,52 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + https://github.com/CycloneDX/cyclonedx-python-lib/actions + + + https://pypi.org/project/cyclonedx-python-lib/ + + + https://cyclonedx.github.io/cyclonedx-python-lib/ + + + https://github.com/CycloneDX/cyclonedx-python-lib/issues + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md + + + https://github.com/CycloneDX/cyclonedx-python-lib + + + https://cyclonedx.org + + + + + + cyclonedx-python-lib + 1.0.0 + + + + + + my-first-service + + + my-second-service + + + \ No newline at end of file diff --git a/tests/test_model_service.py b/tests/test_model_service.py index 47571b76..58fd7ed0 100644 --- a/tests/test_model_service.py +++ b/tests/test_model_service.py @@ -40,5 +40,31 @@ def test_minimal_service(self, mock_uuid: Mock) -> None: self.assertIsNone(s.data) self.assertListEqual(s.licenses, []) self.assertListEqual(s.external_references, []) + self.assertIsNone(s.services) self.assertIsNone(s.release_notes) self.assertIsNone(s.properties) + + @patch('cyclonedx.model.service.uuid4', return_value='859ff614-35a7-4d37-803b-d89130cb2577') + def test_service_with_services(self, mock_uuid: Mock) -> None: + parent_service = Service(name='parent-service') + parent_service.services = [ + Service(name='child-service-1'), + Service(name='child-service-2') + ] + mock_uuid.assert_called() + self.assertEqual(parent_service.name, 'parent-service') + self.assertEqual(parent_service.bom_ref, '859ff614-35a7-4d37-803b-d89130cb2577') + self.assertIsNone(parent_service.provider) + self.assertIsNone(parent_service.group) + self.assertIsNone(parent_service.version) + self.assertIsNone(parent_service.description) + self.assertIsNone(parent_service.endpoints) + self.assertIsNone(parent_service.authenticated) + self.assertIsNone(parent_service.x_trust_boundary) + self.assertIsNone(parent_service.data) + self.assertListEqual(parent_service.licenses, []) + self.assertListEqual(parent_service.external_references, []) + self.assertEqual(len(parent_service.services), 2) + self.assertIsNone(parent_service.release_notes) + self.assertIsNone(parent_service.properties) + self.assertTrue(parent_service.has_service(service=Service(name='child-service-1'))) diff --git a/tests/test_output_json.py b/tests/test_output_json.py index a16a178a..2c179261 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -27,7 +27,7 @@ get_bom_with_component_setuptools_no_component_version, \ get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ MOCK_UUID_1, get_bom_just_complete_metadata, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, \ - get_bom_with_services_complex, MOCK_UUID_6 + get_bom_with_services_complex, MOCK_UUID_6, get_bom_with_nested_services from tests.base import BaseJsonTestCase @@ -224,6 +224,36 @@ def test_bom_v1_2_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) - mock_uuid_c.assert_called() mock_uuid_s.assert_called() + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_2) + def test_bom_v1_4_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_json_bom( + bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_4, + fixture='bom_services_nested.json' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_3) + @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_4) + def test_bom_v1_3_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_json_bom( + bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_3, + fixture='bom_services_nested.json' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) + @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_6) + def test_bom_v1_2_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_json_bom( + bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_2, + fixture='bom_services_nested.json' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + # Helper methods def _validate_json_bom(self, bom: Bom, schema_version: SchemaVersion, fixture: str) -> None: outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=schema_version) diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index 2dee2440..122ca967 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -17,6 +17,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. from os.path import dirname, join +from typing import List from unittest.mock import Mock, patch from cyclonedx.model.bom import Bom @@ -24,12 +25,27 @@ from data import get_bom_with_component_setuptools_basic, get_bom_with_component_setuptools_with_cpe, \ get_bom_with_component_toml_1, get_bom_with_component_setuptools_no_component_version, \ get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ - MOCK_UUID_1, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6, get_bom_just_complete_metadata + MOCK_UUID_1, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6, get_bom_just_complete_metadata, \ + get_bom_with_nested_services, get_bom_with_services_simple, get_bom_with_services_complex from tests.base import BaseXmlTestCase class TestOutputXml(BaseXmlTestCase): + UUID_INDEX: int = 0 + UUIDS: List[str] = [ + MOCK_UUID_1, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6 + ] + + def setUp(self) -> None: + TestOutputXml.UUID_INDEX = 0 + + @staticmethod + def iter_uuid() -> str: + uuid = TestOutputXml.UUIDS[TestOutputXml.UUID_INDEX] + TestOutputXml.UUID_INDEX += 1 + return uuid + def test_simple_bom_v1_4(self) -> None: self._validate_xml_bom( bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_4, @@ -232,6 +248,126 @@ def test_bom_v1_0_with_metadata_component(self, mock_uuid: Mock) -> None: ) mock_uuid.assert_called() + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_4_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_4, + fixture='bom_services_simple.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_3_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_3, + fixture='bom_services_simple.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_2_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_2, + fixture='bom_services_simple.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_1_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_1, + fixture='bom_empty.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_0_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_0, + fixture='bom_empty.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_4_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_4, + fixture='bom_services_complex.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_3_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_3, + fixture='bom_services_complex.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_2_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_2, + fixture='bom_services_complex.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_3) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_1_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_1, + fixture='bom_empty.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_4_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_4, + fixture='bom_services_nested.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_3_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_3, + fixture='bom_services_nested.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + + @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) + @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + def test_bom_v1_2_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + self._validate_xml_bom( + bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_2, + fixture='bom_services_nested.xml' + ) + mock_uuid_c.assert_called() + mock_uuid_s.assert_called() + # Helper methods def _validate_xml_bom(self, bom: Bom, schema_version: SchemaVersion, fixture: str) -> None: outputter = get_instance(bom=bom, schema_version=schema_version) From 2090c0868ca82c4b53c6ffc6f439c0d675147601 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Mon, 31 Jan 2022 13:12:59 +0000 Subject: [PATCH 09/29] attempt to resolve Lift finding Signed-off-by: Paul Horton --- tests/test_model_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_model_service.py b/tests/test_model_service.py index 58fd7ed0..da414ebf 100644 --- a/tests/test_model_service.py +++ b/tests/test_model_service.py @@ -64,6 +64,7 @@ def test_service_with_services(self, mock_uuid: Mock) -> None: self.assertIsNone(parent_service.data) self.assertListEqual(parent_service.licenses, []) self.assertListEqual(parent_service.external_references, []) + self.assertIsNotNone(parent_service.services) self.assertEqual(len(parent_service.services), 2) self.assertIsNone(parent_service.release_notes) self.assertIsNone(parent_service.properties) From a51766d202c3774003dd7cd8c115b2d9b3da1f50 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Mon, 31 Jan 2022 13:17:47 +0000 Subject: [PATCH 10/29] fix: temporary fix for `__hash__` of Component with `properties` #153 Signed-off-by: Paul Horton --- cyclonedx/model/component.py | 4 ++-- tests/test_model_component.py | 36 +++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 8998620c..c1fe7aaf 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -511,8 +511,8 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( self.author, self.copyright, self.description, str(self.external_references), self.group, - str(self.hashes), str(self.licenses), self.mime_type, self.name, self.properties, self.publisher, self.purl, - self.release_notes, self.scope, self.supplier, self.type, self.version, self.cpe + str(self.hashes), str(self.licenses), self.mime_type, self.name, str(self.properties), self.publisher, + self.purl, self.release_notes, self.scope, self.supplier, self.type, self.version, self.cpe )) def __repr__(self) -> str: diff --git a/tests/test_model_component.py b/tests/test_model_component.py index 070e2d3c..21b9d28d 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -16,10 +16,11 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. +from typing import List from unittest import TestCase from unittest.mock import Mock, patch -from cyclonedx.model import ExternalReference, ExternalReferenceType +from cyclonedx.model import ExternalReference, ExternalReferenceType, Property from cyclonedx.model.component import Component, ComponentType @@ -115,7 +116,7 @@ def test_empty_basic_component_no_version(self) -> None: self.assertEqual(len(c.hashes), 0) self.assertEqual(len(c.get_vulnerabilities()), 0) - def test_component_equal(self) -> None: + def test_component_equal_1(self) -> None: c = Component( name='test-component', version='1.2.3' ) @@ -135,3 +136,34 @@ def test_component_equal(self) -> None: )) self.assertEqual(c, c2) + + def test_component_equal_2(self) -> None: + props: List[Property] = [ + Property(name='prop1', value='val1'), + Property(name='prop2', value='val2') + ] + + c = Component( + name='test-component', version='1.2.3', properties=props + ) + c2 = Component( + name='test-component', version='1.2.3', properties=props + ) + + self.assertEqual(c, c2) + + def test_component_equal_3(self) -> None: + c = Component( + name='test-component', version='1.2.3', properties=[ + Property(name='prop1', value='val1'), + Property(name='prop2', value='val2') + ] + ) + c2 = Component( + name='test-component', version='1.2.3', properties=[ + Property(name='prop3', value='val3'), + Property(name='prop4', value='val4') + ] + ) + + self.assertNotEqual(c, c2) From 0ce5de6a223e10161a8b864d0115e95d849d5e87 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Mon, 31 Jan 2022 18:32:00 +0000 Subject: [PATCH 11/29] test: refactor to work on PY < 3.10 Signed-off-by: Paul Horton --- tests/base.py | 3 ++- tests/test_output_xml.py | 43 +++++++++++++++------------------------- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/tests/base.py b/tests/base.py index 043f2526..a3bd114a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -117,7 +117,8 @@ def assertValidAgainstSchema(self, bom_xml: str, schema_version: SchemaVersion) if not schema_validates: print(xml_schema.error_log.last_error) - self.assertTrue(schema_validates, 'Failed to validate Generated SBOM against XSD Schema') + self.assertTrue(schema_validates, f'Failed to validate Generated SBOM against XSD Schema:' + f'{bom_xml}') def assertEqualXml(self, a: str, b: str) -> None: da, db = minidom.parseString(a), minidom.parseString(b) diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index 122ca967..f3297c79 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -17,7 +17,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. from os.path import dirname, join -from typing import List from unittest.mock import Mock, patch from cyclonedx.model.bom import Bom @@ -29,22 +28,12 @@ get_bom_with_nested_services, get_bom_with_services_simple, get_bom_with_services_complex from tests.base import BaseXmlTestCase +TEST_UUIDS = [ + MOCK_UUID_1, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6 +] -class TestOutputXml(BaseXmlTestCase): - - UUID_INDEX: int = 0 - UUIDS: List[str] = [ - MOCK_UUID_1, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6 - ] - def setUp(self) -> None: - TestOutputXml.UUID_INDEX = 0 - - @staticmethod - def iter_uuid() -> str: - uuid = TestOutputXml.UUIDS[TestOutputXml.UUID_INDEX] - TestOutputXml.UUID_INDEX += 1 - return uuid +class TestOutputXml(BaseXmlTestCase): def test_simple_bom_v1_4(self) -> None: self._validate_xml_bom( @@ -249,7 +238,7 @@ def test_bom_v1_0_with_metadata_component(self, mock_uuid: Mock) -> None: mock_uuid.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_4_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_4, @@ -259,7 +248,7 @@ def test_bom_v1_4_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_3_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_3, @@ -269,7 +258,7 @@ def test_bom_v1_3_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_2_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_2, @@ -279,7 +268,7 @@ def test_bom_v1_2_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_1_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_1, @@ -289,7 +278,7 @@ def test_bom_v1_1_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_0_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_0, @@ -299,7 +288,7 @@ def test_bom_v1_0_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_4_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_4, @@ -309,7 +298,7 @@ def test_bom_v1_4_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) - mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_3_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_3, @@ -319,7 +308,7 @@ def test_bom_v1_3_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) - mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_2_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_2, @@ -329,7 +318,7 @@ def test_bom_v1_2_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) - mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_3) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_1_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_1, @@ -339,7 +328,7 @@ def test_bom_v1_1_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) - mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_4_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_4, @@ -349,7 +338,7 @@ def test_bom_v1_4_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_3_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_3, @@ -359,7 +348,7 @@ def test_bom_v1_3_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> mock_uuid_s.assert_called() @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) - @patch('cyclonedx.model.service.uuid4', side_effect=iter_uuid) + @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) def test_bom_v1_2_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_2, From 32c01396251834c69a5b23c82a5554faf8447f61 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Wed, 2 Feb 2022 17:01:06 +0000 Subject: [PATCH 12/29] feat: Complete support for `bom.components` (#155) * fix: implemented correct `__hash__` methods in models (#153) Signed-off-by: Paul Horton --- cyclonedx/model/__init__.py | 269 +++++- cyclonedx/model/bom.py | 30 + cyclonedx/model/component.py | 770 +++++++++++++++++- cyclonedx/model/issue.py | 25 + cyclonedx/model/release_note.py | 18 + cyclonedx/model/service.py | 11 +- cyclonedx/model/vulnerability.py | 122 +++ cyclonedx/output/json.py | 4 +- cyclonedx/output/schema.py | 21 + cyclonedx/output/serializer/__init__.py | 18 + cyclonedx/output/serializer/json.py | 8 +- cyclonedx/output/xml.py | 97 ++- tests/data.py | 202 +++-- .../json/1.2/bom_setuptools_complete.json | 218 +++++ .../json/1.3/bom_setuptools_complete.json | 218 +++++ .../json/1.4/bom_setuptools_complete.json | 252 ++++++ .../xml/1.0/bom_setuptools_complete.xml | 12 + .../xml/1.1/bom_setuptools_complete.xml | 89 ++ .../xml/1.2/bom_setuptools_complete.xml | 110 +++ .../xml/1.3/bom_setuptools_complete.xml | 116 +++ .../xml/1.4/bom_setuptools_complete.xml | 140 ++++ tests/test_model.py | 76 +- tests/test_model_component.py | 198 ++++- tests/test_model_issue.py | 63 ++ tests/test_output_json.py | 21 +- tests/test_output_xml.py | 33 +- 26 files changed, 3028 insertions(+), 113 deletions(-) create mode 100644 tests/fixtures/json/1.2/bom_setuptools_complete.json create mode 100644 tests/fixtures/json/1.3/bom_setuptools_complete.json create mode 100644 tests/fixtures/json/1.4/bom_setuptools_complete.json create mode 100644 tests/fixtures/xml/1.0/bom_setuptools_complete.xml create mode 100644 tests/fixtures/xml/1.1/bom_setuptools_complete.xml create mode 100644 tests/fixtures/xml/1.2/bom_setuptools_complete.xml create mode 100644 tests/fixtures/xml/1.3/bom_setuptools_complete.xml create mode 100644 tests/fixtures/xml/1.4/bom_setuptools_complete.xml create mode 100644 tests/test_model_issue.py diff --git a/cyclonedx/model/__init__.py b/cyclonedx/model/__init__.py index 6fa11761..ecc39756 100644 --- a/cyclonedx/model/__init__.py +++ b/cyclonedx/model/__init__.py @@ -14,11 +14,11 @@ # # SPDX-License-Identifier: Apache-2.0 # - import hashlib import re import sys import warnings +from datetime import datetime from enum import Enum from typing import List, Optional, Union @@ -119,6 +119,17 @@ def classification(self) -> str: def classification(self, classification: str) -> None: self._classification = classification + def __eq__(self, other: object) -> bool: + if isinstance(other, DataClassification): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.flow, self.classification)) + + def __repr__(self) -> str: + return f'' + class Encoding(Enum): """ @@ -191,6 +202,17 @@ def content(self) -> str: def content(self, content: str) -> None: self._content = content + def __eq__(self, other: object) -> bool: + if isinstance(other, AttachedText): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.content, self.content_type, self.encoding)) + + def __repr__(self) -> str: + return f'' + class HashAlgorithm(Enum): """ @@ -270,8 +292,16 @@ def get_algorithm(self) -> HashAlgorithm: def get_hash_value(self) -> str: return self._content + def __eq__(self, other: object) -> bool: + if isinstance(other, HashType): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self._alg, self._content)) + def __repr__(self) -> str: - return f'' + return f'' class ExternalReferenceType(Enum): @@ -299,6 +329,17 @@ class ExternalReferenceType(Enum): VCS = 'vcs' WEBSITE = 'website' + # def __eq__(self, other: object) -> bool: + # if isinstance(other, ExternalReferenceType): + # return hash(other) == hash(self) + # return False + # + # def __hash__(self) -> int: + # return hash(self.value) + # + # def __repr__(self) -> str: + # return f'' + class XsUri: """ @@ -322,9 +363,12 @@ def __init__(self, uri: str) -> None: def __eq__(self, other: object) -> bool: if isinstance(other, XsUri): - return str(self) == str(other) + return hash(other) == hash(self) return False + def __hash__(self) -> int: + return hash(self._uri) + def __repr__(self) -> str: return self._uri @@ -391,8 +435,19 @@ def get_url(self) -> str: """ return self._url + def __eq__(self, other: object) -> bool: + if isinstance(other, ExternalReference): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self._type, self._url, self._comment, + tuple([hash(hash_) for hash_ in set(sorted(self._hashes, key=hash))]) if self._hashes else None + )) + def __repr__(self) -> str: - return f' {self._hashes}' + return f'' class License: @@ -478,6 +533,17 @@ def url(self) -> Optional[XsUri]: def url(self, url: Optional[XsUri]) -> None: self._url = url + def __eq__(self, other: object) -> bool: + if isinstance(other, License): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.id, self.name, self.text, self.url)) + + def __repr__(self) -> str: + return f'' + class LicenseChoice: """ @@ -534,6 +600,17 @@ def expression(self) -> Optional[str]: def expression(self, expression: Optional[str]) -> None: self._expression = expression + def __eq__(self, other: object) -> bool: + if isinstance(other, LicenseChoice): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.license, self.expression)) + + def __repr__(self) -> str: + return f'' + class Property: """ @@ -568,6 +645,17 @@ def get_value(self) -> str: """ return self._value + def __eq__(self, other: object) -> bool: + if isinstance(other, Property): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self._name, self._value)) + + def __repr__(self) -> str: + return f'' + class NoteText: """ @@ -630,6 +718,17 @@ def encoding(self) -> Optional[Encoding]: def encoding(self, encoding: Optional[Encoding]) -> None: self._encoding = encoding + def __eq__(self, other: object) -> bool: + if isinstance(other, NoteText): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.content, self.content_type, self.encoding)) + + def __repr__(self) -> str: + return f'' + class Note: """ @@ -686,6 +785,17 @@ def locale(self, locale: Optional[str]) -> None: f"ISO-3166 (or higher) country code. according to ISO-639 format. Examples include: 'en', 'en-US'." ) + def __eq__(self, other: object) -> bool: + if isinstance(other, Note): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((hash(self.text), self.locale)) + + def __repr__(self) -> str: + return f'' + class OrganizationalContact: """ @@ -735,6 +845,17 @@ def phone(self) -> Optional[str]: """ return self._phone + def __eq__(self, other: object) -> bool: + if isinstance(other, OrganizationalContact): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.name, self.phone, self.email)) + + def __repr__(self) -> str: + return f'' + class OrganizationalEntity: """ @@ -785,6 +906,21 @@ def contacts(self) -> Optional[List[OrganizationalContact]]: """ return self._contact + def __eq__(self, other: object) -> bool: + if isinstance(other, OrganizationalEntity): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.name, + tuple([hash(url) for url in set(sorted(self.urls, key=hash))]) if self.urls else None, + tuple([hash(contact) for contact in set(sorted(self.contacts, key=hash))]) if self.contacts else None + )) + + def __repr__(self) -> str: + return f'' + class Tool: """ @@ -876,8 +1012,131 @@ def get_version(self) -> Optional[str]: """ return self._version + def __eq__(self, other: object) -> bool: + if isinstance(other, Tool): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self._vendor, self._name, self._version, + tuple([hash(hash_) for hash_ in set(sorted(self._hashes, key=hash))]) if self._hashes else None, + tuple([hash(ref) for ref in + set(sorted(self._external_references, key=hash))]) if self._external_references else None + )) + + def __repr__(self) -> str: + return f'' + + +class IdentifiableAction: + """ + This is out internal representation of the `identifiableActionType` complex type. + + .. note:: + See the CycloneDX specification: https://cyclonedx.org/docs/1.4/xml/#type_identifiableActionType + """ + + def __init__(self, timestamp: Optional[datetime] = None, name: Optional[str] = None, + email: Optional[str] = None) -> None: + if not timestamp and not name and not email: + raise NoPropertiesProvidedException( + 'At least one of `timestamp`, `name` or `email` must be provided for an `IdentifiableAction`.' + ) + + self.timestamp = timestamp + self.name = name + self.email = email + + @property + def timestamp(self) -> Optional[datetime]: + """ + The timestamp in which the action occurred. + + Returns: + `datetime` if set else `None` + """ + return self._timestamp + + @timestamp.setter + def timestamp(self, timestamp: Optional[datetime]) -> None: + self._timestamp = timestamp + + @property + def name(self) -> Optional[str]: + """ + The name of the individual who performed the action. + + Returns: + `str` if set else `None` + """ + return self._name + + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name + + @property + def email(self) -> Optional[str]: + """ + The email address of the individual who performed the action. + + Returns: + `str` if set else `None` + """ + return self._email + + @email.setter + def email(self, email: Optional[str]) -> None: + self._email = email + + def __eq__(self, other: object) -> bool: + if isinstance(other, IdentifiableAction): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((hash(self.timestamp), self.name, self.email)) + + def __repr__(self) -> str: + return f'' + + +class Copyright: + """ + This is out internal representation of the `copyrightsType` complex type. + + .. note:: + See the CycloneDX specification: https://cyclonedx.org/docs/1.4/xml/#type_copyrightsType + """ + + def __init__(self, text: str) -> None: + self.text = text + + @property + def text(self) -> str: + """ + Copyright statement. + + Returns: + `str` if set else `None` + """ + return self._text + + @text.setter + def text(self, text: str) -> None: + self._text = text + + def __eq__(self, other: object) -> bool: + if isinstance(other, Copyright): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(self.text) + def __repr__(self) -> str: - return ''.format(self._vendor, self._name, self._version) + return f'' if sys.version_info >= (3, 8): diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index c15cad82..d87dc843 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -107,6 +107,21 @@ def component(self, component: Component) -> None: """ self._component = component + def __eq__(self, other: object) -> bool: + if isinstance(other, BomMetaData): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.timestamp, + tuple([hash(tool) for tool in set(sorted(self.tools, key=hash))]) if self.tools else None, + hash(self.component) + )) + + def __repr__(self) -> str: + return f'' + class Bom: """ @@ -359,3 +374,18 @@ def has_vulnerabilities(self) -> bool: return True return False + + def __eq__(self, other: object) -> bool: + if isinstance(other, Bom): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.uuid, hash(self.metadata), + tuple([hash(c) for c in set(sorted(self.components, key=hash))]) if self.components else None, + tuple([hash(s) for s in set(sorted(self.services, key=hash))]) if self.services else None + )) + + def __repr__(self) -> str: + return f'' diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index c1fe7aaf..18825367 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -26,9 +26,181 @@ # See https://github.com/package-url/packageurl-python/issues/65 from packageurl import PackageURL # type: ignore -from . import ExternalReference, HashAlgorithm, HashType, OrganizationalEntity, sha1sum, LicenseChoice, Property +from . import AttachedText, Copyright, ExternalReference, HashAlgorithm, HashType, IdentifiableAction, LicenseChoice, \ + OrganizationalEntity, Property, sha1sum, XsUri +from .issue import IssueType from .release_note import ReleaseNotes from .vulnerability import Vulnerability +from ..exception.model import NoPropertiesProvidedException + + +class Commit: + """ + Our internal representation of the `commitType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_commitType + """ + + def __init__(self, uid: Optional[str] = None, url: Optional[XsUri] = None, + author: Optional[IdentifiableAction] = None, committer: Optional[IdentifiableAction] = None, + message: Optional[str] = None) -> None: + if not uid and not url and not author and not committer and not message: + raise NoPropertiesProvidedException( + 'At least one of `uid`, `url`, `author`, `committer` or `message` must be provided for a `Commit`.' + ) + + self.uid = uid + self.url = url + self.author = author + self.committer = committer + self.message = message + + @property + def uid(self) -> Optional[str]: + """ + A unique identifier of the commit. This may be version control specific. For example, Subversion uses revision + numbers whereas git uses commit hashes. + + Returns: + `str` if set else `None` + """ + return self._uid + + @uid.setter + def uid(self, uid: Optional[str]) -> None: + self._uid = uid + + @property + def url(self) -> Optional[XsUri]: + """ + The URL to the commit. This URL will typically point to a commit in a version control system. + + Returns: + `XsUri` if set else `None` + """ + return self._url + + @url.setter + def url(self, url: Optional[XsUri]) -> None: + self._url = url + + @property + def author(self) -> Optional[IdentifiableAction]: + """ + The author who created the changes in the commit. + + Returns: + `IdentifiableAction` if set else `None` + """ + return self._author + + @author.setter + def author(self, author: Optional[IdentifiableAction]) -> None: + self._author = author + + @property + def committer(self) -> Optional[IdentifiableAction]: + """ + The person who committed or pushed the commit + + Returns: + `IdentifiableAction` if set else `None` + """ + return self._committer + + @committer.setter + def committer(self, committer: Optional[IdentifiableAction]) -> None: + self._committer = committer + + @property + def message(self) -> Optional[str]: + """ + The text description of the contents of the commit. + + Returns: + `str` if set else `None` + """ + return self._message + + @message.setter + def message(self, message: Optional[str]) -> None: + self._message = message + + def __eq__(self, other: object) -> bool: + if isinstance(other, Commit): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.uid, hash(self.url), hash(self.author), hash(self.committer), self.message)) + + def __repr__(self) -> str: + return f'' + + +class ComponentEvidence: + """ + Our internal representation of the `componentEvidenceType` complex type. + + Provides the ability to document evidence collected through various forms of extraction or analysis. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_componentEvidenceType + """ + + def __init__(self, licenses: Optional[List[LicenseChoice]] = None, + copyright_: Optional[List[Copyright]] = None) -> None: + if not licenses and not copyright_: + raise NoPropertiesProvidedException( + 'At least one of `licenses` or `copyright_` must be supplied for a `ComponentEvidence`.' + ) + + self.licenses = licenses + self.copyright = copyright_ + + @property + def licenses(self) -> Optional[List[LicenseChoice]]: + """ + Optional list of licenses obtained during analysis. + + Returns: + List of `LicenseChoice` if set else `None` + """ + return self._licenses + + @licenses.setter + def licenses(self, licenses: Optional[List[LicenseChoice]]) -> None: + self._licenses = licenses + + @property + def copyright(self) -> Optional[List[Copyright]]: + """ + Optional list of copyright statements. + + Returns: + List of `Copyright` if set else `None` + """ + return self._copyright + + @copyright.setter + def copyright(self, copyright_: Optional[List[Copyright]]) -> None: + self._copyright = copyright_ + + def __eq__(self, other: object) -> bool: + if isinstance(other, ComponentEvidence): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + tuple([hash(licence) for licence in set(sorted(self.licenses, key=hash))]) if self.licenses else None, + tuple( + [hash(copyright_) for copyright_ in set(sorted(self.copyright, key=hash))]) if self.copyright else None + )) + + def __repr__(self) -> str: + return f'' class ComponentScope(Enum): @@ -60,6 +232,486 @@ class ComponentType(Enum): OPERATING_SYSTEM = 'operating-system' +class Diff: + """ + Our internal representation of the `diffType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_diffType + """ + + def __init__(self, text: Optional[AttachedText] = None, url: Optional[XsUri] = None) -> None: + if not text and not url: + raise NoPropertiesProvidedException( + 'At least one of `text` or `url` must be provided for a `Diff`.' + ) + + self.text = text + self.url = url + + @property + def text(self) -> Optional[AttachedText]: + """ + Specifies the optional text of the diff. + + Returns: + `AttachedText` if set else `None` + """ + return self._text + + @text.setter + def text(self, text: Optional[AttachedText]) -> None: + self._text = text + + @property + def url(self) -> Optional[XsUri]: + """ + Specifies the URL to the diff. + + Returns: + `XsUri` if set else `None` + """ + return self._url + + @url.setter + def url(self, url: Optional[XsUri]) -> None: + self._url = url + + def __eq__(self, other: object) -> bool: + if isinstance(other, Diff): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((hash(self.text), hash(self.url))) + + def __repr__(self) -> str: + return f'' + + +class PatchClassification(Enum): + """ + Enum object that defines the permissible `patchClassification`s. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_patchClassification + """ + BACKPORT = 'backport' + CHERRY_PICK = 'cherry-pick' + MONKEY = 'monkey' + UNOFFICIAL = 'unofficial' + + +class Patch: + """ + Our internal representation of the `patchType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_patchType + """ + + def __init__(self, type_: PatchClassification, diff: Optional[Diff] = None, + resolves: Optional[List[IssueType]] = None) -> None: + self.type = type_ + self.diff = diff + self.resolves = resolves + + @property + def type(self) -> PatchClassification: + """ + Specifies the purpose for the patch including the resolution of defects, security issues, or new behavior or + functionality. + + Returns: + `PatchClassification` + """ + return self._type + + @type.setter + def type(self, type_: PatchClassification) -> None: + self._type = type_ + + @property + def diff(self) -> Optional[Diff]: + """ + The patch file (or diff) that show changes. + + .. note:: + Refer to https://en.wikipedia.org/wiki/Diff. + + Returns: + `Diff` if set else `None` + """ + return self._diff + + @diff.setter + def diff(self, diff: Optional[Diff]) -> None: + self._diff = diff + + @property + def resolves(self) -> Optional[List[IssueType]]: + """ + Optional list of issues resolved by this patch. + + Returns: + List of `IssueType` if set else `None` + """ + return self._resolves + + @resolves.setter + def resolves(self, resolves: Optional[List[IssueType]]) -> None: + self._resolves = resolves + + def add_resolves(self, issue: IssueType) -> None: + """ + Add an Issue to the list that this patch resolves. + + Args: + issue: + `IssueType` + """ + self.resolves = (self.resolves or []) + [issue] + + def __eq__(self, other: object) -> bool: + if isinstance(other, Patch): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + hash(self.type), hash(self.diff), + tuple([hash(issue) for issue in set(sorted(self.resolves, key=hash))]) if self.resolves else None + )) + + def __repr__(self) -> str: + return f'' + + +class Pedigree: + """ + Our internal representation of the `pedigreeType` complex type. + + Component pedigree is a way to document complex supply chain scenarios where components are created, distributed, + modified, redistributed, combined with other components, etc. Pedigree supports viewing this complex chain from the + beginning, the end, or anywhere in the middle. It also provides a way to document variants where the exact relation + may not be known. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_pedigreeType + """ + + def __init__(self, ancestors: Optional[List['Component']] = None, descendants: Optional[List['Component']] = None, + variants: Optional[List['Component']] = None, commits: Optional[List[Commit]] = None, + patches: Optional[List[Patch]] = None, notes: Optional[str] = None) -> None: + if not ancestors and not descendants and not variants and not commits and not patches and not notes: + raise NoPropertiesProvidedException( + 'At least one of `ancestors`, `descendants`, `variants`, `commits`, `patches` or `notes` must be ' + 'provided for `Pedigree`' + ) + + self.ancestors = ancestors + self.descendants = descendants + self.variants = variants + self.commits = commits + self.patches = patches + self.notes = notes + + @property + def ancestors(self) -> Optional[List['Component']]: + """ + Describes zero or more components in which a component is derived from. This is commonly used to describe forks + from existing projects where the forked version contains a ancestor node containing the original component it + was forked from. + + For example, Component A is the original component. Component B is the component being used and documented in + the BOM. However, Component B contains a pedigree node with a single ancestor documenting Component A - the + original component from which Component B is derived from. + + Returns: + List of `Component` if set else `None` + """ + return self._ancestors + + @ancestors.setter + def ancestors(self, ancestors: Optional[List['Component']]) -> None: + self._ancestors = ancestors + + def add_ancestor(self, ancestor: 'Component') -> None: + """ + Adds an ancestor. + + Args: + ancestor: + `Component` + """ + self.ancestors = (self.ancestors or []) + [ancestor] + + @property + def descendants(self) -> Optional[List['Component']]: + """ + Descendants are the exact opposite of ancestors. This provides a way to document all forks (and their forks) of + an original or root component. + + Returns: + List of `Component` if set else `None` + """ + return self._descendants + + @descendants.setter + def descendants(self, descendants: Optional[List['Component']]) -> None: + self._descendants = descendants + + def add_descendant(self, descendant: 'Component') -> None: + """ + Adds an descendant. + + Args: + descendant: + `Component` + """ + self.descendants = (self.descendants or []) + [descendant] + + @property + def variants(self) -> Optional[List['Component']]: + """ + Variants describe relations where the relationship between the components are not known. For example, if + Component A contains nearly identical code to Component B. They are both related, but it is unclear if one is + derived from the other, or if they share a common ancestor. + + Returns: + List of `Component` if set else `None` + """ + return self._variants + + @variants.setter + def variants(self, variants: Optional[List['Component']]) -> None: + self._variants = variants + + def add_variant(self, variant: 'Component') -> None: + """ + Adds an variant. + + Args: + variant: + `Component` + """ + self.variants = (self.variants or []) + [variant] + + @property + def commits(self) -> Optional[List[Commit]]: + """ + A list of zero or more commits which provide a trail describing how the component deviates from an ancestor, + descendant, or variant. + + Returns: + List of `Commit` if set else `None` + """ + return self._commits + + @commits.setter + def commits(self, commits: Optional[List[Commit]]) -> None: + self._commits = commits + + def add_commit(self, commit: Commit) -> None: + """ + Adds a Commit. + + Args: + commit: + `Commit` + """ + self.commits = (self.commits or []) + [commit] + + @property + def patches(self) -> Optional[List[Patch]]: + """ + A list of zero or more patches describing how the component deviates from an ancestor, descendant, or variant. + Patches may be complimentary to commits or may be used in place of commits. + + Returns: + List of `Patch` if present else `None` + """ + return self._patches + + @patches.setter + def patches(self, patches: Optional[List[Patch]]) -> None: + self._patches = patches + + def add_patch(self, patch: Patch) -> None: + """ + Adds a Patch. + + Args: + patch: + `Patch` + """ + self.patches = (self.patches or []) + [patch] + + @property + def notes(self) -> Optional[str]: + """ + Notes, observations, and other non-structured commentary describing the components pedigree. + + Returns: + `str` if set else `None` + """ + return self._notes + + @notes.setter + def notes(self, notes: Optional[str]) -> None: + self._notes = notes + + def __eq__(self, other: object) -> bool: + if isinstance(other, Pedigree): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + tuple([hash(ancestor) for ancestor in set(sorted(self.ancestors, key=hash))]) if self.ancestors else None, + tuple([hash(descendant) for descendant in + set(sorted(self.descendants, key=hash))]) if self.descendants else None, + tuple([hash(variant) for variant in set(sorted(self.variants, key=hash))]) if self.variants else None, + tuple([hash(commit) for commit in set(sorted(self.commits, key=hash))]) if self.commits else None, + tuple([hash(patch) for patch in set(sorted(self.patches, key=hash))]) if self.patches else None, + self.notes + )) + + def __repr__(self) -> str: + return f'' + + +class Swid: + """ + Our internal representation of the `swidType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_swidType + """ + + def __init__(self, tag_id: str, name: str, version: Optional[str] = None, + tag_version: Optional[int] = None, patch: Optional[bool] = None, + text: Optional[AttachedText] = None, url: Optional[XsUri] = None) -> None: + self.tag_id = tag_id + self.name = name + self.version = version + self.tag_version = tag_version + self.patch = patch + self.text = text + self.url = url + + @property + def tag_id(self) -> str: + """ + Maps to the tagId of a SoftwareIdentity. + + Returns: + `str` + """ + return self._tag_id + + @tag_id.setter + def tag_id(self, tag_id: str) -> None: + self._tag_id = tag_id + + @property + def name(self) -> str: + """ + Maps to the name of a SoftwareIdentity. + + Returns: + `str` + """ + return self._name + + @name.setter + def name(self, name: str) -> None: + self._name = name + + @property + def version(self) -> Optional[str]: + """ + Maps to the version of a SoftwareIdentity. + + Returns: + `str` if set else `None`. + """ + return self._version + + @version.setter + def version(self, version: Optional[str]) -> None: + self._version = version + + @property + def tag_version(self) -> Optional[int]: + """ + Maps to the tagVersion of a SoftwareIdentity. + + Returns: + `int` if set else `None` + """ + return self._tag_version + + @tag_version.setter + def tag_version(self, tag_version: Optional[int]) -> None: + self._tag_version = tag_version + + @property + def patch(self) -> Optional[bool]: + """ + Maps to the patch of a SoftwareIdentity. + + Returns: + `bool` if set else `None` + """ + return self._patch + + @patch.setter + def patch(self, patch: Optional[bool]) -> None: + self._patch = patch + + @property + def text(self) -> Optional[AttachedText]: + """ + Specifies the full content of the SWID tag. + + Returns: + `AttachedText` if set else `None` + """ + return self._text + + @text.setter + def text(self, text: Optional[AttachedText]) -> None: + self._text = text + + @property + def url(self) -> Optional[XsUri]: + """ + The URL to the SWID file. + + Returns: + `XsUri` if set else `None` + """ + return self._url + + @url.setter + def url(self, url: Optional[XsUri]) -> None: + self._url = url + + def __eq__(self, other: object) -> bool: + if isinstance(other, Swid): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.tag_id, self.name, self.version, self.tag_version, self.patch, hash(self.text), hash(self.url) + )) + + def __repr__(self) -> str: + return f'' + + class Component: """ This is our internal representation of a Component within a Bom. @@ -104,10 +756,11 @@ def __init__(self, name: str, component_type: ComponentType = ComponentType.LIBR publisher: Optional[str] = None, group: Optional[str] = None, version: Optional[str] = None, description: Optional[str] = None, scope: Optional[ComponentScope] = None, hashes: Optional[List[HashType]] = None, licenses: Optional[List[LicenseChoice]] = None, - copyright: Optional[str] = None, purl: Optional[PackageURL] = None, + copyright_: Optional[str] = None, purl: Optional[PackageURL] = None, external_references: Optional[List[ExternalReference]] = None, properties: Optional[List[Property]] = None, release_notes: Optional[ReleaseNotes] = None, - cpe: Optional[str] = None, + cpe: Optional[str] = None, swid: Optional[Swid] = None, pedigree: Optional[Pedigree] = None, + components: Optional[List['Component']] = None, evidence: Optional[ComponentEvidence] = None, # Deprecated parameters kept for backwards compatibility namespace: Optional[str] = None, license_str: Optional[str] = None ) -> None: @@ -124,11 +777,16 @@ def __init__(self, name: str, component_type: ComponentType = ComponentType.LIBR self.scope = scope self.hashes = hashes or [] self.licenses = licenses or [] - self.copyright = copyright - self.purl = purl + self.copyright = copyright_ self.cpe = cpe + self.purl = purl + self.swid = swid + self.pedigree = pedigree self.external_references = external_references if external_references else [] self.properties = properties + self.components = components + self.evidence = evidence + self.release_notes = release_notes # Deprecated for 1.4, but kept for some backwards compatibility if namespace: @@ -147,9 +805,6 @@ def __init__(self, name: str, component_type: ComponentType = ComponentType.LIBR if not licenses: self.licenses = [LicenseChoice(license_expression=license_str)] - # Added for 1.4 - self.release_notes = release_notes - self.__vulnerabilites: List[Vulnerability] = [] @property @@ -380,6 +1035,21 @@ def copyright(self) -> Optional[str]: def copyright(self, copyright: Optional[str]) -> None: self._copyright = copyright + @property + def cpe(self) -> Optional[str]: + """ + Specifies a well-formed CPE name that conforms to the CPE 2.2 or 2.3 specification. + See https://nvd.nist.gov/products/cpe + + Returns: + `str` if set else `None` + """ + return self._cpe + + @cpe.setter + def cpe(self, cpe: Optional[str]) -> None: + self._cpe = cpe + @property def purl(self) -> Optional[PackageURL]: """ @@ -398,19 +1068,33 @@ def purl(self, purl: Optional[PackageURL]) -> None: self._purl = purl @property - def cpe(self) -> Optional[str]: + def swid(self) -> Optional[Swid]: """ - Specifies a well-formed CPE name that conforms to the CPE 2.2 or 2.3 specification. - See https://nvd.nist.gov/products/cpe + Specifies metadata and content for ISO-IEC 19770-2 Software Identification (SWID) Tags. Returns: - `str` if set else `None` + `Swid` if set else `None` """ - return self._cpe + return self._swid - @cpe.setter - def cpe(self, cpe: Optional[str]) -> None: - self._cpe = cpe + @swid.setter + def swid(self, swid: Optional[Swid]) -> None: + self._swid = swid + + @property + def pedigree(self) -> Optional[Pedigree]: + """ + Component pedigree is a way to document complex supply chain scenarios where components are created, + distributed, modified, redistributed, combined with other components, etc. + + Returns: + `Pedigree` if set else `None` + """ + return self._pedigree + + @pedigree.setter + def pedigree(self, pedigree: Optional[Pedigree]) -> None: + self._pedigree = pedigree @property def external_references(self) -> List[ExternalReference]: @@ -452,6 +1136,46 @@ def properties(self) -> Optional[List[Property]]: def properties(self, properties: Optional[List[Property]]) -> None: self._properties = properties + @property + def components(self) -> Optional[List['Component']]: + """ + A list of software and hardware components included in the parent component. This is not a dependency tree. It + provides a way to specify a hierarchical representation of component assemblies, similar to system -> subsystem + -> parts assembly in physical supply chains. + + Returns: + List of `Component` if set else `None` + """ + return self._components + + @components.setter + def components(self, components: Optional[List['Component']]) -> None: + self._components = components + + def add_component(self, component: 'Component') -> None: + """ + Add aa `Component` that is part of this parent `Component`. + + Args: + component: + `Component` to add + """ + self.components = (self.components or []) + [component] + + @property + def evidence(self) -> Optional[ComponentEvidence]: + """ + Provides the ability to document evidence collected through various forms of extraction or analysis. + + Returns: + `ComponentEvidence` if set else `None` + """ + return self._evidence + + @evidence.setter + def evidence(self, evidence: Optional[ComponentEvidence]) -> None: + self._evidence = evidence + @property def release_notes(self) -> Optional[ReleaseNotes]: """ @@ -510,9 +1234,17 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - self.author, self.copyright, self.description, str(self.external_references), self.group, - str(self.hashes), str(self.licenses), self.mime_type, self.name, str(self.properties), self.publisher, - self.purl, self.release_notes, self.scope, self.supplier, self.type, self.version, self.cpe + self.type, self.mime_type, self.supplier, self.author, self.publisher, self.group, self.name, + self.version, self.description, self.scope, + tuple([hash(hash_) for hash_ in set(sorted(self.hashes, key=hash))]) if self.hashes else None, + tuple([hash(license_) for license_ in set(sorted(self.licenses, key=hash))]) if self.licenses else None, + self.copyright, self.cpe, self.purl, self.swid, self.pedigree, + tuple([hash(ref) for ref in + set(sorted(self.external_references, key=hash))]) if self.external_references else None, + tuple([hash(prop) for prop in set(sorted(self.properties, key=hash))]) if self.properties else None, + tuple( + [hash(component) for component in set(sorted(self.components, key=hash))]) if self.components else None, + self.evidence, self.release_notes )) def __repr__(self) -> str: diff --git a/cyclonedx/model/issue.py b/cyclonedx/model/issue.py index 029f1540..b6d176f3 100644 --- a/cyclonedx/model/issue.py +++ b/cyclonedx/model/issue.py @@ -78,6 +78,17 @@ def url(self) -> Optional[XsUri]: def url(self, url: Optional[XsUri]) -> None: self._url = url + def __eq__(self, other: object) -> bool: + if isinstance(other, IssueTypeSource): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.name, self.url)) + + def __repr__(self) -> str: + return f'' + class IssueType: """ @@ -263,3 +274,17 @@ def set_source_url(self, source_url: XsUri) -> None: self._source.url = source_url else: self._source = IssueTypeSource(url=source_url) + + def __eq__(self, other: object) -> bool: + if isinstance(other, IssueType): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self._type, self._id, self._name, self._description, self._source, + tuple([hash(ref) for ref in set(sorted(self._references, key=hash))]) if self._references else None + )) + + def __repr__(self) -> str: + return f'' diff --git a/cyclonedx/model/release_note.py b/cyclonedx/model/release_note.py index 3a3097a8..43389e73 100644 --- a/cyclonedx/model/release_note.py +++ b/cyclonedx/model/release_note.py @@ -238,3 +238,21 @@ def properties(self) -> Optional[List[Property]]: @properties.setter def properties(self, properties: Optional[List[Property]]) -> None: self._properties = properties + + def __eq__(self, other: object) -> bool: + if isinstance(other, ReleaseNotes): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.type, self.title, self.featured_image, self.social_image, self.description, self.timestamp, + tuple([hash(alias) for alias in set(sorted(self.aliases, key=hash))]) if self.aliases else None, + tuple([hash(tag) for tag in set(sorted(self.tags, key=hash))]) if self.tags else None, + tuple([hash(issue) for issue in set(sorted(self.resolves, key=hash))]) if self.resolves else None, + tuple([hash(note) for note in set(sorted(self.notes, key=hash))]) if self.notes else None, + tuple([hash(prop) for prop in set(sorted(self._properties, key=hash))]) if self._properties else None + )) + + def __repr__(self) -> str: + return f'' diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py index 752b3452..ced7fd2d 100644 --- a/cyclonedx/model/service.py +++ b/cyclonedx/model/service.py @@ -333,9 +333,14 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - self.authenticated, self.data, self.description, str(self.endpoints), - str(self.external_references), self.group, str(self.licenses), self.name, self.properties, self.provider, - self.release_notes, str(self.services), self.version, self.x_trust_boundary + self.authenticated, self.data, self.description, + tuple([hash(uri) for uri in set(sorted(self.endpoints, key=hash))]) if self.endpoints else None, + tuple([hash(ref) for ref in + set(sorted(self.external_references, key=hash))]) if self.external_references else None, + self.group, str(self.licenses), self.name, self.properties, self.provider, + self.release_notes, + tuple([hash(service) for service in set(sorted(self.services, key=hash))]) if self.services else None, + self.version, self.x_trust_boundary )) def __repr__(self) -> str: diff --git a/cyclonedx/model/vulnerability.py b/cyclonedx/model/vulnerability.py index 1a7a9720..734c0933 100644 --- a/cyclonedx/model/vulnerability.py +++ b/cyclonedx/model/vulnerability.py @@ -106,6 +106,17 @@ def status(self) -> Optional[ImpactAnalysisAffectedStatus]: def status(self, status: Optional[ImpactAnalysisAffectedStatus]) -> None: self._status = status + def __eq__(self, other: object) -> bool: + if isinstance(other, BomTargetVersionRange): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.version, self.range, hash(self.status))) + + def __repr__(self) -> str: + return f'' + class BomTarget: """ @@ -146,6 +157,20 @@ def versions(self) -> Optional[List[BomTargetVersionRange]]: def versions(self, versions: Optional[List[BomTargetVersionRange]]) -> None: self._versions = versions + def __eq__(self, other: object) -> bool: + if isinstance(other, BomTarget): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.ref, + tuple([hash(version) for version in set(sorted(self.versions, key=hash))]) if self.versions else None + )) + + def __repr__(self) -> str: + return f'' + class VulnerabilityAnalysis: """ @@ -218,6 +243,21 @@ def detail(self) -> Optional[str]: def detail(self, detail: Optional[str]) -> None: self._detail = detail + def __eq__(self, other: object) -> bool: + if isinstance(other, VulnerabilityAnalysis): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.state, self.justification, + tuple([hash(r) for r in set(sorted(self.response, key=hash))]) if self.response else None, + self.detail + )) + + def __repr__(self) -> str: + return f'' + class VulnerabilityAdvisory: """ @@ -253,6 +293,17 @@ def url(self) -> XsUri: def url(self, url: XsUri) -> None: self._url = url + def __eq__(self, other: object) -> bool: + if isinstance(other, VulnerabilityAdvisory): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.title, self.url)) + + def __repr__(self) -> str: + return f'' + class VulnerabilitySource: """ @@ -294,6 +345,17 @@ def url(self) -> Optional[XsUri]: def url(self, url: Optional[XsUri]) -> None: self._url = url + def __eq__(self, other: object) -> bool: + if isinstance(other, VulnerabilitySource): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.name, self.url)) + + def __repr__(self) -> str: + return f'' + class VulnerabilityReference: """ @@ -338,6 +400,17 @@ def source(self) -> Optional[VulnerabilitySource]: def source(self, source: Optional[VulnerabilitySource]) -> None: self._source = source + def __eq__(self, other: object) -> bool: + if isinstance(other, VulnerabilityReference): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash((self.id, hash(self.source))) + + def __repr__(self) -> str: + return f'' + class VulnerabilityScoreSource(Enum): """ @@ -571,6 +644,19 @@ def justification(self) -> Optional[str]: def justification(self, justification: Optional[str]) -> None: self._justification = justification + def __eq__(self, other: object) -> bool: + if isinstance(other, VulnerabilityRating): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + hash(self.source), self.score, self.severity, self.method, self.vector, self.justification + )) + + def __repr__(self) -> str: + return f'' + class VulnerabilityCredits: """ @@ -620,6 +706,20 @@ def individuals(self) -> Optional[List[OrganizationalContact]]: def individuals(self, individuals: Optional[List[OrganizationalContact]]) -> None: self._individuals = individuals + def __eq__(self, other: object) -> bool: + if isinstance(other, VulnerabilityCredits): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + tuple([hash(org) for org in set(sorted(self.organizations, key=hash))]) if self.organizations else None, + tuple([hash(person) for person in set(sorted(self.individuals, key=hash))]) if self.individuals else None + )) + + def __repr__(self) -> str: + return f'' + class Vulnerability: """ @@ -975,3 +1075,25 @@ def get_recommendations(self) -> List[str]: DeprecationWarning ) return [self.recommendation] if self.recommendation else [] + + def __eq__(self, other: object) -> bool: + if isinstance(other, Vulnerability): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.id, hash(self.source), + tuple([hash(ref) for ref in set(sorted(self.references, key=hash))]) if self.references else None, + tuple([hash(rating) for rating in set(sorted(self.ratings, key=hash))]) if self.ratings else None, + tuple([hash(cwe) for cwe in set(sorted(self.cwes, key=hash))]) if self.cwes else None, + self.description, self.detail, self.recommendation, + tuple([hash(advisory) for advisory in set(sorted(self.advisories, key=hash))]) if self.advisories else None, + self.created, self.published, self.updated, hash(self.credits), + tuple([hash(tool) for tool in set(sorted(self.tools, key=hash))]) if self.tools else None, + hash(self.analysis), + tuple([hash(affected) for affected in set(sorted(self.affects, key=hash))]) if self.affects else None + )) + + def __repr__(self) -> str: + return f'' diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py index 7eab33de..f3acd8bc 100644 --- a/cyclonedx/output/json.py +++ b/cyclonedx/output/json.py @@ -89,8 +89,8 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str # Iterate Components if 'components' in bom_json.keys(): for i in range(len(bom_json['components'])): - if 'version' not in bom_json['components'][i].keys() and not self.component_version_optional(): - bom_json['components'][i]['version'] = '' + if self.component_version_optional() and bom_json['components'][i]['version'] == "": + del bom_json['components'][i]['version'] if not self.component_supports_author() and 'author' in bom_json['components'][i].keys(): del bom_json['components'][i]['author'] diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py index c795bc1d..da46314f 100644 --- a/cyclonedx/output/schema.py +++ b/cyclonedx/output/schema.py @@ -71,6 +71,15 @@ def component_supports_licenses_expression(self) -> bool: def component_version_optional(self) -> bool: return False + def component_supports_swid(self) -> bool: + return True + + def component_supports_pedigree(self) -> bool: + return True + + def pedigree_supports_patches(self) -> bool: + return True + def component_supports_external_references(self) -> bool: return True @@ -178,6 +187,9 @@ def bom_supports_services(self) -> bool: def services_supports_properties(self) -> bool: return False + def pedigree_supports_patches(self) -> bool: + return False + def services_supports_release_notes(self) -> bool: return False @@ -196,6 +208,9 @@ def component_supports_mime_type_attribute(self) -> bool: def component_supports_author(self) -> bool: return False + def component_supports_swid(self) -> bool: + return False + def component_supports_release_notes(self) -> bool: return False @@ -248,6 +263,12 @@ def component_supports_licenses_expression(self) -> bool: def component_supports_mime_type_attribute(self) -> bool: return False + def component_supports_swid(self) -> bool: + return False + + def component_supports_pedigree(self) -> bool: + return False + def component_supports_external_references(self) -> bool: return False diff --git a/cyclonedx/output/serializer/__init__.py b/cyclonedx/output/serializer/__init__.py index e69de29b..308d97e7 100644 --- a/cyclonedx/output/serializer/__init__.py +++ b/cyclonedx/output/serializer/__init__.py @@ -0,0 +1,18 @@ +# encoding: utf-8 + +# This file is part of CycloneDX Python Lib +# +# 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. diff --git a/cyclonedx/output/serializer/json.py b/cyclonedx/output/serializer/json.py index 5d0398c6..000b0fae 100644 --- a/cyclonedx/output/serializer/json.py +++ b/cyclonedx/output/serializer/json.py @@ -29,6 +29,7 @@ from packageurl import PackageURL # type: ignore from cyclonedx.model import XsUri +from cyclonedx.model.component import Component HYPHENATED_ATTRIBUTES = [ 'bom_ref', 'mime_type', 'x_trust_boundary' @@ -77,8 +78,11 @@ def default(self, o: Any) -> Any: elif '_' in new_key: new_key = PYTHON_TO_JSON_NAME.sub(lambda x: x.group(1).upper(), new_key) - # Skip any None values - if v or v is False: + # Inject '' for Component.version if it's None + if isinstance(o, Component) and new_key == 'version' and v is None: + d[new_key] = "" + elif v or v is False: + # Skip any None values (exception 'version') if isinstance(v, PackageURL): # Special handling of PackageURL instances which JSON would otherwise automatically encode to # an Array diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 3c13c86f..873c2e01 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -24,9 +24,10 @@ from . import BaseOutput, SchemaVersion from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3, \ SchemaVersion1Dot4 -from ..model import ExternalReference, HashType, OrganizationalEntity, OrganizationalContact, Property, Tool +from ..model import AttachedText, ExternalReference, HashType, IdentifiableAction, OrganizationalEntity, \ + OrganizationalContact, Property, Tool from ..model.bom import Bom -from ..model.component import Component +from ..model.component import Component, Patch from ..model.release_note import ReleaseNotes from ..model.service import Service from ..model.vulnerability import Vulnerability, VulnerabilityRating, VulnerabilitySource, BomTargetVersionRange @@ -114,6 +115,17 @@ def _create_bom_element(self) -> ElementTree.Element: return ElementTree.Element('bom', root_attributes) + @staticmethod + def _add_identifiable_action_element(identifiable_action: IdentifiableAction, tag_name: str) -> ElementTree.Element: + ia_element = ElementTree.Element(tag_name) + if identifiable_action.timestamp: + ElementTree.SubElement(ia_element, 'timestamp').text = identifiable_action.timestamp.isoformat() + if identifiable_action.name: + ElementTree.SubElement(ia_element, 'name').text = identifiable_action.name + if identifiable_action.email: + ElementTree.SubElement(ia_element, 'email').text = identifiable_action.email + return ia_element + def _add_metadata_element(self) -> None: bom_metadata = self.get_bom().metadata metadata_e = ElementTree.SubElement(self._root_bom_element, 'metadata') @@ -200,10 +212,68 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: if component.purl: ElementTree.SubElement(component_element, 'purl').text = component.purl.to_string() + # swid + if self.component_supports_swid() and component.swid: + swid_attrs = { + "tagId": component.swid.tag_id, + "name": component.swid.name + } + if component.swid.version: + swid_attrs['version'] = component.swid.version + if component.swid.tag_version: + swid_attrs['tagVersion'] = str(component.swid.tag_version) + if component.swid.patch is not None: + swid_attrs['patch'] = str(component.swid.patch).lower() + swid_element = ElementTree.SubElement(component_element, 'swid', swid_attrs) + if component.swid.text: + swid_element.append(Xml._add_attached_text(attached_text=component.swid.text)) + if component.swid.url: + ElementTree.SubElement(swid_element, 'url').text = str(component.swid.url) + # modified if self.bom_requires_modified(): ElementTree.SubElement(component_element, 'modified').text = 'false' + # pedigree + if self.component_supports_pedigree() and component.pedigree: + pedigree_element = ElementTree.SubElement(component_element, 'pedigree') + if component.pedigree.ancestors: + ancestors_element = ElementTree.SubElement(pedigree_element, 'ancestors') + for ancestor in component.pedigree.ancestors: + ancestors_element.append(self._add_component_element(component=ancestor)) + if component.pedigree.descendants: + descendants_element = ElementTree.SubElement(pedigree_element, 'descendants') + for descendant in component.pedigree.descendants: + descendants_element.append(self._add_component_element(component=descendant)) + if component.pedigree.variants: + variants_element = ElementTree.SubElement(pedigree_element, 'variants') + for variant in component.pedigree.variants: + variants_element.append(self._add_component_element(component=variant)) + if component.pedigree.commits: + commits_element = ElementTree.SubElement(pedigree_element, 'commits') + for commit in component.pedigree.commits: + commit_element = ElementTree.SubElement(commits_element, 'commit') + if commit.uid: + ElementTree.SubElement(commit_element, 'uid').text = commit.uid + if commit.url: + ElementTree.SubElement(commit_element, 'url').text = str(commit.url) + if commit.author: + commit_element.append(Xml._add_identifiable_action_element( + identifiable_action=commit.author, tag_name='author' + )) + if commit.committer: + commit_element.append(Xml._add_identifiable_action_element( + identifiable_action=commit.committer, tag_name='committer' + )) + if commit.message: + ElementTree.SubElement(commit_element, 'message').text = commit.message + if self.pedigree_supports_patches() and component.pedigree.patches: + patches_element = ElementTree.SubElement(pedigree_element, 'patches') + for patch in component.pedigree.patches: + patches_element.append(Xml.add_patch_element(patch=patch)) + if component.pedigree.notes: + ElementTree.SubElement(pedigree_element, 'notes').text = component.pedigree.notes + # externalReferences if self.component_supports_external_references() and len(component.external_references) > 0: external_references_e = ElementTree.SubElement(component_element, 'externalReferences') @@ -288,6 +358,18 @@ def _add_release_notes_element(release_notes: ReleaseNotes, parent_element: Elem if release_notes.properties: Xml._add_properties_element(properties=release_notes.properties, parent_element=release_notes_e) + @staticmethod + def add_patch_element(patch: Patch) -> ElementTree.Element: + patch_element = ElementTree.Element('patch', {"type": patch.type.value}) + if patch.diff: + diff_element = ElementTree.SubElement(patch_element, 'diff') + if patch.diff.text: + diff_element.append(Xml._add_attached_text(attached_text=patch.diff.text)) + if patch.diff.url: + ElementTree.SubElement(diff_element, 'url').text = str(patch.diff.url) + + return patch_element + @staticmethod def _add_properties_element(properties: List[Property], parent_element: ElementTree.Element) -> None: properties_e = ElementTree.SubElement(parent_element, 'properties') @@ -595,6 +677,17 @@ def _add_external_references_to_element(self, ext_refs: List[ExternalReference], if self.external_references_supports_hashes() and external_reference.get_hashes(): Xml._add_hashes_to_element(hashes=external_reference.get_hashes(), element=ext_ref_element) + @staticmethod + def _add_attached_text(attached_text: AttachedText, tag_name: str = 'text') -> ElementTree.Element: + element_attributes = {} + if attached_text.content_type: + element_attributes['content-type'] = attached_text.content_type + if attached_text.encoding: + element_attributes['encoding'] = attached_text.encoding.value + at_element = ElementTree.Element(tag_name, element_attributes) + at_element.text = attached_text.content + return at_element + @staticmethod def _add_hashes_to_element(hashes: List[HashType], element: ElementTree.Element) -> None: hashes_e = ElementTree.SubElement(element, 'hashes') diff --git a/tests/data.py b/tests/data.py index 0ab2c262..c5a80984 100644 --- a/tests/data.py +++ b/tests/data.py @@ -19,14 +19,16 @@ import base64 from datetime import datetime, timezone from decimal import Decimal -from typing import List +from typing import List, Optional from packageurl import PackageURL -from cyclonedx.model import Encoding, ExternalReference, ExternalReferenceType, HashType, LicenseChoice, Note, \ - NoteText, OrganizationalContact, OrganizationalEntity, Property, Tool, XsUri, DataClassification, DataFlow +from cyclonedx.model import AttachedText, DataClassification, DataFlow, Encoding, ExternalReference, \ + ExternalReferenceType, HashType, LicenseChoice, Note, NoteText, OrganizationalContact, OrganizationalEntity, \ + Property, Tool, XsUri from cyclonedx.model.bom import Bom -from cyclonedx.model.component import Component, ComponentType +from cyclonedx.model.component import Commit, Component, ComponentEvidence, ComponentType, Copyright, Patch, \ + PatchClassification, Pedigree, Swid from cyclonedx.model.issue import IssueClassification, IssueType from cyclonedx.model.release_note import ReleaseNotes from cyclonedx.model.service import Service @@ -45,63 +47,41 @@ def get_bom_with_component_setuptools_basic() -> Bom: - bom = Bom( - components=[Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), license_str='MIT License', author='Test Author' - )] - ) - return bom + return Bom(components=[get_component_setuptools_simple()]) def get_bom_with_component_setuptools_with_cpe() -> Bom: - bom = Bom( - components=[Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), license_str='MIT License', author='Test Author', - cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*' - )] - ) - return bom + component = get_component_setuptools_simple() + component.cpe = 'cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*' + return Bom(components=[component]) def get_bom_with_component_setuptools_no_component_version() -> Bom: - bom = Bom( - components=[Component( - name='setuptools', bom_ref='pkg:pypi/setuptools?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', qualifiers='extension=tar.gz' - ), license_str='MIT License', author='Test Author' - )] - ) - return bom + return Bom(components=[get_component_setuptools_simple_no_version()]) def get_bom_with_component_setuptools_with_release_notes() -> Bom: - bom = Bom( - components=[Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), license_str='MIT License', author='Test Author', - release_notes=get_release_notes() - )] - ) - return bom + component = get_component_setuptools_simple() + component.release_notes = get_release_notes() + return Bom(components=[component]) + + +def get_bom_with_component_setuptools_complete() -> Bom: + component = get_component_setuptools_simple() + component.cpe = 'cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*' + component.swid = get_swid_1() + component.pedigree = get_pedigree_1() + component.components = [ + get_component_setuptools_simple(), + get_component_toml_with_hashes_with_references() + ] + component.evidence = ComponentEvidence(copyright_=[Copyright(text='Commercial'), Copyright(text='Commercial 2')]) + return Bom(components=[component]) def get_bom_with_component_setuptools_with_vulnerability() -> Bom: bom = Bom() - component = Component( - name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' - ), license_str='MIT License', author='Test Author' - ) + component = get_component_setuptools_simple() vulnerability = Vulnerability( bom_ref='my-vuln-ref-1', id='CVE-2018-7489', source=get_vulnerability_source_nvd(), references=[ @@ -163,19 +143,7 @@ def get_bom_with_component_setuptools_with_vulnerability() -> Bom: def get_bom_with_component_toml_1() -> Bom: - bom = Bom(components=[ - Component( - name='toml', version='0.10.2', bom_ref='pkg:pypi/toml@0.10.2?extension=tar.gz', - purl=PackageURL( - type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' - ), hashes=[ - HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') - ], external_references=[ - get_external_reference_1() - ] - ) - ]) - return bom + return Bom(components=[get_component_toml_with_hashes_with_references()]) def get_bom_just_complete_metadata() -> Bom: @@ -276,6 +244,38 @@ def get_bom_with_nested_services() -> Bom: return bom +def get_component_setuptools_simple(bom_ref: Optional[str] = None) -> Component: + return Component( + name='setuptools', version='50.3.2', + bom_ref=bom_ref or 'pkg:pypi/setuptools@50.3.2?extension=tar.gz', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ), license_str='MIT License', author='Test Author' + ) + + +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', + purl=PackageURL( + type='pypi', name='setuptools', qualifiers='extension=tar.gz' + ), license_str='MIT License', author='Test Author' + ) + + +def get_component_toml_with_hashes_with_references(bom_ref: Optional[str] = None) -> Component: + return Component( + name='toml', version='0.10.2', bom_ref=bom_ref or 'pkg:pypi/toml@0.10.2?extension=tar.gz', + purl=PackageURL( + type='pypi', name='toml', version='0.10.2', qualifiers='extension=tar.gz' + ), hashes=[ + HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') + ], external_references=[ + get_external_reference_1() + ] + ) + + def get_external_reference_1() -> ExternalReference: return ExternalReference( reference_type=ExternalReferenceType.DISTRIBUTION, @@ -288,6 +288,30 @@ def get_external_reference_1() -> ExternalReference: ) +def get_issue_1() -> IssueType: + return IssueType( + classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', + description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', + source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), + references=[ + XsUri('https://logging.apache.org/log4j/2.x/security.html'), + XsUri('https://central.sonatype.org/news/20211213_log4shell_help') + ] + ) + + +def get_issue_2() -> IssueType: + return IssueType( + classification=IssueClassification.SECURITY, id='CVE-2021-44229', name='Apache Log4Shell', + description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', + source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), + references=[ + XsUri('https://logging.apache.org/log4j/2.x/security.html'), + XsUri('https://central.sonatype.org/news/20211213_log4shell_help') + ] + ) + + def get_org_entity_1() -> OrganizationalEntity: return OrganizationalEntity( name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[ @@ -298,6 +322,26 @@ def get_org_entity_1() -> OrganizationalEntity: ) +def get_pedigree_1() -> Pedigree: + return Pedigree( + ancestors=[ + get_component_setuptools_simple(bom_ref='ccc8d7ee-4b9c-4750-aee0-a72585152291'), + get_component_setuptools_simple_no_version(bom_ref='8a3893b3-9923-4adb-a1d3-47456636ba0a') + ], + descendants=[ + get_component_setuptools_simple_no_version(bom_ref='28b2d8ce-def0-446f-a221-58dee0b44acc'), + get_component_toml_with_hashes_with_references(bom_ref='555ca729-93c6-48f3-956e-bdaa4a2f0bfa') + ], + variants=[ + get_component_toml_with_hashes_with_references(bom_ref='e7abdcca-5ba2-4f29-b2cf-b1e1ef788e66'), + get_component_setuptools_simple(bom_ref='ded1d73e-1fca-4302-b520-f1bc53979958') + ], + commits=[Commit(uid='a-random-uid', message="A commit message")], + patches=[Patch(type_=PatchClassification.BACKPORT)], + notes='Some notes here please' + ) + + def get_properties_1() -> List[Property]: return [ Property(name='key1', value='val1'), @@ -319,17 +363,7 @@ def get_release_notes() -> ReleaseNotes: "First Test Release" ], tags=['test', 'alpha'], - resolves=[ - IssueType( - classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', - description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', - source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), - references=[ - XsUri('https://logging.apache.org/log4j/2.x/security.html'), - XsUri('https://central.sonatype.org/news/20211213_log4shell_help') - ] - ) - ], + resolves=[get_issue_1()], notes=[ Note( text=NoteText( @@ -348,6 +382,30 @@ def get_release_notes() -> ReleaseNotes: ) +def get_swid_1() -> Swid: + return Swid( + tag_id='swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1', name='Test Application', + version='3.4.5', text=AttachedText( + content='PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+CjxTb2Z0d2FyZUlkZW50aXR5IHhtbDpsYW5nPSJFTiIgbm' + 'FtZT0iQWNtZSBBcHBsaWNhdGlvbiIgdmVyc2lvbj0iOS4xLjEiIAogdmVyc2lvblNjaGVtZT0ibXVsdGlwYXJ0bnVtZXJpYyIg' + 'CiB0YWdJZD0ic3dpZGdlbi1iNTk1MWFjOS00MmMwLWYzODItM2YxZS1iYzdhMmE0NDk3Y2JfOS4xLjEiIAogeG1sbnM9Imh0dH' + 'A6Ly9zdGFuZGFyZHMuaXNvLm9yZy9pc28vMTk3NzAvLTIvMjAxNS9zY2hlbWEueHNkIj4gCiB4bWxuczp4c2k9Imh0dHA6Ly93' + 'd3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiAKIHhzaTpzY2hlbWFMb2NhdGlvbj0iaHR0cDovL3N0YW5kYXJkcy' + '5pc28ub3JnL2lzby8xOTc3MC8tMi8yMDE1LWN1cnJlbnQvc2NoZW1hLnhzZCBzY2hlbWEueHNkIiA+CiAgPE1ldGEgZ2VuZXJh' + 'dG9yPSJTV0lEIFRhZyBPbmxpbmUgR2VuZXJhdG9yIHYwLjEiIC8+IAogIDxFbnRpdHkgbmFtZT0iQWNtZSwgSW5jLiIgcmVnaW' + 'Q9ImV4YW1wbGUuY29tIiByb2xlPSJ0YWdDcmVhdG9yIiAvPiAKPC9Tb2Z0d2FyZUlkZW50aXR5Pg==', + content_type='text/xml', encoding=Encoding.BASE_64 + ) + ) + + +def get_swid_2() -> Swid: + return Swid( + tag_id='swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1', name='Test Application', + version='3.4.5', url=XsUri('https://cyclonedx.org') + ) + + def get_vulnerability_source_nvd() -> VulnerabilitySource: return VulnerabilitySource(name='NVD', url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2018-7489')) diff --git a/tests/fixtures/json/1.2/bom_setuptools_complete.json b/tests/fixtures/json/1.2/bom_setuptools_complete.json new file mode 100644 index 00000000..c0039feb --- /dev/null +++ b/tests/fixtures/json/1.2/bom_setuptools_complete.json @@ -0,0 +1,218 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "swid": { + "tagId": "swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1", + "name": "Test Application", + "version": "3.4.5", + "text": { + "contentType": "text/xml", + "encoding": "base64", + "content": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+CjxTb2Z0d2FyZUlkZW50aXR5IHhtbDpsYW5nPSJFTiIgbmFtZT0iQWNtZSBBcHBsaWNhdGlvbiIgdmVyc2lvbj0iOS4xLjEiIAogdmVyc2lvblNjaGVtZT0ibXVsdGlwYXJ0bnVtZXJpYyIgCiB0YWdJZD0ic3dpZGdlbi1iNTk1MWFjOS00MmMwLWYzODItM2YxZS1iYzdhMmE0NDk3Y2JfOS4xLjEiIAogeG1sbnM9Imh0dHA6Ly9zdGFuZGFyZHMuaXNvLm9yZy9pc28vMTk3NzAvLTIvMjAxNS9zY2hlbWEueHNkIj4gCiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiAKIHhzaTpzY2hlbWFMb2NhdGlvbj0iaHR0cDovL3N0YW5kYXJkcy5pc28ub3JnL2lzby8xOTc3MC8tMi8yMDE1LWN1cnJlbnQvc2NoZW1hLnhzZCBzY2hlbWEueHNkIiA+CiAgPE1ldGEgZ2VuZXJhdG9yPSJTV0lEIFRhZyBPbmxpbmUgR2VuZXJhdG9yIHYwLjEiIC8+IAogIDxFbnRpdHkgbmFtZT0iQWNtZSwgSW5jLiIgcmVnaWQ9ImV4YW1wbGUuY29tIiByb2xlPSJ0YWdDcmVhdG9yIiAvPiAKPC9Tb2Z0d2FyZUlkZW50aXR5Pg==" + } + }, + "pedigree": { + "ancestors": [ + { + "type": "library", + "bom-ref": "ccc8d7ee-4b9c-4750-aee0-a72585152291", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + }, + { + "type": "library", + "bom-ref": "8a3893b3-9923-4adb-a1d3-47456636ba0a", + "author": "Test Author", + "name": "setuptools", + "version": "", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools?extension=tar.gz" + } + ], + "descendants": [ + { + "type": "library", + "bom-ref": "28b2d8ce-def0-446f-a221-58dee0b44acc", + "author": "Test Author", + "name": "setuptools", + "version": "", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools?extension=tar.gz" + }, + { + "type": "library", + "bom-ref": "555ca729-93c6-48f3-956e-bdaa4a2f0bfa", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + } + ], + "variants": [ + { + "type": "library", + "bom-ref": "e7abdcca-5ba2-4f29-b2cf-b1e1ef788e66", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + }, + { + "type": "library", + "bom-ref": "ded1d73e-1fca-4302-b520-f1bc53979958", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ], + "commits": [ + { + "uid": "a-random-uid", + "message": "A commit message" + } + ], + "patches": [ + { + "type": "backport" + } + ], + "notes": "Some notes here please" + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + }, + { + "type": "library", + "bom-ref": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + } + ], + "evidence": { + "copyright": [ + { + "text": "Commercial" + }, + { + "text": "Commercial 2" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/json/1.3/bom_setuptools_complete.json b/tests/fixtures/json/1.3/bom_setuptools_complete.json new file mode 100644 index 00000000..b82097ae --- /dev/null +++ b/tests/fixtures/json/1.3/bom_setuptools_complete.json @@ -0,0 +1,218 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "swid": { + "tagId": "swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1", + "name": "Test Application", + "version": "3.4.5", + "text": { + "contentType": "text/xml", + "encoding": "base64", + "content": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+CjxTb2Z0d2FyZUlkZW50aXR5IHhtbDpsYW5nPSJFTiIgbmFtZT0iQWNtZSBBcHBsaWNhdGlvbiIgdmVyc2lvbj0iOS4xLjEiIAogdmVyc2lvblNjaGVtZT0ibXVsdGlwYXJ0bnVtZXJpYyIgCiB0YWdJZD0ic3dpZGdlbi1iNTk1MWFjOS00MmMwLWYzODItM2YxZS1iYzdhMmE0NDk3Y2JfOS4xLjEiIAogeG1sbnM9Imh0dHA6Ly9zdGFuZGFyZHMuaXNvLm9yZy9pc28vMTk3NzAvLTIvMjAxNS9zY2hlbWEueHNkIj4gCiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiAKIHhzaTpzY2hlbWFMb2NhdGlvbj0iaHR0cDovL3N0YW5kYXJkcy5pc28ub3JnL2lzby8xOTc3MC8tMi8yMDE1LWN1cnJlbnQvc2NoZW1hLnhzZCBzY2hlbWEueHNkIiA+CiAgPE1ldGEgZ2VuZXJhdG9yPSJTV0lEIFRhZyBPbmxpbmUgR2VuZXJhdG9yIHYwLjEiIC8+IAogIDxFbnRpdHkgbmFtZT0iQWNtZSwgSW5jLiIgcmVnaWQ9ImV4YW1wbGUuY29tIiByb2xlPSJ0YWdDcmVhdG9yIiAvPiAKPC9Tb2Z0d2FyZUlkZW50aXR5Pg==" + } + }, + "pedigree": { + "ancestors": [ + { + "type": "library", + "bom-ref": "ccc8d7ee-4b9c-4750-aee0-a72585152291", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + }, + { + "type": "library", + "bom-ref": "8a3893b3-9923-4adb-a1d3-47456636ba0a", + "author": "Test Author", + "name": "setuptools", + "version": "", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools?extension=tar.gz" + } + ], + "descendants": [ + { + "type": "library", + "bom-ref": "28b2d8ce-def0-446f-a221-58dee0b44acc", + "author": "Test Author", + "name": "setuptools", + "version": "", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools?extension=tar.gz" + }, + { + "type": "library", + "bom-ref": "555ca729-93c6-48f3-956e-bdaa4a2f0bfa", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + } + ], + "variants": [ + { + "type": "library", + "bom-ref": "e7abdcca-5ba2-4f29-b2cf-b1e1ef788e66", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + }, + { + "type": "library", + "bom-ref": "ded1d73e-1fca-4302-b520-f1bc53979958", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ], + "commits": [ + { + "uid": "a-random-uid", + "message": "A commit message" + } + ], + "patches": [ + { + "type": "backport" + } + ], + "notes": "Some notes here please" + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + }, + { + "type": "library", + "bom-ref": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + } + ], + "evidence": { + "copyright": [ + { + "text": "Commercial" + }, + { + "text": "Commercial 2" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/json/1.4/bom_setuptools_complete.json b/tests/fixtures/json/1.4/bom_setuptools_complete.json new file mode 100644 index 00000000..ae228504 --- /dev/null +++ b/tests/fixtures/json/1.4/bom_setuptools_complete.json @@ -0,0 +1,252 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-python-lib/" + }, + { + "type": "documentation", + "url": "https://cyclonedx.github.io/cyclonedx-python-lib/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib" + }, + { + "type": "website", + "url": "https://cyclonedx.org" + } + ] + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "swid": { + "tagId": "swidgen-242eb18a-503e-ca37-393b-cf156ef09691_9.1.1", + "name": "Test Application", + "version": "3.4.5", + "text": { + "contentType": "text/xml", + "encoding": "base64", + "content": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+CjxTb2Z0d2FyZUlkZW50aXR5IHhtbDpsYW5nPSJFTiIgbmFtZT0iQWNtZSBBcHBsaWNhdGlvbiIgdmVyc2lvbj0iOS4xLjEiIAogdmVyc2lvblNjaGVtZT0ibXVsdGlwYXJ0bnVtZXJpYyIgCiB0YWdJZD0ic3dpZGdlbi1iNTk1MWFjOS00MmMwLWYzODItM2YxZS1iYzdhMmE0NDk3Y2JfOS4xLjEiIAogeG1sbnM9Imh0dHA6Ly9zdGFuZGFyZHMuaXNvLm9yZy9pc28vMTk3NzAvLTIvMjAxNS9zY2hlbWEueHNkIj4gCiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiAKIHhzaTpzY2hlbWFMb2NhdGlvbj0iaHR0cDovL3N0YW5kYXJkcy5pc28ub3JnL2lzby8xOTc3MC8tMi8yMDE1LWN1cnJlbnQvc2NoZW1hLnhzZCBzY2hlbWEueHNkIiA+CiAgPE1ldGEgZ2VuZXJhdG9yPSJTV0lEIFRhZyBPbmxpbmUgR2VuZXJhdG9yIHYwLjEiIC8+IAogIDxFbnRpdHkgbmFtZT0iQWNtZSwgSW5jLiIgcmVnaWQ9ImV4YW1wbGUuY29tIiByb2xlPSJ0YWdDcmVhdG9yIiAvPiAKPC9Tb2Z0d2FyZUlkZW50aXR5Pg==" + } + }, + "pedigree": { + "ancestors": [ + { + "type": "library", + "bom-ref": "ccc8d7ee-4b9c-4750-aee0-a72585152291", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + }, + { + "type": "library", + "bom-ref": "8a3893b3-9923-4adb-a1d3-47456636ba0a", + "author": "Test Author", + "name": "setuptools", + "version": "", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools?extension=tar.gz" + } + ], + "descendants": [ + { + "type": "library", + "bom-ref": "28b2d8ce-def0-446f-a221-58dee0b44acc", + "author": "Test Author", + "name": "setuptools", + "version": "", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools?extension=tar.gz" + }, + { + "type": "library", + "bom-ref": "555ca729-93c6-48f3-956e-bdaa4a2f0bfa", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + } + ], + "variants": [ + { + "type": "library", + "bom-ref": "e7abdcca-5ba2-4f29-b2cf-b1e1ef788e66", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + }, + { + "type": "library", + "bom-ref": "ded1d73e-1fca-4302-b520-f1bc53979958", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ], + "commits": [ + { + "uid": "a-random-uid", + "message": "A commit message" + } + ], + "patches": [ + { + "type": "backport" + } + ], + "notes": "Some notes here please" + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "licenses": [ + { + "expression": "MIT License" + } + ], + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + }, + { + "type": "library", + "bom-ref": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "name": "toml", + "version": "0.10.2", + "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ] + } + ], + "evidence": { + "copyright": [ + { + "text": "Commercial" + }, + { + "text": "Commercial 2" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/xml/1.0/bom_setuptools_complete.xml b/tests/fixtures/xml/1.0/bom_setuptools_complete.xml new file mode 100644 index 00000000..3c617136 --- /dev/null +++ b/tests/fixtures/xml/1.0/bom_setuptools_complete.xml @@ -0,0 +1,12 @@ + + + + + setuptools + 50.3.2 + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + false + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.1/bom_setuptools_complete.xml b/tests/fixtures/xml/1.1/bom_setuptools_complete.xml new file mode 100644 index 00000000..a7aee096 --- /dev/null +++ b/tests/fixtures/xml/1.1/bom_setuptools_complete.xml @@ -0,0 +1,89 @@ + + + + + setuptools + 50.3.2 + + MIT License + + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + setuptools + + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + + + setuptools + + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + + + + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + + + + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + + a-random-uid + A commit message + + + Some notes here please + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.2/bom_setuptools_complete.xml b/tests/fixtures/xml/1.2/bom_setuptools_complete.xml new file mode 100644 index 00000000..4cb2b93e --- /dev/null +++ b/tests/fixtures/xml/1.2/bom_setuptools_complete.xml @@ -0,0 +1,110 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+CjxTb2Z0d2FyZUlkZW50aXR5IHhtbDpsYW5nPSJFTiIgbmFtZT0iQWNtZSBBcHBsaWNhdGlvbiIgdmVyc2lvbj0iOS4xLjEiIAogdmVyc2lvblNjaGVtZT0ibXVsdGlwYXJ0bnVtZXJpYyIgCiB0YWdJZD0ic3dpZGdlbi1iNTk1MWFjOS00MmMwLWYzODItM2YxZS1iYzdhMmE0NDk3Y2JfOS4xLjEiIAogeG1sbnM9Imh0dHA6Ly9zdGFuZGFyZHMuaXNvLm9yZy9pc28vMTk3NzAvLTIvMjAxNS9zY2hlbWEueHNkIj4gCiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiAKIHhzaTpzY2hlbWFMb2NhdGlvbj0iaHR0cDovL3N0YW5kYXJkcy5pc28ub3JnL2lzby8xOTc3MC8tMi8yMDE1LWN1cnJlbnQvc2NoZW1hLnhzZCBzY2hlbWEueHNkIiA+CiAgPE1ldGEgZ2VuZXJhdG9yPSJTV0lEIFRhZyBPbmxpbmUgR2VuZXJhdG9yIHYwLjEiIC8+IAogIDxFbnRpdHkgbmFtZT0iQWNtZSwgSW5jLiIgcmVnaWQ9ImV4YW1wbGUuY29tIiByb2xlPSJ0YWdDcmVhdG9yIiAvPiAKPC9Tb2Z0d2FyZUlkZW50aXR5Pg== + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + Test Author + setuptools + + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + + + Test Author + setuptools + + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + + + + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + + a-random-uid + A commit message + + + + + + Some notes here please + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.3/bom_setuptools_complete.xml b/tests/fixtures/xml/1.3/bom_setuptools_complete.xml new file mode 100644 index 00000000..675ddf93 --- /dev/null +++ b/tests/fixtures/xml/1.3/bom_setuptools_complete.xml @@ -0,0 +1,116 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+CjxTb2Z0d2FyZUlkZW50aXR5IHhtbDpsYW5nPSJFTiIgbmFtZT0iQWNtZSBBcHBsaWNhdGlvbiIgdmVyc2lvbj0iOS4xLjEiIAogdmVyc2lvblNjaGVtZT0ibXVsdGlwYXJ0bnVtZXJpYyIgCiB0YWdJZD0ic3dpZGdlbi1iNTk1MWFjOS00MmMwLWYzODItM2YxZS1iYzdhMmE0NDk3Y2JfOS4xLjEiIAogeG1sbnM9Imh0dHA6Ly9zdGFuZGFyZHMuaXNvLm9yZy9pc28vMTk3NzAvLTIvMjAxNS9zY2hlbWEueHNkIj4gCiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiAKIHhzaTpzY2hlbWFMb2NhdGlvbj0iaHR0cDovL3N0YW5kYXJkcy5pc28ub3JnL2lzby8xOTc3MC8tMi8yMDE1LWN1cnJlbnQvc2NoZW1hLnhzZCBzY2hlbWEueHNkIiA+CiAgPE1ldGEgZ2VuZXJhdG9yPSJTV0lEIFRhZyBPbmxpbmUgR2VuZXJhdG9yIHYwLjEiIC8+IAogIDxFbnRpdHkgbmFtZT0iQWNtZSwgSW5jLiIgcmVnaWQ9ImV4YW1wbGUuY29tIiByb2xlPSJ0YWdDcmVhdG9yIiAvPiAKPC9Tb2Z0d2FyZUlkZW50aXR5Pg== + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + Test Author + setuptools + + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + + + Test Author + setuptools + + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + + a-random-uid + A commit message + + + + + + Some notes here please + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.4/bom_setuptools_complete.xml b/tests/fixtures/xml/1.4/bom_setuptools_complete.xml new file mode 100644 index 00000000..4136bbf1 --- /dev/null +++ b/tests/fixtures/xml/1.4/bom_setuptools_complete.xml @@ -0,0 +1,140 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + https://github.com/CycloneDX/cyclonedx-python-lib/actions + + + https://pypi.org/project/cyclonedx-python-lib/ + + + https://cyclonedx.github.io/cyclonedx-python-lib/ + + + https://github.com/CycloneDX/cyclonedx-python-lib/issues + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md + + + https://github.com/CycloneDX/cyclonedx-python-lib + + + https://cyclonedx.org + + + + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+CjxTb2Z0d2FyZUlkZW50aXR5IHhtbDpsYW5nPSJFTiIgbmFtZT0iQWNtZSBBcHBsaWNhdGlvbiIgdmVyc2lvbj0iOS4xLjEiIAogdmVyc2lvblNjaGVtZT0ibXVsdGlwYXJ0bnVtZXJpYyIgCiB0YWdJZD0ic3dpZGdlbi1iNTk1MWFjOS00MmMwLWYzODItM2YxZS1iYzdhMmE0NDk3Y2JfOS4xLjEiIAogeG1sbnM9Imh0dHA6Ly9zdGFuZGFyZHMuaXNvLm9yZy9pc28vMTk3NzAvLTIvMjAxNS9zY2hlbWEueHNkIj4gCiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiAKIHhzaTpzY2hlbWFMb2NhdGlvbj0iaHR0cDovL3N0YW5kYXJkcy5pc28ub3JnL2lzby8xOTc3MC8tMi8yMDE1LWN1cnJlbnQvc2NoZW1hLnhzZCBzY2hlbWEueHNkIiA+CiAgPE1ldGEgZ2VuZXJhdG9yPSJTV0lEIFRhZyBPbmxpbmUgR2VuZXJhdG9yIHYwLjEiIC8+IAogIDxFbnRpdHkgbmFtZT0iQWNtZSwgSW5jLiIgcmVnaWQ9ImV4YW1wbGUuY29tIiByb2xlPSJ0YWdDcmVhdG9yIiAvPiAKPC9Tb2Z0d2FyZUlkZW50aXR5Pg== + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + Test Author + setuptools + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + + + Test Author + setuptools + + MIT License + + pkg:pypi/setuptools?extension=tar.gz + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + + a-random-uid + A commit message + + + + + + Some notes here please + + + + \ No newline at end of file diff --git a/tests/test_model.py b/tests/test_model.py index 620cb661..8eb60ac7 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -17,15 +17,32 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. import base64 +import datetime +from time import sleep from unittest import TestCase -from cyclonedx.exception.model import InvalidLocaleTypeException, InvalidUriException, UnknownHashTypeException - -from cyclonedx.model import Encoding, ExternalReference, ExternalReferenceType, HashAlgorithm, HashType, Note, \ - NoteText, XsUri +from cyclonedx.exception.model import InvalidLocaleTypeException, InvalidUriException, UnknownHashTypeException, \ + NoPropertiesProvidedException +from cyclonedx.model import Copyright, Encoding, ExternalReference, ExternalReferenceType, HashAlgorithm, HashType, \ + IdentifiableAction, Note, NoteText, XsUri from cyclonedx.model.issue import IssueClassification, IssueType +class TestModelCopyright(TestCase): + + def test_same(self) -> None: + copy_1 = Copyright(text='Copyright (c) OWASP Foundation. All Rights Reserved.') + copy_2 = Copyright(text='Copyright (c) OWASP Foundation. All Rights Reserved.') + self.assertEqual(hash(copy_1), hash(copy_2)) + self.assertTrue(copy_1 == copy_2) + + def test_not_same(self) -> None: + copy_1 = Copyright(text='Copyright (c) OWASP Foundation. All Rights Reserved.') + copy_2 = Copyright(text='Copyright (c) OWASP Foundation.') + self.assertNotEqual(hash(copy_1), hash(copy_2)) + self.assertFalse(copy_1 == copy_2) + + class TestModelExternalReference(TestCase): def test_external_reference_with_str(self) -> None: @@ -42,6 +59,36 @@ def test_external_reference_with_xsuri(self) -> None: self.assertEqual(e.get_comment(), '') self.assertListEqual(e.get_hashes(), []) + def test_same(self) -> None: + ref_1 = ExternalReference( + reference_type=ExternalReferenceType.OTHER, + url='https://cyclonedx.org', + comment='No comment' + ) + ref_2 = ExternalReference( + reference_type=ExternalReferenceType.OTHER, + url='https://cyclonedx.org', + comment='No comment' + ) + self.assertNotEqual(id(ref_1), id(ref_2)) + self.assertEqual(hash(ref_1), hash(ref_2)) + self.assertTrue(ref_1 == ref_2) + + def test_not_same(self) -> None: + ref_1 = ExternalReference( + reference_type=ExternalReferenceType.OTHER, + url='https://cyclonedx.org', + comment='No comment' + ) + ref_2 = ExternalReference( + reference_type=ExternalReferenceType.OTHER, + url='https://cyclonedx.org/', + comment='No comment' + ) + self.assertNotEqual(id(ref_1), id(ref_2)) + self.assertNotEqual(hash(ref_1), hash(ref_2)) + self.assertFalse(ref_1 == ref_2) + class TestModelHashType(TestCase): @@ -60,6 +107,27 @@ def test_hash_type_from_unknown(self) -> None: HashType.from_composite_str('unknown:dc26cd71b80d6757139f38156a43c545') +class TestModelIdentifiableAction(TestCase): + + def test_no_params(self) -> None: + with self.assertRaises(NoPropertiesProvidedException): + IdentifiableAction() + + def test_same(self) -> None: + ts = datetime.datetime.utcnow() + ia_1 = IdentifiableAction(timestamp=ts, name='A Name', email='something@somewhere.tld') + ia_2 = IdentifiableAction(timestamp=ts, name='A Name', email='something@somewhere.tld') + self.assertEqual(hash(ia_1), hash(ia_2)) + self.assertTrue(ia_1 == ia_2) + + def test_not_same(self) -> None: + ia_1 = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='A Name', email='something@somewhere.tld') + sleep(1) + ia_2 = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='A Name', email='something@somewhere.tld') + self.assertNotEqual(hash(ia_1), hash(ia_2)) + self.assertFalse(ia_1 == ia_2) + + class TestModelIssueType(TestCase): def test_issue_type(self) -> None: diff --git a/tests/test_model_component.py b/tests/test_model_component.py index 21b9d28d..07a111fa 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -16,12 +16,40 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. +import datetime from typing import List from unittest import TestCase from unittest.mock import Mock, patch -from cyclonedx.model import ExternalReference, ExternalReferenceType, Property -from cyclonedx.model.component import Component, ComponentType +from cyclonedx.exception.model import NoPropertiesProvidedException +from cyclonedx.model import AttachedText, Copyright, ExternalReference, ExternalReferenceType, \ + IdentifiableAction, Property, XsUri +from cyclonedx.model.component import Commit, Component, ComponentEvidence, ComponentType, Diff, Patch, \ + PatchClassification, Pedigree +from data import get_component_setuptools_simple, get_component_setuptools_simple_no_version, \ + get_component_toml_with_hashes_with_references, get_issue_1, get_issue_2, get_pedigree_1, get_swid_1, get_swid_2 + + +class TestModelCommit(TestCase): + + def test_no_parameters(self) -> None: + with self.assertRaises(NoPropertiesProvidedException): + Commit() + + def test_same(self) -> None: + ia_comitter = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='The Committer') + c1 = Commit(uid='a-random-uid', author=ia_comitter, committer=ia_comitter, message="A commit message") + c2 = Commit(uid='a-random-uid', author=ia_comitter, committer=ia_comitter, message="A commit message") + self.assertEqual(hash(c1), hash(c2)) + self.assertTrue(c1 == c2) + + def test_not_same(self) -> None: + ia_author = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='The Author') + ia_comitter = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='The Committer') + c1 = Commit(uid='a-random-uid', author=ia_comitter, committer=ia_comitter, message="A commit message") + c2 = Commit(uid='a-random-uid', author=ia_author, committer=ia_comitter, message="A commit message") + self.assertNotEqual(hash(c1), hash(c2)) + self.assertFalse(c1 == c2) class TestModelComponent(TestCase): @@ -167,3 +195,169 @@ def test_component_equal_3(self) -> None: ) self.assertNotEqual(c, c2) + + def test_same_1(self) -> None: + c1 = get_component_setuptools_simple() + c2 = get_component_setuptools_simple() + self.assertNotEqual(id(c1), id(c2)) + self.assertEqual(hash(c1), hash(c2)) + self.assertTrue(c1 == c2) + + def test_same_2(self) -> None: + c1 = get_component_toml_with_hashes_with_references() + c2 = get_component_toml_with_hashes_with_references() + self.assertNotEqual(id(c1), id(c2)) + self.assertEqual(hash(c1), hash(c2)) + self.assertTrue(c1 == c2) + + def test_same_3(self) -> None: + c1 = get_component_setuptools_simple_no_version() + c2 = get_component_setuptools_simple_no_version() + self.assertNotEqual(id(c1), id(c2)) + self.assertEqual(hash(c1), hash(c2)) + self.assertTrue(c1 == c2) + + def test_not_same_1(self) -> None: + c1 = get_component_setuptools_simple() + c2 = get_component_setuptools_simple_no_version() + self.assertNotEqual(id(c1), id(c2)) + self.assertNotEqual(hash(c1), hash(c2)) + self.assertFalse(c1 == c2) + + +class TestModelComponentEvidence(TestCase): + + def test_no_params(self) -> None: + with self.assertRaises(NoPropertiesProvidedException): + ComponentEvidence() + + def test_same_1(self) -> None: + ce_1 = ComponentEvidence(copyright_=[Copyright(text='Commercial')]) + ce_2 = ComponentEvidence(copyright_=[Copyright(text='Commercial')]) + self.assertEqual(hash(ce_1), hash(ce_2)) + self.assertTrue(ce_1 == ce_2) + + def test_same_2(self) -> None: + ce_1 = ComponentEvidence(copyright_=[Copyright(text='Commercial'), Copyright(text='Commercial 2')]) + ce_2 = ComponentEvidence(copyright_=[Copyright(text='Commercial 2'), Copyright(text='Commercial')]) + self.assertEqual(hash(ce_1), hash(ce_2)) + self.assertTrue(ce_1 == ce_2) + + def test_not_same_1(self) -> None: + ce_1 = ComponentEvidence(copyright_=[Copyright(text='Commercial')]) + ce_2 = ComponentEvidence(copyright_=[Copyright(text='Commercial 2')]) + self.assertNotEqual(hash(ce_1), hash(ce_2)) + self.assertFalse(ce_1 == ce_2) + + +class TestModelDiff(TestCase): + + def test_no_params(self) -> None: + with self.assertRaises(NoPropertiesProvidedException): + Diff() + + def test_same(self) -> None: + at = AttachedText(content='A very long diff') + diff_1 = Diff(text=at, url=XsUri('https://cyclonedx.org')) + diff_2 = Diff(text=at, url=XsUri('https://cyclonedx.org')) + self.assertEqual(hash(diff_1), hash(diff_2)) + self.assertTrue(diff_1 == diff_2) + + def test_not_same(self) -> None: + at = AttachedText(content='A very long diff') + diff_1 = Diff(text=at, url=XsUri('https://cyclonedx.org/')) + diff_2 = Diff(text=at, url=XsUri('https://cyclonedx.org')) + self.assertNotEqual(hash(diff_1), hash(diff_2)) + self.assertFalse(diff_1 == diff_2) + + +class TestModelPatch(TestCase): + + def test_same_1(self) -> None: + p1 = Patch( + type_=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_1(), get_issue_2()] + ) + p2 = Patch( + type_=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_2(), get_issue_1()] + ) + self.assertEqual(hash(p1), hash(p2)) + self.assertNotEqual(id(p1), id(p2)) + self.assertTrue(p1 == p2) + + def test_multiple_times_same(self) -> None: + i = 0 + while i < 1000: + p1 = Patch( + type_=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_1(), get_issue_2()] + ) + p2 = Patch( + type_=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_2(), get_issue_1()] + ) + self.assertEqual(hash(p1), hash(p2)) + self.assertNotEqual(id(p1), id(p2)) + self.assertTrue(p1 == p2) + + i += 1 + + def test_not_same_1(self) -> None: + p1 = Patch( + type_=PatchClassification.MONKEY, diff=Diff(url=XsUri('https://cyclonedx.org/')), + resolves=[get_issue_1(), get_issue_2()] + ) + p2 = Patch( + type_=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_2(), get_issue_1()] + ) + self.assertNotEqual(hash(p1), hash(p2)) + self.assertNotEqual(id(p1), id(p2)) + self.assertFalse(p1 == p2) + + +class TestModelPedigree(TestCase): + + def test_no_params(self) -> None: + with self.assertRaises(NoPropertiesProvidedException): + Pedigree() + + def test_same_1(self) -> None: + p1 = get_pedigree_1() + p2 = get_pedigree_1() + self.assertNotEqual(id(p1), id(p2)) + self.assertEqual(hash(p1), hash(p2)) + self.assertTrue(p1 == p2) + + def test_not_same_1(self) -> None: + p1 = get_pedigree_1() + p2 = get_pedigree_1() + p2.notes = 'Some other notes here' + self.assertNotEqual(id(p1), id(p2)) + self.assertNotEqual(hash(p1), hash(p2)) + self.assertFalse(p1 == p2) + + +class TestModelSwid(TestCase): + + def test_same_1(self) -> None: + sw_1 = get_swid_1() + sw_2 = get_swid_1() + self.assertNotEqual(id(sw_1), id(sw_2)) + self.assertEqual(hash(sw_1), hash(sw_2)) + self.assertTrue(sw_1 == sw_2) + + def test_same_2(self) -> None: + sw_1 = get_swid_2() + sw_2 = get_swid_2() + self.assertNotEqual(id(sw_1), id(sw_2)) + self.assertEqual(hash(sw_1), hash(sw_2)) + self.assertTrue(sw_1 == sw_2) + + def test_not_same(self) -> None: + sw_1 = get_swid_1() + sw_2 = get_swid_2() + self.assertNotEqual(id(sw_1), id(sw_2)) + self.assertNotEqual(hash(sw_1), hash(sw_2)) + self.assertFalse(sw_1 == sw_2) diff --git a/tests/test_model_issue.py b/tests/test_model_issue.py new file mode 100644 index 00000000..18fddf1b --- /dev/null +++ b/tests/test_model_issue.py @@ -0,0 +1,63 @@ +# encoding: utf-8 + +# This file is part of CycloneDX Python Lib +# +# 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.exception.model import NoPropertiesProvidedException +from cyclonedx.model import XsUri +from cyclonedx.model.issue import IssueTypeSource + +from data import get_issue_1, get_issue_2 + + +class TestModelIssueType(TestCase): + + def test_same(self) -> None: + i_1 = get_issue_1() + i_2 = get_issue_1() + self.assertNotEqual(id(i_1), id(i_2)) + self.assertEqual(hash(i_1), hash(i_2)) + self.assertTrue(i_1 == i_2) + + def test_not_same(self) -> None: + i_1 = get_issue_1() + i_2 = get_issue_2() + self.assertNotEqual(id(i_1), id(i_2)) + self.assertNotEqual(hash(i_1), hash(i_2)) + self.assertFalse(i_1 == i_2) + + +class TestModelIssueTypeSource(TestCase): + + def test_no_params(self) -> None: + with self.assertRaises(NoPropertiesProvidedException): + IssueTypeSource() + + def test_same(self) -> None: + its_1 = IssueTypeSource(name="The Source", url=XsUri('https://cyclonedx.org')) + its_2 = IssueTypeSource(name="The Source", url=XsUri('https://cyclonedx.org')) + self.assertNotEqual(id(its_1), id(its_2)) + self.assertEqual(hash(its_1), hash(its_2)) + self.assertTrue(its_1 == its_2) + + def test_not_same(self) -> None: + its_1 = IssueTypeSource(name="The Source", url=XsUri('https://cyclonedx.org')) + its_2 = IssueTypeSource(name="Not the Source", url=XsUri('https://cyclonedx.org')) + self.assertNotEqual(id(its_1), id(its_2)) + self.assertNotEqual(hash(its_1), hash(its_2)) + self.assertFalse(its_1 == its_2) diff --git a/tests/test_output_json.py b/tests/test_output_json.py index 2c179261..205164fc 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -27,7 +27,7 @@ get_bom_with_component_setuptools_no_component_version, \ get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ MOCK_UUID_1, get_bom_just_complete_metadata, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, \ - get_bom_with_services_complex, MOCK_UUID_6, get_bom_with_nested_services + get_bom_with_services_complex, MOCK_UUID_6, get_bom_with_nested_services, get_bom_with_component_setuptools_complete from tests.base import BaseJsonTestCase @@ -79,6 +79,25 @@ def test_simple_bom_v1_2_with_cpe(self) -> None: fixture='bom_setuptools_with_cpe.json' ) + def test_bom_v1_4_full_component(self) -> None: + self.maxDiff = None + self._validate_json_bom( + bom=get_bom_with_component_setuptools_complete(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_complete.json' + ) + + def test_bom_v1_3_full_component(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_setuptools_complete(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools_complete.json' + ) + + def test_bom_v1_2_full_component(self) -> None: + self._validate_json_bom( + bom=get_bom_with_component_setuptools_complete(), schema_version=SchemaVersion.V1_2, + fixture='bom_setuptools_complete.json' + ) + def test_bom_v1_4_component_hashes_external_references(self) -> None: self._validate_json_bom( bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_4, diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index f3297c79..915a01ec 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -25,7 +25,8 @@ get_bom_with_component_toml_1, get_bom_with_component_setuptools_no_component_version, \ get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ MOCK_UUID_1, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6, get_bom_just_complete_metadata, \ - get_bom_with_nested_services, get_bom_with_services_simple, get_bom_with_services_complex + get_bom_with_nested_services, get_bom_with_services_simple, get_bom_with_services_complex, \ + get_bom_with_component_setuptools_complete from tests.base import BaseXmlTestCase TEST_UUIDS = [ @@ -95,6 +96,36 @@ def test_simple_bom_v1_0_with_cpe(self) -> None: fixture='bom_setuptools_with_cpe.xml' ) + def test_bom_v1_4_full_component(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_complete(), schema_version=SchemaVersion.V1_4, + fixture='bom_setuptools_complete.xml' + ) + + def test_bom_v1_3_full_component(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_complete(), schema_version=SchemaVersion.V1_3, + fixture='bom_setuptools_complete.xml' + ) + + def test_bom_v1_2_full_component(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_complete(), schema_version=SchemaVersion.V1_2, + fixture='bom_setuptools_complete.xml' + ) + + def test_bom_v1_1_full_component(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_complete(), schema_version=SchemaVersion.V1_1, + fixture='bom_setuptools_complete.xml' + ) + + def test_bom_v1_0_full_component(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_component_setuptools_complete(), schema_version=SchemaVersion.V1_0, + fixture='bom_setuptools_complete.xml' + ) + def test_bom_v1_4_component_hashes_external_references(self) -> None: self._validate_xml_bom( bom=get_bom_with_component_toml_1(), schema_version=SchemaVersion.V1_4, From 1b733d75a78e3757010a8049cab5c7d4656dc2a5 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Wed, 2 Feb 2022 17:58:20 +0000 Subject: [PATCH 13/29] feat: support for `bom.externalReferences` in JSON and XML #124 Signed-off-by: Paul Horton --- cyclonedx/model/bom.py | 33 ++++++++- cyclonedx/output/json.py | 7 ++ cyclonedx/output/schema.py | 6 ++ cyclonedx/output/xml.py | 20 +++--- tests/data.py | 14 ++++ .../json/1.2/bom_external_references.json | 29 ++++++++ .../json/1.3/bom_external_references.json | 35 ++++++++++ .../json/1.4/bom_external_references.json | 69 +++++++++++++++++++ .../xml/1.1/bom_external_references.xml | 13 ++++ .../xml/1.2/bom_external_references.xml | 23 +++++++ .../xml/1.3/bom_external_references.xml | 26 +++++++ .../xml/1.4/bom_external_references.xml | 52 ++++++++++++++ tests/test_bom.py | 8 +++ tests/test_output_json.py | 21 +++++- tests/test_output_xml.py | 32 ++++++++- 15 files changed, 372 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/json/1.2/bom_external_references.json create mode 100644 tests/fixtures/json/1.3/bom_external_references.json create mode 100644 tests/fixtures/json/1.4/bom_external_references.json create mode 100644 tests/fixtures/xml/1.1/bom_external_references.xml create mode 100644 tests/fixtures/xml/1.2/bom_external_references.xml create mode 100644 tests/fixtures/xml/1.3/bom_external_references.xml create mode 100644 tests/fixtures/xml/1.4/bom_external_references.xml diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index d87dc843..558cc794 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -21,7 +21,7 @@ from typing import cast, List, Optional from uuid import uuid4, UUID -from . import ThisTool, Tool +from . import ExternalReference, ThisTool, Tool from .component import Component from .service import Service from ..parser import BaseParser @@ -149,7 +149,8 @@ def from_parser(parser: BaseParser) -> 'Bom': bom.add_components(parser.get_components()) return bom - def __init__(self, components: Optional[List[Component]] = None, services: Optional[List[Service]] = None) -> None: + def __init__(self, components: Optional[List[Component]] = None, services: Optional[List[Service]] = None, + external_references: Optional[List[ExternalReference]] = None) -> None: """ Create a new Bom that you can manually/programmatically add data to later. @@ -160,6 +161,7 @@ def __init__(self, components: Optional[List[Component]] = None, services: Optio self.metadata = BomMetaData() self.components = components self.services = services + self.external_references = external_references @property def uuid(self) -> UUID: @@ -360,6 +362,33 @@ def service_count(self) -> int: return len(self.services) + @property + def external_references(self) -> Optional[List[ExternalReference]]: + """ + Provides the ability to document external references related to the BOM or to the project the BOM describes. + + Returns: + List of `ExternalReference` else `None` + """ + return self._external_references + + @external_references.setter + def external_references(self, external_references: Optional[List[ExternalReference]]) -> None: + self._external_references = external_references + + def add_external_reference(self, external_reference: ExternalReference) -> None: + """ + Add an external reference to this Bom. + + Args: + external_reference: + `ExternalReference` to add to this Bom. + + Returns: + None + """ + self.external_references = (self.external_references or []) + [external_reference] + def has_vulnerabilities(self) -> bool: """ Check whether this Bom has any declared vulnerabilities. diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py index f3acd8bc..cdf44c81 100644 --- a/cyclonedx/output/json.py +++ b/cyclonedx/output/json.py @@ -113,6 +113,13 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str if not self.services_supports_release_notes() and 'releaseNotes' in bom_json['services'][i].keys(): del bom_json['services'][i]['releaseNotes'] + # Iterate externalReferences + if 'externalReferences' in bom_json.keys(): + for i in range(len(bom_json['externalReferences'])): + if not self.external_references_supports_hashes() \ + and 'hashes' in bom_json['externalReferences'][i].keys(): + del bom_json['externalReferences'][i]['hashes'] + # Iterate Vulnerabilities if 'vulnerabilities' in bom_json.keys(): for i in range(len(bom_json['vulnerabilities'])): diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py index da46314f..2f2baa35 100644 --- a/cyclonedx/output/schema.py +++ b/cyclonedx/output/schema.py @@ -41,6 +41,9 @@ def bom_metadata_supports_tools_external_references(self) -> bool: def bom_supports_services(self) -> bool: return True + def bom_supports_external_references(self) -> bool: + return True + def services_supports_properties(self) -> bool: return True @@ -236,6 +239,9 @@ def bom_metadata_supports_tools_external_references(self) -> bool: def bom_supports_services(self) -> bool: return False + def bom_supports_external_references(self) -> bool: + return False + def services_supports_properties(self) -> bool: return False diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 873c2e01..f6a7cd9c 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -84,6 +84,13 @@ def generate(self, force_regeneration: bool = False) -> None: for service in cast(List[Service], self.get_bom().services): services_element.append(self._add_service_element(service=service)) + if self.bom_supports_external_references(): + if self.get_bom().external_references: + self._add_external_references_to_element( + ext_refs=cast(List[ExternalReference], self.get_bom().external_references), + element=self._root_bom_element + ) + if self.bom_supports_vulnerabilities() and has_vulnerabilities: vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities') for component in cast(List[Component], self.get_bom().components): @@ -276,18 +283,7 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: # externalReferences if self.component_supports_external_references() and len(component.external_references) > 0: - external_references_e = ElementTree.SubElement(component_element, 'externalReferences') - for ext_ref in component.external_references: - external_reference_e = ElementTree.SubElement( - external_references_e, 'reference', {'type': ext_ref.get_reference_type().value} - ) - ElementTree.SubElement(external_reference_e, 'url').text = ext_ref.get_url() - - if ext_ref.get_comment(): - ElementTree.SubElement(external_reference_e, 'comment').text = ext_ref.get_comment() - - if self.external_references_supports_hashes() and len(ext_ref.get_hashes()) > 0: - Xml._add_hashes_to_element(hashes=ext_ref.get_hashes(), element=external_reference_e) + self._add_external_references_to_element(ext_refs=component.external_references, element=component_element) # releaseNotes if self.component_supports_release_notes() and component.release_notes: diff --git a/tests/data.py b/tests/data.py index c5a80984..18bba462 100644 --- a/tests/data.py +++ b/tests/data.py @@ -154,6 +154,13 @@ def get_bom_just_complete_metadata() -> Bom: return bom +def get_bom_with_external_references() -> Bom: + bom = Bom(external_references=[ + get_external_reference_1(), get_external_reference_2() + ]) + return bom + + def get_bom_with_services_simple() -> Bom: bom = Bom(services=[ Service(name='my-first-service'), @@ -288,6 +295,13 @@ def get_external_reference_1() -> ExternalReference: ) +def get_external_reference_2() -> ExternalReference: + return ExternalReference( + reference_type=ExternalReferenceType.WEBSITE, + url='https://cyclonedx.org' + ) + + def get_issue_1() -> IssueType: return IssueType( classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', diff --git a/tests/fixtures/json/1.2/bom_external_references.json b/tests/fixtures/json/1.2/bom_external_references.json new file mode 100644 index 00000000..5043c2f3 --- /dev/null +++ b/tests/fixtures/json/1.2/bom_external_references.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ] + }, + "components": [], + "externalReferences": [ + { + "url": "https://cyclonedx.org", + "comment": "No comment", + "type": "distribution" + }, + { + "url": "https://cyclonedx.org", + "type": "website" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/json/1.3/bom_external_references.json b/tests/fixtures/json/1.3/bom_external_references.json new file mode 100644 index 00000000..5e9246e7 --- /dev/null +++ b/tests/fixtures/json/1.3/bom_external_references.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ] + }, + "components": [], + "externalReferences": [ + { + "url": "https://cyclonedx.org", + "comment": "No comment", + "type": "distribution", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + }, + { + "url": "https://cyclonedx.org", + "type": "website" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/json/1.4/bom_external_references.json b/tests/fixtures/json/1.4/bom_external_references.json new file mode 100644 index 00000000..a054cd86 --- /dev/null +++ b/tests/fixtures/json/1.4/bom_external_references.json @@ -0,0 +1,69 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-python-lib/" + }, + { + "type": "documentation", + "url": "https://cyclonedx.github.io/cyclonedx-python-lib/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib" + }, + { + "type": "website", + "url": "https://cyclonedx.org" + } + ] + } + ] + }, + "components": [], + "externalReferences": [ + { + "url": "https://cyclonedx.org", + "comment": "No comment", + "type": "distribution", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + }, + { + "url": "https://cyclonedx.org", + "type": "website" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/xml/1.1/bom_external_references.xml b/tests/fixtures/xml/1.1/bom_external_references.xml new file mode 100644 index 00000000..49ba0114 --- /dev/null +++ b/tests/fixtures/xml/1.1/bom_external_references.xml @@ -0,0 +1,13 @@ + + + + + + https://cyclonedx.org + No comment + + + https://cyclonedx.org + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.2/bom_external_references.xml b/tests/fixtures/xml/1.2/bom_external_references.xml new file mode 100644 index 00000000..977900cb --- /dev/null +++ b/tests/fixtures/xml/1.2/bom_external_references.xml @@ -0,0 +1,23 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + + https://cyclonedx.org + No comment + + + https://cyclonedx.org + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.3/bom_external_references.xml b/tests/fixtures/xml/1.3/bom_external_references.xml new file mode 100644 index 00000000..7219f606 --- /dev/null +++ b/tests/fixtures/xml/1.3/bom_external_references.xml @@ -0,0 +1,26 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + https://cyclonedx.org + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.4/bom_external_references.xml b/tests/fixtures/xml/1.4/bom_external_references.xml new file mode 100644 index 00000000..20dea410 --- /dev/null +++ b/tests/fixtures/xml/1.4/bom_external_references.xml @@ -0,0 +1,52 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + https://github.com/CycloneDX/cyclonedx-python-lib/actions + + + https://pypi.org/project/cyclonedx-python-lib/ + + + https://cyclonedx.github.io/cyclonedx-python-lib/ + + + https://github.com/CycloneDX/cyclonedx-python-lib/issues + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md + + + https://github.com/CycloneDX/cyclonedx-python-lib + + + https://cyclonedx.org + + + + + + + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + https://cyclonedx.org + + + \ No newline at end of file diff --git a/tests/test_bom.py b/tests/test_bom.py index c37fa193..d8d9f1c5 100644 --- a/tests/test_bom.py +++ b/tests/test_bom.py @@ -46,3 +46,11 @@ def test_metadata_component(self) -> None: metadata.component = hextech self.assertFalse(metadata.component is None) self.assertEquals(metadata.component, hextech) + + def test_empty_bom(self) -> None: + bom = Bom() + self.assertIsNotNone(bom.uuid) + self.assertIsNotNone(bom.metadata) + self.assertIsNone(bom.components) + self.assertIsNone(bom.services) + self.assertIsNone(bom.external_references) diff --git a/tests/test_output_json.py b/tests/test_output_json.py index 205164fc..109b121d 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -27,12 +27,31 @@ get_bom_with_component_setuptools_no_component_version, \ get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ MOCK_UUID_1, get_bom_just_complete_metadata, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, \ - get_bom_with_services_complex, MOCK_UUID_6, get_bom_with_nested_services, get_bom_with_component_setuptools_complete + get_bom_with_services_complex, MOCK_UUID_6, get_bom_with_nested_services, \ + get_bom_with_component_setuptools_complete, get_bom_with_external_references from tests.base import BaseJsonTestCase class TestOutputJson(BaseJsonTestCase): + def test_bom_external_references_v1_4(self) -> None: + self._validate_json_bom( + bom=get_bom_with_external_references(), schema_version=SchemaVersion.V1_4, + fixture='bom_external_references.json' + ) + + def test_bom_external_references_v1_3(self) -> None: + self._validate_json_bom( + bom=get_bom_with_external_references(), schema_version=SchemaVersion.V1_3, + fixture='bom_external_references.json' + ) + + def test_bom_external_references_v1_2(self) -> None: + self._validate_json_bom( + bom=get_bom_with_external_references(), schema_version=SchemaVersion.V1_2, + fixture='bom_external_references.json' + ) + def test_simple_bom_v1_4(self) -> None: self._validate_json_bom( bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_4, diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index 915a01ec..780e0de3 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -26,7 +26,7 @@ get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ MOCK_UUID_1, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6, get_bom_just_complete_metadata, \ get_bom_with_nested_services, get_bom_with_services_simple, get_bom_with_services_complex, \ - get_bom_with_component_setuptools_complete + get_bom_with_component_setuptools_complete, get_bom_with_external_references from tests.base import BaseXmlTestCase TEST_UUIDS = [ @@ -36,6 +36,36 @@ class TestOutputXml(BaseXmlTestCase): + def test_bom_external_references_v1_4(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_external_references(), schema_version=SchemaVersion.V1_4, + fixture='bom_external_references.xml' + ) + + def test_bom_external_references_v1_3(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_external_references(), schema_version=SchemaVersion.V1_3, + fixture='bom_external_references.xml' + ) + + def test_bom_external_references_v1_2(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_external_references(), schema_version=SchemaVersion.V1_2, + fixture='bom_external_references.xml' + ) + + def test_bom_external_references_v1_1(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_external_references(), schema_version=SchemaVersion.V1_1, + fixture='bom_external_references.xml' + ) + + def test_bom_external_references_v1_0(self) -> None: + self._validate_xml_bom( + bom=get_bom_with_external_references(), schema_version=SchemaVersion.V1_0, + fixture='bom_empty.xml' + ) + def test_simple_bom_v1_4(self) -> None: self._validate_xml_bom( bom=get_bom_with_component_setuptools_basic(), schema_version=SchemaVersion.V1_4, From 6c280e7794466ad9b6f1ce5eb985035bea21eaaa Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Wed, 2 Feb 2022 18:11:53 +0000 Subject: [PATCH 14/29] chore: bump dependencies Signed-off-by: Paul Horton --- poetry.lock | 57 +++++++++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/poetry.lock b/poetry.lock index 849550d9..1920822e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -277,7 +277,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pyparsing" -version = "3.0.6" +version = "3.0.7" description = "Python parsing module" category = "dev" optional = false @@ -343,7 +343,7 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytes [[package]] name = "typed-ast" -version = "1.5.1" +version = "1.5.2" description = "a fork of Python 2 and 3 ast modules with type comment support" category = "dev" optional = false @@ -351,7 +351,7 @@ python-versions = ">=3.6" [[package]] name = "types-setuptools" -version = "57.4.7" +version = "57.4.8" description = "Typing stubs for setuptools" category = "main" optional = false @@ -625,8 +625,8 @@ pyflakes = [ {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, ] pyparsing = [ - {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, - {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, + {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, + {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, ] pyrsistent = [ {file = "pyrsistent-0.18.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:df46c854f490f81210870e509818b729db4488e1f30f2a1ce1698b2295a878d1"}, @@ -668,29 +668,34 @@ tox = [ {file = "tox-3.24.5.tar.gz", hash = "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993"}, ] typed-ast = [ - {file = "typed_ast-1.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d8314c92414ce7481eee7ad42b353943679cf6f30237b5ecbf7d835519e1212"}, - {file = "typed_ast-1.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b53ae5de5500529c76225d18eeb060efbcec90ad5e030713fe8dab0fb4531631"}, - {file = "typed_ast-1.5.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:24058827d8f5d633f97223f5148a7d22628099a3d2efe06654ce872f46f07cdb"}, - {file = "typed_ast-1.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:a6d495c1ef572519a7bac9534dbf6d94c40e5b6a608ef41136133377bba4aa08"}, - {file = "typed_ast-1.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:de4ecae89c7d8b56169473e08f6bfd2df7f95015591f43126e4ea7865928677e"}, - {file = "typed_ast-1.5.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:256115a5bc7ea9e665c6314ed6671ee2c08ca380f9d5f130bd4d2c1f5848d695"}, - {file = "typed_ast-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:7c42707ab981b6cf4b73490c16e9d17fcd5227039720ca14abe415d39a173a30"}, - {file = "typed_ast-1.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:71dcda943a471d826ea930dd449ac7e76db7be778fcd722deb63642bab32ea3f"}, - {file = "typed_ast-1.5.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4f30a2bcd8e68adbb791ce1567fdb897357506f7ea6716f6bbdd3053ac4d9471"}, - {file = "typed_ast-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ca9e8300d8ba0b66d140820cf463438c8e7b4cdc6fd710c059bfcfb1531d03fb"}, - {file = "typed_ast-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9caaf2b440efb39ecbc45e2fabde809cbe56272719131a6318fd9bf08b58e2cb"}, - {file = "typed_ast-1.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c9bcad65d66d594bffab8575f39420fe0ee96f66e23c4d927ebb4e24354ec1af"}, - {file = "typed_ast-1.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:591bc04e507595887160ed7aa8d6785867fb86c5793911be79ccede61ae96f4d"}, - {file = "typed_ast-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:a80d84f535642420dd17e16ae25bb46c7f4c16ee231105e7f3eb43976a89670a"}, - {file = "typed_ast-1.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:38cf5c642fa808300bae1281460d4f9b7617cf864d4e383054a5ef336e344d32"}, - {file = "typed_ast-1.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b6ab14c56bc9c7e3c30228a0a0b54b915b1579613f6e463ba6f4eb1382e7fd4"}, - {file = "typed_ast-1.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a2b8d7007f6280e36fa42652df47087ac7b0a7d7f09f9468f07792ba646aac2d"}, - {file = "typed_ast-1.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:b6d17f37f6edd879141e64a5db17b67488cfeffeedad8c5cec0392305e9bc775"}, - {file = "typed_ast-1.5.1.tar.gz", hash = "sha256:484137cab8ecf47e137260daa20bafbba5f4e3ec7fda1c1e69ab299b75fa81c5"}, + {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"}, + {file = "typed_ast-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596"}, + {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985"}, + {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76"}, + {file = "typed_ast-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a"}, + {file = "typed_ast-1.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837"}, + {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78"}, + {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e"}, + {file = "typed_ast-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d"}, + {file = "typed_ast-1.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd"}, + {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88"}, + {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7"}, + {file = "typed_ast-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30"}, + {file = "typed_ast-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4"}, + {file = "typed_ast-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca"}, + {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb"}, + {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b"}, + {file = "typed_ast-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7"}, + {file = "typed_ast-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098"}, + {file = "typed_ast-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344"}, + {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e"}, + {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e"}, + {file = "typed_ast-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5"}, + {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, ] types-setuptools = [ - {file = "types-setuptools-57.4.7.tar.gz", hash = "sha256:9677d969b00ec1c14552f5be2b2b47a6fbea4d0ed4de0fdcee18abdaa0cc9267"}, - {file = "types_setuptools-57.4.7-py3-none-any.whl", hash = "sha256:ffda504687ea02d4b7751c0d1df517fbbcdc276836d90849e4f1a5f1ccd79f01"}, + {file = "types-setuptools-57.4.8.tar.gz", hash = "sha256:d5545f2ab3dad24f5cb1c01ba74c1acb7407ab31b2618d423158fc84085160f1"}, + {file = "types_setuptools-57.4.8-py3-none-any.whl", hash = "sha256:af8480491f9894e6081fb568811978fd7ac87434cab122a0605a32be08d98dbe"}, ] types-toml = [ {file = "types-toml-0.10.3.tar.gz", hash = "sha256:215a7a79198651ec5bdfd66193c1e71eb681a42f3ef7226c9af3123ced62564a"}, From 41a4be0cedcd26b6645b6e3606cce8e3708c569f Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Wed, 2 Feb 2022 18:33:34 +0000 Subject: [PATCH 15/29] doc: added page to docs to call out which parts of the specification this library supports Signed-off-by: Paul Horton --- docs/architecture.rst | 1 + docs/schema-support.rst | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 docs/schema-support.rst diff --git a/docs/architecture.rst b/docs/architecture.rst index 3a62f9f2..833989a6 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -20,6 +20,7 @@ When wishing to generate a BOM, the process is as follows: :caption: Contents: modelling + schema-support outputting .. _cyclondex-python: https://pypi.org/project/cyclonedx-bom/ \ No newline at end of file diff --git a/docs/schema-support.rst b/docs/schema-support.rst new file mode 100644 index 00000000..db648597 --- /dev/null +++ b/docs/schema-support.rst @@ -0,0 +1,37 @@ +Schema Support +============== + +This library has partial support for the CycloneDX specification (we continue to grow support). + +The following sub-sections aim to explain what support this library provides and any known gaps in support. We do this +by calling out support for data as defined in the latest CycloneDX standard specification, regardless of whether it is +supported in prior versions of the CycloneDX schema. + ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| Data Path | Supported? | Notes | ++============================+===============+===================================================================================================+ +| ``bom[@version]`` | Yes | | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom[@serialNumber]`` | Yes | | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.metadata`` | Yes (partial) | Not supported: ``authors``, ``manufacture``, ``supplier``, ``licenses``, ``properties``. | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.components`` | Yes | Not supported: ``modified`` (as it is deprecated), ``signature``. | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.services`` | Yes | Not supported: ``signature``. | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.externalReferences`` | Yes | | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.dependencies`` | No | | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.compositions`` | No | | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.properties`` | No | See `schema specification bug 130`_ | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.vulnerabilities`` | Yes | Note: Prior to CycloneDX 1.4, these were present under ``bom.components`` via a schema extension. | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.signature`` | No | | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ + + +.. _schema specification bug 130: https://github.com/CycloneDX/specification/issues/130 \ No newline at end of file From b3c8d9a676190f20dfc4ab1b915c1e53c4ac5a82 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 3 Feb 2022 07:55:56 +0000 Subject: [PATCH 16/29] BREAKING CHANGE: adopted PEP-3102 for model classes (#158) Signed-off-by: Paul Horton --- cyclonedx/model/__init__.py | 44 ++++++++++---------------------- cyclonedx/model/bom.py | 4 +-- cyclonedx/model/component.py | 19 +++++++------- cyclonedx/model/issue.py | 4 +-- cyclonedx/model/release_note.py | 2 +- cyclonedx/model/service.py | 2 +- cyclonedx/model/vulnerability.py | 18 ++++++------- tests/test_model.py | 2 +- 8 files changed, 40 insertions(+), 55 deletions(-) diff --git a/cyclonedx/model/__init__.py b/cyclonedx/model/__init__.py index ecc39756..9b50d20c 100644 --- a/cyclonedx/model/__init__.py +++ b/cyclonedx/model/__init__.py @@ -73,12 +73,7 @@ class DataClassification: https://cyclonedx.org/docs/1.4/xml/#type_dataClassificationType """ - def __init__(self, flow: DataFlow, classification: str) -> None: - if not flow and not classification: - raise NoPropertiesProvidedException( - 'One of `flow` or `classification` must be supplied - neither supplied' - ) - + def __init__(self, *, flow: DataFlow, classification: str) -> None: self.flow = flow self.classification = classification @@ -151,7 +146,7 @@ class AttachedText: DEFAULT_CONTENT_TYPE = 'text/plain' - def __init__(self, content: str, content_type: str = DEFAULT_CONTENT_TYPE, + def __init__(self, *, content: str, content_type: str = DEFAULT_CONTENT_TYPE, encoding: Optional[Encoding] = None) -> None: self.content_type = content_type self.encoding = encoding @@ -282,7 +277,7 @@ def from_composite_str(composite_hash: str) -> 'HashType': raise UnknownHashTypeException(f"Unable to determine hash type from '{composite_hash}'") - def __init__(self, algorithm: HashAlgorithm, hash_value: str) -> None: + def __init__(self, *, algorithm: HashAlgorithm, hash_value: str) -> None: self._alg = algorithm self._content = hash_value @@ -329,17 +324,6 @@ class ExternalReferenceType(Enum): VCS = 'vcs' WEBSITE = 'website' - # def __eq__(self, other: object) -> bool: - # if isinstance(other, ExternalReferenceType): - # return hash(other) == hash(self) - # return False - # - # def __hash__(self) -> int: - # return hash(self.value) - # - # def __repr__(self) -> str: - # return f'' - class XsUri: """ @@ -382,7 +366,7 @@ class ExternalReference: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_externalReference """ - def __init__(self, reference_type: ExternalReferenceType, url: Union[str, XsUri], comment: str = '', + def __init__(self, *, reference_type: ExternalReferenceType, url: Union[str, XsUri], comment: str = '', hashes: Optional[List[HashType]] = None) -> None: self._type: ExternalReferenceType = reference_type self._url = str(url) @@ -459,7 +443,7 @@ class License: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_licenseType """ - def __init__(self, spxd_license_id: Optional[str] = None, license_name: Optional[str] = None, + def __init__(self, *, spxd_license_id: Optional[str] = None, license_name: Optional[str] = None, license_text: Optional[AttachedText] = None, license_url: Optional[XsUri] = None) -> None: if not spxd_license_id and not license_name: raise MutuallyExclusivePropertiesException('Either `spxd_license_id` or `license_name` MUST be supplied') @@ -554,7 +538,7 @@ class LicenseChoice: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_licenseChoiceType """ - def __init__(self, license: Optional[License] = None, license_expression: Optional[str] = None) -> None: + def __init__(self, *, license: Optional[License] = None, license_expression: Optional[str] = None) -> None: if not license and not license_expression: raise NoPropertiesProvidedException( 'One of `license` or `license_expression` must be supplied - neither supplied' @@ -623,7 +607,7 @@ class Property: Specifies an individual property with a name and value. """ - def __init__(self, name: str, value: str) -> None: + def __init__(self, *, name: str, value: str) -> None: self._name = name self._value = value @@ -668,7 +652,7 @@ class NoteText: DEFAULT_CONTENT_TYPE: str = 'text/plain' - def __init__(self, content: str, content_type: Optional[str] = None, + def __init__(self, *, content: str, content_type: Optional[str] = None, content_encoding: Optional[Encoding] = None) -> None: self.content = content self.content_type = content_type or NoteText.DEFAULT_CONTENT_TYPE @@ -741,7 +725,7 @@ class Note: _LOCALE_TYPE_REGEX = re.compile(r'^[a-z]{2}(?:\-[A-Z]{2})?$') - def __init__(self, text: NoteText, locale: Optional[str] = None) -> None: + def __init__(self, *, text: NoteText, locale: Optional[str] = None) -> None: self.text = text self.locale = locale @@ -806,7 +790,7 @@ class OrganizationalContact: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_organizationalContact """ - def __init__(self, name: Optional[str] = None, phone: Optional[str] = None, email: Optional[str] = None) -> None: + def __init__(self, *, name: Optional[str] = None, phone: Optional[str] = None, email: Optional[str] = None) -> None: if not name and not phone and not email: raise NoPropertiesProvidedException( 'One of name, email or phone must be supplied for an OrganizationalContact - none supplied.' @@ -866,7 +850,7 @@ class OrganizationalEntity: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_organizationalEntity """ - def __init__(self, name: Optional[str] = None, urls: Optional[List[XsUri]] = None, + def __init__(self, *, name: Optional[str] = None, urls: Optional[List[XsUri]] = None, contacts: Optional[List[OrganizationalContact]] = None) -> None: if not name and not urls and not contacts: raise NoPropertiesProvidedException( @@ -932,7 +916,7 @@ class Tool: See the CycloneDX Schema for toolType: https://cyclonedx.org/docs/1.3/#type_toolType """ - def __init__(self, vendor: Optional[str] = None, name: Optional[str] = None, version: Optional[str] = None, + def __init__(self, *, vendor: Optional[str] = None, name: Optional[str] = None, version: Optional[str] = None, hashes: Optional[List[HashType]] = None, external_references: Optional[List[ExternalReference]] = None) -> None: self._vendor = vendor @@ -1037,7 +1021,7 @@ class IdentifiableAction: See the CycloneDX specification: https://cyclonedx.org/docs/1.4/xml/#type_identifiableActionType """ - def __init__(self, timestamp: Optional[datetime] = None, name: Optional[str] = None, + def __init__(self, *, timestamp: Optional[datetime] = None, name: Optional[str] = None, email: Optional[str] = None) -> None: if not timestamp and not name and not email: raise NoPropertiesProvidedException( @@ -1110,7 +1094,7 @@ class Copyright: See the CycloneDX specification: https://cyclonedx.org/docs/1.4/xml/#type_copyrightsType """ - def __init__(self, text: str) -> None: + def __init__(self, *, text: str) -> None: self.text = text @property diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 558cc794..24af5d63 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -35,7 +35,7 @@ class BomMetaData: See the CycloneDX Schema for Bom metadata: https://cyclonedx.org/docs/1.3/#type_metadata """ - def __init__(self, tools: Optional[List[Tool]] = None) -> None: + def __init__(self, *, tools: Optional[List[Tool]] = None) -> None: self.timestamp = datetime.now(tz=timezone.utc) self.tools = tools if tools else [] @@ -149,7 +149,7 @@ def from_parser(parser: BaseParser) -> 'Bom': bom.add_components(parser.get_components()) return bom - def __init__(self, components: Optional[List[Component]] = None, services: Optional[List[Service]] = None, + def __init__(self, *, components: Optional[List[Component]] = None, services: Optional[List[Service]] = None, external_references: Optional[List[ExternalReference]] = None) -> None: """ Create a new Bom that you can manually/programmatically add data to later. diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 18825367..9981dedd 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -42,7 +42,7 @@ class Commit: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_commitType """ - def __init__(self, uid: Optional[str] = None, url: Optional[XsUri] = None, + def __init__(self, *, uid: Optional[str] = None, url: Optional[XsUri] = None, author: Optional[IdentifiableAction] = None, committer: Optional[IdentifiableAction] = None, message: Optional[str] = None) -> None: if not uid and not url and not author and not committer and not message: @@ -149,7 +149,7 @@ class ComponentEvidence: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_componentEvidenceType """ - def __init__(self, licenses: Optional[List[LicenseChoice]] = None, + def __init__(self, *, licenses: Optional[List[LicenseChoice]] = None, copyright_: Optional[List[Copyright]] = None) -> None: if not licenses and not copyright_: raise NoPropertiesProvidedException( @@ -240,7 +240,7 @@ class Diff: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_diffType """ - def __init__(self, text: Optional[AttachedText] = None, url: Optional[XsUri] = None) -> None: + def __init__(self, *, text: Optional[AttachedText] = None, url: Optional[XsUri] = None) -> None: if not text and not url: raise NoPropertiesProvidedException( 'At least one of `text` or `url` must be provided for a `Diff`.' @@ -310,7 +310,7 @@ class Patch: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_patchType """ - def __init__(self, type_: PatchClassification, diff: Optional[Diff] = None, + def __init__(self, *, type_: PatchClassification, diff: Optional[Diff] = None, resolves: Optional[List[IssueType]] = None) -> None: self.type = type_ self.diff = diff @@ -400,9 +400,10 @@ class Pedigree: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_pedigreeType """ - def __init__(self, ancestors: Optional[List['Component']] = None, descendants: Optional[List['Component']] = None, - variants: Optional[List['Component']] = None, commits: Optional[List[Commit]] = None, - patches: Optional[List[Patch]] = None, notes: Optional[str] = None) -> None: + def __init__(self, *, ancestors: Optional[List['Component']] = None, + descendants: Optional[List['Component']] = None, variants: Optional[List['Component']] = None, + commits: Optional[List[Commit]] = None, patches: Optional[List[Patch]] = None, + notes: Optional[str] = None) -> None: if not ancestors and not descendants and not variants and not commits and not patches and not notes: raise NoPropertiesProvidedException( 'At least one of `ancestors`, `descendants`, `variants`, `commits`, `patches` or `notes` must be ' @@ -589,7 +590,7 @@ class Swid: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_swidType """ - def __init__(self, tag_id: str, name: str, version: Optional[str] = None, + def __init__(self, *, tag_id: str, name: str, version: Optional[str] = None, tag_version: Optional[int] = None, patch: Optional[bool] = None, text: Optional[AttachedText] = None, url: Optional[XsUri] = None) -> None: self.tag_id = tag_id @@ -750,7 +751,7 @@ def for_file(absolute_file_path: str, path_for_bom: Optional[str]) -> 'Component ) ) - def __init__(self, name: str, component_type: ComponentType = ComponentType.LIBRARY, + def __init__(self, *, name: str, component_type: ComponentType = ComponentType.LIBRARY, mime_type: Optional[str] = None, bom_ref: Optional[str] = None, supplier: Optional[OrganizationalEntity] = None, author: Optional[str] = None, publisher: Optional[str] = None, group: Optional[str] = None, version: Optional[str] = None, diff --git a/cyclonedx/model/issue.py b/cyclonedx/model/issue.py index b6d176f3..c8d3b672 100644 --- a/cyclonedx/model/issue.py +++ b/cyclonedx/model/issue.py @@ -42,7 +42,7 @@ class IssueTypeSource: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_issueType """ - def __init__(self, name: Optional[str] = None, url: Optional[XsUri] = None) -> None: + def __init__(self, *, name: Optional[str] = None, url: Optional[XsUri] = None) -> None: if not name and not url: raise NoPropertiesProvidedException( 'Neither `name` nor `url` were provided - at least one must be provided.' @@ -99,7 +99,7 @@ class IssueType: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_issueType """ - def __init__(self, classification: IssueClassification, id: Optional[str] = None, name: Optional[str] = None, + def __init__(self, *, classification: IssueClassification, id: Optional[str] = None, name: Optional[str] = None, description: Optional[str] = None, source_name: Optional[str] = None, source_url: Optional[XsUri] = None, references: Optional[List[XsUri]] = None) -> None: self._type: IssueClassification = classification diff --git a/cyclonedx/model/release_note.py b/cyclonedx/model/release_note.py index 43389e73..c6687fa7 100644 --- a/cyclonedx/model/release_note.py +++ b/cyclonedx/model/release_note.py @@ -32,7 +32,7 @@ class ReleaseNotes: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/#type_releaseNotesType """ - def __init__(self, type: str, title: Optional[str] = None, featured_image: Optional[XsUri] = None, + def __init__(self, *, type: str, title: Optional[str] = None, featured_image: Optional[XsUri] = None, social_image: Optional[XsUri] = None, description: Optional[str] = None, timestamp: Optional[datetime] = None, aliases: Optional[List[str]] = None, tags: Optional[List[str]] = None, resolves: Optional[List[IssueType]] = None, diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py index ced7fd2d..b674ae26 100644 --- a/cyclonedx/model/service.py +++ b/cyclonedx/model/service.py @@ -36,7 +36,7 @@ class Service: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/xml/#type_service """ - def __init__(self, name: str, bom_ref: Optional[str] = None, provider: Optional[OrganizationalEntity] = None, + def __init__(self, *, name: str, bom_ref: Optional[str] = None, provider: Optional[OrganizationalEntity] = None, group: Optional[str] = None, version: Optional[str] = None, description: Optional[str] = None, endpoints: Optional[List[XsUri]] = None, authenticated: Optional[bool] = None, x_trust_boundary: Optional[bool] = None, data: Optional[List[DataClassification]] = None, diff --git a/cyclonedx/model/vulnerability.py b/cyclonedx/model/vulnerability.py index 734c0933..e64d040e 100644 --- a/cyclonedx/model/vulnerability.py +++ b/cyclonedx/model/vulnerability.py @@ -54,7 +54,7 @@ class BomTargetVersionRange: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_vulnerabilityType """ - def __init__(self, version: Optional[str] = None, version_range: Optional[str] = None, + def __init__(self, *, version: Optional[str] = None, version_range: Optional[str] = None, status: Optional[ImpactAnalysisAffectedStatus] = None) -> None: if not version and not version_range: raise NoPropertiesProvidedException( @@ -131,7 +131,7 @@ class BomTarget: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_vulnerabilityType """ - def __init__(self, ref: str, versions: Optional[List[BomTargetVersionRange]] = None) -> None: + def __init__(self, *, ref: str, versions: Optional[List[BomTargetVersionRange]] = None) -> None: self.ref = ref self.versions = versions @@ -180,7 +180,7 @@ class VulnerabilityAnalysis: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_vulnerabilityType """ - def __init__(self, state: Optional[ImpactAnalysisState] = None, + def __init__(self, *, state: Optional[ImpactAnalysisState] = None, justification: Optional[ImpactAnalysisJustification] = None, responses: Optional[List[ImpactAnalysisResponse]] = None, detail: Optional[str] = None) -> None: @@ -267,7 +267,7 @@ class VulnerabilityAdvisory: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_advisoryType """ - def __init__(self, url: XsUri, title: Optional[str] = None) -> None: + def __init__(self, *, url: XsUri, title: Optional[str] = None) -> None: self.title = title self.url = url @@ -315,7 +315,7 @@ class VulnerabilitySource: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_vulnerabilitySourceType """ - def __init__(self, name: Optional[str] = None, url: Optional[XsUri] = None) -> None: + def __init__(self, *, name: Optional[str] = None, url: Optional[XsUri] = None) -> None: if not name and not url: raise NoPropertiesProvidedException( 'Either name or url must be provided for a VulnerabilitySource - neither provided' @@ -370,7 +370,7 @@ class VulnerabilityReference: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_vulnerabilityType """ - def __init__(self, id: Optional[str] = None, source: Optional[VulnerabilitySource] = None) -> None: + def __init__(self, *, id: Optional[str] = None, source: Optional[VulnerabilitySource] = None) -> None: if not id and not source: raise NoPropertiesProvidedException( 'Either id or source must be provided for a VulnerabilityReference - neither provided' @@ -549,7 +549,7 @@ class VulnerabilityRating: they are redundant if you have the vector (the vector allows you to calculate the scores). """ - def __init__(self, source: Optional[VulnerabilitySource] = None, score: Optional[Decimal] = None, + def __init__(self, *, source: Optional[VulnerabilitySource] = None, score: Optional[Decimal] = None, severity: Optional[VulnerabilitySeverity] = None, method: Optional[VulnerabilityScoreSource] = None, vector: Optional[str] = None, justification: Optional[str] = None, @@ -669,7 +669,7 @@ class VulnerabilityCredits: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_vulnerabilityType """ - def __init__(self, organizations: Optional[List[OrganizationalEntity]] = None, + def __init__(self, *, organizations: Optional[List[OrganizationalEntity]] = None, individuals: Optional[List[OrganizationalContact]] = None) -> None: if not organizations and not individuals: raise NoPropertiesProvidedException( @@ -732,7 +732,7 @@ class Vulnerability: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_vulnerabilityType """ - def __init__(self, bom_ref: Optional[str] = None, id: Optional[str] = None, + def __init__(self, *, bom_ref: Optional[str] = None, id: Optional[str] = None, source: Optional[VulnerabilitySource] = None, references: Optional[List[VulnerabilityReference]] = None, ratings: Optional[List[VulnerabilityRating]] = None, cwes: Optional[List[int]] = None, diff --git a/tests/test_model.py b/tests/test_model.py index 8eb60ac7..eb3f0577 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -177,7 +177,7 @@ def test_issue_type(self) -> None: class TestModelNote(TestCase): def test_note_plain_text(self) -> None: - n = Note(text=NoteText('Some simple plain text')) + n = Note(text=NoteText(content='Some simple plain text')) self.assertEqual(n.text.content, 'Some simple plain text') self.assertEqual(n.text.content_type, NoteText.DEFAULT_CONTENT_TYPE) self.assertIsNone(n.locale) From 0f1fd6dfdd41073cbdbb456cf019c7f2ed9e2175 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 3 Feb 2022 08:55:54 +0000 Subject: [PATCH 17/29] removed unnecessary calls to `hash()` in `__hash__()` methods as pointed out by @jkowalleck Signed-off-by: Paul Horton --- cyclonedx/model/__init__.py | 4 ++-- cyclonedx/model/bom.py | 4 ++-- cyclonedx/model/component.py | 10 ++++------ cyclonedx/model/service.py | 5 +++-- cyclonedx/model/vulnerability.py | 12 ++++++------ 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/cyclonedx/model/__init__.py b/cyclonedx/model/__init__.py index 9b50d20c..7f175f18 100644 --- a/cyclonedx/model/__init__.py +++ b/cyclonedx/model/__init__.py @@ -775,7 +775,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash((hash(self.text), self.locale)) + return hash((self.text, self.locale)) def __repr__(self) -> str: return f'' @@ -1080,7 +1080,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash((hash(self.timestamp), self.name, self.email)) + return hash((self.timestamp, self.name, self.email)) def __repr__(self) -> str: return f'' diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 24af5d63..b7411f80 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -116,7 +116,7 @@ def __hash__(self) -> int: return hash(( self.timestamp, tuple([hash(tool) for tool in set(sorted(self.tools, key=hash))]) if self.tools else None, - hash(self.component) + self.component )) def __repr__(self) -> str: @@ -411,7 +411,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - self.uuid, hash(self.metadata), + self.uuid, self.metadata, tuple([hash(c) for c in set(sorted(self.components, key=hash))]) if self.components else None, tuple([hash(s) for s in set(sorted(self.services, key=hash))]) if self.services else None )) diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 9981dedd..cd8fc1df 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -133,7 +133,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash((self.uid, hash(self.url), hash(self.author), hash(self.committer), self.message)) + return hash((self.uid, self.url, self.author, self.committer, self.message)) def __repr__(self) -> str: return f'' @@ -283,7 +283,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash((hash(self.text), hash(self.url))) + return hash((self.text, self.url)) def __repr__(self) -> str: return f'' @@ -379,7 +379,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - hash(self.type), hash(self.diff), + self.type, self.diff, tuple([hash(issue) for issue in set(sorted(self.resolves, key=hash))]) if self.resolves else None )) @@ -705,9 +705,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash(( - self.tag_id, self.name, self.version, self.tag_version, self.patch, hash(self.text), hash(self.url) - )) + return hash((self.tag_id, self.name, self.version, self.tag_version, self.patch, self.text, self.url)) def __repr__(self) -> str: return f'' diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py index b674ae26..ced75b8d 100644 --- a/cyclonedx/model/service.py +++ b/cyclonedx/model/service.py @@ -337,8 +337,9 @@ def __hash__(self) -> int: tuple([hash(uri) for uri in set(sorted(self.endpoints, key=hash))]) if self.endpoints else None, tuple([hash(ref) for ref in set(sorted(self.external_references, key=hash))]) if self.external_references else None, - self.group, str(self.licenses), self.name, self.properties, self.provider, - self.release_notes, + self.group, + tuple([hash(license_) for license_ in set(sorted(self.licenses, key=hash))]) if self.licenses else None, + self.name, self.properties, self.provider, self.release_notes, tuple([hash(service) for service in set(sorted(self.services, key=hash))]) if self.services else None, self.version, self.x_trust_boundary )) diff --git a/cyclonedx/model/vulnerability.py b/cyclonedx/model/vulnerability.py index e64d040e..f3095bdb 100644 --- a/cyclonedx/model/vulnerability.py +++ b/cyclonedx/model/vulnerability.py @@ -112,7 +112,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash((self.version, self.range, hash(self.status))) + return hash((self.version, self.range, self.status)) def __repr__(self) -> str: return f'' @@ -406,7 +406,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash((self.id, hash(self.source))) + return hash((self.id, self.source)) def __repr__(self) -> str: return f'' @@ -651,7 +651,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - hash(self.source), self.score, self.severity, self.method, self.vector, self.justification + self.source, self.score, self.severity, self.method, self.vector, self.justification )) def __repr__(self) -> str: @@ -1083,15 +1083,15 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - self.id, hash(self.source), + self.id, self.source, tuple([hash(ref) for ref in set(sorted(self.references, key=hash))]) if self.references else None, tuple([hash(rating) for rating in set(sorted(self.ratings, key=hash))]) if self.ratings else None, tuple([hash(cwe) for cwe in set(sorted(self.cwes, key=hash))]) if self.cwes else None, self.description, self.detail, self.recommendation, tuple([hash(advisory) for advisory in set(sorted(self.advisories, key=hash))]) if self.advisories else None, - self.created, self.published, self.updated, hash(self.credits), + self.created, self.published, self.updated, self.credits, tuple([hash(tool) for tool in set(sorted(self.tools, key=hash))]) if self.tools else None, - hash(self.analysis), + self.analysis, tuple([hash(affected) for affected in set(sorted(self.affects, key=hash))]) if self.affects else None )) From 142b8bf4dbb2e61d131b7ca2ec332aac472ef3cd Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Tue, 8 Feb 2022 08:42:53 +0000 Subject: [PATCH 18/29] BREAKING CHANGE: update models to use `Set` rather than `List` (#160) * BREAKING CHANGE: update models to use `Set` and `Iterable` rather than `List[..]` BREAKING CHANGE: update final models to use `@property` wip Signed-off-by: Paul Horton --- cyclonedx/model/__init__.py | 336 ++++++++++++++++------------ cyclonedx/model/bom.py | 197 +++------------- cyclonedx/model/component.py | 292 ++++++++---------------- cyclonedx/model/issue.py | 201 +++++------------ cyclonedx/model/release_note.py | 125 ++++------- cyclonedx/model/service.py | 129 ++++------- cyclonedx/model/vulnerability.py | 329 +++++++++++---------------- cyclonedx/output/schema.py | 4 +- cyclonedx/output/serializer/json.py | 4 + cyclonedx/output/xml.py | 170 +++++++------- poetry.lock | 18 +- pyproject.toml | 1 + tests/base.py | 52 +---- tests/data.py | 24 +- tests/test_bom.py | 23 +- tests/test_component.py | 3 +- tests/test_e2e_environment.py | 3 +- tests/test_model.py | 62 +++-- tests/test_model_component.py | 22 +- tests/test_model_release_note.py | 28 +-- tests/test_model_service.py | 26 +-- tests/test_model_vulnerability.py | 19 +- tests/test_output_generic.py | 2 +- tox.ini | 2 + 24 files changed, 814 insertions(+), 1258 deletions(-) diff --git a/cyclonedx/model/__init__.py b/cyclonedx/model/__init__.py index 7f175f18..47e3eed1 100644 --- a/cyclonedx/model/__init__.py +++ b/cyclonedx/model/__init__.py @@ -13,14 +13,14 @@ # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 -# +# Copyright (c) OWASP Foundation. All Rights Reserved. import hashlib import re import sys import warnings from datetime import datetime from enum import Enum -from typing import List, Optional, Union +from typing import Iterable, Optional, Set from ..exception.model import InvalidLocaleTypeException, InvalidUriException, NoPropertiesProvidedException, \ MutuallyExclusivePropertiesException, UnknownHashTypeException @@ -278,25 +278,47 @@ def from_composite_str(composite_hash: str) -> 'HashType': raise UnknownHashTypeException(f"Unable to determine hash type from '{composite_hash}'") def __init__(self, *, algorithm: HashAlgorithm, hash_value: str) -> None: - self._alg = algorithm - self._content = hash_value + self.alg = algorithm + self.content = hash_value + + @property + def alg(self) -> HashAlgorithm: + """ + Specifies the algorithm used to create the hash. - def get_algorithm(self) -> HashAlgorithm: + Returns: + `HashAlgorithm` + """ return self._alg - def get_hash_value(self) -> str: + @alg.setter + def alg(self, alg: HashAlgorithm) -> None: + self._alg = alg + + @property + def content(self) -> str: + """ + Hash value content. + + Returns: + `str` + """ return self._content + @content.setter + def content(self, content: str) -> None: + self._content = content + def __eq__(self, other: object) -> bool: if isinstance(other, HashType): return hash(other) == hash(self) return False def __hash__(self) -> int: - return hash((self._alg, self._content)) + return hash((self.alg, self.content)) def __repr__(self) -> str: - return f'' + return f'' class ExternalReferenceType(Enum): @@ -366,58 +388,71 @@ class ExternalReference: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_externalReference """ - def __init__(self, *, reference_type: ExternalReferenceType, url: Union[str, XsUri], comment: str = '', - hashes: Optional[List[HashType]] = None) -> None: - self._type: ExternalReferenceType = reference_type - self._url = str(url) - self._comment = comment - self._hashes: List[HashType] = hashes if hashes else [] + def __init__(self, *, reference_type: ExternalReferenceType, url: XsUri, comment: Optional[str] = None, + hashes: Optional[Iterable[HashType]] = None) -> None: + self.url = url + self.comment = comment + self.type = reference_type + self.hashes = set(hashes or []) - def add_hash(self, our_hash: HashType) -> None: + @property + def url(self) -> XsUri: """ - Adds a hash that pins/identifies this External Reference. + The URL to the external reference. - Args: - our_hash: - `HashType` instance + Returns: + `XsUri` """ - self._hashes.append(our_hash) + return self._url + + @url.setter + def url(self, url: XsUri) -> None: + self._url = url - def get_comment(self) -> Union[str, None]: + @property + def comment(self) -> Optional[str]: """ - Get the comment for this External Reference. + An optional comment describing the external reference. Returns: - Any comment as a `str` else `None`. + `str` if set else `None` """ return self._comment - def get_hashes(self) -> List[HashType]: - """ - List of cryptographic hashes that identify this External Reference. + @comment.setter + def comment(self, comment: Optional[str]) -> None: + self._comment = comment - Returns: - `List` of `HashType` objects where there are any hashes, else an empty `List`. + @property + def type(self) -> ExternalReferenceType: """ - return self._hashes + Specifies the type of external reference. - def get_reference_type(self) -> ExternalReferenceType: - """ - Get the type of this External Reference. + There are built-in types to describe common references. If a type does not exist for the reference being + referred to, use the "other" type. Returns: - `ExternalReferenceType` that represents the type of this External Reference. + `ExternalReferenceType` """ return self._type - def get_url(self) -> str: + @type.setter + def type(self, type_: ExternalReferenceType) -> None: + self._type = type_ + + @property + def hashes(self) -> Set[HashType]: """ - Get the URL/URI for this External Reference. + The hashes of the external reference (if applicable). Returns: - URI as a `str`. + Set of `HashType` """ - return self._url + return self._hashes + + @hashes.setter + def hashes(self, hashes: Iterable[HashType]) -> None: + self._hashes = set(hashes) def __eq__(self, other: object) -> bool: if isinstance(other, ExternalReference): @@ -427,7 +462,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( self._type, self._url, self._comment, - tuple([hash(hash_) for hash_ in set(sorted(self._hashes, key=hash))]) if self._hashes else None + tuple(sorted(self._hashes, key=hash)) )) def __repr__(self) -> str: @@ -443,17 +478,17 @@ class License: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_licenseType """ - def __init__(self, *, spxd_license_id: Optional[str] = None, license_name: Optional[str] = None, + def __init__(self, *, spdx_license_id: Optional[str] = None, license_name: Optional[str] = None, license_text: Optional[AttachedText] = None, license_url: Optional[XsUri] = None) -> None: - if not spxd_license_id and not license_name: - raise MutuallyExclusivePropertiesException('Either `spxd_license_id` or `license_name` MUST be supplied') - if spxd_license_id and license_name: + if not spdx_license_id and not license_name: + raise MutuallyExclusivePropertiesException('Either `spdx_license_id` or `license_name` MUST be supplied') + if spdx_license_id and license_name: warnings.warn( - 'Both `spxd_license_id` and `license_name` have been supplied - `license_name` will be ignored!', + 'Both `spdx_license_id` and `license_name` have been supplied - `license_name` will be ignored!', RuntimeWarning ) - self.id = spxd_license_id - if not spxd_license_id: + self.id = spdx_license_id + if not spdx_license_id: self.name = license_name else: self.name = None @@ -538,18 +573,18 @@ class LicenseChoice: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_licenseChoiceType """ - def __init__(self, *, license: Optional[License] = None, license_expression: Optional[str] = None) -> None: - if not license and not license_expression: + def __init__(self, *, license_: Optional[License] = None, license_expression: Optional[str] = None) -> None: + if not license_ and not license_expression: raise NoPropertiesProvidedException( 'One of `license` or `license_expression` must be supplied - neither supplied' ) - if license and license_expression: + if license_ and license_expression: warnings.warn( 'Both `license` and `license_expression` have been supplied - `license` will take precedence', RuntimeWarning ) - self.license = license - if not license: + self.license = license_ + if not license_: self.expression = license_expression else: self.expression = None @@ -565,8 +600,8 @@ def license(self) -> Optional[License]: return self._license @license.setter - def license(self, license: Optional[License]) -> None: - self._license = license + def license(self, license_: Optional[License]) -> None: + self._license = license_ @property def expression(self) -> Optional[str]: @@ -608,37 +643,49 @@ class Property: """ def __init__(self, *, name: str, value: str) -> None: - self._name = name - self._value = value + self.name = name + self.value = value - def get_name(self) -> str: + @property + def name(self) -> str: """ - Get the name of this Property. + The name of the property. + + Duplicate names are allowed, each potentially having a different value. Returns: - Name of this Property as `str`. + `str` """ return self._name - def get_value(self) -> str: + @name.setter + def name(self, name: str) -> None: + self._name = name + + @property + def value(self) -> str: """ - Get the value of this Property. + Value of this Property. Returns: - Value of this Property as `str`. + `str` """ return self._value + @value.setter + def value(self, value: str) -> None: + self._value = value + def __eq__(self, other: object) -> bool: if isinstance(other, Property): return hash(other) == hash(self) return False def __hash__(self) -> int: - return hash((self._name, self._value)) + return hash((self.name, self.value)) def __repr__(self) -> str: - return f'' + return f'' class NoteText: @@ -795,9 +842,9 @@ def __init__(self, *, name: Optional[str] = None, phone: Optional[str] = None, e raise NoPropertiesProvidedException( 'One of name, email or phone must be supplied for an OrganizationalContact - none supplied.' ) - self._name: Optional[str] = name - self._email: Optional[str] = email - self._phone: Optional[str] = phone + self.name = name + self.email = email + self.phone = phone @property def name(self) -> Optional[str]: @@ -809,6 +856,10 @@ def name(self) -> Optional[str]: """ return self._name + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name + @property def email(self) -> Optional[str]: """ @@ -819,6 +870,10 @@ def email(self) -> Optional[str]: """ return self._email + @email.setter + def email(self, email: Optional[str]) -> None: + self._email = email + @property def phone(self) -> Optional[str]: """ @@ -829,6 +884,10 @@ def phone(self) -> Optional[str]: """ return self._phone + @phone.setter + def phone(self, phone: Optional[str]) -> None: + self._phone = phone + def __eq__(self, other: object) -> bool: if isinstance(other, OrganizationalContact): return hash(other) == hash(self) @@ -850,15 +909,15 @@ class OrganizationalEntity: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_organizationalEntity """ - def __init__(self, *, name: Optional[str] = None, urls: Optional[List[XsUri]] = None, - contacts: Optional[List[OrganizationalContact]] = None) -> None: + def __init__(self, *, name: Optional[str] = None, urls: Optional[Iterable[XsUri]] = None, + contacts: Optional[Iterable[OrganizationalContact]] = None) -> None: if not name and not urls and not contacts: raise NoPropertiesProvidedException( 'One of name, urls or contacts must be supplied for an OrganizationalEntity - none supplied.' ) - self._name: Optional[str] = name - self._url: Optional[List[XsUri]] = urls - self._contact: Optional[List[OrganizationalContact]] = contacts + self.name = name + self.url = set(urls or []) + self.contact = set(contacts or []) @property def name(self) -> Optional[str]: @@ -870,37 +929,45 @@ def name(self) -> Optional[str]: """ return self._name + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name + @property - def urls(self) -> Optional[List[XsUri]]: + def url(self) -> Set[XsUri]: """ Get a list of URLs of the organization. Multiple URLs are allowed. Returns: - `List[XsUri]` if set else `None` + Set of `XsUri` """ return self._url + @url.setter + def url(self, urls: Iterable[XsUri]) -> None: + self._url = set(urls) + @property - def contacts(self) -> Optional[List[OrganizationalContact]]: + def contact(self) -> Set[OrganizationalContact]: """ Get a list of contact person at the organization. Multiple contacts are allowed. Returns: - `List[OrganizationalContact]` if set else `None` + Set of `OrganizationalContact` """ return self._contact + @contact.setter + def contact(self, contacts: Iterable[OrganizationalContact]) -> None: + self._contact = set(contacts) + def __eq__(self, other: object) -> bool: if isinstance(other, OrganizationalEntity): return hash(other) == hash(self) return False def __hash__(self) -> int: - return hash(( - self.name, - tuple([hash(url) for url in set(sorted(self.urls, key=hash))]) if self.urls else None, - tuple([hash(contact) for contact in set(sorted(self.contacts, key=hash))]) if self.contacts else None - )) + return hash((self.name, tuple(self.url), tuple(self.contact))) def __repr__(self) -> str: return f'' @@ -917,84 +984,84 @@ class Tool: """ def __init__(self, *, vendor: Optional[str] = None, name: Optional[str] = None, version: Optional[str] = None, - hashes: Optional[List[HashType]] = None, - external_references: Optional[List[ExternalReference]] = None) -> None: - self._vendor = vendor - self._name = name - self._version = version - self._hashes: List[HashType] = hashes or [] - self._external_references: List[ExternalReference] = external_references or [] + hashes: Optional[Iterable[HashType]] = None, + external_references: Optional[Iterable[ExternalReference]] = None) -> None: + self.vendor = vendor + self.name = name + self.version = version + self.hashes = set(hashes or []) + self.external_references = set(external_references or []) - def add_external_reference(self, reference: ExternalReference) -> None: + @property + def vendor(self) -> Optional[str]: """ - Add an external reference to this Tool. - - Args: - reference: - `ExternalReference` to add to this Tool. + The name of the vendor who created the tool. Returns: - None + `str` if set else `None` """ - self._external_references.append(reference) + return self._vendor - def add_external_references(self, references: List[ExternalReference]) -> None: - """ - Add a list of external reference to this Tool. + @vendor.setter + def vendor(self, vendor: Optional[str]) -> None: + self._vendor = vendor - Args: - references: - List of `ExternalReference` to add to this Tool. + @property + def name(self) -> Optional[str]: + """ + The name of the tool. Returns: - None + `str` if set else `None` """ - self._external_references = self._external_references + references + return self._name + + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name - def get_external_references(self) -> List[ExternalReference]: + @property + def version(self) -> Optional[str]: """ - List of External References that relate to this Tool. + The version of the tool. Returns: - `List` of `ExternalReference` objects where there are, else an empty `List`. + `str` if set else `None` """ - return self._external_references + return self._version - def get_hashes(self) -> List[HashType]: + @version.setter + def version(self, version: Optional[str]) -> None: + self._version = version + + @property + def hashes(self) -> Set[HashType]: """ - List of cryptographic hashes that identify this version of this Tool. + The hashes of the tool (if applicable). Returns: - `List` of `HashType` objects where there are any hashes, else an empty `List`. + Set of `HashType` """ return self._hashes - def get_name(self) -> Optional[str]: - """ - The name of this Tool. - - Returns: - `str` representing the name of the Tool - """ - return self._name + @hashes.setter + def hashes(self, hashes: Iterable[HashType]) -> None: + self._hashes = set(hashes) - def get_vendor(self) -> Optional[str]: + @property + def external_references(self) -> Set[ExternalReference]: """ - The vendor of this Tool. + External References provide a way to document systems, sites, and information that may be relevant but which + are not included with the BOM. Returns: - `str` representing the vendor of the Tool - """ - return self._vendor - - def get_version(self) -> Optional[str]: + Set of `ExternalReference` """ - The version of this Tool. + return self._external_references - Returns: - `str` representing the version of the Tool - """ - return self._version + @external_references.setter + def external_references(self, external_references: Iterable[ExternalReference]) -> None: + self._external_references = set(external_references) def __eq__(self, other: object) -> bool: if isinstance(other, Tool): @@ -1002,15 +1069,10 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash(( - self._vendor, self._name, self._version, - tuple([hash(hash_) for hash_ in set(sorted(self._hashes, key=hash))]) if self._hashes else None, - tuple([hash(ref) for ref in - set(sorted(self._external_references, key=hash))]) if self._external_references else None - )) + return hash((self.vendor, self.name, self.version, tuple(self.hashes), tuple(self.external_references))) def __repr__(self) -> str: - return f'' + return f'' class IdentifiableAction: @@ -1133,7 +1195,7 @@ def __repr__(self) -> str: except Exception: __ThisToolVersion = None ThisTool = Tool(vendor='CycloneDX', name='cyclonedx-python-lib', version=__ThisToolVersion or 'UNKNOWN') -ThisTool.add_external_references(references=[ +ThisTool.external_references.update([ ExternalReference( reference_type=ExternalReferenceType.BUILD_SYSTEM, url=XsUri('https://github.com/CycloneDX/cyclonedx-python-lib/actions') diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index b7411f80..93a46574 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -16,9 +16,8 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - from datetime import datetime, timezone -from typing import cast, List, Optional +from typing import Iterable, Optional, Set from uuid import uuid4, UUID from . import ExternalReference, ThisTool, Tool @@ -35,39 +34,28 @@ class BomMetaData: See the CycloneDX Schema for Bom metadata: https://cyclonedx.org/docs/1.3/#type_metadata """ - def __init__(self, *, tools: Optional[List[Tool]] = None) -> None: + def __init__(self, *, tools: Optional[Iterable[Tool]] = None) -> None: self.timestamp = datetime.now(tz=timezone.utc) - self.tools = tools if tools else [] + self.tools = set(tools or []) if not self.tools: - self.add_tool(ThisTool) + self.tools.add(ThisTool) self.component: Optional[Component] = None @property - def tools(self) -> List[Tool]: + def tools(self) -> Set[Tool]: """ Tools used to create this BOM. Returns: - `List` of `Tool` objects where there are any, else an empty `List`. + `Set` of `Tool` objects. """ return self._tools @tools.setter - def tools(self, tools: List[Tool]) -> None: - self._tools = tools - - def add_tool(self, tool: Tool) -> None: - """ - Add a Tool definition to this Bom Metadata. The `cyclonedx-python-lib` is automatically added - you do not need - to add this yourself. - - Args: - tool: - Instance of `Tool` that represents the tool you are using. - """ - self._tools.append(tool) + def tools(self, tools: Iterable[Tool]) -> None: + self._tools = set(tools) @property def timestamp(self) -> datetime: @@ -114,9 +102,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - self.timestamp, - tuple([hash(tool) for tool in set(sorted(self.tools, key=hash))]) if self.tools else None, - self.component + self.timestamp, self.tools, self.component )) def __repr__(self) -> str: @@ -146,11 +132,12 @@ def from_parser(parser: BaseParser) -> 'Bom': `cyclonedx.model.bom.Bom`: A Bom instance that represents the valid data held in the supplied parser. """ bom = Bom() - bom.add_components(parser.get_components()) + bom.components.update(parser.get_components()) return bom - def __init__(self, *, components: Optional[List[Component]] = None, services: Optional[List[Service]] = None, - external_references: Optional[List[ExternalReference]] = None) -> None: + def __init__(self, *, components: Optional[Iterable[Component]] = None, + services: Optional[Iterable[Service]] = None, + external_references: Optional[Iterable[ExternalReference]] = None) -> None: """ Create a new Bom that you can manually/programmatically add data to later. @@ -159,9 +146,9 @@ def __init__(self, *, components: Optional[List[Component]] = None, services: Op """ self.uuid = uuid4() self.metadata = BomMetaData() - self.components = components - self.services = services - self.external_references = external_references + self.components = set(components or []) + self.services = set(services or []) + self.external_references = set(external_references or []) @property def uuid(self) -> UUID: @@ -195,60 +182,22 @@ def metadata(self, metadata: BomMetaData) -> None: self._metadata = metadata @property - def components(self) -> Optional[List[Component]]: + def components(self) -> Set[Component]: """ Get all the Components currently in this Bom. Returns: - List of all Components in this Bom or `None` + Set of `Component` in this Bom """ return self._components @components.setter - def components(self, components: Optional[List[Component]]) -> None: - self._components = components - - def add_component(self, component: Component) -> None: - """ - Add a Component to this Bom instance. - - Args: - component: - `cyclonedx.model.component.Component` instance to add to this Bom. - - Returns: - None - """ - if not self.components: - self.components = [component] - elif not self.has_component(component=component): - self.components.append(component) - - def add_components(self, components: List[Component]) -> None: - """ - Add multiple Components at once to this Bom instance. - - Args: - components: - List of `cyclonedx.model.component.Component` instances to add to this Bom. - - Returns: - None - """ - self.components = (self._components or []) + components - - def component_count(self) -> int: - """ - Returns the current count of Components within this Bom. - - Returns: - The number of Components in this Bom as `int`. - """ - return len(self._components) if self._components else 0 + def components(self, components: Iterable[Component]) -> None: + self._components = set(components) def get_component_by_purl(self, purl: Optional[str]) -> Optional[Component]: """ - Get a Component already in the Bom by it's PURL + Get a Component already in the Bom by its PURL Args: purl: @@ -257,11 +206,8 @@ def get_component_by_purl(self, purl: Optional[str]) -> Optional[Component]: Returns: `Component` or `None` """ - if not self._components: - return None - if purl: - found = list(filter(lambda x: x.purl == purl, cast(List[Component], self.components))) + found = list(filter(lambda x: x.purl == purl, self.components)) if len(found) == 1: return found[0] @@ -287,107 +233,35 @@ def has_component(self, component: Component) -> bool: Returns: `bool` - `True` if the supplied Component is part of this Bom, `False` otherwise. """ - if not self.components: - return False return component in self.components @property - def services(self) -> Optional[List[Service]]: + def services(self) -> Set[Service]: """ Get all the Services currently in this Bom. Returns: - List of `Service` in this Bom or `None` + Set of `Service` in this BOM """ return self._services @services.setter - def services(self, services: Optional[List[Service]]) -> None: - self._services = services - - def add_service(self, service: Service) -> None: - """ - Add a Service to this Bom instance. - - Args: - service: - `cyclonedx.model.service.Service` instance to add to this Bom. - - Returns: - None - """ - if not self.services: - self.services = [service] - elif not self.has_service(service=service): - self.services.append(service) - - def add_services(self, services: List[Service]) -> None: - """ - Add multiple Services at once to this Bom instance. - - Args: - services: - List of `cyclonedx.model.service.Service` instances to add to this Bom. - - Returns: - None - """ - self.services = (self.services or []) + services - - def has_service(self, service: Service) -> bool: - """ - Check whether this Bom contains the provided Service. - - Args: - service: - The instance of `cyclonedx.model.service.Service` to check if this Bom contains. - - Returns: - `bool` - `True` if the supplied Service is part of this Bom, `False` otherwise. - """ - if not self.services: - return False - - return service in self.services - - def service_count(self) -> int: - """ - Returns the current count of Services within this Bom. - - Returns: - The number of Services in this Bom as `int`. - """ - if not self.services: - return 0 - - return len(self.services) + def services(self, services: Iterable[Service]) -> None: + self._services = set(services) @property - def external_references(self) -> Optional[List[ExternalReference]]: + def external_references(self) -> Set[ExternalReference]: """ Provides the ability to document external references related to the BOM or to the project the BOM describes. Returns: - List of `ExternalReference` else `None` + Set of `ExternalReference` """ return self._external_references @external_references.setter - def external_references(self, external_references: Optional[List[ExternalReference]]) -> None: - self._external_references = external_references - - def add_external_reference(self, external_reference: ExternalReference) -> None: - """ - Add an external reference to this Bom. - - Args: - external_reference: - `ExternalReference` to add to this Bom. - - Returns: - None - """ - self.external_references = (self.external_references or []) + [external_reference] + def external_references(self, external_references: Iterable[ExternalReference]) -> None: + self._external_references = set(external_references) def has_vulnerabilities(self) -> bool: """ @@ -397,12 +271,7 @@ def has_vulnerabilities(self) -> bool: `bool` - `True` if at least one `cyclonedx.model.component.Component` has at least one Vulnerability, `False` otherwise. """ - if self.components: - for c in self.components: - if c.has_vulnerabilities(): - return True - - return False + return any(c.has_vulnerabilities() for c in self.components) def __eq__(self, other: object) -> bool: if isinstance(other, Bom): @@ -411,9 +280,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - self.uuid, self.metadata, - tuple([hash(c) for c in set(sorted(self.components, key=hash))]) if self.components else None, - tuple([hash(s) for s in set(sorted(self.services, key=hash))]) if self.services else None + self.uuid, self.metadata, tuple(self.components), tuple(self.services), tuple(self.external_references) )) def __repr__(self) -> str: diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index cd8fc1df..86c3232f 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -16,11 +16,10 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - import warnings from enum import Enum from os.path import exists -from typing import List, Optional +from typing import Iterable, Optional, Set from uuid import uuid4 # See https://github.com/package-url/packageurl-python/issues/65 @@ -149,43 +148,43 @@ class ComponentEvidence: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_componentEvidenceType """ - def __init__(self, *, licenses: Optional[List[LicenseChoice]] = None, - copyright_: Optional[List[Copyright]] = None) -> None: + def __init__(self, *, licenses: Optional[Iterable[LicenseChoice]] = None, + copyright_: Optional[Iterable[Copyright]] = None) -> None: if not licenses and not copyright_: raise NoPropertiesProvidedException( 'At least one of `licenses` or `copyright_` must be supplied for a `ComponentEvidence`.' ) - self.licenses = licenses - self.copyright = copyright_ + self.licenses = set(licenses or []) + self.copyright = set(copyright_ or []) @property - def licenses(self) -> Optional[List[LicenseChoice]]: + def licenses(self) -> Set[LicenseChoice]: """ Optional list of licenses obtained during analysis. Returns: - List of `LicenseChoice` if set else `None` + Set of `LicenseChoice` """ return self._licenses @licenses.setter - def licenses(self, licenses: Optional[List[LicenseChoice]]) -> None: - self._licenses = licenses + def licenses(self, licenses: Iterable[LicenseChoice]) -> None: + self._licenses = set(licenses) @property - def copyright(self) -> Optional[List[Copyright]]: + def copyright(self) -> Set[Copyright]: """ Optional list of copyright statements. Returns: - List of `Copyright` if set else `None` + Set of `Copyright` """ return self._copyright @copyright.setter - def copyright(self, copyright_: Optional[List[Copyright]]) -> None: - self._copyright = copyright_ + def copyright(self, copyright_: Iterable[Copyright]) -> None: + self._copyright = set(copyright_) def __eq__(self, other: object) -> bool: if isinstance(other, ComponentEvidence): @@ -193,11 +192,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash(( - tuple([hash(licence) for licence in set(sorted(self.licenses, key=hash))]) if self.licenses else None, - tuple( - [hash(copyright_) for copyright_ in set(sorted(self.copyright, key=hash))]) if self.copyright else None - )) + return hash((tuple(self.licenses), tuple(self.copyright))) def __repr__(self) -> str: return f'' @@ -311,10 +306,10 @@ class Patch: """ def __init__(self, *, type_: PatchClassification, diff: Optional[Diff] = None, - resolves: Optional[List[IssueType]] = None) -> None: + resolves: Optional[Iterable[IssueType]] = None) -> None: self.type = type_ self.diff = diff - self.resolves = resolves + self.resolves = set(resolves or []) @property def type(self) -> PatchClassification: @@ -349,28 +344,18 @@ def diff(self, diff: Optional[Diff]) -> None: self._diff = diff @property - def resolves(self) -> Optional[List[IssueType]]: + def resolves(self) -> Set[IssueType]: """ Optional list of issues resolved by this patch. Returns: - List of `IssueType` if set else `None` + Set of `IssueType` """ return self._resolves @resolves.setter - def resolves(self, resolves: Optional[List[IssueType]]) -> None: - self._resolves = resolves - - def add_resolves(self, issue: IssueType) -> None: - """ - Add an Issue to the list that this patch resolves. - - Args: - issue: - `IssueType` - """ - self.resolves = (self.resolves or []) + [issue] + def resolves(self, resolves: Iterable[IssueType]) -> None: + self._resolves = set(resolves) def __eq__(self, other: object) -> bool: if isinstance(other, Patch): @@ -378,10 +363,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash(( - self.type, self.diff, - tuple([hash(issue) for issue in set(sorted(self.resolves, key=hash))]) if self.resolves else None - )) + return hash((self.type, self.diff, tuple(self.resolves))) def __repr__(self) -> str: return f'' @@ -400,9 +382,9 @@ class Pedigree: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_pedigreeType """ - def __init__(self, *, ancestors: Optional[List['Component']] = None, - descendants: Optional[List['Component']] = None, variants: Optional[List['Component']] = None, - commits: Optional[List[Commit]] = None, patches: Optional[List[Patch]] = None, + def __init__(self, *, ancestors: Optional[Iterable['Component']] = None, + descendants: Optional[Iterable['Component']] = None, variants: Optional[Iterable['Component']] = None, + commits: Optional[Iterable[Commit]] = None, patches: Optional[Iterable[Patch]] = None, notes: Optional[str] = None) -> None: if not ancestors and not descendants and not variants and not commits and not patches and not notes: raise NoPropertiesProvidedException( @@ -410,15 +392,15 @@ def __init__(self, *, ancestors: Optional[List['Component']] = None, 'provided for `Pedigree`' ) - self.ancestors = ancestors - self.descendants = descendants - self.variants = variants - self.commits = commits - self.patches = patches + self.ancestors = set(ancestors or []) + self.descendants = set(descendants or []) + self.variants = set(variants or []) + self.commits = set(commits or []) + self.patches = set(patches or []) self.notes = notes @property - def ancestors(self) -> Optional[List['Component']]: + def ancestors(self) -> Set['Component']: """ Describes zero or more components in which a component is derived from. This is commonly used to describe forks from existing projects where the forked version contains a ancestor node containing the original component it @@ -429,124 +411,74 @@ def ancestors(self) -> Optional[List['Component']]: original component from which Component B is derived from. Returns: - List of `Component` if set else `None` + Set of `Component` """ return self._ancestors @ancestors.setter - def ancestors(self, ancestors: Optional[List['Component']]) -> None: - self._ancestors = ancestors - - def add_ancestor(self, ancestor: 'Component') -> None: - """ - Adds an ancestor. - - Args: - ancestor: - `Component` - """ - self.ancestors = (self.ancestors or []) + [ancestor] + def ancestors(self, ancestors: Iterable['Component']) -> None: + self._ancestors = set(ancestors) @property - def descendants(self) -> Optional[List['Component']]: + def descendants(self) -> Set['Component']: """ Descendants are the exact opposite of ancestors. This provides a way to document all forks (and their forks) of an original or root component. Returns: - List of `Component` if set else `None` + Set of `Component` """ return self._descendants @descendants.setter - def descendants(self, descendants: Optional[List['Component']]) -> None: - self._descendants = descendants - - def add_descendant(self, descendant: 'Component') -> None: - """ - Adds an descendant. - - Args: - descendant: - `Component` - """ - self.descendants = (self.descendants or []) + [descendant] + def descendants(self, descendants: Iterable['Component']) -> None: + self._descendants = set(descendants) @property - def variants(self) -> Optional[List['Component']]: + def variants(self) -> Set['Component']: """ Variants describe relations where the relationship between the components are not known. For example, if Component A contains nearly identical code to Component B. They are both related, but it is unclear if one is derived from the other, or if they share a common ancestor. Returns: - List of `Component` if set else `None` + Set of `Component` """ return self._variants @variants.setter - def variants(self, variants: Optional[List['Component']]) -> None: - self._variants = variants - - def add_variant(self, variant: 'Component') -> None: - """ - Adds an variant. - - Args: - variant: - `Component` - """ - self.variants = (self.variants or []) + [variant] + def variants(self, variants: Iterable['Component']) -> None: + self._variants = set(variants) @property - def commits(self) -> Optional[List[Commit]]: + def commits(self) -> Set[Commit]: """ A list of zero or more commits which provide a trail describing how the component deviates from an ancestor, descendant, or variant. Returns: - List of `Commit` if set else `None` + Set of `Commit` """ return self._commits @commits.setter - def commits(self, commits: Optional[List[Commit]]) -> None: - self._commits = commits - - def add_commit(self, commit: Commit) -> None: - """ - Adds a Commit. - - Args: - commit: - `Commit` - """ - self.commits = (self.commits or []) + [commit] + def commits(self, commits: Iterable[Commit]) -> None: + self._commits = set(commits) @property - def patches(self) -> Optional[List[Patch]]: + def patches(self) -> Set[Patch]: """ A list of zero or more patches describing how the component deviates from an ancestor, descendant, or variant. Patches may be complimentary to commits or may be used in place of commits. Returns: - List of `Patch` if present else `None` + Set of `Patch` """ return self._patches @patches.setter - def patches(self, patches: Optional[List[Patch]]) -> None: - self._patches = patches - - def add_patch(self, patch: Patch) -> None: - """ - Adds a Patch. - - Args: - patch: - `Patch` - """ - self.patches = (self.patches or []) + [patch] + def patches(self, patches: Iterable[Patch]) -> None: + self._patches = set(patches) @property def notes(self) -> Optional[str]: @@ -569,13 +501,8 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - tuple([hash(ancestor) for ancestor in set(sorted(self.ancestors, key=hash))]) if self.ancestors else None, - tuple([hash(descendant) for descendant in - set(sorted(self.descendants, key=hash))]) if self.descendants else None, - tuple([hash(variant) for variant in set(sorted(self.variants, key=hash))]) if self.variants else None, - tuple([hash(commit) for commit in set(sorted(self.commits, key=hash))]) if self.commits else None, - tuple([hash(patch) for patch in set(sorted(self.patches, key=hash))]) if self.patches else None, - self.notes + tuple(self.ancestors), tuple(self.descendants), tuple(self.variants), tuple(self.commits), + tuple(self.patches), self.notes )) def __repr__(self) -> str: @@ -754,12 +681,12 @@ def __init__(self, *, name: str, component_type: ComponentType = ComponentType.L supplier: Optional[OrganizationalEntity] = None, author: Optional[str] = None, publisher: Optional[str] = None, group: Optional[str] = None, version: Optional[str] = None, description: Optional[str] = None, scope: Optional[ComponentScope] = None, - hashes: Optional[List[HashType]] = None, licenses: Optional[List[LicenseChoice]] = None, + hashes: Optional[Iterable[HashType]] = None, licenses: Optional[Iterable[LicenseChoice]] = None, copyright_: Optional[str] = None, purl: Optional[PackageURL] = None, - external_references: Optional[List[ExternalReference]] = None, - properties: Optional[List[Property]] = None, release_notes: Optional[ReleaseNotes] = None, + external_references: Optional[Iterable[ExternalReference]] = None, + properties: Optional[Iterable[Property]] = None, release_notes: Optional[ReleaseNotes] = None, cpe: Optional[str] = None, swid: Optional[Swid] = None, pedigree: Optional[Pedigree] = None, - components: Optional[List['Component']] = None, evidence: Optional[ComponentEvidence] = None, + components: Optional[Iterable['Component']] = None, evidence: Optional[ComponentEvidence] = None, # Deprecated parameters kept for backwards compatibility namespace: Optional[str] = None, license_str: Optional[str] = None ) -> None: @@ -774,16 +701,16 @@ def __init__(self, *, name: str, component_type: ComponentType = ComponentType.L self.version = version self.description = description self.scope = scope - self.hashes = hashes or [] - self.licenses = licenses or [] + self.hashes = set(hashes or []) + self.licenses = set(licenses or []) self.copyright = copyright_ self.cpe = cpe self.purl = purl self.swid = swid self.pedigree = pedigree - self.external_references = external_references if external_references else [] - self.properties = properties - self.components = components + self.external_references = set(external_references or []) + self.properties = set(properties or []) + self.components = set(components or []) self.evidence = evidence self.release_notes = release_notes @@ -802,9 +729,9 @@ def __init__(self, *, name: str, component_type: ComponentType = ComponentType.L 'standard', DeprecationWarning ) if not licenses: - self.licenses = [LicenseChoice(license_expression=license_str)] + self.licenses = {LicenseChoice(license_expression=license_str)} - self.__vulnerabilites: List[Vulnerability] = [] + self.__vulnerabilites: Set[Vulnerability] = set() @property def type(self) -> ComponentType: @@ -982,42 +909,32 @@ def scope(self, scope: Optional[ComponentScope]) -> None: self._scope = scope @property - def hashes(self) -> List[HashType]: + def hashes(self) -> Set[HashType]: """ - Optional list of hashes that help specifiy the integrity of this Component. + Optional list of hashes that help specify the integrity of this Component. Returns: - List of `HashType` or `None` + Set of `HashType` """ return self._hashes @hashes.setter - def hashes(self, hashes: List[HashType]) -> None: - self._hashes = hashes - - def add_hash(self, a_hash: HashType) -> None: - """ - Adds a hash that pins/identifies this Component. - - Args: - a_hash: - `HashType` instance - """ - self.hashes = self.hashes + [a_hash] + def hashes(self, hashes: Iterable[HashType]) -> None: + self._hashes = set(hashes) @property - def licenses(self) -> List[LicenseChoice]: + def licenses(self) -> Set[LicenseChoice]: """ A optional list of statements about how this Component is licensed. Returns: - List of `LicenseChoice` else `None` + Set of `LicenseChoice` """ return self._licenses @licenses.setter - def licenses(self, licenses: List[LicenseChoice]) -> None: - self._licenses = licenses + def licenses(self, licenses: Iterable[LicenseChoice]) -> None: + self._licenses = set(licenses) @property def copyright(self) -> Optional[str]: @@ -1031,8 +948,8 @@ def copyright(self) -> Optional[str]: return self._copyright @copyright.setter - def copyright(self, copyright: Optional[str]) -> None: - self._copyright = copyright + def copyright(self, copyright_: Optional[str]) -> None: + self._copyright = copyright_ @property def cpe(self) -> Optional[str]: @@ -1096,70 +1013,50 @@ def pedigree(self, pedigree: Optional[Pedigree]) -> None: self._pedigree = pedigree @property - def external_references(self) -> List[ExternalReference]: + def external_references(self) -> Set[ExternalReference]: """ Provides the ability to document external references related to the component or to the project the component describes. Returns: - List of `ExternalReference`s + Set of `ExternalReference` """ return self._external_references @external_references.setter - def external_references(self, external_references: List[ExternalReference]) -> None: - self._external_references = external_references - - def add_external_reference(self, reference: ExternalReference) -> None: - """ - Add an `ExternalReference` to this `Component`. - - Args: - reference: - `ExternalReference` instance to add. - """ - self.external_references = self._external_references + [reference] + def external_references(self, external_references: Iterable[ExternalReference]) -> None: + self._external_references = set(external_references) @property - def properties(self) -> Optional[List[Property]]: + def properties(self) -> Set[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: - List of `Property` or `None` + Set of `Property` """ return self._properties @properties.setter - def properties(self, properties: Optional[List[Property]]) -> None: - self._properties = properties + def properties(self, properties: Iterable[Property]) -> None: + self._properties = set(properties) @property - def components(self) -> Optional[List['Component']]: + def components(self) -> Set['Component']: """ A list of software and hardware components included in the parent component. This is not a dependency tree. It provides a way to specify a hierarchical representation of component assemblies, similar to system -> subsystem -> parts assembly in physical supply chains. Returns: - List of `Component` if set else `None` + Set of `Component` """ return self._components @components.setter - def components(self, components: Optional[List['Component']]) -> None: - self._components = components - - def add_component(self, component: 'Component') -> None: - """ - Add aa `Component` that is part of this parent `Component`. - - Args: - component: - `Component` to add - """ - self.components = (self.components or []) + [component] + def components(self, components: Iterable['Component']) -> None: + self._components = set(components) @property def evidence(self) -> Optional[ComponentEvidence]: @@ -1200,14 +1097,14 @@ def add_vulnerability(self, vulnerability: Vulnerability) -> None: Returns: None """ - self.__vulnerabilites.append(vulnerability) + self.__vulnerabilites.add(vulnerability) - def get_vulnerabilities(self) -> List[Vulnerability]: + def get_vulnerabilities(self) -> Set[Vulnerability]: """ Get all the Vulnerabilities for this Component. Returns: - List of `Vulnerability` objects assigned to this Component. + Set of `Vulnerability` """ return self.__vulnerabilites @@ -1234,16 +1131,9 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( self.type, self.mime_type, self.supplier, self.author, self.publisher, self.group, self.name, - self.version, self.description, self.scope, - tuple([hash(hash_) for hash_ in set(sorted(self.hashes, key=hash))]) if self.hashes else None, - tuple([hash(license_) for license_ in set(sorted(self.licenses, key=hash))]) if self.licenses else None, - self.copyright, self.cpe, self.purl, self.swid, self.pedigree, - tuple([hash(ref) for ref in - set(sorted(self.external_references, key=hash))]) if self.external_references else None, - tuple([hash(prop) for prop in set(sorted(self.properties, key=hash))]) if self.properties else None, - tuple( - [hash(component) for component in set(sorted(self.components, key=hash))]) if self.components else None, - self.evidence, self.release_notes + self.version, self.description, self.scope, tuple(self.hashes), tuple(self.licenses), self.copyright, + self.cpe, self.purl, self.swid, self.pedigree, tuple(self.external_references), tuple(self.properties), + tuple(self.components), self.evidence, self.release_notes )) def __repr__(self) -> str: diff --git a/cyclonedx/model/issue.py b/cyclonedx/model/issue.py index c8d3b672..04ea4f6b 100644 --- a/cyclonedx/model/issue.py +++ b/cyclonedx/model/issue.py @@ -13,9 +13,9 @@ # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 - +# Copyright (c) OWASP Foundation. All Rights Reserved. from enum import Enum -from typing import List, Optional +from typing import Iterable, Optional, Set from . import XsUri from ..exception.model import NoPropertiesProvidedException @@ -99,181 +99,99 @@ class IssueType: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_issueType """ - def __init__(self, *, classification: IssueClassification, id: Optional[str] = None, name: Optional[str] = None, - description: Optional[str] = None, source_name: Optional[str] = None, - source_url: Optional[XsUri] = None, references: Optional[List[XsUri]] = None) -> None: - self._type: IssueClassification = classification - self._id: Optional[str] = id - self._name: Optional[str] = name - self._description: Optional[str] = description - self._source: Optional[IssueTypeSource] = None - self._references: List[XsUri] = references or [] - if source_name or source_url: - self._source = IssueTypeSource( - name=source_name, url=source_url - ) + def __init__(self, *, classification: IssueClassification, id_: Optional[str] = None, name: Optional[str] = None, + description: Optional[str] = None, source: Optional[IssueTypeSource] = None, + references: Optional[Iterable[XsUri]] = None) -> None: + self.type = classification + self.id = id_ + self.name = name + self.description = description + self.source = source + self.references = set(references or []) @property - def source(self) -> Optional[IssueTypeSource]: - return self._source - - @source.setter - def source(self, source: IssueTypeSource) -> None: - self._source = source - - def add_reference(self, reference: XsUri) -> None: + def type(self) -> IssueClassification: """ - Add a reference URL to this Issue. - - Args: - reference: - `XsUri` Reference URL to add - """ - self._references.append(reference) - - def get_classification(self) -> IssueClassification: - """ - Get the classification of this IssueType. + Specifies the type of issue. Returns: - `IssueClassification` that represents the classification of this `IssueType`. + `IssueClassification` """ return self._type - def get_id(self) -> Optional[str]: - """ - Get the ID of this IssueType. - - Returns: - `str` that represents the ID of this `IssueType` if set else `None`. - """ - return self._id + @type.setter + def type(self, classification: IssueClassification) -> None: + self._type = classification - def get_name(self) -> Optional[str]: + @property + def id(self) -> Optional[str]: """ - Get the name of this IssueType. + The identifier of the issue assigned by the source of the issue. Returns: - `str` that represents the name of this `IssueType` if set else `None`. - """ - return self._name - - def get_description(self) -> Optional[str]: + `str` if set else `None` """ - Get the description of this IssueType. + return self._id - Returns: - `str` that represents the description of this `IssueType` if set else `None`. - """ - return self._description + @id.setter + def id(self, id_: Optional[str]) -> None: + self._id = id_ - def get_source_name(self) -> Optional[str]: + @property + def name(self) -> Optional[str]: """ - Get the source_name of this IssueType. - - For example, this might be "NVD" or "National Vulnerability Database". + The name of the issue. Returns: - `str` that represents the source_name of this `IssueType` if set else `None`. - """ - if self._source: - return self._source.name - return None - - def get_source_url(self) -> Optional[XsUri]: + `str` if set else `None` """ - Get the source_url of this IssueType. - - For example, this would likely be a URL to the issue on the NVD. + return self._name - Returns: - `XsUri` that represents the source_url of this `IssueType` if set else `None`. - """ - if self._source: - return self._source.url - return None + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name - def get_references(self) -> List[XsUri]: + @property + def description(self) -> Optional[str]: """ - Get any references for this IssueType. - - References are an arbitrary list of URIs that relate to this issue. + A description of the issue. Returns: - List of `XsUri` objects. - """ - return self._references - - def set_id(self, id: str) -> None: + `str` if set else `None` """ - Set the ID of this Issue. - - Args: - id: - `str` the Issue ID + return self._description - Returns: - None - """ - self._id = id + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description - def set_name(self, name: str) -> None: + @property + def source(self) -> Optional[IssueTypeSource]: """ - Set the name of this Issue. - - Args: - name: - `str` the name of this Issue + The source of this issue. Returns: - None + `IssueTypeSource` if set else `None` """ - self._name = name - - def set_description(self, description: str) -> None: - """ - Set the description of this Issue. - - Args: - description: - `str` the description of this Issue + return self._source - Returns: - None - """ - self._description = description + @source.setter + def source(self, source: Optional[IssueTypeSource]) -> None: + self._source = source - def set_source_name(self, source_name: str) -> None: + @property + def references(self) -> Set[XsUri]: """ - Set the name of the source of this Issue. - - Args: - source_name: - `str` For example, this might be "NVD" or "National Vulnerability Database" + Any reference URLs related to this issue. Returns: - None - """ - if self._source: - self._source.name = source_name - else: - self._source = IssueTypeSource(name=source_name) - - def set_source_url(self, source_url: XsUri) -> None: + Set of `XsUri` """ - Set the URL for the source of this Issue. - - Args: - source_url: - `XsUri` For example, this would likely be a URL to the issue on the NVD + return self._references - Returns: - None - """ - if self._source: - self._source.url = source_url - else: - self._source = IssueTypeSource(url=source_url) + @references.setter + def references(self, references: Iterable[XsUri]) -> None: + self._references = set(references) def __eq__(self, other: object) -> bool: if isinstance(other, IssueType): @@ -282,9 +200,8 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - self._type, self._id, self._name, self._description, self._source, - tuple([hash(ref) for ref in set(sorted(self._references, key=hash))]) if self._references else None + self.type, self.id, self.name, self.description, self.source, tuple(self.references) )) def __repr__(self) -> str: - return f'' + return f'' diff --git a/cyclonedx/model/release_note.py b/cyclonedx/model/release_note.py index c6687fa7..a17d3a69 100644 --- a/cyclonedx/model/release_note.py +++ b/cyclonedx/model/release_note.py @@ -16,9 +16,8 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - from datetime import datetime -from typing import List, Optional +from typing import Iterable, Optional, Set from ..model import Note, Property, XsUri from ..model.issue import IssueType @@ -32,22 +31,22 @@ class ReleaseNotes: See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/#type_releaseNotesType """ - def __init__(self, *, type: str, title: Optional[str] = None, featured_image: Optional[XsUri] = None, + def __init__(self, *, type_: str, title: Optional[str] = None, featured_image: Optional[XsUri] = None, social_image: Optional[XsUri] = None, description: Optional[str] = None, - timestamp: Optional[datetime] = None, aliases: Optional[List[str]] = None, - tags: Optional[List[str]] = None, resolves: Optional[List[IssueType]] = None, - notes: Optional[List[Note]] = None, properties: Optional[List[Property]] = None) -> None: - self.type = type + timestamp: Optional[datetime] = None, aliases: Optional[Iterable[str]] = None, + tags: Optional[Iterable[str]] = None, resolves: Optional[Iterable[IssueType]] = None, + notes: Optional[Iterable[Note]] = None, properties: Optional[Iterable[Property]] = None) -> None: + self.type = type_ self.title = title self.featured_image = featured_image self.social_image = social_image self.description = description self.timestamp = timestamp - self.aliases = aliases - self.tags = tags - self.resolves = resolves - self.notes = notes - self._properties: Optional[List[Property]] = properties or None + self.aliases = set(aliases or []) + self.tags = set(tags or []) + self.resolves = set(resolves or []) + self.notes = set(notes or []) + self.properties = set(properties or []) @property def type(self) -> str: @@ -71,8 +70,8 @@ def type(self) -> str: return self._type @type.setter - def type(self, type: str) -> None: - self._type = type + def type(self, type_: str) -> None: + self._type = type_ @property def title(self) -> Optional[str]: @@ -130,114 +129,78 @@ def timestamp(self, timestamp: Optional[datetime]) -> None: self._timestamp = timestamp @property - def aliases(self) -> Optional[List[str]]: + def aliases(self) -> Set[str]: """ One or more alternate names the release may be referred to. This may include unofficial terms used by development and marketing teams (e.g. code names). + + Returns: + Set of `str` """ return self._aliases @aliases.setter - def aliases(self, aliases: Optional[List[str]]) -> None: - if not aliases: - aliases = None - self._aliases = aliases - - def add_alias(self, alias: str) -> None: - """ - Adds an alias to this Release. - - Args: - alias: - `str` alias - """ - self.aliases = (self.aliases or []) + [alias] + def aliases(self, aliases: Iterable[str]) -> None: + self._aliases = set(aliases) @property - def tags(self) -> Optional[List[str]]: + def tags(self) -> Set[str]: """ One or more tags that may aid in search or retrieval of the release note. + + Returns: + Set of `str` """ return self._tags @tags.setter - def tags(self, tags: Optional[List[str]]) -> None: - if not tags: - tags = None - self._tags = tags - - def add_tag(self, tag: str) -> None: - """ - Add a tag to this Release. - - Args: - tag: - `str` tag to add - """ - self.tags = (self.tags or []) + [tag] + def tags(self, tags: Iterable[str]) -> None: + self._tags = set(tags) @property - def resolves(self) -> Optional[List[IssueType]]: + def resolves(self) -> Set[IssueType]: """ A collection of issues that have been resolved. + + Returns: + Set of `IssueType` """ return self._resolves @resolves.setter - def resolves(self, resolves: Optional[List[IssueType]]) -> None: - if not resolves: - resolves = None - self._resolves = resolves - - def add_resolves(self, issue: IssueType) -> None: - """ - Adds an issue that this Release resolves. - - Args: - issue: - `IssueType` object that is resolved by this Release - """ - self.resolves = (self.resolves or []) + [issue] + def resolves(self, resolves: Iterable[IssueType]) -> None: + self._resolves = set(resolves) @property - def notes(self) -> Optional[List[Note]]: + def notes(self) -> Set[Note]: """ Zero or more release notes containing the locale and content. Multiple note elements may be specified to support release notes in a wide variety of languages. + + Returns: + Set of `Note` """ return self._notes @notes.setter - def notes(self, notes: Optional[List[Note]]) -> None: - if not notes: - notes = None - self._notes = notes - - def add_note(self, note: Note) -> None: - """ - Adds a release note to this Release. - - Args: - note: - `Note` to be added - """ - self.notes = (self.notes or []) + [note] + def notes(self, notes: Iterable[Note]) -> None: + self._notes = set(notes) @property - def properties(self) -> Optional[List[Property]]: + def properties(self) -> Set[Property]: """ Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Returns: - List of `Property` or `None` + Set of `Property` """ return self._properties @properties.setter - def properties(self, properties: Optional[List[Property]]) -> None: - self._properties = properties + def properties(self, properties: Iterable[Property]) -> None: + self._properties = set(properties) def __eq__(self, other: object) -> bool: if isinstance(other, ReleaseNotes): @@ -247,11 +210,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( self.type, self.title, self.featured_image, self.social_image, self.description, self.timestamp, - tuple([hash(alias) for alias in set(sorted(self.aliases, key=hash))]) if self.aliases else None, - tuple([hash(tag) for tag in set(sorted(self.tags, key=hash))]) if self.tags else None, - tuple([hash(issue) for issue in set(sorted(self.resolves, key=hash))]) if self.resolves else None, - tuple([hash(note) for note in set(sorted(self.notes, key=hash))]) if self.notes else None, - tuple([hash(prop) for prop in set(sorted(self._properties, key=hash))]) if self._properties else None + tuple(self.aliases), tuple(self.tags), tuple(self.resolves), tuple(self.notes), tuple(self.properties) )) def __repr__(self) -> str: diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py index ced75b8d..e695ea9c 100644 --- a/cyclonedx/model/service.py +++ b/cyclonedx/model/service.py @@ -13,11 +13,11 @@ # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 - -from typing import List, Optional +# Copyright (c) OWASP Foundation. All Rights Reserved. +from typing import Iterable, Optional, Set from uuid import uuid4 -from . import ExternalReference, DataClassification, LicenseChoice, OrganizationalEntity, Property, XsUri # , Signature +from . import ExternalReference, DataClassification, LicenseChoice, OrganizationalEntity, Property, XsUri from .release_note import ReleaseNotes """ @@ -38,12 +38,12 @@ class Service: def __init__(self, *, name: str, bom_ref: Optional[str] = None, provider: Optional[OrganizationalEntity] = None, group: Optional[str] = None, version: Optional[str] = None, description: Optional[str] = None, - endpoints: Optional[List[XsUri]] = None, authenticated: Optional[bool] = None, - x_trust_boundary: Optional[bool] = None, data: Optional[List[DataClassification]] = None, - licenses: Optional[List[LicenseChoice]] = None, - external_references: Optional[List[ExternalReference]] = None, - properties: Optional[List[Property]] = None, - services: Optional[List['Service']] = None, + endpoints: Optional[Iterable[XsUri]] = None, authenticated: Optional[bool] = None, + x_trust_boundary: Optional[bool] = None, data: Optional[Iterable[DataClassification]] = None, + licenses: Optional[Iterable[LicenseChoice]] = None, + external_references: Optional[Iterable[ExternalReference]] = None, + properties: Optional[Iterable[Property]] = None, + services: Optional[Iterable['Service']] = None, release_notes: Optional[ReleaseNotes] = None, ) -> None: self.bom_ref = bom_ref or str(uuid4()) @@ -52,15 +52,15 @@ def __init__(self, *, name: str, bom_ref: Optional[str] = None, provider: Option self.name = name self.version = version self.description = description - self.endpoints = endpoints + self.endpoints = set(endpoints or []) self.authenticated = authenticated self.x_trust_boundary = x_trust_boundary - self.data = data - self.licenses = licenses or [] - self.external_references = external_references or [] - self.services = services + self.data = set(data or []) + self.licenses = set(licenses or []) + self.external_references = set(external_references or []) + self.services = set(services or []) self.release_notes = release_notes - self.properties = properties + self.properties = set(properties or []) @property def bom_ref(self) -> Optional[str]: @@ -151,31 +151,18 @@ def description(self, description: Optional[str]) -> None: self._description = description @property - def endpoints(self) -> Optional[List[XsUri]]: + def endpoints(self) -> Set[XsUri]: """ A list of endpoints URI's this service provides. Returns: - List of `XsUri` else `None` + Set of `XsUri` """ return self._endpoints @endpoints.setter - def endpoints(self, endpoints: Optional[List[XsUri]]) -> None: - self._endpoints = endpoints - - def add_endpoint(self, endpoint: XsUri) -> None: - """ - Add an endpoint URI for this Service. - - Args: - endpoint: - `XsUri` instance to add - - Returns: - None - """ - self.endpoints = (self._endpoints or []) + [endpoint] + def endpoints(self, endpoints: Iterable[XsUri]) -> None: + self._endpoints = set(endpoints) @property def authenticated(self) -> Optional[bool]: @@ -212,59 +199,49 @@ def x_trust_boundary(self, x_trust_boundary: Optional[bool]) -> None: self._x_trust_boundary = x_trust_boundary @property - def data(self) -> Optional[List[DataClassification]]: + def data(self) -> Set[DataClassification]: """ Specifies the data classification. Returns: - List of `DataClassificiation` or `None` + Set of `DataClassification` """ return self._data @data.setter - def data(self, data: Optional[List[DataClassification]]) -> None: - self._data = data + def data(self, data: Iterable[DataClassification]) -> None: + self._data = set(data) @property - def licenses(self) -> List[LicenseChoice]: + def licenses(self) -> Set[LicenseChoice]: """ A optional list of statements about how this Service is licensed. Returns: - List of `LicenseChoice` else `None` + Set of `LicenseChoice` """ return self._licenses @licenses.setter - def licenses(self, licenses: List[LicenseChoice]) -> None: - self._licenses = licenses + def licenses(self, licenses: Iterable[LicenseChoice]) -> None: + self._licenses = set(licenses) @property - def external_references(self) -> List[ExternalReference]: + def external_references(self) -> Set[ExternalReference]: """ Provides the ability to document external references related to the Service. Returns: - List of `ExternalReference`s + Set of `ExternalReference` """ return self._external_references @external_references.setter - def external_references(self, external_references: List[ExternalReference]) -> None: - self._external_references = external_references - - def add_external_reference(self, reference: ExternalReference) -> None: - """ - Add an `ExternalReference` to this `Service`. - - Args: - reference: - `ExternalReference` instance to add. - """ - self.external_references = self._external_references + [reference] + def external_references(self, external_references: Iterable[ExternalReference]) -> None: + self._external_references = set(external_references) @property - def services(self) -> Optional[List['Service']]: + def services(self) -> Set['Service']: """ A list of services included or deployed behind the parent service. @@ -273,29 +250,13 @@ def services(self) -> Optional[List['Service']]: It provides a way to specify a hierarchical representation of service assemblies. Returns: - List of `Service`s or `None` + Set of `Service` """ return self._services @services.setter - def services(self, services: Optional[List['Service']]) -> None: - self._services = services - - def has_service(self, service: 'Service') -> bool: - """ - Check whether this Service contains the given Service. - - Args: - service: - The instance of `cyclonedx.model.service.Service` to check if this Service contains. - - Returns: - `bool` - `True` if the supplied Service is part of this Service, `False` otherwise. - """ - if not self.services: - return False - - return service in self.services + def services(self, services: Iterable['Service']) -> None: + self._services = set(services) @property def release_notes(self) -> Optional[ReleaseNotes]: @@ -312,19 +273,19 @@ def release_notes(self, release_notes: Optional[ReleaseNotes]) -> None: self._release_notes = release_notes @property - def properties(self) -> Optional[List[Property]]: + def properties(self) -> Set[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: - List of `Property` or `None` + Set of `Property` """ return self._properties @properties.setter - def properties(self, properties: Optional[List[Property]]) -> None: - self._properties = properties + def properties(self, properties: Iterable[Property]) -> None: + self._properties = set(properties) def __eq__(self, other: object) -> bool: if isinstance(other, Service): @@ -333,15 +294,9 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - self.authenticated, self.data, self.description, - tuple([hash(uri) for uri in set(sorted(self.endpoints, key=hash))]) if self.endpoints else None, - tuple([hash(ref) for ref in - set(sorted(self.external_references, key=hash))]) if self.external_references else None, - self.group, - tuple([hash(license_) for license_ in set(sorted(self.licenses, key=hash))]) if self.licenses else None, - self.name, self.properties, self.provider, self.release_notes, - tuple([hash(service) for service in set(sorted(self.services, key=hash))]) if self.services else None, - self.version, self.x_trust_boundary + self.authenticated, tuple(self.data), self.description, tuple(self.endpoints), + tuple(self.external_references), self.group, tuple(self.licenses), self.name, tuple(self.properties), + self.provider, self.release_notes, tuple(self.services), self.version, self.x_trust_boundary )) def __repr__(self) -> str: diff --git a/cyclonedx/model/vulnerability.py b/cyclonedx/model/vulnerability.py index f3095bdb..0eed7f22 100644 --- a/cyclonedx/model/vulnerability.py +++ b/cyclonedx/model/vulnerability.py @@ -16,14 +16,12 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - import re import warnings from datetime import datetime from decimal import Decimal from enum import Enum -from typing import List, Optional, Tuple, Union -from urllib.parse import ParseResult, urlparse +from typing import Iterable, Optional, Set, Tuple, Union from uuid import uuid4 from . import OrganizationalContact, OrganizationalEntity, Tool, XsUri @@ -131,9 +129,9 @@ class BomTarget: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_vulnerabilityType """ - def __init__(self, *, ref: str, versions: Optional[List[BomTargetVersionRange]] = None) -> None: + def __init__(self, *, ref: str, versions: Optional[Iterable[BomTargetVersionRange]] = None) -> None: self.ref = ref - self.versions = versions + self.versions = set(versions or []) @property def ref(self) -> str: @@ -147,15 +145,18 @@ def ref(self, ref: str) -> None: self._ref = ref @property - def versions(self) -> Optional[List[BomTargetVersionRange]]: + def versions(self) -> Set[BomTargetVersionRange]: """ Zero or more individual versions or range of versions. + + Returns: + Set of `BomTargetVersionRange` """ return self._versions @versions.setter - def versions(self, versions: Optional[List[BomTargetVersionRange]]) -> None: - self._versions = versions + def versions(self, versions: Iterable[BomTargetVersionRange]) -> None: + self._versions = set(versions) def __eq__(self, other: object) -> bool: if isinstance(other, BomTarget): @@ -163,10 +164,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash(( - self.ref, - tuple([hash(version) for version in set(sorted(self.versions, key=hash))]) if self.versions else None - )) + return hash((self.ref, tuple(self.versions))) def __repr__(self) -> str: return f'' @@ -182,7 +180,7 @@ class VulnerabilityAnalysis: def __init__(self, *, state: Optional[ImpactAnalysisState] = None, justification: Optional[ImpactAnalysisJustification] = None, - responses: Optional[List[ImpactAnalysisResponse]] = None, + responses: Optional[Iterable[ImpactAnalysisResponse]] = None, detail: Optional[str] = None) -> None: if not state and not justification and not responses and not detail: raise NoPropertiesProvidedException( @@ -191,13 +189,16 @@ def __init__(self, *, state: Optional[ImpactAnalysisState] = None, ) self.state = state self.justification = justification - self.response = responses + self.response = set(responses or []) self.detail = detail @property def state(self) -> Optional[ImpactAnalysisState]: """ The declared current state of an occurrence of a vulnerability, after automated or manual analysis. + + Returns: + `ImpactAnalysisState` if set else `None` """ return self._state @@ -209,6 +210,9 @@ def state(self, state: Optional[ImpactAnalysisState]) -> None: def justification(self) -> Optional[ImpactAnalysisJustification]: """ The rationale of why the impact analysis state was asserted. + + Returns: + `ImpactAnalysisJustification` if set else `None` """ return self._justification @@ -217,17 +221,20 @@ def justification(self, justification: Optional[ImpactAnalysisJustification]) -> self._justification = justification @property - def response(self) -> Optional[List[ImpactAnalysisResponse]]: + def response(self) -> Set[ImpactAnalysisResponse]: """ A list of responses to the vulnerability by the manufacturer, supplier, or project responsible for the affected component or service. More than one response is allowed. Responses are strongly encouraged for vulnerabilities where the analysis state is exploitable. + + Returns: + Set of `ImpactAnalysisResponse` """ return self._response @response.setter - def response(self, responses: Optional[List[ImpactAnalysisResponse]]) -> None: - self._response = responses + def response(self, responses: Iterable[ImpactAnalysisResponse]) -> None: + self._response = set(responses) @property def detail(self) -> Optional[str]: @@ -235,7 +242,9 @@ def detail(self) -> Optional[str]: A detailed description of the impact including methods used during assessment. If a vulnerability is not exploitable, this field should include specific details on why the component or service is not impacted by this vulnerability. - :return: + + Returns: + `str` if set else `None` """ return self._detail @@ -249,11 +258,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash(( - self.state, self.justification, - tuple([hash(r) for r in set(sorted(self.response, key=hash))]) if self.response else None, - self.detail - )) + return hash((self.state, self.justification, tuple(self.response), self.detail)) def __repr__(self) -> str: return f'' @@ -650,9 +655,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash(( - self.source, self.score, self.severity, self.method, self.vector, self.justification - )) + return hash((self.source, self.score, self.severity, self.method, self.vector, self.justification)) def __repr__(self) -> str: return f'' @@ -669,42 +672,42 @@ class VulnerabilityCredits: See the CycloneDX schema: https://cyclonedx.org/docs/1.4/#type_vulnerabilityType """ - def __init__(self, *, organizations: Optional[List[OrganizationalEntity]] = None, - individuals: Optional[List[OrganizationalContact]] = None) -> None: + def __init__(self, *, organizations: Optional[Iterable[OrganizationalEntity]] = None, + individuals: Optional[Iterable[OrganizationalContact]] = None) -> None: if not organizations and not individuals: raise NoPropertiesProvidedException( 'One of `organizations` or `individuals` must be populated - neither were' ) - self.organizations = organizations - self.individuals = individuals + self.organizations = set(organizations or []) + self.individuals = set(individuals or []) @property - def organizations(self) -> Optional[List[OrganizationalEntity]]: + def organizations(self) -> Set[OrganizationalEntity]: """ The organizations credited with vulnerability discovery. Returns: - List of `OrganizationalEntity` or `None` + Set of `OrganizationalEntity` """ return self._organizations @organizations.setter - def organizations(self, organizations: Optional[List[OrganizationalEntity]]) -> None: - self._organizations = organizations + def organizations(self, organizations: Iterable[OrganizationalEntity]) -> None: + self._organizations = set(organizations) @property - def individuals(self) -> Optional[List[OrganizationalContact]]: + def individuals(self) -> Set[OrganizationalContact]: """ The individuals, not associated with organizations, that are credited with vulnerability discovery. Returns: - List of `OrganizationalContact` or `None` + Set of `OrganizationalContact` """ return self._individuals @individuals.setter - def individuals(self, individuals: Optional[List[OrganizationalContact]]) -> None: - self._individuals = individuals + def individuals(self, individuals: Iterable[OrganizationalContact]) -> None: + self._individuals = set(individuals) def __eq__(self, other: object) -> bool: if isinstance(other, VulnerabilityCredits): @@ -712,10 +715,7 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: - return hash(( - tuple([hash(org) for org in set(sorted(self.organizations, key=hash))]) if self.organizations else None, - tuple([hash(person) for person in set(sorted(self.individuals, key=hash))]) if self.individuals else None - )) + return hash((tuple(self.organizations), tuple(self.individuals))) def __repr__(self) -> str: return f'' @@ -734,34 +734,34 @@ class Vulnerability: def __init__(self, *, bom_ref: Optional[str] = None, id: Optional[str] = None, source: Optional[VulnerabilitySource] = None, - references: Optional[List[VulnerabilityReference]] = None, - ratings: Optional[List[VulnerabilityRating]] = None, cwes: Optional[List[int]] = None, + references: Optional[Iterable[VulnerabilityReference]] = None, + ratings: Optional[Iterable[VulnerabilityRating]] = None, cwes: Optional[Iterable[int]] = None, description: Optional[str] = None, detail: Optional[str] = None, recommendation: Optional[str] = None, - advisories: Optional[List[VulnerabilityAdvisory]] = None, created: Optional[datetime] = None, + advisories: Optional[Iterable[VulnerabilityAdvisory]] = None, created: Optional[datetime] = None, published: Optional[datetime] = None, updated: Optional[datetime] = None, credits: Optional[VulnerabilityCredits] = None, - tools: Optional[List[Tool]] = None, analysis: Optional[VulnerabilityAnalysis] = None, - affects_targets: Optional[List[BomTarget]] = None, + tools: Optional[Iterable[Tool]] = None, analysis: Optional[VulnerabilityAnalysis] = None, + affects_targets: Optional[Iterable[BomTarget]] = None, # Deprecated Parameters kept for backwards compatibility source_name: Optional[str] = None, source_url: Optional[str] = None, - recommendations: Optional[List[str]] = None) -> None: + recommendations: Optional[Iterable[str]] = None) -> None: self.bom_ref = bom_ref or str(uuid4()) self.id = id self.source = source - self.references = references or [] - self.ratings = ratings or [] - self.cwes = cwes or [] + self.references = set(references or []) + self.ratings = set(ratings or []) + self.cwes = set(cwes or []) self.description = description self.detail = detail self.recommendation = recommendation - self.advisories = advisories or [] + self.advisories = set(advisories or []) self.created = created self.published = published self.updated = updated self.credits = credits - self.tools = tools or [] + self.tools = set(tools or []) self.analysis = analysis - self.affects = affects_targets or [] + self.affects = set(affects_targets or []) if source_name or source_url: warnings.warn('`source_name` and `source_url` are deprecated - use `source`', DeprecationWarning) @@ -771,7 +771,7 @@ def __init__(self, *, bom_ref: Optional[str] = None, id: Optional[str] = None, if recommendations: warnings.warn('`recommendations` is deprecated - use `recommendation`', DeprecationWarning) if not recommendation: - self.recommendation = recommendations.pop() + self.recommendation = next(iter(recommendations)) @property def bom_ref(self) -> Optional[str]: @@ -781,7 +781,7 @@ def bom_ref(self) -> Optional[str]: If a value was not provided in the constructor, a UUIDv4 will have been assigned. Returns: - `str` unique identifier for this Vulnerability + `str` if set else `None` """ return self._bom_ref @@ -793,6 +793,9 @@ def bom_ref(self, bom_ref: Optional[str]) -> None: def id(self) -> Optional[str]: """ The identifier that uniquely identifies the vulnerability. For example: CVE-2021-39182. + + Returns: + `str` if set else `None` """ return self._id @@ -804,6 +807,9 @@ def id(self, id: Optional[str]) -> None: def source(self) -> Optional[VulnerabilitySource]: """ The source that published the vulnerability. + + Returns: + `VulnerabilitySource` if set else `None` """ return self._source @@ -812,83 +818,60 @@ def source(self, source: Optional[VulnerabilitySource]) -> None: self._source = source @property - def references(self) -> List[VulnerabilityReference]: + def references(self) -> Set[VulnerabilityReference]: """ Zero or more pointers to vulnerabilities that are the equivalent of the vulnerability specified. Often times, the same vulnerability may exist in multiple sources of vulnerability intelligence, but have different identifiers. References provides a way to correlate vulnerabilities across multiple sources of vulnerability intelligence. + + Returns: + Set of `VulnerabilityReference` """ return self._references @references.setter - def references(self, references: List[VulnerabilityReference]) -> None: - self._references = references - - def add_reference(self, reference: VulnerabilityReference) -> None: - """ - Add an additional reference for this Vulnerability. - - Vulnerabilities may benefit from pointers to vulnerabilities that are the equivalent of the vulnerability - specified. Often times, the same vulnerability may exist in multiple sources of vulnerability intelligence, but - have different identifiers. These references provide a way to correlate vulnerabilities across multiple sources - of vulnerability intelligence. - - Args: - reference: - `VulnerabilityReference` reference to add - """ - self.references = self.references + [reference] + def references(self, references: Iterable[VulnerabilityReference]) -> None: + self._references = set(references) @property - def ratings(self) -> List[VulnerabilityRating]: + def ratings(self) -> Set[VulnerabilityRating]: """ List of vulnerability ratings. + + Returns: + Set of `VulnerabilityRating` """ return self._ratings @ratings.setter - def ratings(self, ratings: List[VulnerabilityRating]) -> None: - self._ratings = ratings - - def add_rating(self, rating: VulnerabilityRating) -> None: - """ - Add a vulnerability rating. - - Args: - rating: - `VulnerabilityRating` - """ - self.ratings = self.ratings + [rating] + def ratings(self, ratings: Iterable[VulnerabilityRating]) -> None: + self._ratings = set(ratings) @property - def cwes(self) -> List[int]: + def cwes(self) -> Set[int]: """ A list of CWE (Common Weakness Enumeration) identifiers. .. note:: See https://cwe.mitre.org/ + + Returns: + Set of `int` """ return self._cwes @cwes.setter - def cwes(self, cwes: List[int]) -> None: - self._cwes = cwes - - def add_cwe(self, cwe: int) -> None: - """ - Add a CWE identifier. - - Args: - cwe: - `int` identifier for the CWE - """ - self.cwes = self.cwes + [cwe] + def cwes(self, cwes: Iterable[int]) -> None: + self._cwes = set(cwes) @property def description(self) -> Optional[str]: """ A description of the vulnerability as provided by the source. + + Returns: + `str` if set else `None` """ return self._description @@ -901,6 +884,9 @@ def detail(self) -> Optional[str]: """ If available, an in-depth description of the vulnerability as provided by the source organization. Details often include examples, proof-of-concepts, and other information useful in understanding root cause. + + Returns: + `str` if set else `None` """ return self._detail @@ -912,6 +898,9 @@ def detail(self, detail: Optional[str]) -> None: def recommendation(self) -> Optional[str]: """ Recommendations of how the vulnerability can be remediated or mitigated. + + Returns: + `str` if set else `None` """ return self._recommendation @@ -920,28 +909,27 @@ def recommendation(self, recommendation: Optional[str]) -> None: self._recommendation = recommendation @property - def advisories(self) -> List[VulnerabilityAdvisory]: + def advisories(self) -> Set[VulnerabilityAdvisory]: """ Advisories relating to the Vulnerability. + + Returns: + Set of `VulnerabilityAdvisory` """ return self._advisories @advisories.setter - def advisories(self, advisories: List[VulnerabilityAdvisory]) -> None: - self._advisories = advisories + def advisories(self, advisories: Iterable[VulnerabilityAdvisory]) -> None: + self._advisories = set(advisories) - def add_advisory(self, advisory: VulnerabilityAdvisory) -> None: + @property + def created(self) -> Optional[datetime]: """ - Add a advisory. + The date and time (timestamp) when the vulnerability record was created in the vulnerability database. - Args: - advisory: - `VulnerabilityAdvisory` + Returns: + `datetime` if set else `None` """ - self.advisories = self.advisories + [advisory] - - @property - def created(self) -> Optional[datetime]: return self._created @created.setter @@ -950,6 +938,12 @@ def created(self, created: Optional[datetime]) -> None: @property def published(self) -> Optional[datetime]: + """ + The date and time (timestamp) when the vulnerability record was first published. + + Returns: + `datetime` if set else `None` + """ return self._published @published.setter @@ -958,6 +952,12 @@ def published(self, published: Optional[datetime]) -> None: @property def updated(self) -> Optional[datetime]: + """ + The date and time (timestamp) when the vulnerability record was last updated. + + Returns: + `datetime` if set else `None` + """ return self._updated @updated.setter @@ -968,6 +968,9 @@ def updated(self, updated: Optional[datetime]) -> None: def credits(self) -> Optional[VulnerabilityCredits]: """ Individuals or organizations credited with the discovery of the vulnerability. + + Returns: + `VulnerabilityCredits` if set else `None` """ return self._credits @@ -976,30 +979,26 @@ def credits(self, credits: Optional[VulnerabilityCredits]) -> None: self._credits = credits @property - def tools(self) -> List[Tool]: + def tools(self) -> Set[Tool]: """ The tool(s) used to identify, confirm, or score the vulnerability. + + Returns: + Set of `Tool` """ return self._tools @tools.setter - def tools(self, tools: List[Tool]) -> None: - self._tools = tools - - def add_tool(self, tool: Tool) -> None: - """ - Add a tool used to identify, confirm, or score the vulnerability. - - Args: - tool: - `Tool` - """ - self.tools = self.tools + [tool] + def tools(self, tools: Iterable[Tool]) -> None: + self._tools = set(tools) @property def analysis(self) -> Optional[VulnerabilityAnalysis]: """ Analysis of the Vulnerability in your context. + + Returns: + `VulnerabilityAnalysis` if set else `None` """ return self._analysis @@ -1008,73 +1007,18 @@ def analysis(self, analysis: Optional[VulnerabilityAnalysis]) -> None: self._analysis = analysis @property - def affects(self) -> Optional[List[BomTarget]]: + def affects(self) -> Set[BomTarget]: """ The components or services that are affected by the vulnerability. + + Returns: + Set of `BomTarget` """ return self._affects @affects.setter - def affects(self, affects_targets: Optional[List[BomTarget]]) -> None: - self._affects = affects_targets - - # Methods pre-dating 1.4 that are kept for some backwards compatability - they will be removed in a future release! - def get_source_name(self) -> Optional[str]: - """ - Prior to Schema Version 1.4 when Vulnerabilities were supported by a schema extension, `source_name` and - `source_url` where represented differently in the model. - - .. warning:: - Deprecated - this method will be removed in a future version. - - See `Vulnerability.source.get_name()` - """ - warnings.warn( - 'The source of a Vulnerability is represnted differently in Schema Versions >= 1.4. ' - 'Vulnerability.get_source_name() is deprecated and will be removed in a future release. ' - 'Use Vulnerability.source.get_name()', - DeprecationWarning - ) - if self.source: - return self.source.name - return None - - def get_source_url(self) -> Optional[ParseResult]: - """ - Prior to Schema Version 1.4 when Vulnerabilities were supported by a schema extension, `source_name` and - `source_url` where represented differently in the model. - - .. warning:: - Deprecated - this method will be removed in a future version. - - See `Vulnerability.source.get_url()` - """ - warnings.warn( - 'The source of a Vulnerability is represnted differently in Schema Versions >= 1.4. ' - 'Vulnerability.get_source_name() is deprecated and will be removed in a future release. ' - 'Use Vulnerability.source.get_url()', - DeprecationWarning - ) - if self.source and self.source.url: - return urlparse(str(self.source.url)) - return None - - def get_recommendations(self) -> List[str]: - """ - Prior to Schema Version 1.4 when Vulnerabilities were supported by a schema extension, multiple recommendations - where permissible. - - .. warning:: - Deprecated - this method will be removed in a future version. - - See `Vulnerability.recommendation` - """ - warnings.warn( - 'A Vulnerability has only a single recommendation from Schema Version >= 1.4. ' - 'Vulnerability.get_recommendations() is deprecated and will be removed in a future release.', - DeprecationWarning - ) - return [self.recommendation] if self.recommendation else [] + def affects(self, affects_targets: Iterable[BomTarget]) -> None: + self._affects = set(affects_targets) def __eq__(self, other: object) -> bool: if isinstance(other, Vulnerability): @@ -1083,16 +1027,9 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( - self.id, self.source, - tuple([hash(ref) for ref in set(sorted(self.references, key=hash))]) if self.references else None, - tuple([hash(rating) for rating in set(sorted(self.ratings, key=hash))]) if self.ratings else None, - tuple([hash(cwe) for cwe in set(sorted(self.cwes, key=hash))]) if self.cwes else None, - self.description, self.detail, self.recommendation, - tuple([hash(advisory) for advisory in set(sorted(self.advisories, key=hash))]) if self.advisories else None, - self.created, self.published, self.updated, self.credits, - tuple([hash(tool) for tool in set(sorted(self.tools, key=hash))]) if self.tools else None, - self.analysis, - tuple([hash(affected) for affected in set(sorted(self.affects, key=hash))]) if self.affects else None + self.id, self.source, tuple(self.references), tuple(self.ratings), tuple(self.cwes), self.description, + self.detail, self.recommendation, tuple(self.advisories), self.created, self.published, self.updated, + self.credits, tuple(self.tools), self.analysis, tuple(self.affects) )) def __repr__(self) -> str: diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py index 2f2baa35..3d1e1786 100644 --- a/cyclonedx/output/schema.py +++ b/cyclonedx/output/schema.py @@ -68,7 +68,7 @@ def component_supports_bom_ref_attribute(self) -> bool: def component_supports_mime_type_attribute(self) -> bool: return True - def component_supports_licenses_expression(self) -> bool: + def license_supports_expression(self) -> bool: return True def component_version_optional(self) -> bool: @@ -263,7 +263,7 @@ def component_supports_author(self) -> bool: def component_supports_bom_ref_attribute(self) -> bool: return False - def component_supports_licenses_expression(self) -> bool: + def license_supports_expression(self) -> bool: return False def component_supports_mime_type_attribute(self) -> bool: diff --git a/cyclonedx/output/serializer/json.py b/cyclonedx/output/serializer/json.py index 000b0fae..858e536b 100644 --- a/cyclonedx/output/serializer/json.py +++ b/cyclonedx/output/serializer/json.py @@ -52,6 +52,10 @@ def default(self, o: Any) -> Any: if isinstance(o, Enum): return o.value + # Set + if isinstance(o, set): + return list(o) + # UUID if isinstance(o, UUID): return str(o) diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index f6a7cd9c..bb54498d 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -18,14 +18,14 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. import warnings -from typing import cast, List, Optional +from typing import Optional, Set from xml.etree import ElementTree from . import BaseOutput, SchemaVersion from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3, \ SchemaVersion1Dot4 -from ..model import AttachedText, ExternalReference, HashType, IdentifiableAction, OrganizationalEntity, \ - OrganizationalContact, Property, Tool +from ..model import AttachedText, ExternalReference, HashType, IdentifiableAction, LicenseChoice, \ + OrganizationalEntity, OrganizationalContact, Property, Tool from ..model.bom import Bom from ..model.component import Component, Patch from ..model.release_note import ReleaseNotes @@ -58,7 +58,7 @@ def generate(self, force_regeneration: bool = False) -> None: has_vulnerabilities: bool = False if self.get_bom().components: - for component in cast(List[Component], self.get_bom().components): + for component in self.get_bom().components: component_element = self._add_component_element(component=component) components_element.append(component_element) if self.bom_supports_vulnerabilities_via_extension() and component.has_vulnerabilities(): @@ -67,8 +67,8 @@ def generate(self, force_regeneration: bool = False) -> None: for vulnerability in component.get_vulnerabilities(): if component.bom_ref: vulnerabilities.append( - self._get_vulnerability_as_xml_element_pre_1_3(bom_ref=component.bom_ref, - vulnerability=vulnerability) + Xml._get_vulnerability_as_xml_element_pre_1_3(bom_ref=component.bom_ref, + vulnerability=vulnerability) ) else: warnings.warn( @@ -81,19 +81,19 @@ def generate(self, force_regeneration: bool = False) -> None: if self.bom_supports_services(): if self.get_bom().services: services_element = ElementTree.SubElement(self._root_bom_element, 'services') - for service in cast(List[Service], self.get_bom().services): + for service in self.get_bom().services: services_element.append(self._add_service_element(service=service)) if self.bom_supports_external_references(): if self.get_bom().external_references: self._add_external_references_to_element( - ext_refs=cast(List[ExternalReference], self.get_bom().external_references), + ext_refs=self.get_bom().external_references, element=self._root_bom_element ) if self.bom_supports_vulnerabilities() and has_vulnerabilities: vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities') - for component in cast(List[Component], self.get_bom().components): + for component in self.get_bom().components: for vulnerability in component.get_vulnerabilities(): vulnerabilities_element.append( self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability) @@ -183,31 +183,8 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: # licenses if component.licenses: - license_output: bool = False licenses_e = ElementTree.SubElement(component_element, 'licenses') - for license in component.licenses: - if license.license: - license_e = ElementTree.SubElement(licenses_e, 'license') - if license.license.id: - ElementTree.SubElement(license_e, 'id').text = license.license.id - elif license.license.name: - ElementTree.SubElement(license_e, 'name').text = license.license.name - if license.license.text: - license_text_e_attrs = {} - if license.license.text.content_type: - license_text_e_attrs['content-type'] = license.license.text.content_type - if license.license.text.encoding: - license_text_e_attrs['encoding'] = license.license.text.encoding.value - ElementTree.SubElement(license_e, 'text', - license_text_e_attrs).text = license.license.text.content - - ElementTree.SubElement(license_e, 'text').text = license.license.id - license_output = True - else: - if self.component_supports_licenses_expression(): - ElementTree.SubElement(licenses_e, 'expression').text = license.expression - license_output = True - + license_output: bool = self._add_licenses_to_element(licenses=component.licenses, parent_element=licenses_e) if not license_output: component_element.remove(licenses_e) @@ -291,6 +268,32 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: return component_element + def _add_licenses_to_element(self, licenses: Set[LicenseChoice], parent_element: ElementTree.Element) -> bool: + license_output = False + for license_ in licenses: + if license_.license: + license_e = ElementTree.SubElement(parent_element, 'license') + if license_.license.id: + ElementTree.SubElement(license_e, 'id').text = license_.license.id + elif license_.license.name: + ElementTree.SubElement(license_e, 'name').text = license_.license.name + if license_.license.text: + license_text_e_attrs = {} + if license_.license.text.content_type: + license_text_e_attrs['content-type'] = license_.license.text.content_type + if license_.license.text.encoding: + license_text_e_attrs['encoding'] = license_.license.text.encoding.value + ElementTree.SubElement(license_e, 'text', + license_text_e_attrs).text = license_.license.text.content + + ElementTree.SubElement(license_e, 'text').text = license_.license.id + license_output = True + else: + if self.license_supports_expression(): + ElementTree.SubElement(parent_element, 'expression').text = license_.expression + license_output = True + return license_output + @staticmethod def _add_release_notes_element(release_notes: ReleaseNotes, parent_element: ElementTree.Element) -> None: release_notes_e = ElementTree.SubElement(parent_element, 'releaseNotes') @@ -321,23 +324,23 @@ def _add_release_notes_element(release_notes: ReleaseNotes, parent_element: Elem release_notes_resolves_e = ElementTree.SubElement(release_notes_e, 'resolves') for issue in release_notes.resolves: issue_e = ElementTree.SubElement( - release_notes_resolves_e, 'issue', {'type': issue.get_classification().value} + release_notes_resolves_e, 'issue', {'type': issue.type.value} ) - if issue.get_id(): - ElementTree.SubElement(issue_e, 'id').text = issue.get_id() - if issue.get_name(): - ElementTree.SubElement(issue_e, 'name').text = issue.get_name() - if issue.get_description(): - ElementTree.SubElement(issue_e, 'description').text = issue.get_description() + if issue.id: + ElementTree.SubElement(issue_e, 'id').text = issue.id + if issue.name: + ElementTree.SubElement(issue_e, 'name').text = issue.name + if issue.description: + ElementTree.SubElement(issue_e, 'description').text = issue.description if issue.source: issue_source_e = ElementTree.SubElement(issue_e, 'source') if issue.source.name: ElementTree.SubElement(issue_source_e, 'name').text = issue.source.name if issue.source.url: ElementTree.SubElement(issue_source_e, 'url').text = str(issue.source.url) - if issue.get_references(): + if issue.references: issue_references_e = ElementTree.SubElement(issue_e, 'references') - for reference in issue.get_references(): + for reference in issue.references: ElementTree.SubElement(issue_references_e, 'url').text = str(reference) if release_notes.notes: release_notes_notes_e = ElementTree.SubElement(release_notes_e, 'notes') @@ -367,12 +370,12 @@ def add_patch_element(patch: Patch) -> ElementTree.Element: return patch_element @staticmethod - def _add_properties_element(properties: List[Property], parent_element: ElementTree.Element) -> None: + def _add_properties_element(properties: Set[Property], parent_element: ElementTree.Element) -> None: properties_e = ElementTree.SubElement(parent_element, 'properties') - for property in properties: + for property_ in properties: ElementTree.SubElement( - properties_e, 'property', {'name': property.get_name()} - ).text = property.get_value() + properties_e, 'property', {'name': property_.name} + ).text = property_.value def _add_service_element(self, service: Service) -> ElementTree.Element: element_attributes = {} @@ -425,25 +428,9 @@ def _add_service_element(self, service: Service) -> ElementTree.Element: # licenses if service.licenses: licenses_e = ElementTree.SubElement(service_element, 'licenses') - for license in service.licenses: - if license.license: - license_e = ElementTree.SubElement(licenses_e, 'license') - if license.license.id: - ElementTree.SubElement(license_e, 'id').text = license.license.id - elif license.license.name: - ElementTree.SubElement(license_e, 'name').text = license.license.name - if license.license.text: - license_text_e_attrs = {} - if license.license.text.content_type: - license_text_e_attrs['content-type'] = license.license.text.content_type - if license.license.text.encoding: - license_text_e_attrs['encoding'] = license.license.text.encoding.value - ElementTree.SubElement(license_e, 'text', - license_text_e_attrs).text = license.license.text.content - - ElementTree.SubElement(license_e, 'text').text = license.license.id - else: - ElementTree.SubElement(licenses_e, 'expression').text = license.expression + license_output: bool = self._add_licenses_to_element(licenses=service.licenses, parent_element=licenses_e) + if not license_output: + service_element.remove(licenses_e) # externalReferences if service.external_references: @@ -596,7 +583,8 @@ def _get_vulnerability_as_xml_element_post_1_4(self, vulnerability: Vulnerabilit return vulnerability_element - def _get_vulnerability_as_xml_element_pre_1_3(self, bom_ref: str, + @staticmethod + def _get_vulnerability_as_xml_element_pre_1_3(bom_ref: str, vulnerability: Vulnerability) -> ElementTree.Element: vulnerability_element = ElementTree.Element('v:vulnerability', { 'ref': bom_ref @@ -660,18 +648,18 @@ def _get_vulnerability_as_xml_element_pre_1_3(self, bom_ref: str, return vulnerability_element - def _add_external_references_to_element(self, ext_refs: List[ExternalReference], + def _add_external_references_to_element(self, ext_refs: Set[ExternalReference], element: ElementTree.Element) -> None: ext_refs_element = ElementTree.SubElement(element, 'externalReferences') for external_reference in ext_refs: ext_ref_element = ElementTree.SubElement( - ext_refs_element, 'reference', {'type': external_reference.get_reference_type().value} + ext_refs_element, 'reference', {'type': external_reference.type.value} ) - ElementTree.SubElement(ext_ref_element, 'url').text = external_reference.get_url() - if external_reference.get_comment(): - ElementTree.SubElement(ext_ref_element, 'comment').text = external_reference.get_comment() - if self.external_references_supports_hashes() and external_reference.get_hashes(): - Xml._add_hashes_to_element(hashes=external_reference.get_hashes(), element=ext_ref_element) + ElementTree.SubElement(ext_ref_element, 'url').text = str(external_reference.url) + if external_reference.comment: + ElementTree.SubElement(ext_ref_element, 'comment').text = external_reference.comment + if self.external_references_supports_hashes() and external_reference.hashes: + Xml._add_hashes_to_element(hashes=external_reference.hashes, element=ext_ref_element) @staticmethod def _add_attached_text(attached_text: AttachedText, tag_name: str = 'text') -> ElementTree.Element: @@ -685,12 +673,12 @@ def _add_attached_text(attached_text: AttachedText, tag_name: str = 'text') -> E return at_element @staticmethod - def _add_hashes_to_element(hashes: List[HashType], element: ElementTree.Element) -> None: + def _add_hashes_to_element(hashes: Set[HashType], element: ElementTree.Element) -> None: hashes_e = ElementTree.SubElement(element, 'hashes') for h in hashes: ElementTree.SubElement( - hashes_e, 'hash', {'alg': h.get_algorithm().value} - ).text = h.get_hash_value() + hashes_e, 'hash', {'alg': h.alg.value} + ).text = h.content @staticmethod def _add_bom_target_version_range(parent_element: ElementTree.Element, version: BomTargetVersionRange) -> None: @@ -705,18 +693,16 @@ def _add_bom_target_version_range(parent_element: ElementTree.Element, version: def _add_tool(self, parent_element: ElementTree.Element, tool: Tool, tag_name: str = 'tool') -> None: tool_element = ElementTree.SubElement(parent_element, tag_name) - if tool.get_vendor(): - ElementTree.SubElement(tool_element, 'vendor').text = tool.get_vendor() - if tool.get_name(): - ElementTree.SubElement(tool_element, 'name').text = tool.get_name() - if tool.get_version(): - ElementTree.SubElement(tool_element, 'version').text = tool.get_version() - if tool.get_hashes(): - Xml._add_hashes_to_element(hashes=tool.get_hashes(), element=tool_element) - if self.bom_metadata_supports_tools_external_references() and tool.get_external_references(): - self._add_external_references_to_element( - ext_refs=tool.get_external_references(), element=tool_element - ) + if tool.vendor: + ElementTree.SubElement(tool_element, 'vendor').text = tool.vendor + if tool.name: + ElementTree.SubElement(tool_element, 'name').text = tool.name + if tool.version: + ElementTree.SubElement(tool_element, 'version').text = tool.version + if tool.hashes: + Xml._add_hashes_to_element(hashes=tool.hashes, element=tool_element) + if self.bom_metadata_supports_tools_external_references() and tool.external_references: + self._add_external_references_to_element(ext_refs=tool.external_references, element=tool_element) @staticmethod def _add_organizational_contact(parent_element: ElementTree.Element, contact: OrganizationalContact, @@ -735,11 +721,11 @@ def _add_organizational_entity(parent_element: ElementTree.Element, organization oe_element = ElementTree.SubElement(parent_element, tag_name) if organization.name: ElementTree.SubElement(oe_element, 'name').text = organization.name - if organization.urls: - for url in organization.urls: + if organization.url: + for url in organization.url: ElementTree.SubElement(oe_element, 'url').text = str(url) - if organization.contacts: - for contact in organization.contacts: + if organization.contact: + for contact in organization.contact: Xml._add_organizational_contact(parent_element=oe_element, contact=contact, tag_name='contact') @staticmethod diff --git a/poetry.lock b/poetry.lock index 1920822e..1c44a2d3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -393,6 +393,18 @@ six = ">=1.9.0,<2" docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] +[[package]] +name = "xmldiff" +version = "2.4" +description = "Creates diffs of XML files" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +lxml = ">=3.1.0" +six = "*" + [[package]] name = "zipp" version = "3.6.0" @@ -408,7 +420,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6" -content-hash = "7d2eb65a2400fc39ea675281879e506d7aeb58689dda1f3b389b875040aae9cc" +content-hash = "6a766bb8018a3c7492f24fb8d7567298cd38cad8253ade1a579871066dcbdf60" [metadata.files] attrs = [ @@ -710,6 +722,10 @@ virtualenv = [ {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, ] +xmldiff = [ + {file = "xmldiff-2.4-py2.py3-none-any.whl", hash = "sha256:213c2f4c39ed71811a9ceeec1c8bdf2e673e5527261ea11708b3acfa6c2bdb00"}, + {file = "xmldiff-2.4.tar.gz", hash = "sha256:05bea20ce1f2c9678683bcce0c3ba9981f87d92b709d190e018bcbf047eccf63"}, +] zipp = [ {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, diff --git a/pyproject.toml b/pyproject.toml index 9546bc59..2c648b90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ flake8-annotations = {version = "^2.7.0", python = ">= 3.6.2"} flake8-bugbear = "^22.1.11" jsonschema = { version = ">= 4.4.0", python = "> 3.6"} lxml = ">=4.7.0" +xmldiff = ">=2.4" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/base.py b/tests/base.py index a3bd114a..b2df5ae7 100644 --- a/tests/base.py +++ b/tests/base.py @@ -22,13 +22,13 @@ import sys import xml.etree.ElementTree from datetime import datetime, timezone -from lxml import etree -from typing import Any from unittest import TestCase from uuid import uuid4 -from xml.dom import minidom +from lxml import etree from lxml.etree import DocumentInvalid +from xmldiff import main +from xmldiff.actions import MoveNode from cyclonedx.output import SchemaVersion @@ -68,8 +68,8 @@ def assertValidAgainstSchema(self, bom_json: str, schema_version: SchemaVersion) def assertEqualJson(self, a: str, b: str) -> None: self.assertEqual( - json.dumps(json.loads(a), sort_keys=True), - json.dumps(json.loads(b), sort_keys=True) + json.dumps(sorted(json.loads(a)), sort_keys=True), + json.dumps(sorted(json.loads(b)), sort_keys=True) ) def assertEqualJsonBom(self, a: str, b: str) -> None: @@ -121,15 +121,16 @@ def assertValidAgainstSchema(self, bom_xml: str, schema_version: SchemaVersion) f'{bom_xml}') def assertEqualXml(self, a: str, b: str) -> None: - da, db = minidom.parseString(a), minidom.parseString(b) - self.assertTrue(self._is_equal_xml_element(da.documentElement, db.documentElement), - 'XML Documents are not equal: \n{}\n{}'.format(da.toxml(), db.toxml())) + diff_results = main.diff_texts(a, b, diff_options={'F': 0.5}) + diff_results = list(filter(lambda o: not isinstance(o, MoveNode), diff_results)) + self.assertEqual(len(diff_results), 0) def assertEqualXmlBom(self, a: str, b: str, namespace: str) -> None: """ Sanitise some fields such as timestamps which cannot have their values directly compared for equality. """ - ba, bb = xml.etree.ElementTree.fromstring(a), xml.etree.ElementTree.fromstring(b) + ba = xml.etree.ElementTree.fromstring(a, etree.XMLParser(remove_blank_text=True, remove_comments=True)) + bb = xml.etree.ElementTree.fromstring(b, etree.XMLParser(remove_blank_text=True, remove_comments=True)) # Align serialNumbers ba.set('serialNumber', single_uuid) @@ -147,42 +148,13 @@ def assertEqualXmlBom(self, a: str, b: str, namespace: str) -> None: # Align 'this' Tool Version this_tool = ba.find('.//*/{{{}}}tool[{{{}}}version="VERSION"]'.format(namespace, namespace)) - if this_tool: + if this_tool is not None: this_tool.find('./{{{}}}version'.format(namespace)).text = cyclonedx_lib_version this_tool = bb.find('.//*/{{{}}}tool[{{{}}}version="VERSION"]'.format(namespace, namespace)) - if this_tool: + if this_tool is not None: this_tool.find('./{{{}}}version'.format(namespace)).text = cyclonedx_lib_version self.assertEqualXml( xml.etree.ElementTree.tostring(ba, 'unicode'), xml.etree.ElementTree.tostring(bb, 'unicode') ) - - def _is_equal_xml_element(self, a: Any, b: Any) -> bool: - if a.tagName != b.tagName: - return False - if sorted(a.attributes.items()) != sorted(b.attributes.items()): - return False - - """ - Remove any pure whitespace Dom Text Nodes before we compare - - See: https://xml-sig.python.narkive.com/8o0UIicu - """ - for n in a.childNodes: - if n.nodeType == n.TEXT_NODE and n.data.strip() == '': - a.removeChild(n) - for n in b.childNodes: - if n.nodeType == n.TEXT_NODE and n.data.strip() == '': - b.removeChild(n) - - if len(a.childNodes) != len(b.childNodes): - return False - for ac, bc in zip(a.childNodes, b.childNodes): - if ac.nodeType != bc.nodeType: - return False - if ac.nodeType == ac.TEXT_NODE and ac.data != bc.data: - return False - if ac.nodeType == ac.ELEMENT_NODE and not self._is_equal_xml_element(ac, bc): - return False - return True diff --git a/tests/data.py b/tests/data.py index 18bba462..e85b434e 100644 --- a/tests/data.py +++ b/tests/data.py @@ -29,7 +29,7 @@ from cyclonedx.model.bom import Bom from cyclonedx.model.component import Commit, Component, ComponentEvidence, ComponentType, Copyright, Patch, \ PatchClassification, Pedigree, Swid -from cyclonedx.model.issue import IssueClassification, IssueType +from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource from cyclonedx.model.release_note import ReleaseNotes from cyclonedx.model.service import Service from cyclonedx.model.vulnerability import ImpactAnalysisState, ImpactAnalysisJustification, ImpactAnalysisResponse, \ @@ -71,10 +71,10 @@ def get_bom_with_component_setuptools_complete() -> Bom: component.cpe = 'cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*' component.swid = get_swid_1() component.pedigree = get_pedigree_1() - component.components = [ + component.components.update([ get_component_setuptools_simple(), get_component_toml_with_hashes_with_references() - ] + ]) component.evidence = ComponentEvidence(copyright_=[Copyright(text='Commercial'), Copyright(text='Commercial 2')]) return Bom(components=[component]) @@ -130,7 +130,7 @@ def get_bom_with_component_setuptools_with_vulnerability() -> Bom: ), affects_targets=[ BomTarget( - ref=component.purl.to_string() if component.purl else component.to_package_url().to_string(), + ref=component.purl.to_string() if component.purl else None, versions=[BomTargetVersionRange( version_range='49.0.0 - 54.0.0', status=ImpactAnalysisAffectedStatus.AFFECTED )] @@ -138,7 +138,7 @@ def get_bom_with_component_setuptools_with_vulnerability() -> Bom: ] ) component.add_vulnerability(vulnerability=vulnerability) - bom.add_component(component=component) + bom.components.add(component) return bom @@ -286,7 +286,7 @@ def get_component_toml_with_hashes_with_references(bom_ref: Optional[str] = None def get_external_reference_1() -> ExternalReference: return ExternalReference( reference_type=ExternalReferenceType.DISTRIBUTION, - url='https://cyclonedx.org', + url=XsUri('https://cyclonedx.org'), comment='No comment', hashes=[ HashType.from_composite_str( @@ -298,15 +298,15 @@ def get_external_reference_1() -> ExternalReference: def get_external_reference_2() -> ExternalReference: return ExternalReference( reference_type=ExternalReferenceType.WEBSITE, - url='https://cyclonedx.org' + url=XsUri('https://cyclonedx.org') ) def get_issue_1() -> IssueType: return IssueType( - classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', + classification=IssueClassification.SECURITY, id_='CVE-2021-44228', name='Apache Log3Shell', description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', - source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), + source=IssueTypeSource(name='NVD', url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228')), references=[ XsUri('https://logging.apache.org/log4j/2.x/security.html'), XsUri('https://central.sonatype.org/news/20211213_log4shell_help') @@ -316,9 +316,9 @@ def get_issue_1() -> IssueType: def get_issue_2() -> IssueType: return IssueType( - classification=IssueClassification.SECURITY, id='CVE-2021-44229', name='Apache Log4Shell', + classification=IssueClassification.SECURITY, id_='CVE-2021-44229', name='Apache Log4Shell', description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...', - source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), + source=IssueTypeSource(name='NVD', url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228')), references=[ XsUri('https://logging.apache.org/log4j/2.x/security.html'), XsUri('https://central.sonatype.org/news/20211213_log4shell_help') @@ -369,7 +369,7 @@ def get_release_notes() -> ReleaseNotes: ).decode(encoding='UTF-8') return ReleaseNotes( - type='major', title="Release Notes Title", + type_='major', title="Release Notes Title", featured_image=XsUri('https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png'), social_image=XsUri('https://cyclonedx.org/cyclonedx-icon.png'), description="This release is a test release", timestamp=MOCK_TIMESTAMP, diff --git a/tests/test_bom.py b/tests/test_bom.py index d8d9f1c5..aedc4ddd 100644 --- a/tests/test_bom.py +++ b/tests/test_bom.py @@ -16,24 +16,24 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - from unittest import TestCase from cyclonedx.model.bom import Bom, ThisTool, Tool from cyclonedx.model.component import Component, ComponentType +from data import get_bom_with_component_setuptools_with_vulnerability class TestBom(TestCase): def test_bom_metadata_tool_this_tool(self) -> None: - self.assertEqual(ThisTool.get_vendor(), 'CycloneDX') - self.assertEqual(ThisTool.get_name(), 'cyclonedx-python-lib') - self.assertNotEqual(ThisTool.get_version(), 'UNKNOWN') + self.assertEqual(ThisTool.vendor, 'CycloneDX') + self.assertEqual(ThisTool.name, 'cyclonedx-python-lib') + self.assertNotEqual(ThisTool.version, 'UNKNOWN') def test_bom_metadata_tool_multiple_tools(self) -> None: bom = Bom() self.assertEqual(len(bom.metadata.tools), 1) - bom.metadata.add_tool( + bom.metadata.tools.add( Tool(vendor='TestVendor', name='TestTool', version='0.0.0') ) self.assertEqual(len(bom.metadata.tools), 2) @@ -41,8 +41,7 @@ def test_bom_metadata_tool_multiple_tools(self) -> None: def test_metadata_component(self) -> None: metadata = Bom().metadata self.assertTrue(metadata.component is None) - hextech = Component(name='Hextech', version='1.0.0', - component_type=ComponentType.LIBRARY) + hextech = Component(name='Hextech', version='1.0.0', component_type=ComponentType.LIBRARY) metadata.component = hextech self.assertFalse(metadata.component is None) self.assertEquals(metadata.component, hextech) @@ -51,6 +50,10 @@ def test_empty_bom(self) -> None: bom = Bom() self.assertIsNotNone(bom.uuid) self.assertIsNotNone(bom.metadata) - self.assertIsNone(bom.components) - self.assertIsNone(bom.services) - self.assertIsNone(bom.external_references) + self.assertFalse(bom.components) + self.assertFalse(bom.services) + self.assertFalse(bom.external_references) + + def test_bom_with_vulnerabilities(self) -> None: + bom = get_bom_with_component_setuptools_with_vulnerability() + self.assertTrue(bom.has_vulnerabilities()) diff --git a/tests/test_component.py b/tests/test_component.py index c5c6056f..3f9b440e 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -108,8 +108,7 @@ def test_from_file_with_path_for_bom(self) -> None: def test_has_component_1(self) -> None: bom = Bom() - bom.add_component(component=TestComponent._component) - bom.add_component(component=TestComponent._component_2) + bom.components.update([TestComponent._component, TestComponent._component_2]) self.assertEqual(len(bom.components), 2) self.assertTrue(bom.has_component(component=TestComponent._component_2)) self.assertIsNot(TestComponent._component, TestComponent._component_2) diff --git a/tests/test_e2e_environment.py b/tests/test_e2e_environment.py index e850d821..22b6af3d 100644 --- a/tests/test_e2e_environment.py +++ b/tests/test_e2e_environment.py @@ -16,7 +16,6 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - import json import pkg_resources from lxml import etree @@ -39,7 +38,7 @@ class TestE2EEnvironment(TestCase): @classmethod def setUpClass(cls) -> None: cls.bom: Bom = Bom() - cls.bom.add_component( + cls.bom.components.add( Component( name=OUR_PACKAGE_NAME, author=OUR_PACKAGE_AUTHOR, version=OUR_PACKAGE_VERSION, purl=PackageURL(type='pypi', name=OUR_PACKAGE_NAME, version=OUR_PACKAGE_VERSION) diff --git a/tests/test_model.py b/tests/test_model.py index eb3f0577..86e75b22 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -25,7 +25,7 @@ NoPropertiesProvidedException from cyclonedx.model import Copyright, Encoding, ExternalReference, ExternalReferenceType, HashAlgorithm, HashType, \ IdentifiableAction, Note, NoteText, XsUri -from cyclonedx.model.issue import IssueClassification, IssueType +from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource class TestModelCopyright(TestCase): @@ -45,29 +45,22 @@ def test_not_same(self) -> None: class TestModelExternalReference(TestCase): - def test_external_reference_with_str(self) -> None: - e = ExternalReference(reference_type=ExternalReferenceType.VCS, url='https://www.google.com') - self.assertEqual(e.get_reference_type(), ExternalReferenceType.VCS) - self.assertEqual(e.get_url(), 'https://www.google.com') - self.assertEqual(e.get_comment(), '') - self.assertListEqual(e.get_hashes(), []) - def test_external_reference_with_xsuri(self) -> None: e = ExternalReference(reference_type=ExternalReferenceType.VCS, url=XsUri('https://www.google.com')) - self.assertEqual(e.get_reference_type(), ExternalReferenceType.VCS) - self.assertEqual(e.get_url(), 'https://www.google.com') - self.assertEqual(e.get_comment(), '') - self.assertListEqual(e.get_hashes(), []) + self.assertEqual(e.type, ExternalReferenceType.VCS) + self.assertEqual(e.url, XsUri('https://www.google.com')) + self.assertIsNone(e.comment) + self.assertSetEqual(e.hashes, set()) def test_same(self) -> None: ref_1 = ExternalReference( reference_type=ExternalReferenceType.OTHER, - url='https://cyclonedx.org', + url=XsUri('https://cyclonedx.org'), comment='No comment' ) ref_2 = ExternalReference( reference_type=ExternalReferenceType.OTHER, - url='https://cyclonedx.org', + url=XsUri('https://cyclonedx.org'), comment='No comment' ) self.assertNotEqual(id(ref_1), id(ref_2)) @@ -77,12 +70,12 @@ def test_same(self) -> None: def test_not_same(self) -> None: ref_1 = ExternalReference( reference_type=ExternalReferenceType.OTHER, - url='https://cyclonedx.org', + url=XsUri('https://cyclonedx.org'), comment='No comment' ) ref_2 = ExternalReference( reference_type=ExternalReferenceType.OTHER, - url='https://cyclonedx.org/', + url=XsUri('https://cyclonedx.org/'), comment='No comment' ) self.assertNotEqual(id(ref_1), id(ref_2)) @@ -94,13 +87,13 @@ class TestModelHashType(TestCase): def test_hash_type_from_composite_str_1(self) -> None: h = HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') - self.assertEqual(h.get_algorithm(), HashAlgorithm.SHA_256) - self.assertEqual(h.get_hash_value(), '806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') + self.assertEqual(h.alg, HashAlgorithm.SHA_256) + self.assertEqual(h.content, '806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b') def test_hash_type_from_composite_str_2(self) -> None: h = HashType.from_composite_str('md5:dc26cd71b80d6757139f38156a43c545') - self.assertEqual(h.get_algorithm(), HashAlgorithm.MD5) - self.assertEqual(h.get_hash_value(), 'dc26cd71b80d6757139f38156a43c545') + self.assertEqual(h.alg, HashAlgorithm.MD5) + self.assertEqual(h.content, 'dc26cd71b80d6757139f38156a43c545') def test_hash_type_from_unknown(self) -> None: with self.assertRaises(UnknownHashTypeException): @@ -132,7 +125,7 @@ class TestModelIssueType(TestCase): def test_issue_type(self) -> None: it = IssueType( - classification=IssueClassification.SECURITY, id='CVE-2021-44228', name='Apache Log3Shell', + classification=IssueClassification.SECURITY, id_='CVE-2021-44228', name='Apache Log3Shell', description='Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features used in ' 'configuration, log messages, and parameters do not protect against attacker controlled LDAP ' 'and other JNDI related endpoints. An attacker who can control log messages or log message ' @@ -141,17 +134,17 @@ def test_issue_type(self) -> None: 'version 2.16.0, this functionality has been completely removed. Note that this vulnerability ' 'is specific to log4j-core and does not affect log4net, log4cxx, or other Apache Logging ' 'Services projects.', - source_name='NVD', source_url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'), + source=IssueTypeSource(name='NVD', url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228')), references=[ XsUri('https://logging.apache.org/log4j/2.x/security.html'), XsUri('https://central.sonatype.org/news/20211213_log4shell_help') ] ) - self.assertEqual(it.get_classification(), IssueClassification.SECURITY), - self.assertEqual(it.get_id(), 'CVE-2021-44228'), - self.assertEqual(it.get_name(), 'Apache Log3Shell') + self.assertEqual(it.type, IssueClassification.SECURITY), + self.assertEqual(it.id, 'CVE-2021-44228'), + self.assertEqual(it.name, 'Apache Log3Shell') self.assertEqual( - it.get_description(), + it.description, 'Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features used in ' 'configuration, log messages, and parameters do not protect against attacker controlled LDAP ' 'and other JNDI related endpoints. An attacker who can control log messages or log message ' @@ -161,17 +154,12 @@ def test_issue_type(self) -> None: 'is specific to log4j-core and does not affect log4net, log4cxx, or other Apache Logging ' 'Services projects.' ) - self.assertEqual(it.get_source_name(), 'NVD'), - self.assertEqual(str(it.get_source_url()), str(XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228'))) - self.assertEqual(str(it.get_source_url()), str('https://nvd.nist.gov/vuln/detail/CVE-2021-44228')) - self.assertListEqual(list(map(lambda u: str(u), it.get_references())), [ - 'https://logging.apache.org/log4j/2.x/security.html', - 'https://central.sonatype.org/news/20211213_log4shell_help' - ]) - self.assertListEqual(list(map(lambda u: str(u), it.get_references())), [ - 'https://logging.apache.org/log4j/2.x/security.html', - 'https://central.sonatype.org/news/20211213_log4shell_help' - ]) + self.assertEqual(it.source.name, 'NVD'), + self.assertEqual(it.source.url, XsUri('https://nvd.nist.gov/vuln/detail/CVE-2021-44228')) + self.assertSetEqual(it.references, { + XsUri('https://logging.apache.org/log4j/2.x/security.html'), + XsUri('https://central.sonatype.org/news/20211213_log4shell_help') + }) class TestModelNote(TestCase): diff --git a/tests/test_model_component.py b/tests/test_model_component.py index 07a111fa..b1c05927 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -71,12 +71,12 @@ def test_empty_basic_component(self, mock_uuid: Mock) -> None: self.assertEqual(c.version, '1.2.3') self.assertIsNone(c.description) self.assertIsNone(c.scope) - self.assertListEqual(c.hashes, []) - self.assertListEqual(c.licenses, []) + self.assertSetEqual(c.hashes, set()) + self.assertSetEqual(c.licenses, set()) self.assertIsNone(c.copyright) self.assertIsNone(c.purl) - self.assertListEqual(c.external_references, []) - self.assertIsNone(c.properties) + self.assertSetEqual(c.external_references, set()) + self.assertFalse(c.properties) self.assertIsNone(c.release_notes) self.assertEqual(len(c.get_vulnerabilities()), 0) @@ -111,9 +111,9 @@ def test_external_references(self) -> None: c = Component( name='test-component', version='1.2.3' ) - c.add_external_reference(ExternalReference( + c.external_references.add(ExternalReference( reference_type=ExternalReferenceType.OTHER, - url='https://cyclonedx.org', + url=XsUri('https://cyclonedx.org'), comment='No comment' )) self.assertEqual(c.name, 'test-component') @@ -148,18 +148,18 @@ def test_component_equal_1(self) -> None: c = Component( name='test-component', version='1.2.3' ) - c.add_external_reference(ExternalReference( + c.external_references.add(ExternalReference( reference_type=ExternalReferenceType.OTHER, - url='https://cyclonedx.org', + url=XsUri('https://cyclonedx.org'), comment='No comment' )) c2 = Component( name='test-component', version='1.2.3' ) - c2.add_external_reference(ExternalReference( + c2.external_references.add(ExternalReference( reference_type=ExternalReferenceType.OTHER, - url='https://cyclonedx.org', + url=XsUri('https://cyclonedx.org'), comment='No comment' )) @@ -295,7 +295,7 @@ def test_multiple_times_same(self) -> None: ) p2 = Patch( type_=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), - resolves=[get_issue_2(), get_issue_1()] + resolves=[get_issue_2(), get_issue_1(), get_issue_1(), get_issue_1(), get_issue_2()] ) self.assertEqual(hash(p1), hash(p2)) self.assertNotEqual(id(p1), id(p2)) diff --git a/tests/test_model_release_note.py b/tests/test_model_release_note.py index a360bd70..b544e58f 100644 --- a/tests/test_model_release_note.py +++ b/tests/test_model_release_note.py @@ -26,23 +26,23 @@ class TestModelReleaseNote(TestCase): def test_simple(self) -> None: - rn = ReleaseNotes(type='major') + rn = ReleaseNotes(type_='major') self.assertEqual(rn.type, 'major') self.assertIsNone(rn.title) self.assertIsNone(rn.featured_image) self.assertIsNone(rn.social_image) self.assertIsNone(rn.description) self.assertIsNone(rn.timestamp) - self.assertIsNone(rn.aliases) - self.assertIsNone(rn.tags) - self.assertIsNone(rn.resolves) - self.assertIsNone(rn.notes) - self.assertIsNone(rn.properties) + self.assertFalse(rn.aliases) + self.assertFalse(rn.tags) + self.assertFalse(rn.resolves) + self.assertFalse(rn.notes) + self.assertFalse(rn.properties) def test_complete(self) -> None: timestamp: datetime.datetime = datetime.datetime.utcnow() rn = ReleaseNotes( - type='major', title="Release Notes Title", + type_='major', title="Release Notes Title", featured_image=XsUri('https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png'), social_image=XsUri('https://cyclonedx.org/cyclonedx-icon.png'), description="This release is a test release", timestamp=timestamp, @@ -53,8 +53,8 @@ def test_complete(self) -> None: resolves=[], notes=[] ) - rn.add_alias(alias="Release Alpha") - rn.add_tag(tag='testing') + rn.aliases.add("Release Alpha") + rn.tags.add('testing') self.assertEqual(rn.type, 'major') self.assertEqual(rn.title, 'Release Notes Title') @@ -64,8 +64,8 @@ def test_complete(self) -> None: ) self.assertEqual(str(rn.social_image), 'https://cyclonedx.org/cyclonedx-icon.png') self.assertEqual(rn.description, 'This release is a test release') - self.assertListEqual(rn.aliases, ["First Test Release", "Release Alpha"]) - self.assertListEqual(rn.tags, ['test', 'alpha', 'testing']) - self.assertIsNone(rn.resolves) - self.assertIsNone(rn.notes) - self.assertIsNone(rn.properties) + self.assertSetEqual(rn.aliases, {"Release Alpha", "First Test Release"}) + self.assertSetEqual(rn.tags, {'test', 'testing', 'alpha'}) + self.assertSetEqual(rn.resolves, set()) + self.assertFalse(rn.notes) + self.assertSetEqual(rn.properties, set()) diff --git a/tests/test_model_service.py b/tests/test_model_service.py index da414ebf..12f589cf 100644 --- a/tests/test_model_service.py +++ b/tests/test_model_service.py @@ -34,15 +34,15 @@ def test_minimal_service(self, mock_uuid: Mock) -> None: self.assertIsNone(s.group) self.assertIsNone(s.version) self.assertIsNone(s.description) - self.assertIsNone(s.endpoints) + self.assertFalse(s.endpoints) self.assertIsNone(s.authenticated) self.assertIsNone(s.x_trust_boundary) - self.assertIsNone(s.data) - self.assertListEqual(s.licenses, []) - self.assertListEqual(s.external_references, []) - self.assertIsNone(s.services) - self.assertIsNone(s.release_notes) - self.assertIsNone(s.properties) + self.assertFalse(s.data) + self.assertFalse(s.licenses) + self.assertFalse(s.external_references) + self.assertFalse(s.services) + self.assertFalse(s.release_notes) + self.assertFalse(s.properties) @patch('cyclonedx.model.service.uuid4', return_value='859ff614-35a7-4d37-803b-d89130cb2577') def test_service_with_services(self, mock_uuid: Mock) -> None: @@ -58,14 +58,14 @@ def test_service_with_services(self, mock_uuid: Mock) -> None: self.assertIsNone(parent_service.group) self.assertIsNone(parent_service.version) self.assertIsNone(parent_service.description) - self.assertIsNone(parent_service.endpoints) + self.assertFalse(parent_service.endpoints) self.assertIsNone(parent_service.authenticated) self.assertIsNone(parent_service.x_trust_boundary) - self.assertIsNone(parent_service.data) - self.assertListEqual(parent_service.licenses, []) - self.assertListEqual(parent_service.external_references, []) + self.assertFalse(parent_service.data) + self.assertFalse(parent_service.licenses) + self.assertFalse(parent_service.external_references) self.assertIsNotNone(parent_service.services) self.assertEqual(len(parent_service.services), 2) self.assertIsNone(parent_service.release_notes) - self.assertIsNone(parent_service.properties) - self.assertTrue(parent_service.has_service(service=Service(name='child-service-1'))) + self.assertFalse(parent_service.properties) + self.assertTrue(Service(name='child-service-1') in parent_service.services) diff --git a/tests/test_model_vulnerability.py b/tests/test_model_vulnerability.py index 361b9f1d..450793cf 100644 --- a/tests/test_model_vulnerability.py +++ b/tests/test_model_vulnerability.py @@ -16,7 +16,6 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - import unittest from unittest import TestCase from unittest.mock import Mock, patch @@ -30,17 +29,17 @@ class TestModelVulnerability(TestCase): @unittest.skip('Deprecated in Schema Version 1.4') def test_v_rating_scores_empty(self) -> None: vr = VulnerabilityRating() - self.assertFalse(vr.has_score()) + self.assertFalse(vr.score) @unittest.skip('Deprecated in Schema Version 1.4') def test_v_rating_scores_base_only(self) -> None: vr = VulnerabilityRating(score_base=1.0) - self.assertTrue(vr.has_score()) + self.assertTrue(vr.score) @unittest.skip('Deprecated in Schema Version 1.4') def test_v_rating_scores_all(self) -> None: vr = VulnerabilityRating(score_base=1.0, score_impact=3.5, score_exploitability=5.6) - self.assertTrue(vr.has_score()) + self.assertTrue(vr.score) def test_v_severity_from_cvss_scores_single_critical(self) -> None: self.assertEqual( @@ -178,17 +177,17 @@ def test_empty_vulnerability(self, mock_uuid: Mock) -> None: self.assertEqual(v.bom_ref, '0afa65bc-4acd-428b-9e17-8e97b1969745') self.assertIsNone(v.id) self.assertIsNone(v.source) - self.assertListEqual(v.references, []) - self.assertListEqual(v.ratings, []) - self.assertListEqual(v.cwes, []) + self.assertFalse(v.references) + self.assertFalse(v.ratings) + self.assertFalse(v.cwes) self.assertIsNone(v.description) self.assertIsNone(v.detail) self.assertIsNone(v.recommendation) - self.assertListEqual(v.advisories, []) + self.assertFalse(v.advisories) self.assertIsNone(v.created) self.assertIsNone(v.published) self.assertIsNone(v.updated) self.assertIsNone(v.credits) - self.assertListEqual(v.tools, []) + self.assertFalse(v.tools) self.assertIsNone(v.analysis) - self.assertListEqual(v.affects, []) + self.assertFalse(v.affects) diff --git a/tests/test_output_generic.py b/tests/test_output_generic.py index 22c4896c..5d8d9441 100644 --- a/tests/test_output_generic.py +++ b/tests/test_output_generic.py @@ -30,7 +30,7 @@ class TestOutputGeneric(TestCase): @classmethod def setUpClass(cls) -> None: cls._bom = Bom() - cls._bom.add_component(Component(name='setuptools')) + cls._bom.components.add(Component(name='setuptools')) def test_get_instance_default(self) -> None: i = get_instance(bom=TestOutputGeneric._bom) diff --git a/tox.ini b/tox.ini index c902de7d..d14cb059 100644 --- a/tox.ini +++ b/tox.ini @@ -27,6 +27,8 @@ commands_pre = poetry run pip freeze commands = poetry run coverage run --source=cyclonedx -m unittest discover -s tests -v +setenv = + PYTHONHASHSEED = 0 [testenv:mypy{,-locked,-lowest}] commands = From 2938a6c001a5b0b25477241d4ad6601030c55165 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Tue, 8 Feb 2022 14:05:31 +0000 Subject: [PATCH 19/29] feat: support complete model for `bom.metadata` (#162) * feat: support complete model for `bom.metadata` fix: JSON comparison in unit tests was broken chore: corrected some source license headers Signed-off-by: Paul Horton --- cyclonedx/model/bom.py | 111 ++++++++++++++++-- cyclonedx/output/json.py | 10 +- cyclonedx/output/schema.py | 12 ++ cyclonedx/output/xml.py | 24 ++++ tests/base.py | 16 ++- tests/data.py | 29 +++-- .../json/1.2/bom_with_full_metadata.json | 41 +++++++ .../json/1.3/bom_with_full_metadata.json | 58 ++++++++- .../json/1.4/bom_with_full_metadata.json | 58 ++++++++- .../xml/1.2/bom_with_full_metadata.xml | 33 ++++++ .../xml/1.3/bom_with_full_metadata.xml | 40 +++++++ .../xml/1.4/bom_with_full_metadata.xml | 40 +++++++ 12 files changed, 446 insertions(+), 26 deletions(-) diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 93a46574..e459f859 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -20,7 +20,7 @@ from typing import Iterable, Optional, Set from uuid import uuid4, UUID -from . import ExternalReference, ThisTool, Tool +from . import ExternalReference, OrganizationalContact, OrganizationalEntity, LicenseChoice, Property, ThisTool, Tool from .component import Component from .service import Service from ..parser import BaseParser @@ -31,17 +31,40 @@ class BomMetaData: This is our internal representation of the metadata complex type within the CycloneDX standard. .. note:: - See the CycloneDX Schema for Bom metadata: https://cyclonedx.org/docs/1.3/#type_metadata + See the CycloneDX Schema for Bom metadata: https://cyclonedx.org/docs/1.4/#type_metadata """ - def __init__(self, *, tools: Optional[Iterable[Tool]] = None) -> None: + def __init__(self, *, tools: Optional[Iterable[Tool]] = None, + authors: Optional[Iterable[OrganizationalContact]] = None, component: Optional[Component] = None, + manufacture: Optional[OrganizationalEntity] = None, + supplier: Optional[OrganizationalEntity] = None, + licenses: Optional[Iterable[LicenseChoice]] = None, + properties: Optional[Iterable[Property]] = None) -> None: self.timestamp = datetime.now(tz=timezone.utc) self.tools = set(tools or []) + self.authors = set(authors or []) + self.component = component + self.manufacture = manufacture + self.supplier = supplier + self.licenses = set(licenses or []) + self.properties = set(properties or []) if not self.tools: self.tools.add(ThisTool) - self.component: Optional[Component] = None + @property + def timestamp(self) -> datetime: + """ + The date and time (in UTC) when this BomMetaData was created. + + Returns: + `datetime` instance in UTC timezone + """ + return self._timestamp + + @timestamp.setter + def timestamp(self, timestamp: datetime) -> None: + self._timestamp = timestamp @property def tools(self) -> Set[Tool]: @@ -58,18 +81,22 @@ def tools(self, tools: Iterable[Tool]) -> None: self._tools = set(tools) @property - def timestamp(self) -> datetime: + def authors(self) -> Set[OrganizationalContact]: """ - The date and time (in UTC) when this BomMetaData was created. + The person(s) who created the BOM. + + Authors are common in BOMs created through manual processes. + + BOMs created through automated means may not have authors. Returns: - `datetime` instance in UTC timezone + Set of `OrganizationalContact` """ - return self._timestamp + return self._authors - @timestamp.setter - def timestamp(self, timestamp: datetime) -> None: - self._timestamp = timestamp + @authors.setter + def authors(self, authors: Iterable[OrganizationalContact]) -> None: + self._authors = set(authors) @property def component(self) -> Optional[Component]: @@ -95,6 +122,68 @@ def component(self, component: Component) -> None: """ self._component = component + @property + def manufacture(self) -> Optional[OrganizationalEntity]: + """ + The organization that manufactured the component that the BOM describes. + + Returns: + `OrganizationalEntity` if set else `None` + """ + return self._manufacture + + @manufacture.setter + def manufacture(self, manufacture: Optional[OrganizationalEntity]) -> None: + self._manufacture = manufacture + + @property + def supplier(self) -> Optional[OrganizationalEntity]: + """ + The organization that supplied the component that the BOM describes. + + The supplier may often be the manufacturer, but may also be a distributor or repackager. + + Returns: + `OrganizationalEntity` if set else `None` + """ + return self._supplier + + @supplier.setter + def supplier(self, supplier: Optional[OrganizationalEntity]) -> None: + self._supplier = supplier + + @property + def licenses(self) -> Set[LicenseChoice]: + """ + A optional list of statements about how this BOM is licensed. + + Returns: + Set of `LicenseChoice` + """ + return self._licenses + + @licenses.setter + def licenses(self, licenses: Iterable[LicenseChoice]) -> None: + self._licenses = set(licenses) + + @property + def properties(self) -> Set[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. + + Property names of interest to the general public are encouraged to be registered in the CycloneDX Property + Taxonomy - https://github.com/CycloneDX/cyclonedx-property-taxonomy. Formal registration is OPTIONAL. + + Return: + Set of `Property` + """ + return self._properties + + @properties.setter + def properties(self, properties: Iterable[Property]) -> None: + self._properties = set(properties) + def __eq__(self, other: object) -> bool: if isinstance(other, BomMetaData): return hash(other) == hash(self) diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py index cdf44c81..235be936 100644 --- a/cyclonedx/output/json.py +++ b/cyclonedx/output/json.py @@ -16,7 +16,6 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - import json from abc import abstractmethod from typing import cast, Any, Dict, List, Optional, Union @@ -79,13 +78,20 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str if not self.bom_supports_metadata(): if 'metadata' in bom_json.keys(): del bom_json['metadata'] - elif not self.bom_metadata_supports_tools(): + + if not self.bom_metadata_supports_tools(): del bom_json['metadata']['tools'] elif not self.bom_metadata_supports_tools_external_references(): for i in range(len(bom_json['metadata']['tools'])): if 'externalReferences' in bom_json['metadata']['tools'][i].keys(): del bom_json['metadata']['tools'][i]['externalReferences'] + if not self.bom_metadata_supports_licenses() and 'licenses' in bom_json['metadata'].keys(): + del bom_json['metadata']['licenses'] + + if not self.bom_metadata_supports_properties() and 'properties' in bom_json['metadata'].keys(): + del bom_json['metadata']['properties'] + # Iterate Components if 'components' in bom_json.keys(): for i in range(len(bom_json['components'])): diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py index 3d1e1786..369ec1a7 100644 --- a/cyclonedx/output/schema.py +++ b/cyclonedx/output/schema.py @@ -38,6 +38,12 @@ def bom_metadata_supports_tools(self) -> bool: def bom_metadata_supports_tools_external_references(self) -> bool: return True + def bom_metadata_supports_licenses(self) -> bool: + return True + + def bom_metadata_supports_properties(self) -> bool: + return True + def bom_supports_services(self) -> bool: return True @@ -147,6 +153,12 @@ def schema_version_enum(self) -> SchemaVersion: def bom_metadata_supports_tools_external_references(self) -> bool: return False + def bom_metadata_supports_licenses(self) -> bool: + return False + + def bom_metadata_supports_properties(self) -> bool: + return False + def services_supports_properties(self) -> bool: return False diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index bb54498d..bd6230a3 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -144,9 +144,33 @@ def _add_metadata_element(self) -> None: for tool in bom_metadata.tools: self._add_tool(parent_element=tools_e, tool=tool) + if bom_metadata.authors: + authors_e = ElementTree.SubElement(metadata_e, 'authors') + for author in bom_metadata.authors: + Xml._add_organizational_contact( + parent_element=authors_e, contact=author, tag_name='author' + ) + if bom_metadata.component: metadata_e.append(self._add_component_element(component=bom_metadata.component)) + if bom_metadata.manufacture: + Xml._add_organizational_entity( + parent_element=metadata_e, organization=bom_metadata.manufacture, tag_name='manufacture' + ) + + if bom_metadata.supplier: + Xml._add_organizational_entity( + parent_element=metadata_e, organization=bom_metadata.supplier, tag_name='supplier' + ) + + if self.bom_metadata_supports_licenses() and bom_metadata.licenses: + licenses_e = ElementTree.SubElement(metadata_e, 'licenses') + self._add_licenses_to_element(licenses=bom_metadata.licenses, parent_element=licenses_e) + + if self.bom_metadata_supports_properties() and bom_metadata.properties: + Xml._add_properties_element(properties=bom_metadata.properties, parent_element=metadata_e) + def _add_component_element(self, component: Component) -> ElementTree.Element: element_attributes = {'type': component.type.value} if self.component_supports_bom_ref_attribute() and component.bom_ref: diff --git a/tests/base.py b/tests/base.py index b2df5ae7..10cb89c5 100644 --- a/tests/base.py +++ b/tests/base.py @@ -22,6 +22,7 @@ import sys import xml.etree.ElementTree from datetime import datetime, timezone +from typing import Any from unittest import TestCase from uuid import uuid4 @@ -66,10 +67,19 @@ def assertValidAgainstSchema(self, bom_json: str, schema_version: SchemaVersion) else: self.assertTrue(True, 'JSON Schema Validation is not possible in Python < 3.7') + @staticmethod + def _sort_json_dict(item: object) -> Any: + if isinstance(item, dict): + return sorted((key, BaseJsonTestCase._sort_json_dict(values)) for key, values in item.items()) + if isinstance(item, list): + return sorted(BaseJsonTestCase._sort_json_dict(x) for x in item) + else: + return item + def assertEqualJson(self, a: str, b: str) -> None: self.assertEqual( - json.dumps(sorted(json.loads(a)), sort_keys=True), - json.dumps(sorted(json.loads(b)), sort_keys=True) + BaseJsonTestCase._sort_json_dict(json.loads(a)), + BaseJsonTestCase._sort_json_dict(json.loads(b)) ) def assertEqualJsonBom(self, a: str, b: str) -> None: @@ -123,7 +133,7 @@ def assertValidAgainstSchema(self, bom_xml: str, schema_version: SchemaVersion) def assertEqualXml(self, a: str, b: str) -> None: diff_results = main.diff_texts(a, b, diff_options={'F': 0.5}) diff_results = list(filter(lambda o: not isinstance(o, MoveNode), diff_results)) - self.assertEqual(len(diff_results), 0) + self.assertEqual(len(diff_results), 0, f'There are XML differences: {diff_results}') def assertEqualXmlBom(self, a: str, b: str, namespace: str) -> None: """ diff --git a/tests/data.py b/tests/data.py index e85b434e..2177567a 100644 --- a/tests/data.py +++ b/tests/data.py @@ -117,9 +117,7 @@ def get_bom_with_component_setuptools_with_vulnerability() -> Bom: organizations=[ get_org_entity_1() ], - individuals=[ - OrganizationalContact(name='A N Other', email='someone@somewhere.tld', phone='+44 (0)1234 567890'), - ] + individuals=[get_org_contact_2()] ), tools=[ Tool(vendor='CycloneDX', name='cyclonedx-python-lib') @@ -148,9 +146,14 @@ def get_bom_with_component_toml_1() -> Bom: def get_bom_just_complete_metadata() -> Bom: bom = Bom() + bom.metadata.authors = [get_org_contact_1(), get_org_contact_2()] bom.metadata.component = Component( name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY ) + bom.metadata.manufacture = get_org_entity_1() + bom.metadata.supplier = get_org_entity_2() + bom.metadata.licenses = [LicenseChoice(license_expression='Commercial')] + bom.metadata.properties = get_properties_1() return bom @@ -326,13 +329,23 @@ def get_issue_2() -> IssueType: ) +def get_org_contact_1() -> OrganizationalContact: + return OrganizationalContact(name='Paul Horton', email='paul.horton@owasp.org') + + +def get_org_contact_2() -> OrganizationalContact: + return OrganizationalContact(name='A N Other', email='someone@somewhere.tld', phone='+44 (0)1234 567890') + + def get_org_entity_1() -> OrganizationalEntity: return OrganizationalEntity( - name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[ - OrganizationalContact(name='Paul Horton', email='paul.horton@owasp.org'), - OrganizationalContact(name='A N Other', email='someone@somewhere.tld', - phone='+44 (0)1234 567890') - ] + name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[get_org_contact_1(), get_org_contact_2()] + ) + + +def get_org_entity_2() -> OrganizationalEntity: + return OrganizationalEntity( + name='Cyclone DX', urls=[XsUri('https://cyclonedx.org/')], contacts=[get_org_contact_2()] ) diff --git a/tests/fixtures/json/1.2/bom_with_full_metadata.json b/tests/fixtures/json/1.2/bom_with_full_metadata.json index 0ae4d8c3..76b1dd11 100644 --- a/tests/fixtures/json/1.2/bom_with_full_metadata.json +++ b/tests/fixtures/json/1.2/bom_with_full_metadata.json @@ -13,11 +13,52 @@ "version": "VERSION" } ], + "authors": [ + { + "name": "Paul Horton", + "email": "paul.horton@owasp.org" + }, + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ], "component": { "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "type": "library", "name": "cyclonedx-python-lib", "version": "1.0.0" + }, + "manufacture": { + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ], + "contact": [ + { + "name": "Paul Horton", + "email": "paul.horton@owasp.org" + }, + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ] + }, + "supplier": { + "name": "Cyclone DX", + "url": [ + "https://cyclonedx.org/" + ], + "contact": [ + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ] } }, "components": [] diff --git a/tests/fixtures/json/1.3/bom_with_full_metadata.json b/tests/fixtures/json/1.3/bom_with_full_metadata.json index 1804190f..ec87032e 100644 --- a/tests/fixtures/json/1.3/bom_with_full_metadata.json +++ b/tests/fixtures/json/1.3/bom_with_full_metadata.json @@ -13,12 +13,68 @@ "version": "VERSION" } ], + "authors": [ + { + "name": "Paul Horton", + "email": "paul.horton@owasp.org" + }, + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ], "component": { "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "type": "library", "name": "cyclonedx-python-lib", "version": "1.0.0" - } + }, + "manufacture": { + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ], + "contact": [ + { + "name": "Paul Horton", + "email": "paul.horton@owasp.org" + }, + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ] + }, + "supplier": { + "name": "Cyclone DX", + "url": [ + "https://cyclonedx.org/" + ], + "contact": [ + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ] + }, + "licenses": [ + { + "expression": "Commercial" + } + ], + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ] }, "components": [] } \ No newline at end of file diff --git a/tests/fixtures/json/1.4/bom_with_full_metadata.json b/tests/fixtures/json/1.4/bom_with_full_metadata.json index 9d96c43c..8588cd52 100644 --- a/tests/fixtures/json/1.4/bom_with_full_metadata.json +++ b/tests/fixtures/json/1.4/bom_with_full_metadata.json @@ -47,12 +47,68 @@ ] } ], + "authors": [ + { + "name": "Paul Horton", + "email": "paul.horton@owasp.org" + }, + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ], "component": { "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "type": "library", "name": "cyclonedx-python-lib", "version": "1.0.0" - } + }, + "manufacture": { + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ], + "contact": [ + { + "name": "Paul Horton", + "email": "paul.horton@owasp.org" + }, + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ] + }, + "supplier": { + "name": "Cyclone DX", + "url": [ + "https://cyclonedx.org/" + ], + "contact": [ + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ] + }, + "licenses": [ + { + "expression": "Commercial" + } + ], + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ] }, "components": [] } \ No newline at end of file diff --git a/tests/fixtures/xml/1.2/bom_with_full_metadata.xml b/tests/fixtures/xml/1.2/bom_with_full_metadata.xml index df4d6db7..5ac9d81b 100644 --- a/tests/fixtures/xml/1.2/bom_with_full_metadata.xml +++ b/tests/fixtures/xml/1.2/bom_with_full_metadata.xml @@ -9,10 +9,43 @@ VERSION + + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + cyclonedx-python-lib 1.0.0 + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + + Cyclone DX + https://cyclonedx.org/ + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.3/bom_with_full_metadata.xml b/tests/fixtures/xml/1.3/bom_with_full_metadata.xml index 8d06d737..be3a90f1 100644 --- a/tests/fixtures/xml/1.3/bom_with_full_metadata.xml +++ b/tests/fixtures/xml/1.3/bom_with_full_metadata.xml @@ -9,10 +9,50 @@ VERSION + + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + cyclonedx-python-lib 1.0.0 + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + + Cyclone DX + https://cyclonedx.org/ + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + + Commercial + + + val1 + val2 + \ No newline at end of file diff --git a/tests/fixtures/xml/1.4/bom_with_full_metadata.xml b/tests/fixtures/xml/1.4/bom_with_full_metadata.xml index a16f9b10..395ae6d3 100644 --- a/tests/fixtures/xml/1.4/bom_with_full_metadata.xml +++ b/tests/fixtures/xml/1.4/bom_with_full_metadata.xml @@ -35,10 +35,50 @@ + + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + cyclonedx-python-lib 1.0.0 + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + + Cyclone DX + https://cyclonedx.org/ + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + + Commercial + + + val1 + val2 + \ No newline at end of file From 9b6ce4bd7b5a2a332e9f01f93db57b78f65af048 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Tue, 8 Feb 2022 14:19:00 +0000 Subject: [PATCH 20/29] BREAKING CHANGE: Updated default schema version to 1.4 from 1.3 (#164) Signed-off-by: Paul Horton --- cyclonedx/output/__init__.py | 2 +- docs/architecture.rst | 14 ++++++++++++++ docs/changelog.rst | 14 ++++++++++++++ docs/install.rst | 14 ++++++++++++++ docs/modelling.rst | 14 ++++++++++++++ docs/outputting.rst | 18 ++++++++++++++++-- docs/schema-support.rst | 16 +++++++++++++++- docs/support.rst | 14 ++++++++++++++ tests/test_output_generic.py | 8 ++++---- 9 files changed, 106 insertions(+), 8 deletions(-) diff --git a/cyclonedx/output/__init__.py b/cyclonedx/output/__init__.py index 37211e81..64265d97 100644 --- a/cyclonedx/output/__init__.py +++ b/cyclonedx/output/__init__.py @@ -50,7 +50,7 @@ def to_version(self) -> str: return f'{self.value[1]}.{self.value[5]}' -DEFAULT_SCHEMA_VERSION = SchemaVersion.V1_3 +DEFAULT_SCHEMA_VERSION = SchemaVersion.V1_4 class BaseOutput(ABC): diff --git a/docs/architecture.rst b/docs/architecture.rst index 833989a6..3cf16d7c 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -1,3 +1,17 @@ +.. # 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 + Architecture ============ diff --git a/docs/changelog.rst b/docs/changelog.rst index 5ec4a0ec..5941b19b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1 +1,15 @@ +.. # 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 + .. mdinclude:: ../CHANGELOG.md \ No newline at end of file diff --git a/docs/install.rst b/docs/install.rst index ed72965d..43123c61 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -1,3 +1,17 @@ +.. # 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 + Installation ============ diff --git a/docs/modelling.rst b/docs/modelling.rst index 68626f4b..119f9b8f 100644 --- a/docs/modelling.rst +++ b/docs/modelling.rst @@ -1,3 +1,17 @@ +.. # 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 + Modelling ========= diff --git a/docs/outputting.rst b/docs/outputting.rst index a8f2ee01..2b9d7028 100644 --- a/docs/outputting.rst +++ b/docs/outputting.rst @@ -1,3 +1,17 @@ +.. # 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 + Outputting ========== @@ -9,7 +23,7 @@ We provide two helper methods: * Output to string (for you to do with as you require) * Output directly to a filename you provide -The default output will be XML at Schema Version 1.3. +The default output will be in XML at Schema Version 1.4. Supported CycloneDX Schema Versions ----------------------------------- @@ -25,7 +39,7 @@ This library supports the following schema versions: Outputting to JSON ------------------ -The below example relies on the default schema version being 1.3, but sets the output format to JSON. Output is returned +The below example relies on the default schema version being 1.4, but sets the output format to JSON. Output is returned as a ``str``. .. code-block:: python diff --git a/docs/schema-support.rst b/docs/schema-support.rst index db648597..bd317868 100644 --- a/docs/schema-support.rst +++ b/docs/schema-support.rst @@ -1,3 +1,17 @@ +.. # 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 + Schema Support ============== @@ -14,7 +28,7 @@ supported in prior versions of the CycloneDX schema. +----------------------------+---------------+---------------------------------------------------------------------------------------------------+ | ``bom[@serialNumber]`` | Yes | | +----------------------------+---------------+---------------------------------------------------------------------------------------------------+ -| ``bom.metadata`` | Yes (partial) | Not supported: ``authors``, ``manufacture``, ``supplier``, ``licenses``, ``properties``. | +| ``bom.metadata`` | Yes | | +----------------------------+---------------+---------------------------------------------------------------------------------------------------+ | ``bom.components`` | Yes | Not supported: ``modified`` (as it is deprecated), ``signature``. | +----------------------------+---------------+---------------------------------------------------------------------------------------------------+ diff --git a/docs/support.rst b/docs/support.rst index 62b441a2..48773249 100644 --- a/docs/support.rst +++ b/docs/support.rst @@ -1,3 +1,17 @@ +.. # 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 + Support ======= diff --git a/tests/test_output_generic.py b/tests/test_output_generic.py index 5d8d9441..b0be42e0 100644 --- a/tests/test_output_generic.py +++ b/tests/test_output_generic.py @@ -22,7 +22,7 @@ from cyclonedx.model.bom import Bom from cyclonedx.model.component import Component from cyclonedx.output import get_instance, OutputFormat, SchemaVersion -from cyclonedx.output.xml import XmlV1Dot3 +from cyclonedx.output.xml import XmlV1Dot3, XmlV1Dot4 class TestOutputGeneric(TestCase): @@ -34,11 +34,11 @@ def setUpClass(cls) -> None: def test_get_instance_default(self) -> None: i = get_instance(bom=TestOutputGeneric._bom) - self.assertIsInstance(i, XmlV1Dot3) + self.assertIsInstance(i, XmlV1Dot4) - def test_get_instance_xml(self) -> None: + def test_get_instance_xml_default(self) -> None: i = get_instance(bom=TestOutputGeneric._bom, output_format=OutputFormat.XML) - self.assertIsInstance(i, XmlV1Dot3) + self.assertIsInstance(i, XmlV1Dot4) def test_get_instance_xml_v1_3(self) -> None: i = get_instance(bom=TestOutputGeneric._bom, output_format=OutputFormat.XML, schema_version=SchemaVersion.V1_3) From 5c954d1e39ce8509ab36e6de7d521927ad3c997c Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Tue, 15 Feb 2022 16:05:51 +0000 Subject: [PATCH 21/29] fix: `Component.bom_ref` is not Optional in our model implementation (in the schema it is) - we generate a UUID if `bom_ref` is not supplied explicitly Signed-off-by: Paul Horton --- cyclonedx/model/component.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 86c3232f..c8151746 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -766,7 +766,7 @@ def mime_type(self, mime_type: Optional[str]) -> None: self._mime_type = mime_type @property - def bom_ref(self) -> Optional[str]: + def bom_ref(self) -> str: """ An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be unique within the BOM. @@ -779,7 +779,7 @@ def bom_ref(self) -> Optional[str]: return self._bom_ref @bom_ref.setter - def bom_ref(self, bom_ref: Optional[str]) -> None: + def bom_ref(self, bom_ref: str) -> None: self._bom_ref = bom_ref @property From a926b34c7facb8b3709936fe00b62a0b80338f31 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Wed, 16 Feb 2022 13:05:14 +0000 Subject: [PATCH 22/29] feat: completed work on #155 (#172) fix: resolved #169 (part of #155) feat: as part of solving #155, #147 has been implemented Signed-off-by: Paul Horton --- cyclonedx/output/json.py | 77 ++++++++---- cyclonedx/output/schema.py | 33 ++++++ cyclonedx/output/xml.py | 46 +++++++ tests/data.py | 14 ++- .../json/1.2/bom_external_references.json | 1 - .../json/1.2/bom_services_complex.json | 1 - .../json/1.2/bom_services_nested.json | 2 - .../json/1.2/bom_services_simple.json | 1 - .../json/1.2/bom_setuptools_complete.json | 66 +++++------ tests/fixtures/json/1.2/bom_toml_1.json | 8 +- .../json/1.2/bom_with_full_metadata.json | 3 +- .../json/1.3/bom_external_references.json | 1 - .../json/1.3/bom_services_complex.json | 1 - .../json/1.3/bom_services_nested.json | 2 - .../json/1.3/bom_services_simple.json | 1 - .../json/1.3/bom_setuptools_complete.json | 48 +++++++- .../json/1.3/bom_with_full_metadata.json | 3 +- .../json/1.4/bom_external_references.json | 1 - .../json/1.4/bom_services_complex.json | 1 - .../json/1.4/bom_services_nested.json | 2 - .../json/1.4/bom_services_simple.json | 1 - .../json/1.4/bom_setuptools_complete.json | 109 ++++++++++++++++- .../json/1.4/bom_with_full_metadata.json | 3 +- .../xml/1.0/bom_setuptools_complete.xml | 21 ++++ .../xml/1.1/bom_setuptools_complete.xml | 40 ++++++- .../xml/1.2/bom_setuptools_complete.xml | 56 ++++++++- .../xml/1.3/bom_setuptools_complete.xml | 72 ++++++++++- .../xml/1.4/bom_setuptools_complete.xml | 112 +++++++++++++++++- 28 files changed, 623 insertions(+), 103 deletions(-) diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py index 235be936..0c646891 100644 --- a/cyclonedx/output/json.py +++ b/cyclonedx/output/json.py @@ -93,22 +93,7 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str del bom_json['metadata']['properties'] # Iterate Components - if 'components' in bom_json.keys(): - for i in range(len(bom_json['components'])): - if self.component_version_optional() and bom_json['components'][i]['version'] == "": - del bom_json['components'][i]['version'] - - if not self.component_supports_author() and 'author' in bom_json['components'][i].keys(): - del bom_json['components'][i]['author'] - - if not self.component_supports_mime_type_attribute() \ - and 'mime-type' in bom_json['components'][i].keys(): - del bom_json['components'][i]['mime-type'] - - if not self.component_supports_release_notes() and 'releaseNotes' in bom_json['components'][i].keys(): - del bom_json['components'][i]['releaseNotes'] - else: - bom_json['components'] = [] + bom_json = self._recurse_specialise_component(bom_json=bom_json) # Iterate Services if 'services' in bom_json.keys(): @@ -126,11 +111,6 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str and 'hashes' in bom_json['externalReferences'][i].keys(): del bom_json['externalReferences'][i]['hashes'] - # Iterate Vulnerabilities - if 'vulnerabilities' in bom_json.keys(): - for i in range(len(bom_json['vulnerabilities'])): - print("Checking " + str(bom_json['vulnerabilities'][i])) - return json.dumps(bom_json) def output_as_string(self) -> str: @@ -151,6 +131,61 @@ def _create_bom_element(self) -> Dict[str, Union[str, int]]: def _get_schema_uri(self) -> Optional[str]: pass + def _recurse_specialise_component(self, bom_json: Dict[Any, Any], base_key: str = 'components') -> Dict[Any, Any]: + if base_key in bom_json.keys(): + for i in range(len(bom_json[base_key])): + if not self.component_supports_mime_type_attribute() \ + and 'mime-type' in bom_json[base_key][i].keys(): + del bom_json[base_key][i]['mime-type'] + + if not self.component_supports_supplier() and 'supplier' in bom_json[base_key][i].keys(): + del bom_json[base_key][i]['supplier'] + + if not self.component_supports_author() and 'author' in bom_json[base_key][i].keys(): + del bom_json[base_key][i]['author'] + + if self.component_version_optional() and bom_json[base_key][i]['version'] == "": + del bom_json[base_key][i]['version'] + + if not self.component_supports_pedigree() and 'pedigree' in bom_json[base_key][i].keys(): + del bom_json[base_key][i]['pedigree'] + elif 'pedigree' in bom_json[base_key][i].keys(): + if 'ancestors' in bom_json[base_key][i]['pedigree'].keys(): + # recurse into ancestors + bom_json[base_key][i]['pedigree'] = self._recurse_specialise_component( + bom_json=bom_json[base_key][i]['pedigree'], base_key='ancestors' + ) + if 'descendants' in bom_json[base_key][i]['pedigree'].keys(): + # recurse into descendants + bom_json[base_key][i]['pedigree'] = self._recurse_specialise_component( + bom_json=bom_json[base_key][i]['pedigree'], base_key='descendants' + ) + if 'variants' in bom_json[base_key][i]['pedigree'].keys(): + # recurse into variants + bom_json[base_key][i]['pedigree'] = self._recurse_specialise_component( + bom_json=bom_json[base_key][i]['pedigree'], base_key='variants' + ) + + if not self.external_references_supports_hashes() and 'externalReferences' \ + in bom_json[base_key][i].keys(): + for j in range(len(bom_json[base_key][i]['externalReferences'])): + del bom_json[base_key][i]['externalReferences'][j]['hashes'] + + if not self.component_supports_properties() and 'properties' in bom_json[base_key][i].keys(): + del bom_json[base_key][i]['properties'] + + # recurse + if 'components' in bom_json[base_key][i].keys(): + bom_json[base_key][i] = self._recurse_specialise_component(bom_json=bom_json[base_key][i]) + + if not self.component_supports_evidence() and 'evidence' in bom_json[base_key][i].keys(): + del bom_json[base_key][i]['evidence'] + + if not self.component_supports_release_notes() and 'releaseNotes' in bom_json[base_key][i].keys(): + del bom_json[base_key][i]['releaseNotes'] + + return bom_json + class JsonV1Dot0(Json, SchemaVersion1Dot0): diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py index 369ec1a7..781762df 100644 --- a/cyclonedx/output/schema.py +++ b/cyclonedx/output/schema.py @@ -65,6 +65,9 @@ def bom_supports_vulnerabilities_via_extension(self) -> bool: def bom_requires_modified(self) -> bool: return False + def component_supports_supplier(self) -> bool: + return True + def component_supports_author(self) -> bool: return True @@ -92,6 +95,12 @@ def pedigree_supports_patches(self) -> bool: def component_supports_external_references(self) -> bool: return True + def component_supports_properties(self) -> bool: + return True + + def component_supports_evidence(self) -> bool: + return True + def component_supports_release_notes(self) -> bool: return True @@ -174,6 +183,12 @@ def bom_supports_vulnerabilities_via_extension(self) -> bool: def component_supports_mime_type_attribute(self) -> bool: return False + def component_supports_properties(self) -> bool: + return False + + def component_supports_evidence(self) -> bool: + return False + def component_supports_release_notes(self) -> bool: return False @@ -220,12 +235,21 @@ def bom_supports_metadata(self) -> bool: def component_supports_mime_type_attribute(self) -> bool: return False + def component_supports_supplier(self) -> bool: + return False + def component_supports_author(self) -> bool: return False def component_supports_swid(self) -> bool: return False + def component_supports_properties(self) -> bool: + return False + + def component_supports_evidence(self) -> bool: + return False + def component_supports_release_notes(self) -> bool: return False @@ -281,6 +305,9 @@ def license_supports_expression(self) -> bool: def component_supports_mime_type_attribute(self) -> bool: return False + def component_supports_supplier(self) -> bool: + return False + def component_supports_swid(self) -> bool: return False @@ -290,6 +317,12 @@ def component_supports_pedigree(self) -> bool: def component_supports_external_references(self) -> bool: return False + def component_supports_properties(self) -> bool: + return False + + def component_supports_evidence(self) -> bool: + return False + def component_supports_release_notes(self) -> bool: return False diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index bd6230a3..acc5a5fb 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -180,9 +180,20 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: component_element = ElementTree.Element('component', element_attributes) + # supplier + if self.component_supports_supplier() and component.supplier: + self._add_organizational_entity( + parent_element=component_element, organization=component.supplier, tag_name='supplier' + ) + + # author if self.component_supports_author() and component.author is not None: ElementTree.SubElement(component_element, 'author').text = component.author + # publisher + if component.publisher: + ElementTree.SubElement(component_element, 'publisher').text = component.publisher + # group if component.group: ElementTree.SubElement(component_element, 'group').text = component.group @@ -201,6 +212,14 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: else: ElementTree.SubElement(component_element, 'version').text = component.version + # description + if component.description: + ElementTree.SubElement(component_element, 'description').text = component.description + + # scope + if component.scope: + ElementTree.SubElement(component_element, 'scope').text = component.scope.value + # hashes if component.hashes: Xml._add_hashes_to_element(hashes=component.hashes, element=component_element) @@ -212,6 +231,10 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: if not license_output: component_element.remove(licenses_e) + # copyright + if component.copyright: + ElementTree.SubElement(component_element, 'copyright').text = component.copyright + # cpe if component.cpe: ElementTree.SubElement(component_element, 'cpe').text = component.cpe @@ -286,6 +309,29 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: if self.component_supports_external_references() and len(component.external_references) > 0: self._add_external_references_to_element(ext_refs=component.external_references, element=component_element) + # properties + if self.component_supports_properties() and component.properties: + Xml._add_properties_element(properties=component.properties, parent_element=component_element) + + # components + if component.components: + components_element = ElementTree.SubElement(component_element, 'components') + for nested_component in component.components: + components_element.append(self._add_component_element(component=nested_component)) + + # evidence + if self.component_supports_evidence() and component.evidence: + evidence_element = ElementTree.SubElement(component_element, 'evidence') + if component.evidence.licenses: + evidence_licenses_element = ElementTree.SubElement(evidence_element, 'licenses') + self._add_licenses_to_element( + licenses=component.evidence.licenses, parent_element=evidence_licenses_element + ) + if component.evidence.copyright: + evidence_copyrights_element = ElementTree.SubElement(evidence_element, 'copyright') + for evidence_copyright in component.evidence.copyright: + ElementTree.SubElement(evidence_copyrights_element, 'text').text = evidence_copyright.text + # releaseNotes if self.component_supports_release_notes() and component.release_notes: Xml._add_release_notes_element(release_notes=component.release_notes, parent_element=component_element) diff --git a/tests/data.py b/tests/data.py index 2177567a..cd0571fc 100644 --- a/tests/data.py +++ b/tests/data.py @@ -28,7 +28,7 @@ Property, Tool, XsUri from cyclonedx.model.bom import Bom from cyclonedx.model.component import Commit, Component, ComponentEvidence, ComponentType, Copyright, Patch, \ - PatchClassification, Pedigree, Swid + PatchClassification, Pedigree, Swid, ComponentScope from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource from cyclonedx.model.release_note import ReleaseNotes from cyclonedx.model.service import Service @@ -67,15 +67,25 @@ def get_bom_with_component_setuptools_with_release_notes() -> Bom: def get_bom_with_component_setuptools_complete() -> Bom: - component = get_component_setuptools_simple() + component = get_component_setuptools_simple(bom_ref=MOCK_UUID_6) + component.supplier = get_org_entity_1() + component.publisher = 'CycloneDX' + component.description = 'This component is awesome' + component.scope = ComponentScope.REQUIRED + component.copyright = 'Apache 2.0 baby!' component.cpe = 'cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*' component.swid = get_swid_1() component.pedigree = get_pedigree_1() + component.external_references.add( + get_external_reference_1() + ) + component.properties = get_properties_1() component.components.update([ get_component_setuptools_simple(), get_component_toml_with_hashes_with_references() ]) component.evidence = ComponentEvidence(copyright_=[Copyright(text='Commercial'), Copyright(text='Commercial 2')]) + component.release_notes = get_release_notes() return Bom(components=[component]) diff --git a/tests/fixtures/json/1.2/bom_external_references.json b/tests/fixtures/json/1.2/bom_external_references.json index 5043c2f3..78e0db56 100644 --- a/tests/fixtures/json/1.2/bom_external_references.json +++ b/tests/fixtures/json/1.2/bom_external_references.json @@ -14,7 +14,6 @@ } ] }, - "components": [], "externalReferences": [ { "url": "https://cyclonedx.org", diff --git a/tests/fixtures/json/1.2/bom_services_complex.json b/tests/fixtures/json/1.2/bom_services_complex.json index 693cf3dc..cbf93031 100644 --- a/tests/fixtures/json/1.2/bom_services_complex.json +++ b/tests/fixtures/json/1.2/bom_services_complex.json @@ -20,7 +20,6 @@ "version": "1.0.0" } }, - "components": [], "services": [ { "bom-ref": "my-specific-bom-ref-for-my-first-service", diff --git a/tests/fixtures/json/1.2/bom_services_nested.json b/tests/fixtures/json/1.2/bom_services_nested.json index 22032681..b1a8a0d2 100644 --- a/tests/fixtures/json/1.2/bom_services_nested.json +++ b/tests/fixtures/json/1.2/bom_services_nested.json @@ -1,8 +1,6 @@ { "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", "bomFormat": "CycloneDX", - "components": [ - ], "metadata": { "component": { "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", diff --git a/tests/fixtures/json/1.2/bom_services_simple.json b/tests/fixtures/json/1.2/bom_services_simple.json index 4695a368..696cafe4 100644 --- a/tests/fixtures/json/1.2/bom_services_simple.json +++ b/tests/fixtures/json/1.2/bom_services_simple.json @@ -20,7 +20,6 @@ "version": "1.0.0" } }, - "components": [], "services": [ { "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", diff --git a/tests/fixtures/json/1.2/bom_setuptools_complete.json b/tests/fixtures/json/1.2/bom_setuptools_complete.json index c0039feb..42288aa2 100644 --- a/tests/fixtures/json/1.2/bom_setuptools_complete.json +++ b/tests/fixtures/json/1.2/bom_setuptools_complete.json @@ -17,15 +17,36 @@ "components": [ { "type": "library", - "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "supplier": { + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ], + "contact": [ + { + "name": "Paul Horton", + "email": "paul.horton@owasp.org" + }, + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ] + }, "author": "Test Author", + "publisher": "CycloneDX", "name": "setuptools", "version": "50.3.2", + "description": "This component is awesome", + "scope": "required", "licenses": [ { "expression": "MIT License" } ], + "copyright": "Apache 2.0 baby!", "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", "swid": { @@ -97,13 +118,7 @@ { "type": "distribution", "url": "https://cyclonedx.org", - "comment": "No comment", - "hashes": [ - { - "alg": "SHA-256", - "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" - } - ] + "comment": "No comment" } ] } @@ -125,13 +140,7 @@ { "type": "distribution", "url": "https://cyclonedx.org", - "comment": "No comment", - "hashes": [ - { - "alg": "SHA-256", - "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" - } - ] + "comment": "No comment" } ] }, @@ -162,6 +171,13 @@ ], "notes": "Some notes here please" }, + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment" + } + ], "components": [ { "type": "library", @@ -192,27 +208,11 @@ { "type": "distribution", "url": "https://cyclonedx.org", - "comment": "No comment", - "hashes": [ - { - "alg": "SHA-256", - "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" - } - ] + "comment": "No comment" } ] } - ], - "evidence": { - "copyright": [ - { - "text": "Commercial" - }, - { - "text": "Commercial 2" - } - ] - } + ] } ] } \ No newline at end of file diff --git a/tests/fixtures/json/1.2/bom_toml_1.json b/tests/fixtures/json/1.2/bom_toml_1.json index 9cd6ac54..0e07e956 100644 --- a/tests/fixtures/json/1.2/bom_toml_1.json +++ b/tests/fixtures/json/1.2/bom_toml_1.json @@ -31,13 +31,7 @@ { "type": "distribution", "url": "https://cyclonedx.org", - "comment": "No comment", - "hashes": [ - { - "alg": "SHA-256", - "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" - } - ] + "comment": "No comment" } ] } diff --git a/tests/fixtures/json/1.2/bom_with_full_metadata.json b/tests/fixtures/json/1.2/bom_with_full_metadata.json index 76b1dd11..d479798b 100644 --- a/tests/fixtures/json/1.2/bom_with_full_metadata.json +++ b/tests/fixtures/json/1.2/bom_with_full_metadata.json @@ -60,6 +60,5 @@ } ] } - }, - "components": [] + } } \ No newline at end of file diff --git a/tests/fixtures/json/1.3/bom_external_references.json b/tests/fixtures/json/1.3/bom_external_references.json index 5e9246e7..9ce40c64 100644 --- a/tests/fixtures/json/1.3/bom_external_references.json +++ b/tests/fixtures/json/1.3/bom_external_references.json @@ -14,7 +14,6 @@ } ] }, - "components": [], "externalReferences": [ { "url": "https://cyclonedx.org", diff --git a/tests/fixtures/json/1.3/bom_services_complex.json b/tests/fixtures/json/1.3/bom_services_complex.json index ad262fdf..24d0ca06 100644 --- a/tests/fixtures/json/1.3/bom_services_complex.json +++ b/tests/fixtures/json/1.3/bom_services_complex.json @@ -20,7 +20,6 @@ "version": "1.0.0" } }, - "components": [], "services": [ { "bom-ref": "my-specific-bom-ref-for-my-first-service", diff --git a/tests/fixtures/json/1.3/bom_services_nested.json b/tests/fixtures/json/1.3/bom_services_nested.json index 106b5255..23c0bd1d 100644 --- a/tests/fixtures/json/1.3/bom_services_nested.json +++ b/tests/fixtures/json/1.3/bom_services_nested.json @@ -1,8 +1,6 @@ { "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", "bomFormat": "CycloneDX", - "components": [ - ], "metadata": { "component": { "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", diff --git a/tests/fixtures/json/1.3/bom_services_simple.json b/tests/fixtures/json/1.3/bom_services_simple.json index 4e26ad37..a16b1ed9 100644 --- a/tests/fixtures/json/1.3/bom_services_simple.json +++ b/tests/fixtures/json/1.3/bom_services_simple.json @@ -20,7 +20,6 @@ "version": "1.0.0" } }, - "components": [], "services": [ { "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", diff --git a/tests/fixtures/json/1.3/bom_setuptools_complete.json b/tests/fixtures/json/1.3/bom_setuptools_complete.json index b82097ae..813cd334 100644 --- a/tests/fixtures/json/1.3/bom_setuptools_complete.json +++ b/tests/fixtures/json/1.3/bom_setuptools_complete.json @@ -17,15 +17,36 @@ "components": [ { "type": "library", - "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "supplier": { + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ], + "contact": [ + { + "name": "Paul Horton", + "email": "paul.horton@owasp.org" + }, + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ] + }, "author": "Test Author", + "publisher": "CycloneDX", "name": "setuptools", "version": "50.3.2", + "description": "This component is awesome", + "scope": "required", "licenses": [ { "expression": "MIT License" } ], + "copyright": "Apache 2.0 baby!", "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", "swid": { @@ -162,6 +183,19 @@ ], "notes": "Some notes here please" }, + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ], "components": [ { "type": "library", @@ -212,7 +246,17 @@ "text": "Commercial 2" } ] - } + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ] } ] } \ No newline at end of file diff --git a/tests/fixtures/json/1.3/bom_with_full_metadata.json b/tests/fixtures/json/1.3/bom_with_full_metadata.json index ec87032e..21baef21 100644 --- a/tests/fixtures/json/1.3/bom_with_full_metadata.json +++ b/tests/fixtures/json/1.3/bom_with_full_metadata.json @@ -75,6 +75,5 @@ "value": "val2" } ] - }, - "components": [] + } } \ No newline at end of file diff --git a/tests/fixtures/json/1.4/bom_external_references.json b/tests/fixtures/json/1.4/bom_external_references.json index a054cd86..84d69448 100644 --- a/tests/fixtures/json/1.4/bom_external_references.json +++ b/tests/fixtures/json/1.4/bom_external_references.json @@ -48,7 +48,6 @@ } ] }, - "components": [], "externalReferences": [ { "url": "https://cyclonedx.org", diff --git a/tests/fixtures/json/1.4/bom_services_complex.json b/tests/fixtures/json/1.4/bom_services_complex.json index c6747a6d..78784f48 100644 --- a/tests/fixtures/json/1.4/bom_services_complex.json +++ b/tests/fixtures/json/1.4/bom_services_complex.json @@ -54,7 +54,6 @@ "version": "1.0.0" } }, - "components": [], "services": [ { "bom-ref": "my-specific-bom-ref-for-my-first-service", diff --git a/tests/fixtures/json/1.4/bom_services_nested.json b/tests/fixtures/json/1.4/bom_services_nested.json index 20c7ee12..93705699 100644 --- a/tests/fixtures/json/1.4/bom_services_nested.json +++ b/tests/fixtures/json/1.4/bom_services_nested.json @@ -1,8 +1,6 @@ { "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", "bomFormat": "CycloneDX", - "components": [ - ], "metadata": { "component": { "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", diff --git a/tests/fixtures/json/1.4/bom_services_simple.json b/tests/fixtures/json/1.4/bom_services_simple.json index 37ce9475..ba87360d 100644 --- a/tests/fixtures/json/1.4/bom_services_simple.json +++ b/tests/fixtures/json/1.4/bom_services_simple.json @@ -54,7 +54,6 @@ "version": "1.0.0" } }, - "components": [], "services": [ { "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", diff --git a/tests/fixtures/json/1.4/bom_setuptools_complete.json b/tests/fixtures/json/1.4/bom_setuptools_complete.json index ae228504..b224cee0 100644 --- a/tests/fixtures/json/1.4/bom_setuptools_complete.json +++ b/tests/fixtures/json/1.4/bom_setuptools_complete.json @@ -51,15 +51,36 @@ "components": [ { "type": "library", - "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "supplier": { + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org" + ], + "contact": [ + { + "name": "Paul Horton", + "email": "paul.horton@owasp.org" + }, + { + "name": "A N Other", + "email": "someone@somewhere.tld", + "phone": "+44 (0)1234 567890" + } + ] + }, "author": "Test Author", + "publisher": "CycloneDX", "name": "setuptools", "version": "50.3.2", + "description": "This component is awesome", + "scope": "required", "licenses": [ { "expression": "MIT License" } ], + "copyright": "Apache 2.0 baby!", "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", "swid": { @@ -92,7 +113,6 @@ "bom-ref": "8a3893b3-9923-4adb-a1d3-47456636ba0a", "author": "Test Author", "name": "setuptools", - "version": "", "licenses": [ { "expression": "MIT License" @@ -107,7 +127,6 @@ "bom-ref": "28b2d8ce-def0-446f-a221-58dee0b44acc", "author": "Test Author", "name": "setuptools", - "version": "", "licenses": [ { "expression": "MIT License" @@ -196,6 +215,19 @@ ], "notes": "Some notes here please" }, + "externalReferences": [ + { + "type": "distribution", + "url": "https://cyclonedx.org", + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ] + } + ], "components": [ { "type": "library", @@ -246,7 +278,76 @@ "text": "Commercial 2" } ] - } + }, + "releaseNotes": { + "type": "major", + "title": "Release Notes Title", + "featuredImage": "https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png", + "socialImage": "https://cyclonedx.org/cyclonedx-icon.png", + "description": "This release is a test release", + "timestamp": "2021-12-31T10:00:00+00:00", + "aliases": [ + "First Test Release" + ], + "tags": [ + "test", + "alpha" + ], + "resolves": [ + { + "type": "security", + "id": "CVE-2021-44228", + "name": "Apache Log3Shell", + "description": "Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features...", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-44228" + }, + "references": [ + "https://logging.apache.org/log4j/2.x/security.html", + "https://central.sonatype.org/news/20211213_log4shell_help" + ] + } + ], + "notes": [ + { + "locale": "en-GB", + "text": { + "contentType": "text/plain; charset=UTF-8", + "encoding": "base64", + "content": "U29tZSBzaW1wbGUgcGxhaW4gdGV4dA==" + } + }, + { + "locale": "en-US", + "text": { + "contentType": "text/plain; charset=UTF-8", + "encoding": "base64", + "content": "U29tZSBzaW1wbGUgcGxhaW4gdGV4dA==" + } + } + ], + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ] + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ] } ] } \ No newline at end of file diff --git a/tests/fixtures/json/1.4/bom_with_full_metadata.json b/tests/fixtures/json/1.4/bom_with_full_metadata.json index 8588cd52..230f4f26 100644 --- a/tests/fixtures/json/1.4/bom_with_full_metadata.json +++ b/tests/fixtures/json/1.4/bom_with_full_metadata.json @@ -109,6 +109,5 @@ "value": "val2" } ] - }, - "components": [] + } } \ No newline at end of file diff --git a/tests/fixtures/xml/1.0/bom_setuptools_complete.xml b/tests/fixtures/xml/1.0/bom_setuptools_complete.xml index 3c617136..32a3dafb 100644 --- a/tests/fixtures/xml/1.0/bom_setuptools_complete.xml +++ b/tests/fixtures/xml/1.0/bom_setuptools_complete.xml @@ -2,11 +2,32 @@ + CycloneDX setuptools 50.3.2 + This component is awesome + required + Apache 2.0 baby! cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* pkg:pypi/setuptools@50.3.2?extension=tar.gz false + + + setuptools + 50.3.2 + pkg:pypi/setuptools@50.3.2?extension=tar.gz + false + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + false + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.1/bom_setuptools_complete.xml b/tests/fixtures/xml/1.1/bom_setuptools_complete.xml index a7aee096..0a116a29 100644 --- a/tests/fixtures/xml/1.1/bom_setuptools_complete.xml +++ b/tests/fixtures/xml/1.1/bom_setuptools_complete.xml @@ -1,12 +1,16 @@ - + + CycloneDX setuptools 50.3.2 + This component is awesome + required MIT License + Apache 2.0 baby! cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* pkg:pypi/setuptools@50.3.2?extension=tar.gz @@ -21,7 +25,7 @@ setuptools - + MIT License @@ -31,7 +35,7 @@ setuptools - + MIT License @@ -84,6 +88,36 @@ Some notes here please + + + https://cyclonedx.org + No comment + + + + + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.2/bom_setuptools_complete.xml b/tests/fixtures/xml/1.2/bom_setuptools_complete.xml index 4cb2b93e..195ffd0f 100644 --- a/tests/fixtures/xml/1.2/bom_setuptools_complete.xml +++ b/tests/fixtures/xml/1.2/bom_setuptools_complete.xml @@ -11,13 +11,30 @@ - + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + Test Author + CycloneDX setuptools 50.3.2 + This component is awesome + required MIT License + Apache 2.0 baby! cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* pkg:pypi/setuptools@50.3.2?extension=tar.gz @@ -37,7 +54,7 @@ Test Author setuptools - + MIT License @@ -48,7 +65,7 @@ Test Author setuptools - + MIT License @@ -101,10 +118,41 @@ - + Some notes here please + + + https://cyclonedx.org + No comment + + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.3/bom_setuptools_complete.xml b/tests/fixtures/xml/1.3/bom_setuptools_complete.xml index 675ddf93..0a2a149c 100644 --- a/tests/fixtures/xml/1.3/bom_setuptools_complete.xml +++ b/tests/fixtures/xml/1.3/bom_setuptools_complete.xml @@ -11,13 +11,30 @@ - + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + Test Author + CycloneDX setuptools 50.3.2 + This component is awesome + required MIT License + Apache 2.0 baby! cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* pkg:pypi/setuptools@50.3.2?extension=tar.gz @@ -37,7 +54,7 @@ Test Author setuptools - + MIT License @@ -48,7 +65,7 @@ Test Author setuptools - + MIT License @@ -107,10 +124,57 @@ - + Some notes here please + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + val1 + val2 + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + + + + Commercial + Commercial 2 + + \ No newline at end of file diff --git a/tests/fixtures/xml/1.4/bom_setuptools_complete.xml b/tests/fixtures/xml/1.4/bom_setuptools_complete.xml index 4136bbf1..39968cf8 100644 --- a/tests/fixtures/xml/1.4/bom_setuptools_complete.xml +++ b/tests/fixtures/xml/1.4/bom_setuptools_complete.xml @@ -37,13 +37,30 @@ - + + + CycloneDX + https://cyclonedx.org + + Paul Horton + paul.horton@owasp.org + + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + Test Author + CycloneDX setuptools 50.3.2 + This component is awesome + required MIT License + Apache 2.0 baby! cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* pkg:pypi/setuptools@50.3.2?extension=tar.gz @@ -131,10 +148,101 @@ - + Some notes here please + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + val1 + val2 + + + + Test Author + setuptools + 50.3.2 + + MIT License + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + toml + 0.10.2 + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + pkg:pypi/toml@0.10.2?extension=tar.gz + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + + + + Commercial + Commercial 2 + + + + major + Release Notes Title + https://cyclonedx.org/theme/assets/images/CycloneDX-Twitter-Card.png + https://cyclonedx.org/cyclonedx-icon.png + This release is a test release + 2021-12-31T10:00:00+00:00 + + First Test Release + + + test + alpha + + + + CVE-2021-44228 + Apache Log3Shell + Apache Log4j2 2.0-beta9 through 2.12.1 and 2.13.0 through 2.15.0 JNDI features... + + NVD + https://nvd.nist.gov/vuln/detail/CVE-2021-44228 + + + https://logging.apache.org/log4j/2.x/security.html + https://central.sonatype.org/news/20211213_log4shell_help + + + + + + en-GB + U29tZSBzaW1wbGUgcGxhaW4gdGV4dA== + + + en-US + U29tZSBzaW1wbGUgcGxhaW4gdGV4dA== + + + + val1 + val2 + + \ No newline at end of file From 020fcf03ef3985dac82a38b8810d6d6cd301809c Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Wed, 16 Feb 2022 13:20:40 +0000 Subject: [PATCH 23/29] BREAKING CHANGE: replaced concept of default schema version with latest supported #171 (#173) Signed-off-by: Paul Horton --- cyclonedx/output/__init__.py | 4 ++-- docs/outputting.rst | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cyclonedx/output/__init__.py b/cyclonedx/output/__init__.py index 64265d97..050ee622 100644 --- a/cyclonedx/output/__init__.py +++ b/cyclonedx/output/__init__.py @@ -50,7 +50,7 @@ def to_version(self) -> str: return f'{self.value[1]}.{self.value[5]}' -DEFAULT_SCHEMA_VERSION = SchemaVersion.V1_4 +LATEST_SUPPORTED_SCHEMA_VERSION = SchemaVersion.V1_4 class BaseOutput(ABC): @@ -105,7 +105,7 @@ def output_to_file(self, filename: str, allow_overwrite: bool = False) -> None: def get_instance(bom: Bom, output_format: OutputFormat = OutputFormat.XML, - schema_version: SchemaVersion = DEFAULT_SCHEMA_VERSION) -> BaseOutput: + schema_version: SchemaVersion = LATEST_SUPPORTED_SCHEMA_VERSION) -> BaseOutput: """ Helper method to quickly get the correct output class/formatter. diff --git a/docs/outputting.rst b/docs/outputting.rst index 2b9d7028..50e800d1 100644 --- a/docs/outputting.rst +++ b/docs/outputting.rst @@ -23,7 +23,7 @@ We provide two helper methods: * Output to string (for you to do with as you require) * Output directly to a filename you provide -The default output will be in XML at Schema Version 1.4. +By default output will be in XML at latest supported schema version - see :py:mod:`cyclonedx.output.LATEST_SUPPORTED_SCHEMA_VERSION`. Supported CycloneDX Schema Versions ----------------------------------- @@ -34,12 +34,12 @@ This library supports the following schema versions: * 1.1 (XML) - `(note, 1.1 schema version has no support for JSON)` * 1.2 (XML, JSON) * 1.3 (XML, JSON) -* 1.4 (XML, JSON) +* 1.4 (XML, JSON) - the latest supported schema version Outputting to JSON ------------------ -The below example relies on the default schema version being 1.4, but sets the output format to JSON. Output is returned +The below example relies on the latest schema version, but sets the output format to JSON. Output is returned as a ``str``. .. code-block:: python From d189f2c16870deb683e62cd06a6072b008eab05d Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 17 Feb 2022 11:41:37 +0000 Subject: [PATCH 24/29] BREAKING CHANGE: added new model `BomRef` unlocking logic later to ensure uniquness and dependency references (#174) Signed-off-by: Paul Horton --- cyclonedx/model/bom_ref.py | 53 +++++++++ cyclonedx/model/component.py | 12 +- cyclonedx/model/service.py | 12 +- cyclonedx/output/serializer/json.py | 10 +- cyclonedx/output/xml.py | 9 +- tests/data.py | 4 + .../json/1.2/bom_services_complex.json | 4 +- .../json/1.2/bom_services_nested.json | 8 +- .../json/1.2/bom_services_simple.json | 6 +- .../json/1.3/bom_services_complex.json | 4 +- .../json/1.3/bom_services_nested.json | 8 +- .../json/1.3/bom_services_simple.json | 6 +- .../json/1.4/bom_services_complex.json | 4 +- .../json/1.4/bom_services_nested.json | 6 +- .../json/1.4/bom_services_simple.json | 6 +- .../fixtures/xml/1.2/bom_services_complex.xml | 2 +- .../fixtures/xml/1.2/bom_services_simple.xml | 2 +- .../fixtures/xml/1.3/bom_services_complex.xml | 2 +- .../fixtures/xml/1.3/bom_services_nested.xml | 2 +- .../fixtures/xml/1.3/bom_services_simple.xml | 2 +- .../fixtures/xml/1.4/bom_services_complex.xml | 2 +- .../fixtures/xml/1.4/bom_services_nested.xml | 2 +- .../fixtures/xml/1.4/bom_services_simple.xml | 2 +- tests/test_model_component.py | 6 +- tests/test_model_service.py | 8 +- tests/test_output_json.py | 83 +++++-------- tests/test_output_xml.py | 112 +++++++----------- 27 files changed, 192 insertions(+), 185 deletions(-) create mode 100644 cyclonedx/model/bom_ref.py diff --git a/cyclonedx/model/bom_ref.py b/cyclonedx/model/bom_ref.py new file mode 100644 index 00000000..69bd9f31 --- /dev/null +++ b/cyclonedx/model/bom_ref.py @@ -0,0 +1,53 @@ +# encoding: utf-8 + +# This file is part of CycloneDX Python Lib +# +# 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 Optional +from uuid import uuid4 + + +class BomRef: + """ + An identifier that can be used to reference objects elsewhere in the BOM. + + This copies a similar pattern used in the CycloneDX Python Library. + + .. note:: + See https://github.com/CycloneDX/cyclonedx-php-library/blob/master/docs/dev/decisions/BomDependencyDataModel.md + """ + + def __init__(self, value: Optional[str] = None) -> None: + self.value = value or str(uuid4()) + + @property + def value(self) -> str: + return self._value + + @value.setter + def value(self, value: str) -> None: + self._value = value + + def __eq__(self, other: object) -> bool: + if isinstance(other, BomRef): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(self.value) + + def __repr__(self) -> str: + return self.value diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index c8151746..8d07db45 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -20,13 +20,13 @@ from enum import Enum from os.path import exists from typing import Iterable, Optional, Set -from uuid import uuid4 # See https://github.com/package-url/packageurl-python/issues/65 from packageurl import PackageURL # type: ignore from . import AttachedText, Copyright, ExternalReference, HashAlgorithm, HashType, IdentifiableAction, LicenseChoice, \ OrganizationalEntity, Property, sha1sum, XsUri +from .bom_ref import BomRef from .issue import IssueType from .release_note import ReleaseNotes from .vulnerability import Vulnerability @@ -692,7 +692,7 @@ def __init__(self, *, name: str, component_type: ComponentType = ComponentType.L ) -> None: self.type = component_type self.mime_type = mime_type - self.bom_ref = bom_ref or str(uuid4()) + self._bom_ref = BomRef(value=bom_ref) self.supplier = supplier self.author = author self.publisher = publisher @@ -766,7 +766,7 @@ def mime_type(self, mime_type: Optional[str]) -> None: self._mime_type = mime_type @property - def bom_ref(self) -> str: + 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. @@ -774,14 +774,10 @@ def bom_ref(self) -> str: If a value was not provided in the constructor, a UUIDv4 will have been assigned. Returns: - `str` as a unique identifiers for this Component + `BomRef` """ return self._bom_ref - @bom_ref.setter - def bom_ref(self, bom_ref: str) -> None: - self._bom_ref = bom_ref - @property def supplier(self) -> Optional[OrganizationalEntity]: """ diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py index e695ea9c..5f387973 100644 --- a/cyclonedx/model/service.py +++ b/cyclonedx/model/service.py @@ -15,9 +15,9 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. from typing import Iterable, Optional, Set -from uuid import uuid4 from . import ExternalReference, DataClassification, LicenseChoice, OrganizationalEntity, Property, XsUri +from .bom_ref import BomRef from .release_note import ReleaseNotes """ @@ -46,7 +46,7 @@ def __init__(self, *, name: str, bom_ref: Optional[str] = None, provider: Option services: Optional[Iterable['Service']] = None, release_notes: Optional[ReleaseNotes] = None, ) -> None: - self.bom_ref = bom_ref or str(uuid4()) + self._bom_ref = BomRef(value=bom_ref) self.provider = provider self.group = group self.name = name @@ -63,7 +63,7 @@ def __init__(self, *, name: str, bom_ref: Optional[str] = None, provider: Option self.properties = set(properties or []) @property - def bom_ref(self) -> Optional[str]: + def bom_ref(self) -> BomRef: """ An optional identifier which can be used to reference the service elsewhere in the BOM. Uniqueness is enforced within all elements and children of the root-level bom element. @@ -71,14 +71,10 @@ def bom_ref(self) -> Optional[str]: If a value was not provided in the constructor, a UUIDv4 will have been assigned. Returns: - `str` unique identifier for this Service + `BomRef` unique identifier for this Service """ return self._bom_ref - @bom_ref.setter - def bom_ref(self, bom_ref: Optional[str]) -> None: - self._bom_ref = bom_ref - @property def provider(self) -> Optional[OrganizationalEntity]: """ diff --git a/cyclonedx/output/serializer/json.py b/cyclonedx/output/serializer/json.py index 858e536b..3598c8ec 100644 --- a/cyclonedx/output/serializer/json.py +++ b/cyclonedx/output/serializer/json.py @@ -16,7 +16,6 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - from datetime import datetime from decimal import Decimal from enum import Enum @@ -28,8 +27,9 @@ # See https://github.com/package-url/packageurl-python/issues/65 from packageurl import PackageURL # type: ignore -from cyclonedx.model import XsUri -from cyclonedx.model.component import Component +from ...model import XsUri +from ...model.bom_ref import BomRef +from ...model.component import Component HYPHENATED_ATTRIBUTES = [ 'bom_ref', 'mime_type', 'x_trust_boundary' @@ -40,6 +40,10 @@ class CycloneDxJSONEncoder(JSONEncoder): def default(self, o: Any) -> Any: + # BomRef + if isinstance(o, BomRef): + return str(o) + # datetime if isinstance(o, datetime): return o.isoformat() diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index acc5a5fb..94ad2043 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -27,6 +27,7 @@ from ..model import AttachedText, ExternalReference, HashType, IdentifiableAction, LicenseChoice, \ OrganizationalEntity, OrganizationalContact, Property, Tool from ..model.bom import Bom +from ..model.bom_ref import BomRef from ..model.component import Component, Patch from ..model.release_note import ReleaseNotes from ..model.service import Service @@ -174,7 +175,7 @@ def _add_metadata_element(self) -> None: def _add_component_element(self, component: Component) -> ElementTree.Element: element_attributes = {'type': component.type.value} if self.component_supports_bom_ref_attribute() and component.bom_ref: - element_attributes['bom-ref'] = component.bom_ref + element_attributes['bom-ref'] = str(component.bom_ref) if self.component_supports_mime_type_attribute() and component.mime_type: element_attributes['mime-type'] = component.mime_type @@ -450,7 +451,7 @@ def _add_properties_element(properties: Set[Property], parent_element: ElementTr def _add_service_element(self, service: Service) -> ElementTree.Element: element_attributes = {} if service.bom_ref: - element_attributes['bom-ref'] = service.bom_ref + element_attributes['bom-ref'] = str(service.bom_ref) service_element = ElementTree.Element('service', element_attributes) @@ -654,10 +655,10 @@ def _get_vulnerability_as_xml_element_post_1_4(self, vulnerability: Vulnerabilit return vulnerability_element @staticmethod - def _get_vulnerability_as_xml_element_pre_1_3(bom_ref: str, + def _get_vulnerability_as_xml_element_pre_1_3(bom_ref: BomRef, vulnerability: Vulnerability) -> ElementTree.Element: vulnerability_element = ElementTree.Element('v:vulnerability', { - 'ref': bom_ref + 'ref': str(bom_ref) }) # id diff --git a/tests/data.py b/tests/data.py index cd0571fc..2e5fc805 100644 --- a/tests/data.py +++ b/tests/data.py @@ -45,6 +45,10 @@ MOCK_UUID_5 = 'bb5911d6-1a1d-41c9-b6e0-46e848d16655' MOCK_UUID_6 = 'df70b5f1-8f53-47a4-be48-669ae78795e6' +TEST_UUIDS = [ + MOCK_UUID_1, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6 +] + def get_bom_with_component_setuptools_basic() -> Bom: return Bom(components=[get_component_setuptools_simple()]) diff --git a/tests/fixtures/json/1.2/bom_services_complex.json b/tests/fixtures/json/1.2/bom_services_complex.json index cbf93031..8cc31cd2 100644 --- a/tests/fixtures/json/1.2/bom_services_complex.json +++ b/tests/fixtures/json/1.2/bom_services_complex.json @@ -15,7 +15,7 @@ ], "component": { "type": "library", - "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "name": "cyclonedx-python-lib", "version": "1.0.0" } @@ -76,7 +76,7 @@ ] }, { - "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "my-second-service" } ] diff --git a/tests/fixtures/json/1.2/bom_services_nested.json b/tests/fixtures/json/1.2/bom_services_nested.json index b1a8a0d2..46de1b3b 100644 --- a/tests/fixtures/json/1.2/bom_services_nested.json +++ b/tests/fixtures/json/1.2/bom_services_nested.json @@ -3,7 +3,7 @@ "bomFormat": "CycloneDX", "metadata": { "component": { - "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", "name": "cyclonedx-python-lib", "type": "library", "version": "1.0.0" @@ -72,7 +72,7 @@ }, "services": [ { - "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "first-nested-service" }, { @@ -105,11 +105,11 @@ "x-trust-boundary": true }, { - "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "name": "my-second-service", "services": [ { - "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "group": "what-group", "name": "yet-another-nested-service", "provider": { diff --git a/tests/fixtures/json/1.2/bom_services_simple.json b/tests/fixtures/json/1.2/bom_services_simple.json index 696cafe4..db950b0e 100644 --- a/tests/fixtures/json/1.2/bom_services_simple.json +++ b/tests/fixtures/json/1.2/bom_services_simple.json @@ -15,18 +15,18 @@ ], "component": { "type": "library", - "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "name": "cyclonedx-python-lib", "version": "1.0.0" } }, "services": [ { - "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "my-first-service" }, { - "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "name": "my-second-service" } ] diff --git a/tests/fixtures/json/1.3/bom_services_complex.json b/tests/fixtures/json/1.3/bom_services_complex.json index 24d0ca06..64460386 100644 --- a/tests/fixtures/json/1.3/bom_services_complex.json +++ b/tests/fixtures/json/1.3/bom_services_complex.json @@ -15,7 +15,7 @@ ], "component": { "type": "library", - "bom-ref": "bb5911d6-1a1d-41c9-b6e0-46e848d16655", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "name": "cyclonedx-python-lib", "version": "1.0.0" } @@ -86,7 +86,7 @@ ] }, { - "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "my-second-service" } ] diff --git a/tests/fixtures/json/1.3/bom_services_nested.json b/tests/fixtures/json/1.3/bom_services_nested.json index 23c0bd1d..216e52e0 100644 --- a/tests/fixtures/json/1.3/bom_services_nested.json +++ b/tests/fixtures/json/1.3/bom_services_nested.json @@ -3,7 +3,7 @@ "bomFormat": "CycloneDX", "metadata": { "component": { - "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", "name": "cyclonedx-python-lib", "type": "library", "version": "1.0.0" @@ -82,7 +82,7 @@ }, "services": [ { - "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "first-nested-service" }, { @@ -115,11 +115,11 @@ "x-trust-boundary": true }, { - "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "name": "my-second-service", "services": [ { - "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "group": "what-group", "name": "yet-another-nested-service", "provider": { diff --git a/tests/fixtures/json/1.3/bom_services_simple.json b/tests/fixtures/json/1.3/bom_services_simple.json index a16b1ed9..31bb109b 100644 --- a/tests/fixtures/json/1.3/bom_services_simple.json +++ b/tests/fixtures/json/1.3/bom_services_simple.json @@ -15,18 +15,18 @@ ], "component": { "type": "library", - "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "name": "cyclonedx-python-lib", "version": "1.0.0" } }, "services": [ { - "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "my-first-service" }, { - "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "name": "my-second-service" } ] diff --git a/tests/fixtures/json/1.4/bom_services_complex.json b/tests/fixtures/json/1.4/bom_services_complex.json index 78784f48..656a8bbf 100644 --- a/tests/fixtures/json/1.4/bom_services_complex.json +++ b/tests/fixtures/json/1.4/bom_services_complex.json @@ -49,7 +49,7 @@ ], "component": { "type": "library", - "bom-ref": "df70b5f1-8f53-47a4-be48-669ae78795e6", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "name": "cyclonedx-python-lib", "version": "1.0.0" } @@ -179,7 +179,7 @@ ] }, { - "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "my-second-service" } ] diff --git a/tests/fixtures/json/1.4/bom_services_nested.json b/tests/fixtures/json/1.4/bom_services_nested.json index 93705699..e0e01221 100644 --- a/tests/fixtures/json/1.4/bom_services_nested.json +++ b/tests/fixtures/json/1.4/bom_services_nested.json @@ -3,7 +3,7 @@ "bomFormat": "CycloneDX", "metadata": { "component": { - "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", + "bom-ref": "cd3e9c95-9d41-49e7-9924-8cf0465ae789", "name": "cyclonedx-python-lib", "type": "library", "version": "1.0.0" @@ -175,7 +175,7 @@ }, "services": [ { - "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "first-nested-service" }, { @@ -208,7 +208,7 @@ "x-trust-boundary": true }, { - "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "name": "my-second-service", "services": [ { diff --git a/tests/fixtures/json/1.4/bom_services_simple.json b/tests/fixtures/json/1.4/bom_services_simple.json index ba87360d..c23f4c63 100644 --- a/tests/fixtures/json/1.4/bom_services_simple.json +++ b/tests/fixtures/json/1.4/bom_services_simple.json @@ -49,18 +49,18 @@ ], "component": { "type": "library", - "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", + "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", "name": "cyclonedx-python-lib", "version": "1.0.0" } }, "services": [ { - "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "bom-ref": "be2c6502-7e9a-47db-9a66-e34f729810a3", "name": "my-first-service" }, { - "bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857", + "bom-ref": "17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda", "name": "my-second-service" } ] diff --git a/tests/fixtures/xml/1.2/bom_services_complex.xml b/tests/fixtures/xml/1.2/bom_services_complex.xml index f330ee7a..1dd5f92d 100644 --- a/tests/fixtures/xml/1.2/bom_services_complex.xml +++ b/tests/fixtures/xml/1.2/bom_services_complex.xml @@ -9,7 +9,7 @@ VERSION - + cyclonedx-python-lib 1.0.0 diff --git a/tests/fixtures/xml/1.2/bom_services_simple.xml b/tests/fixtures/xml/1.2/bom_services_simple.xml index e02ea1b7..a6cf63c2 100644 --- a/tests/fixtures/xml/1.2/bom_services_simple.xml +++ b/tests/fixtures/xml/1.2/bom_services_simple.xml @@ -9,7 +9,7 @@ VERSION - + cyclonedx-python-lib 1.0.0 diff --git a/tests/fixtures/xml/1.3/bom_services_complex.xml b/tests/fixtures/xml/1.3/bom_services_complex.xml index 56e2cce9..8d2f93c0 100644 --- a/tests/fixtures/xml/1.3/bom_services_complex.xml +++ b/tests/fixtures/xml/1.3/bom_services_complex.xml @@ -9,7 +9,7 @@ VERSION - + cyclonedx-python-lib 1.0.0 diff --git a/tests/fixtures/xml/1.3/bom_services_nested.xml b/tests/fixtures/xml/1.3/bom_services_nested.xml index df05c422..4ebb2152 100644 --- a/tests/fixtures/xml/1.3/bom_services_nested.xml +++ b/tests/fixtures/xml/1.3/bom_services_nested.xml @@ -9,7 +9,7 @@ VERSION - + cyclonedx-python-lib 1.0.0 diff --git a/tests/fixtures/xml/1.3/bom_services_simple.xml b/tests/fixtures/xml/1.3/bom_services_simple.xml index c20a7c55..d3feae46 100644 --- a/tests/fixtures/xml/1.3/bom_services_simple.xml +++ b/tests/fixtures/xml/1.3/bom_services_simple.xml @@ -9,7 +9,7 @@ VERSION - + cyclonedx-python-lib 1.0.0 diff --git a/tests/fixtures/xml/1.4/bom_services_complex.xml b/tests/fixtures/xml/1.4/bom_services_complex.xml index e8eb750c..d7187c56 100644 --- a/tests/fixtures/xml/1.4/bom_services_complex.xml +++ b/tests/fixtures/xml/1.4/bom_services_complex.xml @@ -35,7 +35,7 @@ - + cyclonedx-python-lib 1.0.0 diff --git a/tests/fixtures/xml/1.4/bom_services_nested.xml b/tests/fixtures/xml/1.4/bom_services_nested.xml index 0312b4d8..2a3348ef 100644 --- a/tests/fixtures/xml/1.4/bom_services_nested.xml +++ b/tests/fixtures/xml/1.4/bom_services_nested.xml @@ -35,7 +35,7 @@ - + cyclonedx-python-lib 1.0.0 diff --git a/tests/fixtures/xml/1.4/bom_services_simple.xml b/tests/fixtures/xml/1.4/bom_services_simple.xml index 1dea0823..8eb59474 100644 --- a/tests/fixtures/xml/1.4/bom_services_simple.xml +++ b/tests/fixtures/xml/1.4/bom_services_simple.xml @@ -35,7 +35,7 @@ - + cyclonedx-python-lib 1.0.0 diff --git a/tests/test_model_component.py b/tests/test_model_component.py index b1c05927..4ae89060 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -54,7 +54,7 @@ def test_not_same(self) -> None: class TestModelComponent(TestCase): - @patch('cyclonedx.model.component.uuid4', return_value='6f266d1c-760f-4552-ae3b-41a9b74232fa') + @patch('cyclonedx.model.bom_ref.uuid4', return_value='6f266d1c-760f-4552-ae3b-41a9b74232fa') def test_empty_basic_component(self, mock_uuid: Mock) -> None: c = Component( name='test-component', version='1.2.3' @@ -63,7 +63,7 @@ def test_empty_basic_component(self, mock_uuid: Mock) -> None: self.assertEqual(c.name, 'test-component') self.assertEqual(c.type, ComponentType.LIBRARY) self.assertIsNone(c.mime_type) - self.assertEqual(c.bom_ref, '6f266d1c-760f-4552-ae3b-41a9b74232fa') + self.assertEqual(str(c.bom_ref), '6f266d1c-760f-4552-ae3b-41a9b74232fa') self.assertIsNone(c.supplier) self.assertIsNone(c.author) self.assertIsNone(c.publisher) @@ -81,7 +81,7 @@ def test_empty_basic_component(self, mock_uuid: Mock) -> None: self.assertEqual(len(c.get_vulnerabilities()), 0) - @patch('cyclonedx.model.component.uuid4', return_value='6f266d1c-760f-4552-ae3b-41a9b74232fa') + @patch('cyclonedx.model.bom_ref.uuid4', return_value='6f266d1c-760f-4552-ae3b-41a9b74232fa') def test_multiple_basic_components(self, mock_uuid: Mock) -> None: c1 = Component( name='test-component', version='1.2.3' diff --git a/tests/test_model_service.py b/tests/test_model_service.py index 12f589cf..555bcad3 100644 --- a/tests/test_model_service.py +++ b/tests/test_model_service.py @@ -24,12 +24,12 @@ class TestModelService(TestCase): - @patch('cyclonedx.model.service.uuid4', return_value='77d15ab9-5602-4cca-8ed2-59ae579aafd3') + @patch('cyclonedx.model.bom_ref.uuid4', return_value='77d15ab9-5602-4cca-8ed2-59ae579aafd3') def test_minimal_service(self, mock_uuid: Mock) -> None: s = Service(name='my-test-service') mock_uuid.assert_called() self.assertEqual(s.name, 'my-test-service') - self.assertEqual(s.bom_ref, '77d15ab9-5602-4cca-8ed2-59ae579aafd3') + self.assertEqual(str(s.bom_ref), '77d15ab9-5602-4cca-8ed2-59ae579aafd3') self.assertIsNone(s.provider) self.assertIsNone(s.group) self.assertIsNone(s.version) @@ -44,7 +44,7 @@ def test_minimal_service(self, mock_uuid: Mock) -> None: self.assertFalse(s.release_notes) self.assertFalse(s.properties) - @patch('cyclonedx.model.service.uuid4', return_value='859ff614-35a7-4d37-803b-d89130cb2577') + @patch('cyclonedx.model.bom_ref.uuid4', return_value='859ff614-35a7-4d37-803b-d89130cb2577') def test_service_with_services(self, mock_uuid: Mock) -> None: parent_service = Service(name='parent-service') parent_service.services = [ @@ -53,7 +53,7 @@ def test_service_with_services(self, mock_uuid: Mock) -> None: ] mock_uuid.assert_called() self.assertEqual(parent_service.name, 'parent-service') - self.assertEqual(parent_service.bom_ref, '859ff614-35a7-4d37-803b-d89130cb2577') + self.assertEqual(str(parent_service.bom_ref), '859ff614-35a7-4d37-803b-d89130cb2577') self.assertIsNone(parent_service.provider) self.assertIsNone(parent_service.group) self.assertIsNone(parent_service.version) diff --git a/tests/test_output_json.py b/tests/test_output_json.py index 109b121d..2f8d6ab0 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -26,9 +26,8 @@ get_bom_with_services_simple, get_bom_with_component_toml_1, \ get_bom_with_component_setuptools_no_component_version, \ get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ - MOCK_UUID_1, get_bom_just_complete_metadata, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, \ - get_bom_with_services_complex, MOCK_UUID_6, get_bom_with_nested_services, \ - get_bom_with_component_setuptools_complete, get_bom_with_external_references + MOCK_UUID_1, get_bom_just_complete_metadata, MOCK_UUID_2, MOCK_UUID_3, TEST_UUIDS, get_bom_with_services_complex, \ + get_bom_with_nested_services, get_bom_with_component_setuptools_complete, get_bom_with_external_references from tests.base import BaseJsonTestCase @@ -178,7 +177,7 @@ def test_bom_v1_3_component_with_vulnerability(self) -> None: fixture='bom_setuptools.json' ) - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + @patch('cyclonedx.model.bom_ref.uuid4', return_value=MOCK_UUID_1) def test_bom_v1_4_with_metadata_component(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_4, @@ -186,7 +185,7 @@ def test_bom_v1_4_with_metadata_component(self, mock_uuid: Mock) -> None: ) mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_2) + @patch('cyclonedx.model.bom_ref.uuid4', return_value=MOCK_UUID_2) def test_bom_v1_3_with_metadata_component(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_3, @@ -194,7 +193,7 @@ def test_bom_v1_3_with_metadata_component(self, mock_uuid: Mock) -> None: ) mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_3) + @patch('cyclonedx.model.bom_ref.uuid4', return_value=MOCK_UUID_3) def test_bom_v1_2_with_metadata_component(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_2, @@ -202,95 +201,77 @@ def test_bom_v1_2_with_metadata_component(self, mock_uuid: Mock) -> None: ) mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) - @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_3) - def test_bom_v1_4_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_4_services_simple(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_4, fixture='bom_services_simple.json' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_2) - @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_4) - def test_bom_v1_3_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_3_services_simple(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_3, fixture='bom_services_simple.json' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) - @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_5) - def test_bom_v1_2_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_2_services_simple(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_2, fixture='bom_services_simple.json' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) - @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_4) - def test_bom_v1_4_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_4_services_complex(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_4, fixture='bom_services_complex.json' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) - @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_3) - def test_bom_v1_3_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_3_services_complex(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_3, fixture='bom_services_complex.json' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) - @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_2) - def test_bom_v1_2_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_2_services_complex(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_2, fixture='bom_services_complex.json' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) - @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_2) - def test_bom_v1_4_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_4_services_nested(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_4, fixture='bom_services_nested.json' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_3) - @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_4) - def test_bom_v1_3_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_3_services_nested(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_3, fixture='bom_services_nested.json' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) - @patch('cyclonedx.model.service.uuid4', return_value=MOCK_UUID_6) - def test_bom_v1_2_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_2_services_nested(self, mock_uuid: Mock) -> None: self._validate_json_bom( bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_2, fixture='bom_services_nested.json' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() # Helper methods def _validate_json_bom(self, bom: Bom, schema_version: SchemaVersion, fixture: str) -> None: diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index 780e0de3..a2bed4bd 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -24,15 +24,11 @@ from data import get_bom_with_component_setuptools_basic, get_bom_with_component_setuptools_with_cpe, \ get_bom_with_component_toml_1, get_bom_with_component_setuptools_no_component_version, \ get_bom_with_component_setuptools_with_release_notes, get_bom_with_component_setuptools_with_vulnerability, \ - MOCK_UUID_1, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6, get_bom_just_complete_metadata, \ + MOCK_UUID_1, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6, TEST_UUIDS, get_bom_just_complete_metadata, \ get_bom_with_nested_services, get_bom_with_services_simple, get_bom_with_services_complex, \ get_bom_with_component_setuptools_complete, get_bom_with_external_references from tests.base import BaseXmlTestCase -TEST_UUIDS = [ - MOCK_UUID_1, MOCK_UUID_2, MOCK_UUID_3, MOCK_UUID_4, MOCK_UUID_5, MOCK_UUID_6 -] - class TestOutputXml(BaseXmlTestCase): @@ -258,7 +254,7 @@ def test_bom_v1_0_component_with_vulnerability(self) -> None: fixture='bom_setuptools.xml' ) - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) + @patch('cyclonedx.model.bom_ref.uuid4', return_value=MOCK_UUID_6) def test_bom_v1_4_with_metadata_component(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_4, @@ -266,7 +262,7 @@ def test_bom_v1_4_with_metadata_component(self, mock_uuid: Mock) -> None: ) mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) + @patch('cyclonedx.model.bom_ref.uuid4', return_value=MOCK_UUID_5) def test_bom_v1_3_with_metadata_component(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_3, @@ -274,7 +270,7 @@ def test_bom_v1_3_with_metadata_component(self, mock_uuid: Mock) -> None: ) mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) + @patch('cyclonedx.model.bom_ref.uuid4', return_value=MOCK_UUID_4) def test_bom_v1_2_with_metadata_component(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_2, @@ -282,7 +278,7 @@ def test_bom_v1_2_with_metadata_component(self, mock_uuid: Mock) -> None: ) mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + @patch('cyclonedx.model.bom_ref.uuid4', return_value=MOCK_UUID_1) def test_bom_v1_1_with_metadata_component(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_1, @@ -290,7 +286,7 @@ def test_bom_v1_1_with_metadata_component(self, mock_uuid: Mock) -> None: ) mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) + @patch('cyclonedx.model.bom_ref.uuid4', return_value=MOCK_UUID_1) def test_bom_v1_0_with_metadata_component(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_just_complete_metadata(), schema_version=SchemaVersion.V1_0, @@ -298,125 +294,101 @@ def test_bom_v1_0_with_metadata_component(self, mock_uuid: Mock) -> None: ) mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_4_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_4_services_simple(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_4, fixture='bom_services_simple.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_3_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_3_services_simple(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_3, fixture='bom_services_simple.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_2_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_2_services_simple(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_2, fixture='bom_services_simple.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_1_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_1_services_simple(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_1, fixture='bom_empty.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_1) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_0_services_simple(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_0_services_simple(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_simple(), schema_version=SchemaVersion.V1_0, fixture='bom_empty.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_4_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_4_services_complex(self, mock_uuid4: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_4, fixture='bom_services_complex.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid4.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_3_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_3_services_complex(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_3, fixture='bom_services_complex.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_2_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_2_services_complex(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_2, fixture='bom_services_complex.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_3) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_1_services_complex(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_1_services_complex(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_services_complex(), schema_version=SchemaVersion.V1_1, fixture='bom_empty.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_6) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_4_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_4_services_nested(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_4, fixture='bom_services_nested.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_5) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_3_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_3_services_nested(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_3, fixture='bom_services_nested.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() - @patch('cyclonedx.model.component.uuid4', return_value=MOCK_UUID_4) - @patch('cyclonedx.model.service.uuid4', side_effect=TEST_UUIDS) - def test_bom_v1_2_services_nested(self, mock_uuid_c: Mock, mock_uuid_s: Mock) -> None: + @patch('cyclonedx.model.bom_ref.uuid4', side_effect=TEST_UUIDS) + def test_bom_v1_2_services_nested(self, mock_uuid: Mock) -> None: self._validate_xml_bom( bom=get_bom_with_nested_services(), schema_version=SchemaVersion.V1_2, fixture='bom_services_nested.xml' ) - mock_uuid_c.assert_called() - mock_uuid_s.assert_called() + mock_uuid.assert_called() # Helper methods def _validate_xml_bom(self, bom: Bom, schema_version: SchemaVersion, fixture: str) -> None: From 0d82c019afce3e4aefe56bff9607cfd60186c6b0 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Thu, 17 Feb 2022 13:29:38 +0000 Subject: [PATCH 25/29] Continuation of #170 - missed updating Vulnerability to use `BomRef` (#175) * BREAKING CHANGE: added new model `BomRef` unlocking logic later to ensure uniquness and dependency references Signed-off-by: Paul Horton * updated Vulnerability to also use new `BomRef` model Signed-off-by: Paul Horton --- cyclonedx/model/vulnerability.py | 12 ++++-------- cyclonedx/output/xml.py | 2 +- tests/test_model_vulnerability.py | 4 ++-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/cyclonedx/model/vulnerability.py b/cyclonedx/model/vulnerability.py index 0eed7f22..96e64c6c 100644 --- a/cyclonedx/model/vulnerability.py +++ b/cyclonedx/model/vulnerability.py @@ -22,9 +22,9 @@ from decimal import Decimal from enum import Enum from typing import Iterable, Optional, Set, Tuple, Union -from uuid import uuid4 from . import OrganizationalContact, OrganizationalEntity, Tool, XsUri +from .bom_ref import BomRef from .impact_analysis import ImpactAnalysisAffectedStatus, ImpactAnalysisJustification, ImpactAnalysisResponse, \ ImpactAnalysisState from ..exception.model import MutuallyExclusivePropertiesException, NoPropertiesProvidedException @@ -745,7 +745,7 @@ def __init__(self, *, bom_ref: Optional[str] = None, id: Optional[str] = None, # Deprecated Parameters kept for backwards compatibility source_name: Optional[str] = None, source_url: Optional[str] = None, recommendations: Optional[Iterable[str]] = None) -> None: - self.bom_ref = bom_ref or str(uuid4()) + self._bom_ref = BomRef(value=bom_ref) self.id = id self.source = source self.references = set(references or []) @@ -774,21 +774,17 @@ def __init__(self, *, bom_ref: Optional[str] = None, id: Optional[str] = None, self.recommendation = next(iter(recommendations)) @property - def bom_ref(self) -> Optional[str]: + def bom_ref(self) -> BomRef: """ Get the unique reference for this Vulnerability in this BOM. If a value was not provided in the constructor, a UUIDv4 will have been assigned. Returns: - `str` if set else `None` + `BomRef` """ return self._bom_ref - @bom_ref.setter - def bom_ref(self, bom_ref: Optional[str]) -> None: - self._bom_ref = bom_ref - @property def id(self) -> Optional[str]: """ diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 94ad2043..4af463bd 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -526,7 +526,7 @@ def _add_service_element(self, service: Service) -> ElementTree.Element: def _get_vulnerability_as_xml_element_post_1_4(self, vulnerability: Vulnerability) -> ElementTree.Element: vulnerability_element = ElementTree.Element( 'vulnerability', - {'bom-ref': vulnerability.bom_ref} if vulnerability.bom_ref else {} + {'bom-ref': str(vulnerability.bom_ref)} if vulnerability.bom_ref else {} ) # id diff --git a/tests/test_model_vulnerability.py b/tests/test_model_vulnerability.py index 450793cf..0e239b7c 100644 --- a/tests/test_model_vulnerability.py +++ b/tests/test_model_vulnerability.py @@ -170,11 +170,11 @@ def test_v_source_get_localised_vector_other_2(self) -> None: 'SOMETHING_OR_OTHER' ) - @patch('cyclonedx.model.vulnerability.uuid4', return_value='0afa65bc-4acd-428b-9e17-8e97b1969745') + @patch('cyclonedx.model.bom_ref.uuid4', return_value='0afa65bc-4acd-428b-9e17-8e97b1969745') def test_empty_vulnerability(self, mock_uuid: Mock) -> None: v = Vulnerability() mock_uuid.assert_called() - self.assertEqual(v.bom_ref, '0afa65bc-4acd-428b-9e17-8e97b1969745') + self.assertEqual(str(v.bom_ref), '0afa65bc-4acd-428b-9e17-8e97b1969745') self.assertIsNone(v.id) self.assertIsNone(v.source) self.assertFalse(v.references) From 670bde47a8a60db764aa706797f1d8ed7cf2c227 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Mon, 21 Feb 2022 13:13:25 +0000 Subject: [PATCH 26/29] implemented `__str__` for `BomRef` Signed-off-by: Paul Horton --- cyclonedx/model/bom_ref.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cyclonedx/model/bom_ref.py b/cyclonedx/model/bom_ref.py index 69bd9f31..27eec97b 100644 --- a/cyclonedx/model/bom_ref.py +++ b/cyclonedx/model/bom_ref.py @@ -50,4 +50,7 @@ def __hash__(self) -> int: return hash(self.value) def __repr__(self) -> str: + return f' str: return self.value From f014d7c4411de9ed5e9cb877878ae416d85b2d92 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Mon, 21 Feb 2022 13:35:06 +0000 Subject: [PATCH 27/29] fix: `license_url` not serialised in XML output #179 (#180) Signed-off-by: Paul Horton --- cyclonedx/model/__init__.py | 3 +++ cyclonedx/output/xml.py | 3 ++- tests/data.py | 10 +++++++--- tests/fixtures/json/1.3/bom_with_full_metadata.json | 10 +++++++++- tests/fixtures/json/1.4/bom_with_full_metadata.json | 10 +++++++++- tests/fixtures/xml/1.3/bom_with_full_metadata.xml | 6 +++++- tests/fixtures/xml/1.4/bom_with_full_metadata.xml | 6 +++++- 7 files changed, 40 insertions(+), 8 deletions(-) diff --git a/cyclonedx/model/__init__.py b/cyclonedx/model/__init__.py index 47e3eed1..fae2284b 100644 --- a/cyclonedx/model/__init__.py +++ b/cyclonedx/model/__init__.py @@ -376,6 +376,9 @@ def __hash__(self) -> int: return hash(self._uri) def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: return self._uri diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 4af463bd..94192c62 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -356,8 +356,9 @@ def _add_licenses_to_element(self, licenses: Set[LicenseChoice], parent_element: license_text_e_attrs['encoding'] = license_.license.text.encoding.value ElementTree.SubElement(license_e, 'text', license_text_e_attrs).text = license_.license.text.content + if license_.license.url: + ElementTree.SubElement(license_e, 'url').text = str(license_.license.url) - ElementTree.SubElement(license_e, 'text').text = license_.license.id license_output = True else: if self.license_supports_expression(): diff --git a/tests/data.py b/tests/data.py index 2e5fc805..0ecf6887 100644 --- a/tests/data.py +++ b/tests/data.py @@ -24,8 +24,8 @@ from packageurl import PackageURL from cyclonedx.model import AttachedText, DataClassification, DataFlow, Encoding, ExternalReference, \ - ExternalReferenceType, HashType, LicenseChoice, Note, NoteText, OrganizationalContact, OrganizationalEntity, \ - Property, Tool, XsUri + ExternalReferenceType, HashType, LicenseChoice, License, Note, NoteText, OrganizationalContact, \ + OrganizationalEntity, Property, Tool, XsUri from cyclonedx.model.bom import Bom from cyclonedx.model.component import Commit, Component, ComponentEvidence, ComponentType, Copyright, Patch, \ PatchClassification, Pedigree, Swid, ComponentScope @@ -166,7 +166,11 @@ def get_bom_just_complete_metadata() -> Bom: ) bom.metadata.manufacture = get_org_entity_1() bom.metadata.supplier = get_org_entity_2() - bom.metadata.licenses = [LicenseChoice(license_expression='Commercial')] + bom.metadata.licenses = [LicenseChoice(license_=License( + spdx_license_id='Apache-2.0', license_text=AttachedText( + content='VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE=', encoding=Encoding.BASE_64 + ), license_url=XsUri('https://www.apache.org/licenses/LICENSE-2.0.txt') + ))] bom.metadata.properties = get_properties_1() return bom diff --git a/tests/fixtures/json/1.3/bom_with_full_metadata.json b/tests/fixtures/json/1.3/bom_with_full_metadata.json index 21baef21..f23a5703 100644 --- a/tests/fixtures/json/1.3/bom_with_full_metadata.json +++ b/tests/fixtures/json/1.3/bom_with_full_metadata.json @@ -62,7 +62,15 @@ }, "licenses": [ { - "expression": "Commercial" + "license": { + "id": "Apache-2.0", + "text": { + "contentType": "text/plain", + "encoding": "base64", + "content": "VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE=" + }, + "url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } } ], "properties": [ diff --git a/tests/fixtures/json/1.4/bom_with_full_metadata.json b/tests/fixtures/json/1.4/bom_with_full_metadata.json index 230f4f26..0a4ce155 100644 --- a/tests/fixtures/json/1.4/bom_with_full_metadata.json +++ b/tests/fixtures/json/1.4/bom_with_full_metadata.json @@ -96,7 +96,15 @@ }, "licenses": [ { - "expression": "Commercial" + "license": { + "id": "Apache-2.0", + "text": { + "contentType": "text/plain", + "encoding": "base64", + "content": "VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE=" + }, + "url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } } ], "properties": [ diff --git a/tests/fixtures/xml/1.3/bom_with_full_metadata.xml b/tests/fixtures/xml/1.3/bom_with_full_metadata.xml index be3a90f1..639e270a 100644 --- a/tests/fixtures/xml/1.3/bom_with_full_metadata.xml +++ b/tests/fixtures/xml/1.3/bom_with_full_metadata.xml @@ -47,7 +47,11 @@ - Commercial + + Apache-2.0 + VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE= + https://www.apache.org/licenses/LICENSE-2.0.txt + val1 diff --git a/tests/fixtures/xml/1.4/bom_with_full_metadata.xml b/tests/fixtures/xml/1.4/bom_with_full_metadata.xml index 395ae6d3..dd333058 100644 --- a/tests/fixtures/xml/1.4/bom_with_full_metadata.xml +++ b/tests/fixtures/xml/1.4/bom_with_full_metadata.xml @@ -73,7 +73,11 @@ - Commercial + + Apache-2.0 + VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE= + https://www.apache.org/licenses/LICENSE-2.0.txt + val1 From b20d9d1aceebfa8bae21250e6ae39234caffbb0e Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Mon, 21 Feb 2022 14:48:14 +0000 Subject: [PATCH 28/29] doc: added RTD badge to README Signed-off-by: Paul Horton --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b970b56..e3ec8db0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ [![shield_gh-workflow-test]][link_gh-workflow-test] [![shield_pypi-version]][link_pypi] [![shield_conda-forge-version]][link_conda-forge] -[![shield_license]][license_file] +[![shiled_rtd]][link_rtd] +[![shield_license]][license_file] [![shield_website]][link_website] [![shield_slack]][link_slack] [![shield_groups]][link_discussion] @@ -55,6 +56,7 @@ See the [LICENSE][license_file] file for the full license. [shield_gh-workflow-test]: https://img.shields.io/github/workflow/status/CycloneDX/cyclonedx-python-lib/Python%20CI/main?logo=GitHub&logoColor=white "build" [shield_pypi-version]: https://img.shields.io/pypi/v/cyclonedx-python-lib?logo=pypi&logoColor=white&label=PyPI "PyPI" [shield_conda-forge-version]: https://img.shields.io/conda/vn/conda-forge/cyclonedx-python-lib?logo=anaconda&logoColor=white&label=conda-forge "conda-forge" +[shiled_rtd]: https://readthedocs.org/projects/cyclonedx-python-library/badge/?version=latest "Read the Docs" [shield_license]: https://img.shields.io/github/license/CycloneDX/cyclonedx-python-lib "license" [shield_website]: https://img.shields.io/badge/https://-cyclonedx.org-blue.svg "homepage" [shield_slack]: https://img.shields.io/badge/slack-join-blue?logo=Slack&logoColor=white "slack join" @@ -63,6 +65,7 @@ See the [LICENSE][license_file] file for the full license. [link_gh-workflow-test]: https://github.com/CycloneDX/cyclonedx-python-lib/actions/workflows/poetry.yml?query=branch%3Amain [link_pypi]: https://pypi.org/project/cyclonedx-python-lib/ [link_conda-forge]: https://anaconda.org/conda-forge/cyclonedx-python-lib +[link_rtd]: https://cyclonedx-python-library.readthedocs.io/en/latest/?badge=latest [link_website]: https://cyclonedx.org/ [link_slack]: https://cyclonedx.org/slack/invite [link_discussion]: https://groups.io/g/CycloneDX From da3f0ca3e8b90b37301c03f889eb089bca649b09 Mon Sep 17 00:00:00 2001 From: Paul Horton Date: Mon, 21 Feb 2022 14:51:34 +0000 Subject: [PATCH 29/29] feat: bump dependencies BREAKING CHANGE: Adopt PEP-3102 BREAKING CHANGE: Optional Lists are now non-optional Sets BREAKING CHANGE: Remove concept of DEFAULT schema version - replaced with LATEST schema version BREAKING CHANGE: Added `BomRef` data type Signed-off-by: Paul Horton --- poetry.lock | 149 ++++++++++++++++++++++++++-------------------------- 1 file changed, 75 insertions(+), 74 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1c44a2d3..b47adf8f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -156,7 +156,7 @@ format_nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- [[package]] name = "lxml" -version = "4.7.1" +version = "4.8.0" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." category = "dev" optional = false @@ -204,14 +204,14 @@ python-versions = "*" [[package]] name = "packageurl-python" -version = "0.9.6" +version = "0.9.9" description = "A purl aka. Package URL parser and builder" category = "main" optional = false python-versions = ">=3.6" [package.extras] -test = ["pytest", "isort"] +test = ["isort", "pytest"] [[package]] name = "packaging" @@ -351,7 +351,7 @@ python-versions = ">=3.6" [[package]] name = "types-setuptools" -version = "57.4.8" +version = "57.4.9" description = "Typing stubs for setuptools" category = "main" optional = false @@ -359,7 +359,7 @@ python-versions = "*" [[package]] name = "types-toml" -version = "0.10.3" +version = "0.10.4" description = "Typing stubs for toml" category = "main" optional = false @@ -375,7 +375,7 @@ python-versions = "*" [[package]] name = "virtualenv" -version = "20.13.0" +version = "20.13.1" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -517,66 +517,67 @@ jsonschema = [ {file = "jsonschema-4.4.0.tar.gz", hash = "sha256:636694eb41b3535ed608fe04129f26542b59ed99808b4f688aa32dcf55317a83"}, ] lxml = [ - {file = "lxml-4.7.1-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:d546431636edb1d6a608b348dd58cc9841b81f4116745857b6cb9f8dadb2725f"}, - {file = "lxml-4.7.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6308062534323f0d3edb4e702a0e26a76ca9e0e23ff99be5d82750772df32a9e"}, - {file = "lxml-4.7.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f76dbe44e31abf516114f6347a46fa4e7c2e8bceaa4b6f7ee3a0a03c8eba3c17"}, - {file = "lxml-4.7.1-cp27-cp27m-win32.whl", hash = "sha256:d5618d49de6ba63fe4510bdada62d06a8acfca0b4b5c904956c777d28382b419"}, - {file = "lxml-4.7.1-cp27-cp27m-win_amd64.whl", hash = "sha256:9393a05b126a7e187f3e38758255e0edf948a65b22c377414002d488221fdaa2"}, - {file = "lxml-4.7.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:50d3dba341f1e583265c1a808e897b4159208d814ab07530202b6036a4d86da5"}, - {file = "lxml-4.7.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:44f552e0da3c8ee3c28e2eb82b0b784200631687fc6a71277ea8ab0828780e7d"}, - {file = "lxml-4.7.1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:e662c6266e3a275bdcb6bb049edc7cd77d0b0f7e119a53101d367c841afc66dc"}, - {file = "lxml-4.7.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4c093c571bc3da9ebcd484e001ba18b8452903cd428c0bc926d9b0141bcb710e"}, - {file = "lxml-4.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3e26ad9bc48d610bf6cc76c506b9e5ad9360ed7a945d9be3b5b2c8535a0145e3"}, - {file = "lxml-4.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a5f623aeaa24f71fce3177d7fee875371345eb9102b355b882243e33e04b7175"}, - {file = "lxml-4.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7b5e2acefd33c259c4a2e157119c4373c8773cf6793e225006a1649672ab47a6"}, - {file = "lxml-4.7.1-cp310-cp310-win32.whl", hash = "sha256:67fa5f028e8a01e1d7944a9fb616d1d0510d5d38b0c41708310bd1bc45ae89f6"}, - {file = "lxml-4.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:b1d381f58fcc3e63fcc0ea4f0a38335163883267f77e4c6e22d7a30877218a0e"}, - {file = "lxml-4.7.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:38d9759733aa04fb1697d717bfabbedb21398046bd07734be7cccc3d19ea8675"}, - {file = "lxml-4.7.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dfd0d464f3d86a1460683cd742306d1138b4e99b79094f4e07e1ca85ee267fe7"}, - {file = "lxml-4.7.1-cp35-cp35m-win32.whl", hash = "sha256:534e946bce61fd162af02bad7bfd2daec1521b71d27238869c23a672146c34a5"}, - {file = "lxml-4.7.1-cp35-cp35m-win_amd64.whl", hash = "sha256:6ec829058785d028f467be70cd195cd0aaf1a763e4d09822584ede8c9eaa4b03"}, - {file = "lxml-4.7.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:ade74f5e3a0fd17df5782896ddca7ddb998845a5f7cd4b0be771e1ffc3b9aa5b"}, - {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41358bfd24425c1673f184d7c26c6ae91943fe51dfecc3603b5e08187b4bcc55"}, - {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6e56521538f19c4a6690f439fefed551f0b296bd785adc67c1777c348beb943d"}, - {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5b0f782f0e03555c55e37d93d7a57454efe7495dab33ba0ccd2dbe25fc50f05d"}, - {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:490712b91c65988012e866c411a40cc65b595929ececf75eeb4c79fcc3bc80a6"}, - {file = "lxml-4.7.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:34c22eb8c819d59cec4444d9eebe2e38b95d3dcdafe08965853f8799fd71161d"}, - {file = "lxml-4.7.1-cp36-cp36m-win32.whl", hash = "sha256:2a906c3890da6a63224d551c2967413b8790a6357a80bf6b257c9a7978c2c42d"}, - {file = "lxml-4.7.1-cp36-cp36m-win_amd64.whl", hash = "sha256:36b16fecb10246e599f178dd74f313cbdc9f41c56e77d52100d1361eed24f51a"}, - {file = "lxml-4.7.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:a5edc58d631170de90e50adc2cc0248083541affef82f8cd93bea458e4d96db8"}, - {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:87c1b0496e8c87ec9db5383e30042357b4839b46c2d556abd49ec770ce2ad868"}, - {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:0a5f0e4747f31cff87d1eb32a6000bde1e603107f632ef4666be0dc065889c7a"}, - {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bf6005708fc2e2c89a083f258b97709559a95f9a7a03e59f805dd23c93bc3986"}, - {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc15874816b9320581133ddc2096b644582ab870cf6a6ed63684433e7af4b0d3"}, - {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0b5e96e25e70917b28a5391c2ed3ffc6156513d3db0e1476c5253fcd50f7a944"}, - {file = "lxml-4.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ec9027d0beb785a35aa9951d14e06d48cfbf876d8ff67519403a2522b181943b"}, - {file = "lxml-4.7.1-cp37-cp37m-win32.whl", hash = "sha256:9fbc0dee7ff5f15c4428775e6fa3ed20003140560ffa22b88326669d53b3c0f4"}, - {file = "lxml-4.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1104a8d47967a414a436007c52f533e933e5d52574cab407b1e49a4e9b5ddbd1"}, - {file = "lxml-4.7.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:fc9fb11b65e7bc49f7f75aaba1b700f7181d95d4e151cf2f24d51bfd14410b77"}, - {file = "lxml-4.7.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:317bd63870b4d875af3c1be1b19202de34c32623609ec803b81c99193a788c1e"}, - {file = "lxml-4.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:610807cea990fd545b1559466971649e69302c8a9472cefe1d6d48a1dee97440"}, - {file = "lxml-4.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:09b738360af8cb2da275998a8bf79517a71225b0de41ab47339c2beebfff025f"}, - {file = "lxml-4.7.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a2ab9d089324d77bb81745b01f4aeffe4094306d939e92ba5e71e9a6b99b71e"}, - {file = "lxml-4.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:eed394099a7792834f0cb4a8f615319152b9d801444c1c9e1b1a2c36d2239f9e"}, - {file = "lxml-4.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:735e3b4ce9c0616e85f302f109bdc6e425ba1670a73f962c9f6b98a6d51b77c9"}, - {file = "lxml-4.7.1-cp38-cp38-win32.whl", hash = "sha256:772057fba283c095db8c8ecde4634717a35c47061d24f889468dc67190327bcd"}, - {file = "lxml-4.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:13dbb5c7e8f3b6a2cf6e10b0948cacb2f4c9eb05029fe31c60592d08ac63180d"}, - {file = "lxml-4.7.1-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:718d7208b9c2d86aaf0294d9381a6acb0158b5ff0f3515902751404e318e02c9"}, - {file = "lxml-4.7.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:5bee1b0cbfdb87686a7fb0e46f1d8bd34d52d6932c0723a86de1cc532b1aa489"}, - {file = "lxml-4.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e410cf3a2272d0a85526d700782a2fa92c1e304fdcc519ba74ac80b8297adf36"}, - {file = "lxml-4.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:585ea241ee4961dc18a95e2f5581dbc26285fcf330e007459688096f76be8c42"}, - {file = "lxml-4.7.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a555e06566c6dc167fbcd0ad507ff05fd9328502aefc963cb0a0547cfe7f00db"}, - {file = "lxml-4.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:adaab25be351fff0d8a691c4f09153647804d09a87a4e4ea2c3f9fe9e8651851"}, - {file = "lxml-4.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:82d16a64236970cb93c8d63ad18c5b9f138a704331e4b916b2737ddfad14e0c4"}, - {file = "lxml-4.7.1-cp39-cp39-win32.whl", hash = "sha256:59e7da839a1238807226f7143c68a479dee09244d1b3cf8c134f2fce777d12d0"}, - {file = "lxml-4.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:a1bbc4efa99ed1310b5009ce7f3a1784698082ed2c1ef3895332f5df9b3b92c2"}, - {file = "lxml-4.7.1-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:0607ff0988ad7e173e5ddf7bf55ee65534bd18a5461183c33e8e41a59e89edf4"}, - {file = "lxml-4.7.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:6c198bfc169419c09b85ab10cb0f572744e686f40d1e7f4ed09061284fc1303f"}, - {file = "lxml-4.7.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a58d78653ae422df6837dd4ca0036610b8cb4962b5cfdbd337b7b24de9e5f98a"}, - {file = "lxml-4.7.1-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:e18281a7d80d76b66a9f9e68a98cf7e1d153182772400d9a9ce855264d7d0ce7"}, - {file = "lxml-4.7.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8e54945dd2eeb50925500957c7c579df3cd07c29db7810b83cf30495d79af267"}, - {file = "lxml-4.7.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:447d5009d6b5447b2f237395d0018901dcc673f7d9f82ba26c1b9f9c3b444b60"}, - {file = "lxml-4.7.1.tar.gz", hash = "sha256:a1613838aa6b89af4ba10a0f3a972836128801ed008078f8c1244e65958f1b24"}, + {file = "lxml-4.8.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:e1ab2fac607842ac36864e358c42feb0960ae62c34aa4caaf12ada0a1fb5d99b"}, + {file = "lxml-4.8.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28d1af847786f68bec57961f31221125c29d6f52d9187c01cd34dc14e2b29430"}, + {file = "lxml-4.8.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b92d40121dcbd74831b690a75533da703750f7041b4bf951befc657c37e5695a"}, + {file = "lxml-4.8.0-cp27-cp27m-win32.whl", hash = "sha256:e01f9531ba5420838c801c21c1b0f45dbc9607cb22ea2cf132844453bec863a5"}, + {file = "lxml-4.8.0-cp27-cp27m-win_amd64.whl", hash = "sha256:6259b511b0f2527e6d55ad87acc1c07b3cbffc3d5e050d7e7bcfa151b8202df9"}, + {file = "lxml-4.8.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1010042bfcac2b2dc6098260a2ed022968dbdfaf285fc65a3acf8e4eb1ffd1bc"}, + {file = "lxml-4.8.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fa56bb08b3dd8eac3a8c5b7d075c94e74f755fd9d8a04543ae8d37b1612dd170"}, + {file = "lxml-4.8.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:31ba2cbc64516dcdd6c24418daa7abff989ddf3ba6d3ea6f6ce6f2ed6e754ec9"}, + {file = "lxml-4.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:31499847fc5f73ee17dbe1b8e24c6dafc4e8d5b48803d17d22988976b0171f03"}, + {file = "lxml-4.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5f7d7d9afc7b293147e2d506a4596641d60181a35279ef3aa5778d0d9d9123fe"}, + {file = "lxml-4.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a3c5f1a719aa11866ffc530d54ad965063a8cbbecae6515acbd5f0fae8f48eaa"}, + {file = "lxml-4.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6268e27873a3d191849204d00d03f65c0e343b3bcb518a6eaae05677c95621d1"}, + {file = "lxml-4.8.0-cp310-cp310-win32.whl", hash = "sha256:330bff92c26d4aee79c5bc4d9967858bdbe73fdbdbacb5daf623a03a914fe05b"}, + {file = "lxml-4.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b2582b238e1658c4061ebe1b4df53c435190d22457642377fd0cb30685cdfb76"}, + {file = "lxml-4.8.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a2bfc7e2a0601b475477c954bf167dee6d0f55cb167e3f3e7cefad906e7759f6"}, + {file = "lxml-4.8.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a1547ff4b8a833511eeaceacbcd17b043214fcdb385148f9c1bc5556ca9623e2"}, + {file = "lxml-4.8.0-cp35-cp35m-win32.whl", hash = "sha256:a9f1c3489736ff8e1c7652e9dc39f80cff820f23624f23d9eab6e122ac99b150"}, + {file = "lxml-4.8.0-cp35-cp35m-win_amd64.whl", hash = "sha256:530f278849031b0eb12f46cca0e5db01cfe5177ab13bd6878c6e739319bae654"}, + {file = "lxml-4.8.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:078306d19a33920004addeb5f4630781aaeabb6a8d01398045fcde085091a169"}, + {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:86545e351e879d0b72b620db6a3b96346921fa87b3d366d6c074e5a9a0b8dadb"}, + {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24f5c5ae618395ed871b3d8ebfcbb36e3f1091fd847bf54c4de623f9107942f3"}, + {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bbab6faf6568484707acc052f4dfc3802bdb0cafe079383fbaa23f1cdae9ecd4"}, + {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7993232bd4044392c47779a3c7e8889fea6883be46281d45a81451acfd704d7e"}, + {file = "lxml-4.8.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d6483b1229470e1d8835e52e0ff3c6973b9b97b24cd1c116dca90b57a2cc613"}, + {file = "lxml-4.8.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:ad4332a532e2d5acb231a2e5d33f943750091ee435daffca3fec0a53224e7e33"}, + {file = "lxml-4.8.0-cp36-cp36m-win32.whl", hash = "sha256:db3535733f59e5605a88a706824dfcb9bd06725e709ecb017e165fc1d6e7d429"}, + {file = "lxml-4.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5f148b0c6133fb928503cfcdfdba395010f997aa44bcf6474fcdd0c5398d9b63"}, + {file = "lxml-4.8.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:8a31f24e2a0b6317f33aafbb2f0895c0bce772980ae60c2c640d82caac49628a"}, + {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:719544565c2937c21a6f76d520e6e52b726d132815adb3447ccffbe9f44203c4"}, + {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:c0b88ed1ae66777a798dc54f627e32d3b81c8009967c63993c450ee4cbcbec15"}, + {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fa9b7c450be85bfc6cd39f6df8c5b8cbd76b5d6fc1f69efec80203f9894b885f"}, + {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e9f84ed9f4d50b74fbc77298ee5c870f67cb7e91dcdc1a6915cb1ff6a317476c"}, + {file = "lxml-4.8.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1d650812b52d98679ed6c6b3b55cbb8fe5a5460a0aef29aeb08dc0b44577df85"}, + {file = "lxml-4.8.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:80bbaddf2baab7e6de4bc47405e34948e694a9efe0861c61cdc23aa774fcb141"}, + {file = "lxml-4.8.0-cp37-cp37m-win32.whl", hash = "sha256:6f7b82934c08e28a2d537d870293236b1000d94d0b4583825ab9649aef7ddf63"}, + {file = "lxml-4.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e1fd7d2fe11f1cb63d3336d147c852f6d07de0d0020d704c6031b46a30b02ca8"}, + {file = "lxml-4.8.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5045ee1ccd45a89c4daec1160217d363fcd23811e26734688007c26f28c9e9e7"}, + {file = "lxml-4.8.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0c1978ff1fd81ed9dcbba4f91cf09faf1f8082c9d72eb122e92294716c605428"}, + {file = "lxml-4.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cbf2ff155b19dc4d4100f7442f6a697938bf4493f8d3b0c51d45568d5666b5"}, + {file = "lxml-4.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ce13d6291a5f47c1c8dbd375baa78551053bc6b5e5c0e9bb8e39c0a8359fd52f"}, + {file = "lxml-4.8.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11527dc23d5ef44d76fef11213215c34f36af1608074561fcc561d983aeb870"}, + {file = "lxml-4.8.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:60d2f60bd5a2a979df28ab309352cdcf8181bda0cca4529769a945f09aba06f9"}, + {file = "lxml-4.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:62f93eac69ec0f4be98d1b96f4d6b964855b8255c345c17ff12c20b93f247b68"}, + {file = "lxml-4.8.0-cp38-cp38-win32.whl", hash = "sha256:20b8a746a026017acf07da39fdb10aa80ad9877046c9182442bf80c84a1c4696"}, + {file = "lxml-4.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:891dc8f522d7059ff0024cd3ae79fd224752676447f9c678f2a5c14b84d9a939"}, + {file = "lxml-4.8.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:b6fc2e2fb6f532cf48b5fed57567ef286addcef38c28874458a41b7837a57807"}, + {file = "lxml-4.8.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:74eb65ec61e3c7c019d7169387d1b6ffcfea1b9ec5894d116a9a903636e4a0b1"}, + {file = "lxml-4.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:627e79894770783c129cc5e89b947e52aa26e8e0557c7e205368a809da4b7939"}, + {file = "lxml-4.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:545bd39c9481f2e3f2727c78c169425efbfb3fbba6e7db4f46a80ebb249819ca"}, + {file = "lxml-4.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5a58d0b12f5053e270510bf12f753a76aaf3d74c453c00942ed7d2c804ca845c"}, + {file = "lxml-4.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ec4b4e75fc68da9dc0ed73dcdb431c25c57775383fec325d23a770a64e7ebc87"}, + {file = "lxml-4.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5804e04feb4e61babf3911c2a974a5b86f66ee227cc5006230b00ac6d285b3a9"}, + {file = "lxml-4.8.0-cp39-cp39-win32.whl", hash = "sha256:aa0cf4922da7a3c905d000b35065df6184c0dc1d866dd3b86fd961905bbad2ea"}, + {file = "lxml-4.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:dd10383f1d6b7edf247d0960a3db274c07e96cf3a3fc7c41c8448f93eac3fb1c"}, + {file = "lxml-4.8.0-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:2403a6d6fb61c285969b71f4a3527873fe93fd0abe0832d858a17fe68c8fa507"}, + {file = "lxml-4.8.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:986b7a96228c9b4942ec420eff37556c5777bfba6758edcb95421e4a614b57f9"}, + {file = "lxml-4.8.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6fe4ef4402df0250b75ba876c3795510d782def5c1e63890bde02d622570d39e"}, + {file = "lxml-4.8.0-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:f10ce66fcdeb3543df51d423ede7e238be98412232fca5daec3e54bcd16b8da0"}, + {file = "lxml-4.8.0-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:730766072fd5dcb219dd2b95c4c49752a54f00157f322bc6d71f7d2a31fecd79"}, + {file = "lxml-4.8.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8b99ec73073b37f9ebe8caf399001848fced9c08064effdbfc4da2b5a8d07b93"}, + {file = "lxml-4.8.0.tar.gz", hash = "sha256:f63f62fc60e6228a4ca9abae28228f35e1bd3ce675013d1dfb828688d50c6e23"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -609,8 +610,8 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] packageurl-python = [ - {file = "packageurl-python-0.9.6.tar.gz", hash = "sha256:c01fbaf62ad2eb791e97158d1f30349e830bee2dd3e9503a87f6c3ffae8d1cf0"}, - {file = "packageurl_python-0.9.6-py3-none-any.whl", hash = "sha256:676dcb8278721df952e2444bfcd8d7bf3518894498050f0c6a5faddbe0860cd0"}, + {file = "packageurl-python-0.9.9.tar.gz", hash = "sha256:872a0434b9a448b3fa97571711f69dd2a3fb72345ad66c90b17d827afea82f09"}, + {file = "packageurl_python-0.9.9-py3-none-any.whl", hash = "sha256:07aa852d1c48b0e86e625f6a32d83f96427739806b269d0f8142788ee807114b"}, ] packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, @@ -706,12 +707,12 @@ typed-ast = [ {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, ] types-setuptools = [ - {file = "types-setuptools-57.4.8.tar.gz", hash = "sha256:d5545f2ab3dad24f5cb1c01ba74c1acb7407ab31b2618d423158fc84085160f1"}, - {file = "types_setuptools-57.4.8-py3-none-any.whl", hash = "sha256:af8480491f9894e6081fb568811978fd7ac87434cab122a0605a32be08d98dbe"}, + {file = "types-setuptools-57.4.9.tar.gz", hash = "sha256:536ef74744f8e1e4be4fc719887f886e74e4cf3c792b4a06984320be4df450b5"}, + {file = "types_setuptools-57.4.9-py3-none-any.whl", hash = "sha256:948dc6863373750e2cd0b223a84f1fb608414cde5e55cf38ea657b93aeb411d2"}, ] types-toml = [ - {file = "types-toml-0.10.3.tar.gz", hash = "sha256:215a7a79198651ec5bdfd66193c1e71eb681a42f3ef7226c9af3123ced62564a"}, - {file = "types_toml-0.10.3-py3-none-any.whl", hash = "sha256:988457744d9774d194e3539388772e3a685d8057b7c4a89407afeb0a6cbd1b14"}, + {file = "types-toml-0.10.4.tar.gz", hash = "sha256:9340e7c1587715581bb13905b3af30b79fe68afaccfca377665d5e63b694129a"}, + {file = "types_toml-0.10.4-py3-none-any.whl", hash = "sha256:4a9ffd47bbcec49c6fde6351a889b2c1bd3c0ef309fa0eed60dc28e58c8b9ea6"}, ] typing-extensions = [ {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, @@ -719,8 +720,8 @@ typing-extensions = [ {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, ] virtualenv = [ - {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, - {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, + {file = "virtualenv-20.13.1-py2.py3-none-any.whl", hash = "sha256:45e1d053cad4cd453181ae877c4ffc053546ae99e7dd049b9ff1d9be7491abf7"}, + {file = "virtualenv-20.13.1.tar.gz", hash = "sha256:e0621bcbf4160e4e1030f05065c8834b4e93f4fcc223255db2a823440aca9c14"}, ] xmldiff = [ {file = "xmldiff-2.4-py2.py3-none-any.whl", hash = "sha256:213c2f4c39ed71811a9ceeec1c8bdf2e673e5527261ea11708b3acfa6c2bdb00"},