Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support Bom.compositions #607

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 30 additions & 14 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from . import ExternalReference, Property, ThisTool, Tool
from .bom_ref import BomRef
from .component import Component
from .composition import Composition
from .contact import OrganizationalContact, OrganizationalEntity
from .dependency import Dependable, Dependency
from .license import License, LicenseExpression, LicenseRepository
Expand Down Expand Up @@ -310,6 +311,7 @@ def __init__(self, *, components: Optional[Iterable[Component]] = None,
serial_number: Optional[UUID] = None, version: int = 1,
metadata: Optional[BomMetaData] = None,
dependencies: Optional[Iterable[Dependency]] = None,
compositions: Optional[Iterable[Composition]] = None,
vulnerabilities: Optional[Iterable[Vulnerability]] = None,
properties: Optional[Iterable[Property]] = None) -> None:
"""
Expand All @@ -324,8 +326,9 @@ def __init__(self, *, components: Optional[Iterable[Component]] = None,
self.components = components or [] # type:ignore[assignment]
self.services = services or [] # type:ignore[assignment]
self.external_references = external_references or [] # type:ignore[assignment]
self.vulnerabilities = vulnerabilities or [] # type:ignore[assignment]
self.compositions = compositions or [] # type:ignore[assignment]
self.dependencies = dependencies or [] # type:ignore[assignment]
self.vulnerabilities = vulnerabilities or [] # type:ignore[assignment]
self.properties = properties or [] # type:ignore[assignment]

@property
Expand Down Expand Up @@ -453,24 +456,37 @@ def external_references(self, external_references: Iterable[ExternalReference])
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'dependency')
@serializable.xml_sequence(50)
def dependencies(self) -> 'SortedSet[Dependency]':
"""
Provides the ability to document dependency relationships.

Returns:
Set of `Dependency`
"""
return self._dependencies

@dependencies.setter
def dependencies(self, dependencies: Iterable[Dependency]) -> None:
self._dependencies = SortedSet(dependencies)

# @property
# ...
# @serializable.view(SchemaVersion1Dot3)
# @serializable.view(SchemaVersion1Dot4)
# @serializable.view(SchemaVersion1Dot5)
# @serializable.xml_sequence(6)
# def compositions(self) -> ...:
# ... # TODO Since CDX 1.3
#
# @compositions.setter
# def compositions(self, ...) -> None:
# ... # TODO Since CDX 1.3
@property
@serializable.view(SchemaVersion1Dot4)
madpah marked this conversation as resolved.
Show resolved Hide resolved
@serializable.view(SchemaVersion1Dot5)
@serializable.view(SchemaVersion1Dot6)
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'composition')
@serializable.xml_sequence(60)
def compositions(self) -> 'SortedSet[Composition]':
"""
Compositions describe constituent parts (including components, services, and dependency relationships) and
their completeness.

Returns:
`SortedSet[Composition]`
"""
return self._compositions

@compositions.setter
def compositions(self, compositions: Optional[Iterable[Composition]]) -> None:
self._compositions = SortedSet(compositions)

@property
# @serializable.view(SchemaVersion1Dot3) @todo: Update py-serializable to support view by OutputFormat filtering
Expand Down Expand Up @@ -694,7 +710,7 @@ def __eq__(self, other: object) -> bool:
def __hash__(self) -> int:
return hash((
self.serial_number, self.version, self.metadata, tuple(self.components), tuple(self.services),
tuple(self.external_references), tuple(self.dependencies), tuple(self.properties),
tuple(self.external_references), tuple(self.dependencies), tuple(self.compositions), tuple(self.properties),
tuple(self.vulnerabilities),
))

Expand Down
231 changes: 231 additions & 0 deletions cyclonedx/model/composition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# 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 enum import Enum
from typing import Any, Iterable, Optional

import serializable
from sortedcontainers import SortedSet

from .._internal.compare import ComparableTuple as _ComparableTuple
from ..serialization import BomRefHelper
from .bom_ref import BomRef


@serializable.serializable_enum
class AggregateType(str, Enum):
madpah marked this conversation as resolved.
Show resolved Hide resolved
"""
This is our internal representation of the composition.aggregate ENUM type within the CycloneDX standard.

.. note::
Introduced in CycloneDX v1.4

