diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py index 10597370..ee34aaa2 100644 --- a/cyclonedx/model/service.py +++ b/cyclonedx/model/service.py @@ -67,6 +67,7 @@ def __init__( endpoints: Optional[Iterable[XsUri]] = None, authenticated: Optional[bool] = None, x_trust_boundary: Optional[bool] = None, + trust_zone: Optional[str] = None, data: Optional[Iterable[DataClassification]] = None, licenses: Optional[Iterable[License]] = None, external_references: Optional[Iterable[ExternalReference]] = None, @@ -83,6 +84,7 @@ def __init__( self.endpoints = endpoints or [] self.authenticated = authenticated self.x_trust_boundary = x_trust_boundary + self.trust_zone = trust_zone self.data = data or [] self.licenses = licenses or [] self.external_references = external_references or [] @@ -239,16 +241,26 @@ def x_trust_boundary(self) -> Optional[bool]: def x_trust_boundary(self, x_trust_boundary: Optional[bool]) -> None: self._x_trust_boundary = x_trust_boundary - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(9) - # def trust_zone(self) -> ...: - # ... # since CDX1.5 - # - # @trust_zone.setter - # def trust_zone(self, ...) -> None: - # ... # since CDX1.5 + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(9) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def trust_zone(self) -> Optional[str]: + """ + The name of the trust zone the service resides in. + + Supported from CycloneDX v1.5 onwards. + + Returns: + `str` if set else `None` + """ + return self._trust_zone + + @trust_zone.setter + def trust_zone(self, trust_zone: Optional[str]) -> None: + self._trust_zone = trust_zone @property @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'classification') @@ -369,7 +381,7 @@ def __comparable_tuple(self) -> _ComparableTuple: self.authenticated, _ComparableTuple(self.data), _ComparableTuple(self.endpoints), _ComparableTuple(self.external_references), _ComparableTuple(self.licenses), _ComparableTuple(self.properties), self.release_notes, _ComparableTuple(self.services), - self.x_trust_boundary + self.x_trust_boundary, self.trust_zone )) def __eq__(self, other: object) -> bool: diff --git a/tests/_data/models.py b/tests/_data/models.py index 55a5cdb9..7f732836 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -614,7 +614,7 @@ def get_bom_with_services_complex() -> Bom: XsUri('/api/thing/1'), XsUri('/api/thing/2') ], - authenticated=False, x_trust_boundary=True, data=[ + authenticated=False, x_trust_boundary=True, trust_zone='internal-vpc', data=[ DataClassification(flow=DataFlow.OUTBOUND, classification='public') ], licenses=[DisjunctiveLicense(name='Commercial')], diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.5.json.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.5.json.bin index 7672db57..dd798d1c 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.5.json.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.5.json.bin @@ -154,6 +154,7 @@ "title": "Release Notes Title", "type": "major" }, + "trustZone": "internal-vpc", "version": "1.2.3", "x-trust-boundary": true }, diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.5.xml.bin index 7fb7fc50..65e78a5d 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.5.xml.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.5.xml.bin @@ -33,6 +33,7 @@ false true + internal-vpc public diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.6.json.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.6.json.bin index 45b78218..9ea9865a 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.6.json.bin @@ -160,6 +160,7 @@ "title": "Release Notes Title", "type": "major" }, + "trustZone": "internal-vpc", "version": "1.2.3", "x-trust-boundary": true }, diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.6.xml.bin index 7a054cfa..ccac1c2d 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.6.xml.bin @@ -39,6 +39,7 @@ false true + internal-vpc public diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.7.json.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.7.json.bin index 9aa33fa2..54206c3c 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.7.json.bin @@ -160,6 +160,7 @@ "title": "Release Notes Title", "type": "major" }, + "trustZone": "internal-vpc", "version": "1.2.3", "x-trust-boundary": true }, diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.7.xml.bin index 770f7a84..760dcfe1 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.7.xml.bin @@ -39,6 +39,7 @@ false true + internal-vpc public diff --git a/tests/test_deserialize_json.py b/tests/test_deserialize_json.py index 85f8b11a..9aa53183 100644 --- a/tests/test_deserialize_json.py +++ b/tests/test_deserialize_json.py @@ -17,6 +17,7 @@ from collections.abc import Callable +from io import StringIO from json import loads as json_loads from os.path import join from typing import Any @@ -27,6 +28,9 @@ from cyclonedx.model.bom import Bom from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression, LicenseRepository +from cyclonedx.model.service import Service +from cyclonedx.output.json import BY_SCHEMA_VERSION as JSON_BY_SCHEMA_VERSION +from cyclonedx.output.xml import BY_SCHEMA_VERSION as XML_BY_SCHEMA_VERSION from cyclonedx.schema import OutputFormat, SchemaVersion from tests import OWN_DATA_DIRECTORY, DeepCompareMixin, SnapshotMixin, mksname from tests._data.models import ( @@ -130,6 +134,31 @@ def test_regression_issue690(self) -> None: bom: Bom = Bom.from_json(json) # <<< is expected to not crash self.assertIsNotNone(bom) + def test_service_trust_zone_from_json(self) -> None: + bom = Bom.from_json({ + 'bomFormat': 'CycloneDX', + 'specVersion': '1.7', + 'version': 1, + 'services': [ + { + 'bom-ref': 'svc-ref', + 'name': 'svc', + 'trustZone': 'internal-vpc', + } + ], + }) + self.assertEqual('internal-vpc', next(iter(bom.services)).trust_zone) + + def test_service_trust_zone_json_to_xml_roundtrip(self) -> None: + bom = Bom(services=[ + Service(name='svc', bom_ref='svc-ref', trust_zone='internal-vpc') + ]) + json_text = JSON_BY_SCHEMA_VERSION[SchemaVersion.V1_7](bom).output_as_string() + json_bom = Bom.from_json(json_loads(json_text)) + xml_text = XML_BY_SCHEMA_VERSION[SchemaVersion.V1_7](json_bom).output_as_string() + xml_bom = Bom.from_xml(StringIO(xml_text)) + self.assertEqual('internal-vpc', next(iter(xml_bom.services)).trust_zone) + def test_component_evidence_identity(self) -> None: json_file = join(OWN_DATA_DIRECTORY, 'json', SchemaVersion.V1_6.to_version(), diff --git a/tests/test_deserialize_xml.py b/tests/test_deserialize_xml.py index 8ad0f31c..2115ce5b 100644 --- a/tests/test_deserialize_xml.py +++ b/tests/test_deserialize_xml.py @@ -16,6 +16,7 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. from collections.abc import Callable +from io import StringIO from os.path import join from typing import Any from unittest import TestCase @@ -50,6 +51,19 @@ def test_prepared(self, get_bom: Callable[[], Bom], *_: Any, **__: Any) -> None: self.assertBomDeepEqual(expected, bom, fuzzy_deps=get_bom in all_get_bom_funct_with_incomplete_deps) + def test_service_trust_zone_from_xml(self) -> None: + bom = Bom.from_xml(StringIO(""" + + + + svc + internal-vpc + + + +""")) + self.assertEqual('internal-vpc', next(iter(bom.services)).trust_zone) + def test_component_evidence_identity(self) -> None: xml_file = join(OWN_DATA_DIRECTORY, 'xml', SchemaVersion.V1_6.to_version(), diff --git a/tests/test_model_service.py b/tests/test_model_service.py index c66c2521..94f87da5 100644 --- a/tests/test_model_service.py +++ b/tests/test_model_service.py @@ -18,6 +18,8 @@ from unittest import TestCase +from sortedcontainers import SortedSet + from cyclonedx.model.service import Service from tests import reorder @@ -35,6 +37,7 @@ def test_minimal_service(self) -> None: self.assertFalse(s.endpoints) self.assertIsNone(s.authenticated) self.assertIsNone(s.x_trust_boundary) + self.assertIsNone(s.trust_zone) self.assertFalse(s.data) self.assertFalse(s.licenses) self.assertFalse(s.external_references) @@ -57,6 +60,7 @@ def test_service_with_services(self) -> None: self.assertFalse(parent_service.endpoints) self.assertIsNone(parent_service.authenticated) self.assertIsNone(parent_service.x_trust_boundary) + self.assertIsNone(parent_service.trust_zone) self.assertFalse(parent_service.data) self.assertFalse(parent_service.licenses) self.assertFalse(parent_service.external_references) @@ -80,3 +84,19 @@ def test_sort(self) -> None: sorted_services = sorted(services) expected_services = reorder(services, expected_order) self.assertListEqual(sorted_services, expected_services) + + def test_trust_zone_default(self) -> None: + s = Service(name='my-test-service') + self.assertIsNone(s.trust_zone) + + def test_trust_zone_setter(self) -> None: + s = Service(name='my-test-service', trust_zone='internal-vpc') + self.assertEqual('internal-vpc', s.trust_zone) + s.trust_zone = 'public-internet' + self.assertEqual('public-internet', s.trust_zone) + + def test_trust_zone_affects_equality_and_sorted_set_membership(self) -> None: + s1 = Service(name='my-test-service', trust_zone='internal-vpc') + s2 = Service(name='my-test-service', trust_zone='public-internet') + self.assertNotEqual(s1, s2) + self.assertEqual(2, len(SortedSet((s1, s2)))) diff --git a/tests/test_output_json.py b/tests/test_output_json.py index b9340a4e..8ccd1cd4 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -18,6 +18,7 @@ import re from collections.abc import Callable +from json import dumps, loads from typing import Any from unittest import TestCase from unittest.mock import Mock, patch @@ -34,6 +35,7 @@ ) from cyclonedx.exception.output import FormatNotSupportedException from cyclonedx.model.bom import Bom +from cyclonedx.model.service import Service from cyclonedx.output.json import BY_SCHEMA_VERSION, Json from cyclonedx.schema import OutputFormat, SchemaVersion from cyclonedx.validation.json import JsonStrictValidator @@ -105,6 +107,57 @@ def test_bomref_not_duplicate(self) -> None: self.assertEqual(nr_bomrefs, len(found)) self.assertCountEqual(set(found), found, 'expected unique items') + def test_service_trust_zone_by_schema_version(self) -> None: + bom = Bom(services=[ + Service(name='svc', bom_ref='svc-ref', trust_zone='internal-vpc') + ]) + supported_schema_versions = ( + SchemaVersion.V1_5, + SchemaVersion.V1_6, + SchemaVersion.V1_7, + ) + unsupported_schema_versions = ( + SchemaVersion.V1_2, + SchemaVersion.V1_3, + SchemaVersion.V1_4, + ) + for sv in supported_schema_versions: + with self.subTest(schema_version=sv): + output = BY_SCHEMA_VERSION[sv](bom).output_as_string() + service = loads(output)['services'][0] + self.assertEqual('internal-vpc', service['trustZone']) + self.assertNotIn('trust_zone', service) + try: + errors = JsonStrictValidator(sv).validate_str(output) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNone(errors, output) + for sv in unsupported_schema_versions: + with self.subTest(schema_version=sv): + output = BY_SCHEMA_VERSION[sv](bom).output_as_string() + service = loads(output)['services'][0] + self.assertNotIn('trustZone', service) + self.assertNotIn('trust_zone', service) + try: + errors = JsonStrictValidator(sv).validate_str(output) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNone(errors, output) + + def test_service_trust_zone_rejected_before_15(self) -> None: + bom = Bom(services=[ + Service(name='svc', bom_ref='svc-ref', trust_zone='internal-vpc') + ]) + output = BY_SCHEMA_VERSION[SchemaVersion.V1_4](bom).output_as_string() + data = loads(output) + data['services'][0]['trustZone'] = 'internal-vpc' + mutated_output = dumps(data) + try: + errors = JsonStrictValidator(SchemaVersion.V1_4).validate_str(mutated_output) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNotNone(errors) + @ddt class TestFunctionalBySchemaVersion(TestCase): diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index 6e887ded..4d00f1d8 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -32,6 +32,7 @@ UnknownComponentDependencyException, ) from cyclonedx.model.bom import Bom +from cyclonedx.model.service import Service from cyclonedx.output.xml import BY_SCHEMA_VERSION, Xml from cyclonedx.schema import OutputFormat, SchemaVersion from cyclonedx.validation.xml import XmlValidator @@ -93,6 +94,65 @@ def test_bomref_not_duplicate(self) -> None: self.assertEqual(nr_bomrefs, len(found)) self.assertCountEqual(set(found), found, 'expected unique items') + def test_service_trust_zone_by_schema_version(self) -> None: + bom = Bom(services=[ + Service(name='svc', bom_ref='svc-ref', trust_zone='internal-vpc') + ]) + supported_schema_versions = ( + SchemaVersion.V1_5, + SchemaVersion.V1_6, + SchemaVersion.V1_7, + ) + unsupported_schema_versions = ( + SchemaVersion.V1_2, + SchemaVersion.V1_3, + SchemaVersion.V1_4, + ) + for sv in supported_schema_versions: + with self.subTest(schema_version=sv): + output = BY_SCHEMA_VERSION[sv](bom).output_as_string() + self.assertIn('internal-vpc', output) + try: + errors = XmlValidator(sv).validate_str(output) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNone(errors, output) + for sv in unsupported_schema_versions: + with self.subTest(schema_version=sv): + output = BY_SCHEMA_VERSION[sv](bom).output_as_string() + self.assertNotIn('', output) + try: + errors = XmlValidator(sv).validate_str(output) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNone(errors, output) + + def test_service_trust_zone_xml_string_is_normalized(self) -> None: + bom = Bom(services=[ + Service(name='svc', bom_ref='svc-ref', trust_zone='internal\tvpc\nzone') + ]) + output = BY_SCHEMA_VERSION[SchemaVersion.V1_7](bom).output_as_string() + self.assertIn('internal vpc zone', output) + + def test_service_trust_zone_rejected_before_15(self) -> None: + bom = Bom(services=[ + Service(name='svc', bom_ref='svc-ref', trust_zone='internal-vpc') + ]) + output = BY_SCHEMA_VERSION[SchemaVersion.V1_4](bom).output_as_string() + self.assertEqual(1, output.count('')) + mutated_output = output.replace( + '', + ' internal-vpc\n ', + 1 + ) + self.assertNotEqual(output, mutated_output) + try: + errors = XmlValidator(SchemaVersion.V1_4).validate_str(mutated_output) + except MissingOptionalDependencyException: + self.skipTest('MissingOptionalDependencyException') + self.assertIsNotNone(errors) + self.assertIn('trustZone', str(errors)) + @ddt class TestFunctionalBySchemaVersion(TestCase):