Skip to content

Commit 9a3a45e

Browse files
Churrojkowalleck
authored andcommitted
feat: add support for Lifecycles in BOM metadata (CycloneDX#698)
--------- Signed-off-by: Johannes Feichtner <[email protected]> Signed-off-by: Jan Kowalleck <[email protected]> Signed-off-by: Johannes Feichtner <[email protected]> Co-authored-by: Jan Kowalleck <[email protected]> Signed-off-by: Saquib Saifee <[email protected]>
1 parent 67a2d10 commit 9a3a45e

34 files changed

+896
-13
lines changed

cyclonedx/model/bom.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from .contact import OrganizationalContact, OrganizationalEntity
4444
from .dependency import Dependable, Dependency
4545
from .license import License, LicenseExpression, LicenseRepository
46+
from .lifecycle import Lifecycle, LifecycleRepository, _LifecycleRepositoryHelper
4647
from .service import Service
4748
from .tool import Tool, ToolRepository, _ToolRepositoryHelper
4849
from .vulnerability import Vulnerability
@@ -70,6 +71,7 @@ def __init__(
7071
properties: Optional[Iterable[Property]] = None,
7172
timestamp: Optional[datetime] = None,
7273
manufacturer: Optional[OrganizationalEntity] = None,
74+
lifecycles: Optional[Iterable[Lifecycle]] = None,
7375
# Deprecated as of v1.6
7476
manufacture: Optional[OrganizationalEntity] = None,
7577
) -> None:
@@ -81,6 +83,7 @@ def __init__(
8183
self.licenses = licenses or [] # type:ignore[assignment]
8284
self.properties = properties or [] # type:ignore[assignment]
8385
self.manufacturer = manufacturer
86+
self.lifecycles = lifecycles or [] # type:ignore[assignment]
8487

8588
self.manufacture = manufacture
8689
if manufacture:
@@ -105,16 +108,23 @@ def timestamp(self) -> datetime:
105108
def timestamp(self, timestamp: datetime) -> None:
106109
self._timestamp = timestamp
107110

108-
# @property
109-
# ...
110-
# @serializable.view(SchemaVersion1Dot5)
111-
# @serializable.xml_sequence(2)
112-
# def lifecycles(self) -> ...:
113-
# ... # TODO since CDX1.5
114-
#
115-
# @lifecycles.setter
116-
# def lifecycles(self, ...) -> None:
117-
# ... # TODO since CDX1.5
111+
@property
112+
@serializable.view(SchemaVersion1Dot5)
113+
@serializable.view(SchemaVersion1Dot6)
114+
@serializable.type_mapping(_LifecycleRepositoryHelper)
115+
@serializable.xml_sequence(2)
116+
def lifecycles(self) -> LifecycleRepository:
117+
"""
118+
An optional list of BOM lifecycle stages.
119+
120+
Returns:
121+
Set of `Lifecycle`
122+
"""
123+
return self._lifecycles
124+
125+
@lifecycles.setter
126+
def lifecycles(self, lifecycles: Iterable[Lifecycle]) -> None:
127+
self._lifecycles = LifecycleRepository(lifecycles)
118128

119129
@property
120130
@serializable.type_mapping(_ToolRepositoryHelper)
@@ -290,7 +300,7 @@ def __eq__(self, other: object) -> bool:
290300
def __hash__(self) -> int:
291301
return hash((
292302
tuple(self.authors), self.component, tuple(self.licenses), self.manufacture, tuple(self.properties),
293-
self.supplier, self.timestamp, self.tools, self.manufacturer,
303+
tuple(self.lifecycles), self.supplier, self.timestamp, self.tools, self.manufacturer
294304
))
295305

296306
def __repr__(self) -> str:

cyclonedx/model/lifecycle.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# This file is part of CycloneDX Python Library
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
18+
"""
19+
This set of classes represents the lifecycles types in the CycloneDX standard.
20+
21+
.. note::
22+
Introduced in CycloneDX v1.5
23+
24+
.. note::
25+
See the CycloneDX Schema for lifecycles: https://cyclonedx.org/docs/1.5/#metadata_lifecycles
26+
"""
27+
28+
from enum import Enum
29+
from json import loads as json_loads
30+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union
31+
from xml.etree.ElementTree import Element # nosec B405
32+
33+
import serializable
34+
from serializable.helpers import BaseHelper
35+
from sortedcontainers import SortedSet
36+
37+
from .._internal.compare import ComparableTuple as _ComparableTuple
38+
from ..exception.serialization import CycloneDxDeserializationException
39+
40+
if TYPE_CHECKING: # pragma: no cover
41+
from serializable import ViewType
42+
43+
44+
@serializable.serializable_enum
45+
class LifecyclePhase(str, Enum):
46+
"""
47+
Enum object that defines the permissible 'phase' for a Lifecycle according to the CycloneDX schema.
48+
49+
.. note::
50+
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_classification
51+
"""
52+
DESIGN = 'design'
53+
PRE_BUILD = 'pre-build'
54+
BUILD = 'build'
55+
POST_BUILD = 'post-build'
56+
OPERATIONS = 'operations'
57+
DISCOVERY = 'discovery'
58+
DECOMMISSION = 'decommission'
59+
60+
61+
@serializable.serializable_class
62+
class PredefinedLifecycle:
63+
"""
64+
Object that defines pre-defined phases in the product lifecycle.
65+
66+
.. note::
67+
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.5/#metadata_lifecycles
68+
"""
69+
70+
def __init__(self, phase: LifecyclePhase) -> None:
71+
self._phase = phase
72+
73+
@property
74+
def phase(self) -> LifecyclePhase:
75+
return self._phase
76+
77+
@phase.setter
78+
def phase(self, phase: LifecyclePhase) -> None:
79+
self._phase = phase
80+
81+
def __hash__(self) -> int:
82+
return hash(self._phase)
83+
84+
def __eq__(self, other: object) -> bool:
85+
if isinstance(other, PredefinedLifecycle):
86+
return hash(other) == hash(self)
87+
return False
88+
89+
def __lt__(self, other: Any) -> bool:
90+
if isinstance(other, PredefinedLifecycle):
91+
return self._phase < other._phase
92+
if isinstance(other, NamedLifecycle):
93+
return True # put PredefinedLifecycle before any NamedLifecycle
94+
return NotImplemented
95+
96+
def __repr__(self) -> str:
97+
return f'<PredefinedLifecycle phase={self._phase}>'
98+
99+
100+
@serializable.serializable_class
101+
class NamedLifecycle:
102+
"""
103+
Object that defines custom state in the product lifecycle.
104+
105+
.. note::
106+
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.5/#metadata_lifecycles
107+
"""
108+
109+
def __init__(self, name: str, *, description: Optional[str] = None) -> None:
110+
self._name = name
111+
self._description = description
112+
113+
@property
114+
@serializable.xml_sequence(1)
115+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
116+
def name(self) -> str:
117+
"""
118+
Name of the lifecycle phase.
119+
120+
Returns:
121+
`str`
122+
"""
123+
return self._name
124+
125+
@name.setter
126+
def name(self, name: str) -> None:
127+
self._name = name
128+
129+
@property
130+
@serializable.xml_sequence(2)
131+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
132+
def description(self) -> Optional[str]:
133+
"""
134+
Description of the lifecycle phase.
135+
136+
Returns:
137+
`str`
138+
"""
139+
return self._description
140+
141+
@description.setter
142+
def description(self, description: Optional[str]) -> None:
143+
self._description = description
144+
145+
def __hash__(self) -> int:
146+
return hash((self._name, self._description))
147+
148+
def __eq__(self, other: object) -> bool:
149+
if isinstance(other, NamedLifecycle):
150+
return hash(other) == hash(self)
151+
return False
152+
153+
def __lt__(self, other: Any) -> bool:
154+
if isinstance(other, NamedLifecycle):
155+
return _ComparableTuple((self._name, self._description)) < _ComparableTuple(
156+
(other._name, other._description)
157+
)
158+
if isinstance(other, PredefinedLifecycle):
159+
return False # put NamedLifecycle after any PredefinedLifecycle
160+
return NotImplemented
161+
162+
def __repr__(self) -> str:
163+
return f'<NamedLifecycle name={self._name}>'
164+
165+
166+
Lifecycle = Union[PredefinedLifecycle, NamedLifecycle]
167+
"""TypeAlias for a union of supported lifecycle models.
168+
169+
- :class:`PredefinedLifecycle`
170+
- :class:`NamedLifecycle`
171+
"""
172+
173+
if TYPE_CHECKING: # pragma: no cover
174+
# workaround for https://github.com/python/mypy/issues/5264
175+
# this code path is taken when static code analysis or documentation tools runs through.
176+
class LifecycleRepository(SortedSet[Lifecycle]):
177+
"""Collection of :class:`Lifecycle`.
178+
179+
This is a `set`, not a `list`. Order MUST NOT matter here.
180+
"""
181+
else:
182+
class LifecycleRepository(SortedSet):
183+
"""Collection of :class:`Lifecycle`.
184+
185+
This is a `set`, not a `list`. Order MUST NOT matter here.
186+
"""
187+
188+
189+
class _LifecycleRepositoryHelper(BaseHelper):
190+
@classmethod
191+
def json_normalize(cls, o: LifecycleRepository, *,
192+
view: Optional[Type['ViewType']],
193+
**__: Any) -> Any:
194+
if len(o) == 0:
195+
return None
196+
return [json_loads(li.as_json( # type:ignore[union-attr]
197+
view_=view)) for li in o]
198+
199+
@classmethod
200+
def json_denormalize(cls, o: List[Dict[str, Any]],
201+
**__: Any) -> LifecycleRepository:
202+
repo = LifecycleRepository()
203+
for li in o:
204+
if 'phase' in li:
205+
repo.add(PredefinedLifecycle.from_json( # type:ignore[attr-defined]
206+
li))
207+
elif 'name' in li:
208+
repo.add(NamedLifecycle.from_json( # type:ignore[attr-defined]
209+
li))
210+
else:
211+
raise CycloneDxDeserializationException(f'unexpected: {li!r}')
212+
return repo
213+
214+
@classmethod
215+
def xml_normalize(cls, o: LifecycleRepository, *,
216+
element_name: str,
217+
view: Optional[Type['ViewType']],
218+
xmlns: Optional[str],
219+
**__: Any) -> Optional[Element]:
220+
if len(o) == 0:
221+
return None
222+
elem = Element(element_name)
223+
for li in o:
224+
elem.append(li.as_xml( # type:ignore[union-attr]
225+
view_=view, as_string=False, element_name='lifecycle', xmlns=xmlns))
226+
return elem
227+
228+
@classmethod
229+
def xml_denormalize(cls, o: Element,
230+
default_ns: Optional[str],
231+
**__: Any) -> LifecycleRepository:
232+
repo = LifecycleRepository()
233+
ns_map = {'bom': default_ns or ''}
234+
# Do not iterate over `o` and do not check for expected `.tag` of items.
235+
# This check could have been done by schema validators before even deserializing.
236+
for li in o.iterfind('bom:lifecycle', ns_map):
237+
if li.find('bom:phase', ns_map) is not None:
238+
repo.add(PredefinedLifecycle.from_xml( # type:ignore[attr-defined]
239+
li, default_ns))
240+
elif li.find('bom:name', ns_map) is not None:
241+
repo.add(NamedLifecycle.from_xml( # type:ignore[attr-defined]
242+
li, default_ns))
243+
else:
244+
raise CycloneDxDeserializationException(f'unexpected content: {li!r}')
245+
return repo

tests/_data/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
)
8888
from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource
8989
from cyclonedx.model.license import DisjunctiveLicense, License, LicenseAcknowledgement, LicenseExpression
90+
from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle
9091
from cyclonedx.model.release_note import ReleaseNotes
9192
from cyclonedx.model.service import Service
9293
from cyclonedx.model.tool import Tool, ToolRepository
@@ -534,6 +535,7 @@ def get_bom_just_complete_metadata() -> Bom:
534535
content='VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE='
535536
)
536537
)]
538+
bom.metadata.lifecycles = [PredefinedLifecycle(LifecyclePhase.BUILD)]
537539
bom.metadata.properties = get_properties_1()
538540
return bom
539541