.. note::
See the CycloneDX Schema for hashType: https://cyclonedx.org/docs/1.4/xml/#type_aggregateType
"""

"""
The relationship is complete. No further relationships including constituent components, services, or dependencies
are known to exist.
"""
COMPLETE = 'complete'

"""
The relationship is incomplete. Additional relationships exist and may include constituent components, services, or
dependencies.
"""
INCOMPLETE = 'incomplete'

"""
The relationship is incomplete. Only relationships for first-party components, services, or their dependencies are
represented.
"""
INCOMPLETE_FIRST_PARTY_ONLY = 'incomplete_first_party_only'

"""
The relationship is incomplete. Only relationships for first-party components, services, or their dependencies are
represented, limited specifically to those that are proprietary.
"""
INCOMPLETE_FIRST_PARTY_PROPRIETARY_ONLY = 'incomplete_first_party_proprietary_only'

"""
The relationship is incomplete. Only relationships for first-party components, services, or their dependencies are
represented, limited specifically to those that are opensource.
"""
INCOMPLETE_FIRST_PARTY_OPENSOURCE_ONLY = 'incomplete_first_party_opensource_only'

"""
The relationship is incomplete. Only relationships for third-party components, services, or their dependencies are
represented.
"""
INCOMPLETE_THIRD_PARTY_ONLY = 'incomplete_third_party_only'

"""
The relationship is incomplete. Only relationships for third-party components, services, or their dependencies are
represented, limited specifically to those that are proprietary.
"""
INCOMPLETE_THIRD_PARTY_PROPRIETARY_ONLY = 'incomplete_third_party_proprietary_only'

"""
The relationship is incomplete. Only relationships for third-party components, services, or their dependencies are
represented, limited specifically to those that are opensource.
"""
INCOMPLETE_THIRD_PARTY_OPENSOURCE_ONLY = 'incomplete_third_party_opensource_only'

"""
The relationship may be complete or incomplete. This usually signifies a 'best-effort' to obtain constituent
components, services, or dependencies but the completeness is inconclusive.
"""
UNKNOWN = 'unknown'

"""
The relationship completeness is not specified.
"""
NOT_SPECIFIED = 'not_specified'


@serializable.serializable_class
class CompositionReference:
Copy link
Member

@jkowalleck jkowalleck Apr 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


what is the purpose of this class?
why not use simple BomRef instances instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think there was a structural reason - let me check @jkowalleck

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - it was added for structural reasons - happy to leave as is @jkowalleck ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i do not understand. what were these structural reasons?
I mean everybody ysing the library would ask the same question I did.

from the schema it looks like all these compositions.assembies and compositions.dependencies are simple sets of BomRef.
see https://github.com/CycloneDX/specification/blob/8e131b1688ccfe41e1bfdd4b3280f33dcc06d04c/schema/bom-1.6.schema.json#L2235-L2252

"""
Models a reference for an assembly or dependency in a Composition.

.. note::
See https://cyclonedx.org/docs/1.4/xml/#type_compositionType
"""

def __init__(self, *, ref: BomRef) -> None:
self.ref = ref

@property
@serializable.json_name('.')
@serializable.type_mapping(BomRefHelper)
@serializable.xml_attribute()
def ref(self) -> BomRef:
"""
References a component or service by its bom-ref attribute.

Returns:
`BomRef`
"""
return self._ref

@ref.setter
def ref(self, ref: BomRef) -> None:
self._ref = ref

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

def __lt__(self, other: Any) -> bool:
if isinstance(other, CompositionReference):
return self.ref < other.ref
return NotImplemented

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

def __repr__(self) -> str:
return f'<CompositionReference ref={self.ref!r}>'


@serializable.serializable_class
class Composition:
"""
This is our internal representation of the `compositionType` type within the CycloneDX standard.

.. note::
Introduced in CycloneDX v1.4

.. note::
See the CycloneDX Schema for hashType: https://cyclonedx.org/docs/1.4/xml/#type_compositionType
"""

def __init__(self, *, aggregate: AggregateType, assemblies: Optional[Iterable[CompositionReference]] = None,
madpah marked this conversation as resolved.
Show resolved Hide resolved
dependencies: Optional[Iterable[CompositionReference]] = None) -> None:
self.aggregate = aggregate
self.assemblies = assemblies or [] # type:ignore[assignment]
self.dependencies = dependencies or [] # type:ignore[assignment]

@property
@serializable.xml_sequence(10)
def aggregate(self) -> AggregateType:
"""
Specifies an aggregate type that describe how complete a relationship is.

