diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py
index 09031eef7..518ef8248 100644
--- a/cyclonedx/model/component.py
+++ b/cyclonedx/model/component.py
@@ -51,7 +51,7 @@
SchemaVersion1Dot6,
SchemaVersion1Dot7,
)
-from ..serialization import PackageUrl as PackageUrlSH
+from ..serialization import PackageUrl as PackageUrlSH, XmlBoolAttribute as _XmlBoolAttributeSH
from . import (
AttachedText,
ExternalReference,
@@ -993,6 +993,7 @@ def __init__(
version: Optional[str] = None,
description: Optional[str] = None,
scope: Optional[ComponentScope] = None,
+ is_external: Optional[bool] = None,
hashes: Optional[Iterable[HashType]] = None,
licenses: Optional[Iterable[License]] = None,
copyright: Optional[str] = None,
@@ -1026,6 +1027,7 @@ def __init__(
self.name = name
self.description = description
self.scope = scope
+ self.is_external = is_external
self.hashes = hashes or []
self.licenses = licenses or []
self.copyright = copyright
@@ -1304,6 +1306,29 @@ def scope(self) -> Optional[ComponentScope]:
def scope(self, scope: Optional[ComponentScope]) -> None:
self._scope = scope
+ @property
+ @serializable.json_name('isExternal')
+ @serializable.xml_name('isExternal')
+ @serializable.xml_attribute()
+ @serializable.type_mapping(_XmlBoolAttributeSH)
+ @serializable.view(SchemaVersion1Dot7)
+ def is_external(self) -> Optional[bool]:
+ """
+ Determine whether this component is external. An external component is one that is not part of an assembly,
+ but is expected to be provided by the environment, regardless of the component's scope. This setting can be
+ useful for distinguishing which components are bundled with the product and which can be relied upon to be
+ present in the deployment environment. This may be set to true for runtime components only. For
+ metadata.component, it must be set to false.
+
+ Returns:
+ `bool` if set else `None`
+ """
+ return self._is_external
+
+ @is_external.setter
+ def is_external(self, is_external: Optional[bool]) -> None:
+ self._is_external = is_external
+
@property
@serializable.type_mapping(_HashTypeRepositorySerializationHelper)
@serializable.xml_sequence(11)
@@ -1683,7 +1708,7 @@ def __comparable_tuple(self) -> _ComparableTuple:
self.swid, self.cpe, _ComparableTuple(self.swhids),
self.supplier, self.author, self.publisher,
self.description,
- self.mime_type, self.scope, _ComparableTuple(self.hashes),
+ self.mime_type, self.scope, self.is_external, _ComparableTuple(self.hashes),
_ComparableTuple(self.licenses), self.copyright,
self.pedigree,
_ComparableTuple(self.external_references), _ComparableTuple(self.properties),
diff --git a/cyclonedx/serialization/__init__.py b/cyclonedx/serialization/__init__.py
index 1fec0026f..a5b946cbc 100644
--- a/cyclonedx/serialization/__init__.py
+++ b/cyclonedx/serialization/__init__.py
@@ -95,6 +95,63 @@ def deserialize(cls, o: Any) -> UUID:
) from err
+class XmlBoolAttribute(BaseHelper):
+ """Helper for serializing boolean values as XML attribute-compatible 'true'/'false' strings,
+ while keeping native boolean values for JSON."""
+
+ @classmethod
+ def json_serialize(cls, o: Any) -> Optional[bool]:
+ if o is None:
+ return None
+ if isinstance(o, bool):
+ return o
+ raise SerializationOfUnexpectedValueException(
+ f'Attempt to serialize a non-boolean: {o!r}')
+
+ @classmethod
+ def json_deserialize(cls, o: Any) -> Optional[bool]:
+ if o is None:
+ return None
+ if isinstance(o, bool):
+ return o
+ raise CycloneDxDeserializationException(
+ f'Invalid boolean value: {o!r}'
+ )
+
+ @classmethod
+ def xml_serialize(cls, o: Any) -> Optional[str]:
+ if o is None:
+ return None
+ if isinstance(o, bool):
+ return 'true' if o else 'false'
+ raise SerializationOfUnexpectedValueException(
+ f'Attempt to serialize a non-boolean: {o!r}')
+
+ @classmethod
+ def xml_deserialize(cls, o: Any) -> Optional[bool]:
+ if o is None:
+ return None
+ if isinstance(o, bool):
+ return o
+ if isinstance(o, str):
+ o_lower = o.lower()
+ if o_lower in ('1', 'true'):
+ return True
+ if o_lower in ('0', 'false'):
+ return False
+ raise CycloneDxDeserializationException(
+ f'Invalid boolean value: {o!r}'
+ )
+
+ @classmethod
+ def serialize(cls, o: Any) -> Any:
+ return cls.xml_serialize(o)
+
+ @classmethod
+ def deserialize(cls, o: Any) -> Any:
+ return cls.xml_deserialize(o)
+
+
@deprecated('No public API planned for replacing this,')
class LicenseRepositoryHelper(_LicenseRepositorySerializationHelper):
"""**DEPRECATED**
diff --git a/tests/_data/models.py b/tests/_data/models.py
index 55a5cdb9a..edd9f8157 100644
--- a/tests/_data/models.py
+++ b/tests/_data/models.py
@@ -593,6 +593,11 @@ def get_bom_with_external_references() -> Bom:
return bom
+def get_bom_with_external_component_1_7() -> Bom:
+ bom = _make_bom(components=[get_component_external()])
+ return bom
+
+
def get_bom_with_services_simple() -> Bom:
bom = _make_bom(services=[
Service(name='my-first-service', bom_ref='my-specific-bom-ref-for-my-first-service'),
@@ -853,6 +858,16 @@ def get_component_setuptools_simple(
)
+def get_component_external() -> Component:
+ return Component(
+ name='external-lib', version='1.0.0',
+ type=ComponentType.LIBRARY,
+ is_external=True,
+ scope=ComponentScope.REQUIRED,
+ bom_ref='external-lib-1.0.0',
+ )
+
+
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',
@@ -1611,6 +1626,7 @@ def get_bom_for_issue540_duplicate_components() -> Bom:
get_bom_with_licenses,
get_bom_with_multiple_licenses,
get_bom_for_issue_497_urls,
+ get_bom_with_external_component_1_7,
get_bom_for_issue_598_multiple_components_with_purl_qualifiers,
get_bom_with_component_setuptools_with_v16_fields,
get_bom_for_issue_630_empty_property,
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin
new file mode 100644
index 000000000..aaae83372
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin
@@ -0,0 +1,11 @@
+
+
+
+
+ external-lib
+ 1.0.0
+ required
+ false
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin
new file mode 100644
index 000000000..06e044d33
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin
@@ -0,0 +1,10 @@
+
+
+
+
+ external-lib
+ 1.0.0
+ required
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin
new file mode 100644
index 000000000..fe5a4e0ac
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin
@@ -0,0 +1,24 @@
+{
+ "components": [
+ {
+ "bom-ref": "external-lib-1.0.0",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": "1.0.0"
+ }
+ ],
+ "dependencies": [
+ {
+ "ref": "external-lib-1.0.0"
+ }
+ ],
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.2b.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.2"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin
new file mode 100644
index 000000000..266020af7
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin
@@ -0,0 +1,16 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
+
+ external-lib
+ 1.0.0
+ required
+
+
+
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin
new file mode 100644
index 000000000..8500a9f79
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin
@@ -0,0 +1,24 @@
+{
+ "components": [
+ {
+ "bom-ref": "external-lib-1.0.0",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": "1.0.0"
+ }
+ ],
+ "dependencies": [
+ {
+ "ref": "external-lib-1.0.0"
+ }
+ ],
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.3a.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.3"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin
new file mode 100644
index 000000000..120d5d288
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin
@@ -0,0 +1,16 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
+
+ external-lib
+ 1.0.0
+ required
+
+
+
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin
new file mode 100644
index 000000000..c2c3bbea0
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin
@@ -0,0 +1,24 @@
+{
+ "components": [
+ {
+ "bom-ref": "external-lib-1.0.0",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": "1.0.0"
+ }
+ ],
+ "dependencies": [
+ {
+ "ref": "external-lib-1.0.0"
+ }
+ ],
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.4"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin
new file mode 100644
index 000000000..2d85c7dec
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin
@@ -0,0 +1,16 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
+
+ external-lib
+ 1.0.0
+ required
+
+
+
+
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin
new file mode 100644
index 000000000..f3d168966
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin
@@ -0,0 +1,34 @@
+{
+ "components": [
+ {
+ "bom-ref": "external-lib-1.0.0",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": "1.0.0"
+ }
+ ],
+ "dependencies": [
+ {
+ "ref": "external-lib-1.0.0"
+ }
+ ],
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "properties": [
+ {
+ "name": "key1",
+ "value": "val1"
+ },
+ {
+ "name": "key2",
+ "value": "val2"
+ }
+ ],
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.5"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin
new file mode 100644
index 000000000..f06f8a0b5
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin
@@ -0,0 +1,20 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
+
+ external-lib
+ 1.0.0
+ required
+
+
+
+
+
+
+ val1
+ val2
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin
new file mode 100644
index 000000000..bf66e8484
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin
@@ -0,0 +1,34 @@
+{
+ "components": [
+ {
+ "bom-ref": "external-lib-1.0.0",
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": "1.0.0"
+ }
+ ],
+ "dependencies": [
+ {
+ "ref": "external-lib-1.0.0"
+ }
+ ],
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "properties": [
+ {
+ "name": "key1",
+ "value": "val1"
+ },
+ {
+ "name": "key2",
+ "value": "val2"
+ }
+ ],
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.6"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin
new file mode 100644
index 000000000..c81104a76
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin
@@ -0,0 +1,20 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
+
+ external-lib
+ 1.0.0
+ required
+
+
+
+
+
+
+ val1
+ val2
+
+
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin
new file mode 100644
index 000000000..c3c2f85b3
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin
@@ -0,0 +1,35 @@
+{
+ "components": [
+ {
+ "bom-ref": "external-lib-1.0.0",
+ "isExternal": true,
+ "name": "external-lib",
+ "scope": "required",
+ "type": "library",
+ "version": "1.0.0"
+ }
+ ],
+ "dependencies": [
+ {
+ "ref": "external-lib-1.0.0"
+ }
+ ],
+ "metadata": {
+ "timestamp": "2023-01-07T13:44:32.312678+00:00"
+ },
+ "properties": [
+ {
+ "name": "key1",
+ "value": "val1"
+ },
+ {
+ "name": "key2",
+ "value": "val2"
+ }
+ ],
+ "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
+ "version": 1,
+ "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.7"
+}
\ No newline at end of file
diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin
new file mode 100644
index 000000000..b16d837f9
--- /dev/null
+++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin
@@ -0,0 +1,20 @@
+
+
+
+ 2023-01-07T13:44:32.312678+00:00
+
+
+
+ external-lib
+ 1.0.0
+ required
+
+
+
+
+
+
+ val1
+ val2
+
+
diff --git a/tests/test_model_component.py b/tests/test_model_component.py
index 44f59a121..ca37e59e9 100644
--- a/tests/test_model_component.py
+++ b/tests/test_model_component.py
@@ -264,6 +264,45 @@ def test_sort(self) -> None:
expected_components = reorder(components, expected_order)
self.assertListEqual(sorted_components, expected_components)
+ def test_is_external_default_value(self) -> None:
+ c = Component(name='test-component')
+ self.assertIsNone(c.is_external)
+
+ def test_is_external_set_true(self) -> None:
+ c = Component(name='test-component', is_external=True)
+ self.assertTrue(c.is_external)
+
+ def test_is_external_set_false(self) -> None:
+ c = Component(name='test-component', is_external=False)
+ self.assertFalse(c.is_external)
+
+ def test_is_external_equality_same(self) -> None:
+ c1 = Component(name='test-component', is_external=True)
+ c2 = Component(name='test-component', is_external=True)
+ self.assertEqual(c1, c2)
+
+ def test_is_external_equality_different(self) -> None:
+ c1 = Component(name='test-component', is_external=True)
+ c2 = Component(name='test-component', is_external=False)
+ c3 = Component(name='test-component')
+ self.assertNotEqual(c1, c2)
+ self.assertNotEqual(c1, c3)
+ self.assertNotEqual(c2, c3)
+
+ def test_is_external_sorting(self) -> None:
+ # expected sort order: (type, group, name, version, is_external)
+ # ComparableTuple treats None as greater than any value
+ # so order is: False < True < None
+ expected_order = [1, 0, 2]
+ components = [
+ Component(name='component-a', is_external=True),
+ Component(name='component-a', is_external=False),
+ Component(name='component-a'), # is_external=None
+ ]
+ sorted_components = sorted(components)
+ expected_components = reorder(components, expected_order)
+ self.assertListEqual(sorted_components, expected_components)
+
def test_nested_components_1(self) -> None:
comp_b = Component(name='comp_b')
comp_c = Component(name='comp_c')