@@ -1273,6 +1275,20 @@ def get_bom_for_issue_630_empty_property() -> Bom:
12731275
})
12741276

12751277

1278+
def get_bom_with_lifecycles() -> Bom:
1279+
return _make_bom(
1280+
metadata=BomMetaData(
1281+
lifecycles=[
1282+
PredefinedLifecycle(LifecyclePhase.BUILD),
1283+
PredefinedLifecycle(LifecyclePhase.POST_BUILD),
1284+
NamedLifecycle(name='platform-integration-testing',
1285+
description='Integration testing specific to the runtime platform'),
1286+
],
1287+
component=Component(name='app', type=ComponentType.APPLICATION, bom_ref='my-app'),
1288+
),
1289+
)
1290+
1291+
12761292
# ---
12771293

12781294

@@ -1318,4 +1334,5 @@ def get_bom_for_issue_630_empty_property() -> Bom:
13181334
get_bom_for_issue_598_multiple_components_with_purl_qualifiers,
13191335
get_bom_with_component_setuptools_with_v16_fields,
13201336
get_bom_for_issue_630_empty_property,
1337+
get_bom_with_lifecycles,
13211338
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" ?>
2+
<bom xmlns="http://cyclonedx.org/schema/bom/1.0" version="1">
3+
<components/>
4+
</bom>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" ?>
2+
<bom xmlns="http://cyclonedx.org/schema/bom/1.1" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
3+
<components/>
4+
</bom>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"metadata": {
3+
"timestamp": "2023-01-07T13:44:32.312678+00:00"
4+
},
5+
"serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
6+
"version": 1,
7+
"$schema": "http://cyclonedx.org/schema/bom-1.2b.schema.json",
8+
"bomFormat": "CycloneDX",
9+
"specVersion": "1.2"
10+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0" ?>
2+
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
3+
<metadata>
4+
<timestamp>2023-01-07T13:44:32.312678+00:00</timestamp>
5+
</metadata>
6+
</bom>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"metadata": {
3+
"timestamp": "2023-01-07T13:44:32.312678+00:00"
4+
},
5+
"serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
6+
"version": 1,
7+
"$schema": "http://cyclonedx.org/schema/bom-1.3a.schema.json",
8+
"bomFormat": "CycloneDX",
9+
"specVersion": "1.3"
10+
}

0 commit comments

Comments
 (0)