Returns:
`AggregateType`
"""
return self._aggregate

@aggregate.setter
def aggregate(self, aggregate: AggregateType) -> None:
self._aggregate = aggregate

@property
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'assembly')
@serializable.xml_sequence(20)
def assemblies(self) -> 'SortedSet[CompositionReference]':
"""
The bom-ref identifiers of the components or services being described. Assemblies refer to nested relationships
whereby a constituent part may include other constituent parts. References do not cascade to child parts.
References are explicit for the specified constituent part only.

Returns:
'SortedSet[CompositionReference]`
"""
return self._assemblies

@assemblies.setter
def assemblies(self, assemblies: Optional[Iterable[CompositionReference]]) -> None:
self._assemblies = SortedSet(assemblies)

@property
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'dependency')
@serializable.xml_sequence(30)
def dependencies(self) -> 'SortedSet[CompositionReference]':
"""
The bom-ref identifiers of the components or services being described. Dependencies refer to a relationship
whereby an independent constituent part requires another independent constituent part. References do not
cascade to transitive dependencies. References are explicit for the specified dependency only.

Returns:
'SortedSet[CompositionReference]`
"""
return self._dependencies

@dependencies.setter
def dependencies(self, dependencies: Optional[Iterable[CompositionReference]]) -> None:
self._dependencies = SortedSet(dependencies)

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

def __lt__(self, other: Any) -> bool:
if isinstance(other, Composition):
return _ComparableTuple((
self.aggregate, _ComparableTuple(self.assemblies), _ComparableTuple(self.dependencies)
)) < _ComparableTuple((
other.aggregate, _ComparableTuple(other.assemblies), _ComparableTuple(other.dependencies)
))
return NotImplemented

def __hash__(self) -> int:
return hash((self.aggregate, tuple(self.assemblies), tuple(self.dependencies)))

def __repr__(self) -> str:
return f'<Composition aggregate={self.aggregate!r}>'
2 changes: 1 addition & 1 deletion docs/schema-support.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Root Level Schema Support
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.dependencies`` | Yes | Since ``2.3.0`` |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.compositions`` | No | |
| ``bom.compositions`` | Yes | Since ``7.4.0`` |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.properties`` | Yes | Supported when outputting to Schema Version >= 1.5. See `schema specification bug 130`_ |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
Expand Down
19 changes: 19 additions & 0 deletions tests/_data/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
Swhid,
Swid,
)
from cyclonedx.model.composition import AggregateType, Composition, CompositionReference
from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity, PostalAddress
from cyclonedx.model.crypto import (
AlgorithmProperties,
Expand Down Expand Up @@ -391,6 +392,24 @@ def get_bom_with_component_setuptools_with_release_notes() -> Bom:
return _make_bom(components=[component])


def get_bom_with_compositions_migrate() -> Bom:
c1 = get_component_setuptools_simple()
c2 = get_component_toml_with_hashes_with_references()
bom = _make_bom(components=[c1, c2])
bom.compositions = [
Composition(
aggregate=AggregateType.COMPLETE,
assemblies=[
CompositionReference(ref=c1.bom_ref)
],
dependencies=[
CompositionReference(ref=c2.bom_ref)
]
)
]
return bom


def get_bom_with_dependencies_valid() -> Bom:
c1 = get_component_setuptools_simple()
c2 = get_component_toml_with_hashes_with_references()
Expand Down
20 changes: 20 additions & 0 deletions tests/_data/snapshots/get_bom_with_compositions-1.0.xml.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" ?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.0" version="1">
<components>
<component type="library">
<name>setuptools</name>
<version>50.3.2</version>
<purl>pkg:pypi/setuptools@50.3.2?extension=tar.gz</purl>
<modified>false</modified>
</component>
<component type="library">
<name>toml</name>
<version>0.10.2</version>
<hashes>
<hash alg="SHA-256">806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b</hash>
</hashes>
<purl>pkg:pypi/toml@0.10.2?extension=tar.gz</purl>
<modified>false</modified>
</component>
</components>
</bom>
Loading