diff --git a/cyclonedx/model/__init__.py b/cyclonedx/model/__init__.py index 113ed928..759d3e50 100644 --- a/cyclonedx/model/__init__.py +++ b/cyclonedx/model/__init__.py @@ -51,6 +51,185 @@ 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 SignatureAlgorithm(Enum): + """ + This is out internal representation of the algorithm simple type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema: https://cyclonedx.org/docs/1.4/json/#tab-pane_signature_oneOf_i2_algorithm_oneOf_i0 + """ + RS256 = "RS256" + RS384 = "RS384" + RS512 = "RS512" + PS256 = "PS256" + PS384 = "PS384" + PS512 = "PS512" + ES256 = "ES256" + ES384 = "ES384" + ES512 = "ES512" + ED25519 = "Ed25519" + ED448 = "Ed448" + HS256 = "HS256" + HS384 = "HS384" + HS512 = "HS512" + + +class SignaturePublicKeyKty(Enum): + """ + This is our internal representation of the kty simple type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema: https://cyclonedx.org/docs/1.4/json/#signature_oneOf_i2_publicKey_kty + """ + EC = "EC" + OKP = "OKP" + RSA = "RSA" + + +class SignaturePublicKeyCrv(Enum): + """ + This is our internal representation of the crv simple type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema: https://cyclonedx.org/docs/1.4/json/#signature_oneOf_i2_publicKey_allOf_i1_then_crv + """ + ED25519 = "Ed25519" + Ed448 = "Ed448" + + +class SignaturePublicKey: + """ + This is our internal representation of the public key complex type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema: https://cyclonedx.org/docs/1.4/json/#signature_oneOf_i2_publicKey + JSON only + """ + + def __init__(self, kty: SignaturePublicKeyKty = None, crv: Optional[SignaturePublicKeyCrv] = None, + x: Optional[str] = None, y: Optional[str] = None, + n: Optional[str] = None, e: Optional[str] = None, + value: str = None) -> None: + if not kty and not value: + raise NoPropertiesProvidedException( + '`kty` must be supplied' + ) + if kty == SignaturePublicKeyKty.EC and not crv and not x and not y: + raise NoPropertiesProvidedException( + 'if `kty` equals EC, `crv`, `x` and `y` must be supplied' + ) + if kty == SignaturePublicKeyKty.OKP and not crv and not x: + raise NoPropertiesProvidedException( + 'if `kty` equals OKP, `crv`, and `x` must be supplied' + ) + if kty == SignaturePublicKeyKty.RSA and not n and not e: + raise NoPropertiesProvidedException( + 'if `kty` equals RSA, `n`, and `e` must be supplied' + ) + self.kty = kty + self.crv = crv + self.x = x + self.y = y + self.n = n + self.e = e + self.value = value + + +class Signature: + """ + This is out internal representation of the signature complex type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema: https://cyclonedx.org/docs/1.4/json/#signature + JSON only + """ + + def __init__(self, algorithm: SignatureAlgorithm, key_id: Optional[str], + public_key: Optional[SignaturePublicKey] = None, + certificate_path: Optional[List[str]] = None, + excludes: Optional[List[str]] = None, + value: str = None) -> None: + if not algorithm and not value: + raise NoPropertiesProvidedException( + 'One of `algorithm` or `value` must be supplied - neither supplied' + ) + self.algorithm = algorithm + self.key_id = key_id + self.public_key = public_key + self.certificate_path = certificate_path + self.excludes = excludes + self.value = value + + class Encoding(Enum): """ This is out internal representation of the encoding simple type within the CycloneDX standard. diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index d0aa0068..5786ea0a 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -23,6 +23,7 @@ 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: @@ -200,7 +202,9 @@ def add_component(self, component: Component) -> None: Returns: None """ - if not self.has_component(component=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: @@ -263,7 +267,9 @@ 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 def has_vulnerabilities(self) -> bool: """ @@ -278,3 +284,19 @@ def has_vulnerabilities(self) -> bool: return True return False + + @property + def services(self) -> Optional[List[Service]]: + """ + A list of services. + + This may include microservices, function-as-a-service, and other types of network or intra-process services. + + Returns: + List of `Service` or `None` + """ + return self._services + + @services.setter + def services(self, services: Optional[List[Service]]) -> None: + self._services = services 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..72e1cc86 --- /dev/null +++ b/cyclonedx/model/service.py @@ -0,0 +1,310 @@ +# 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, Signature, XsUri +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, + signature: Optional[Signature] = 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 + self.signature = signature + + @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 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 + [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 + + @property + def signature(self) -> Optional[Signature]: + """ + Enveloped signature in JSON Signature Format (JSF). + + Returns: + `Signature` if set else `None` + """ + return self._signature + + @signature.setter + def signature(self, signature: Optional[Signature]) -> None: + self._signature = signature diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py index 1b7bcdec..fef1916c 100644 --- a/cyclonedx/output/json.py +++ b/cyclonedx/output/json.py @@ -51,11 +51,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 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 +95,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/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.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..b21975ba 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -20,15 +20,18 @@ from decimal import Decimal from datetime import datetime, timezone from os.path import dirname, join +from typing import List + from packageurl import PackageURL from unittest.mock import Mock, patch 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,156 @@ 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()) + + @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() + )