Skip to content

Commit

Permalink
feat: add support for Lifecycles in BOM metadata (#698)
Browse files Browse the repository at this point in the history

---------

Signed-off-by: Johannes Feichtner <johannes@web-wack.at>
Signed-off-by: Jan Kowalleck <jan.kowalleck@gmail.com>
Signed-off-by: Johannes Feichtner <343448+Churro@users.noreply.github.com>
Co-authored-by: Jan Kowalleck <jan.kowalleck@gmail.com>
  • Loading branch information
Churro and jkowalleck authored Oct 21, 2024
1 parent 369009f commit 6cfeb71
Show file tree
Hide file tree
Showing 34 changed files with 896 additions and 13 deletions.
32 changes: 21 additions & 11 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from .contact import OrganizationalContact, OrganizationalEntity
from .dependency import Dependable, Dependency
from .license import License, LicenseExpression, LicenseRepository
from .lifecycle import Lifecycle, LifecycleRepository, _LifecycleRepositoryHelper
from .service import Service
from .tool import Tool, ToolRepository, _ToolRepositoryHelper
from .vulnerability import Vulnerability
Expand Down Expand Up @@ -70,6 +71,7 @@ def __init__(
properties: Optional[Iterable[Property]] = None,
timestamp: Optional[datetime] = None,
manufacturer: Optional[OrganizationalEntity] = None,
lifecycles: Optional[Iterable[Lifecycle]] = None,
# Deprecated as of v1.6
manufacture: Optional[OrganizationalEntity] = None,
) -> None:
Expand All @@ -81,6 +83,7 @@ def __init__(
self.licenses = licenses or [] # type:ignore[assignment]
self.properties = properties or [] # type:ignore[assignment]
self.manufacturer = manufacturer
self.lifecycles = lifecycles or [] # type:ignore[assignment]

self.manufacture = manufacture
if manufacture:
Expand All @@ -105,16 +108,23 @@ def timestamp(self) -> datetime:
def timestamp(self, timestamp: datetime) -> None:
self._timestamp = timestamp

# @property
# ...
# @serializable.view(SchemaVersion1Dot5)
# @serializable.xml_sequence(2)
# def lifecycles(self) -> ...:
# ... # TODO since CDX1.5
#
# @lifecycles.setter
# def lifecycles(self, ...) -> None:
# ... # TODO since CDX1.5
@property
@serializable.view(SchemaVersion1Dot5)
@serializable.view(SchemaVersion1Dot6)
@serializable.type_mapping(_LifecycleRepositoryHelper)
@serializable.xml_sequence(2)
def lifecycles(self) -> LifecycleRepository:
"""
An optional list of BOM lifecycle stages.
Returns:
Set of `Lifecycle`
"""
return self._lifecycles

@lifecycles.setter
def lifecycles(self, lifecycles: Iterable[Lifecycle]) -> None:
self._lifecycles = LifecycleRepository(lifecycles)

@property
@serializable.type_mapping(_ToolRepositoryHelper)
Expand Down Expand Up @@ -290,7 +300,7 @@ def __eq__(self, other: object) -> bool:
def __hash__(self) -> int:
return hash((
tuple(self.authors), self.component, tuple(self.licenses), self.manufacture, tuple(self.properties),
self.supplier, self.timestamp, self.tools, self.manufacturer,
tuple(self.lifecycles), self.supplier, self.timestamp, self.tools, self.manufacturer
))

def __repr__(self) -> str:
Expand Down
245 changes: 245 additions & 0 deletions cyclonedx/model/lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
# This file is part of CycloneDX Python Library
#
# 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.

"""
This set of classes represents the lifecycles types in the CycloneDX standard.
.. note::
Introduced in CycloneDX v1.5
.. note::
See the CycloneDX Schema for lifecycles: https://cyclonedx.org/docs/1.5/#metadata_lifecycles
"""

from enum import Enum
from json import loads as json_loads
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union
from xml.etree.ElementTree import Element # nosec B405

import serializable
from serializable.helpers import BaseHelper
from sortedcontainers import SortedSet

from .._internal.compare import ComparableTuple as _ComparableTuple
from ..exception.serialization import CycloneDxDeserializationException

if TYPE_CHECKING: # pragma: no cover
from serializable import ViewType


@serializable.serializable_enum
class LifecyclePhase(str, Enum):
"""
Enum object that defines the permissible 'phase' for a Lifecycle according to the CycloneDX schema.
.. note::
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_classification
"""
DESIGN = 'design'
PRE_BUILD = 'pre-build'
BUILD = 'build'
POST_BUILD = 'post-build'
OPERATIONS = 'operations'
DISCOVERY = 'discovery'
DECOMMISSION = 'decommission'


@serializable.serializable_class
class PredefinedLifecycle:
"""
Object that defines pre-defined phases in the product lifecycle.
.. note::
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.5/#metadata_lifecycles
"""

def __init__(self, phase: LifecyclePhase) -> None:
self._phase = phase

@property
def phase(self) -> LifecyclePhase:
return self._phase

@phase.setter
def phase(self, phase: LifecyclePhase) -> None:
self._phase = phase

def __hash__(self) -> int:
return hash(self._phase)

def __eq__(self, other: object) -> bool:
if isinstance(other, PredefinedLifecycle):
return hash(other) == hash(self)
return False

def __lt__(self, other: Any) -> bool:
if isinstance(other, PredefinedLifecycle):
return self._phase < other._phase
if isinstance(other, NamedLifecycle):
return True # put PredefinedLifecycle before any NamedLifecycle
return NotImplemented

def __repr__(self) -> str:
return f'<PredefinedLifecycle phase={self._phase}>'


@serializable.serializable_class
class NamedLifecycle:
"""
Object that defines custom state in the product lifecycle.
.. note::
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.5/#metadata_lifecycles
"""

def __init__(self, name: str, *, description: Optional[str] = None) -> None:
self._name = name
self._description = description

@property
@serializable.xml_sequence(1)
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
def name(self) -> str:
"""
Name of the lifecycle phase.
Returns:
`str`
"""
return self._name

@name.setter
def name(self, name: str) -> None:
self._name = name

@property
@serializable.xml_sequence(2)
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
def description(self) -> Optional[str]:
"""
Description of the lifecycle phase.
Returns:
`str`
"""
return self._description

@description.setter
def description(self, description: Optional[str]) -> None:
self._description = description

def __hash__(self) -> int:
return hash((self._name, self._description))

def __eq__(self, other: object) -> bool:
if isinstance(other, NamedLifecycle):
return hash(other) == hash(self)
return False

def __lt__(self, other: Any) -> bool:
if isinstance(other, NamedLifecycle):
return _ComparableTuple((self._name, self._description)) < _ComparableTuple(
(other._name, other._description)
)
if isinstance(other, PredefinedLifecycle):
return False # put NamedLifecycle after any PredefinedLifecycle
return NotImplemented

def __repr__(self) -> str:
return f'<NamedLifecycle name={self._name}>'


Lifecycle = Union[PredefinedLifecycle, NamedLifecycle]
"""TypeAlias for a union of supported lifecycle models.
- :class:`PredefinedLifecycle`
- :class:`NamedLifecycle`
"""

if TYPE_CHECKING: # pragma: no cover
# workaround for https://github.com/python/mypy/issues/5264
# this code path is taken when static code analysis or documentation tools runs through.
class LifecycleRepository(SortedSet[Lifecycle]):
"""Collection of :class:`Lifecycle`.
This is a `set`, not a `list`. Order MUST NOT matter here.
"""
else:
class LifecycleRepository(SortedSet):
"""Collection of :class:`Lifecycle`.
This is a `set`, not a `list`. Order MUST NOT matter here.
"""


class _LifecycleRepositoryHelper(BaseHelper):
@classmethod
def json_normalize(cls, o: LifecycleRepository, *,
view: Optional[Type['ViewType']],
**__: Any) -> Any:
if len(o) == 0:
return None
return [json_loads(li.as_json( # type:ignore[union-attr]
view_=view)) for li in o]

@classmethod
def json_denormalize(cls, o: List[Dict[str, Any]],
**__: Any) -> LifecycleRepository:
repo = LifecycleRepository()
for li in o:
if 'phase' in li:
repo.add(PredefinedLifecycle.from_json( # type:ignore[attr-defined]
li))
elif 'name' in li:
repo.add(NamedLifecycle.from_json( # type:ignore[attr-defined]
li))
else:
raise CycloneDxDeserializationException(f'unexpected: {li!r}')
return repo

@classmethod
def xml_normalize(cls, o: LifecycleRepository, *,
element_name: str,
view: Optional[Type['ViewType']],
xmlns: Optional[str],
**__: Any) -> Optional[Element]:
if len(o) == 0:
return None
elem = Element(element_name)
for li in o:
elem.append(li.as_xml( # type:ignore[union-attr]
view_=view, as_string=False, element_name='lifecycle', xmlns=xmlns))
return elem

@classmethod
def xml_denormalize(cls, o: Element,
default_ns: Optional[str],
**__: Any) -> LifecycleRepository:
repo = LifecycleRepository()
ns_map = {'bom': default_ns or ''}
# Do not iterate over `o` and do not check for expected `.tag` of items.
# This check could have been done by schema validators before even deserializing.
for li in o.iterfind('bom:lifecycle', ns_map):
if li.find('bom:phase', ns_map) is not None:
repo.add(PredefinedLifecycle.from_xml( # type:ignore[attr-defined]
li, default_ns))
elif li.find('bom:name', ns_map) is not None:
repo.add(NamedLifecycle.from_xml( # type:ignore[attr-defined]
li, default_ns))
else:
raise CycloneDxDeserializationException(f'unexpected content: {li!r}')
return repo
17 changes: 17 additions & 0 deletions tests/_data/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
)
from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource
from cyclonedx.model.license import DisjunctiveLicense, License, LicenseAcknowledgement, LicenseExpression
from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle
from cyclonedx.model.release_note import ReleaseNotes
from cyclonedx.model.service import Service
from cyclonedx.model.tool import Tool, ToolRepository
Expand Down Expand Up @@ -534,6 +535,7 @@ def get_bom_just_complete_metadata() -> Bom:
content='VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE='
)
)]
bom.metadata.lifecycles = [PredefinedLifecycle(LifecyclePhase.BUILD)]
bom.metadata.properties = get_properties_1()
return bom

Expand Down Expand Up @@ -1273,6 +1275,20 @@ def get_bom_for_issue_630_empty_property() -> Bom:
})


def get_bom_with_lifecycles() -> Bom:
return _make_bom(
metadata=BomMetaData(
lifecycles=[
PredefinedLifecycle(LifecyclePhase.BUILD),
PredefinedLifecycle(LifecyclePhase.POST_BUILD),
NamedLifecycle(name='platform-integration-testing',
description='Integration testing specific to the runtime platform'),
],
component=Component(name='app', type=ComponentType.APPLICATION, bom_ref='my-app'),
),
)


# ---


Expand Down Expand Up @@ -1318,4 +1334,5 @@ def get_bom_for_issue_630_empty_property() -> Bom:
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,
get_bom_with_lifecycles,
}
4 changes: 4 additions & 0 deletions tests/_data/snapshots/enum_LifecyclePhase-1.0.xml.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" ?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.0" version="1">
<components/>
</bom>
4 changes: 4 additions & 0 deletions tests/_data/snapshots/enum_LifecyclePhase-1.1.xml.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" ?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.1" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
<components/>
</bom>
10 changes: 10 additions & 0 deletions tests/_data/snapshots/enum_LifecyclePhase-1.2.json.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"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"
}
6 changes: 6 additions & 0 deletions tests/_data/snapshots/enum_LifecyclePhase-1.2.xml.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" ?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
<metadata>
<timestamp>2023-01-07T13:44:32.312678+00:00</timestamp>
</metadata>
</bom>
10 changes: 10 additions & 0 deletions tests/_data/snapshots/enum_LifecyclePhase-1.3.json.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"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"
}
Loading

0 comments on commit 6cfeb71

Please sign in to comment.