# encoding: utf-8
# This file is part of CycloneDX Python Lib
#
# 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.
import warnings
from datetime import datetime
from typing import TYPE_CHECKING, Iterable, Optional, Set
from uuid import UUID, uuid4
import serializable
from sortedcontainers import SortedSet
from cyclonedx.serialization import UrnUuidHelper
from ..exception.model import UnknownComponentDependencyException
from ..parser import BaseParser
from ..schema.schema import (
SchemaVersion1Dot0,
SchemaVersion1Dot1,
SchemaVersion1Dot2,
SchemaVersion1Dot3,
SchemaVersion1Dot4,
)
from . import (
ExternalReference,
LicenseChoice,
OrganizationalContact,
OrganizationalEntity,
Property,
ThisTool,
Tool,
get_now_utc,
)
from .bom_ref import BomRef
from .component import Component
from .dependency import Dependable, Dependency
from .service import Service
from .vulnerability import Vulnerability
if TYPE_CHECKING:
from packageurl import PackageURL
@serializable.serializable_class
@serializable.serializable_class(
ignore_during_deserialization=['$schema', 'bom_format', 'spec_version']) # type: ignore[misc]
[docs]
class Bom:
"""
This is our internal representation of a bill-of-materials (BOM).
You can either create a `cyclonedx.model.bom.Bom` yourself programmatically, or generate a `cyclonedx.model.bom.Bom`
from a `cyclonedx.parser.BaseParser` implementation.
Once you have an instance of `cyclonedx.model.bom.Bom`, you can pass this to an instance of
`cyclonedx.output.BaseOutput` to produce a CycloneDX document according to a specific schema version and format.
"""
@staticmethod
[docs]
def from_parser(parser: BaseParser) -> 'Bom':
"""
Create a Bom instance from a Parser object.
Args:
parser (`cyclonedx.parser.BaseParser`): A valid parser instance.
Returns:
`cyclonedx.model.bom.Bom`: A Bom instance that represents the valid data held in the supplied parser.
"""
bom = Bom()
bom.components.update(parser.get_components())
return bom
def __init__(self, *, components: Optional[Iterable[Component]] = None,
services: Optional[Iterable[Service]] = None,
external_references: Optional[Iterable[ExternalReference]] = None,
serial_number: Optional[UUID] = None, version: int = 1,
metadata: Optional[BomMetaData] = None,
dependencies: Optional[Iterable[Dependency]] = None,
vulnerabilities: Optional[Iterable[Vulnerability]] = None) -> None:
"""
Create a new Bom that you can manually/programmatically add data to later.
Returns:
New, empty `cyclonedx.model.bom.Bom` instance.
"""
self.serial_number = serial_number or uuid4()
self.metadata = metadata or BomMetaData()
self.components = components or [] # type: ignore
self.services = services or [] # type: ignore
self.external_references = SortedSet(external_references or [])
self.vulnerabilities = SortedSet(vulnerabilities or [])
self.version = version
self.dependencies = SortedSet(dependencies) or SortedSet()
@property # type: ignore[misc]
@serializable.type_mapping(UrnUuidHelper)
@serializable.view(SchemaVersion1Dot1)
@serializable.view(SchemaVersion1Dot2)
@serializable.view(SchemaVersion1Dot3)
@serializable.view(SchemaVersion1Dot4)
@serializable.xml_attribute()
[docs]
def serial_number(self) -> UUID:
"""
Unique UUID for this BOM
Returns:
`UUID` instance
`UUID` instance
"""
return self._serial_number
@serial_number.setter
def serial_number(self, serial_number: UUID) -> None:
self._serial_number = serial_number
@property # type: ignore[misc]
@serializable.view(SchemaVersion1Dot2)
@serializable.view(SchemaVersion1Dot3)
@serializable.view(SchemaVersion1Dot4)
@serializable.xml_sequence(1)
@metadata.setter
def metadata(self, metadata: BomMetaData) -> None:
self._metadata = metadata
@property # type: ignore[misc]
@serializable.include_none(SchemaVersion1Dot0)
@serializable.include_none(SchemaVersion1Dot1)
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'component')
@serializable.xml_sequence(2)
[docs]
def components(self) -> "SortedSet[Component]":
"""
Get all the Components currently in this Bom.
Returns:
Set of `Component` in this Bom
"""
return self._components
@components.setter
def components(self, components: Iterable[Component]) -> None:
self._components = SortedSet(components)
[docs]
def get_component_by_purl(self, purl: Optional["PackageURL"]) -> Optional[Component]:
"""
Get a Component already in the Bom by its PURL
Args:
purl:
An instance of `packageurl.PackageURL` to look and find `Component`.
Returns:
`Component` or `None`
"""
if purl:
found = [x for x in self.components if x.purl == purl]
if len(found) == 1:
return found[0]
return None
[docs]
def get_urn_uuid(self) -> str:
"""
Get the unique reference for this Bom.
Returns:
URN formatted UUID that uniquely identified this Bom instance.
"""
return self.serial_number.urn
[docs]
def has_component(self, component: Component) -> bool:
"""
Check whether this Bom contains the provided Component.
Args:
component:
The instance of `cyclonedx.model.component.Component` to check if this Bom contains.
Returns:
`bool` - `True` if the supplied Component is part of this Bom, `False` otherwise.
"""
return component in self.components
@property # type: ignore[misc]
@serializable.view(SchemaVersion1Dot2)
@serializable.view(SchemaVersion1Dot3)
@serializable.view(SchemaVersion1Dot4)
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'service')
@serializable.xml_sequence(3)
[docs]
def services(self) -> "SortedSet[Service]":
"""
Get all the Services currently in this Bom.
Returns:
Set of `Service` in this BOM
"""
return self._services
@services.setter
def services(self, services: Iterable[Service]) -> None:
self._services = SortedSet(services)
@property # type: ignore[misc]
@serializable.view(SchemaVersion1Dot1)
@serializable.view(SchemaVersion1Dot2)
@serializable.view(SchemaVersion1Dot3)
@serializable.view(SchemaVersion1Dot4)
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference')
@serializable.xml_sequence(4)
[docs]
def external_references(self) -> "SortedSet[ExternalReference]":
"""
Provides the ability to document external references related to the BOM or to the project the BOM describes.
Returns:
Set of `ExternalReference`
"""
return self._external_references
@external_references.setter
def external_references(self, external_references: Iterable[ExternalReference]) -> None:
self._external_references = SortedSet(external_references)
[docs]
def _get_all_components(self) -> Set[Component]:
components: Set[Component] = set()
if self.metadata.component:
components.update(self.metadata.component.get_all_nested_components(include_self=True))
# Add Components and sub Components
for c in self.components:
components.update(c.get_all_nested_components(include_self=True))
return components
[docs]
def get_vulnerabilities_for_bom_ref(self, bom_ref: BomRef) -> "SortedSet[Vulnerability]":
"""
Get all known Vulnerabilities that affect the supplied bom_ref.
Args:
bom_ref: `BomRef`
Returns:
`SortedSet` of `Vulnerability`
"""
vulnerabilities: SortedSet[Vulnerability] = SortedSet()
for v in self.vulnerabilities:
for target in v.affects:
if target.ref == bom_ref.value:
vulnerabilities.add(v)
return vulnerabilities
[docs]
def has_vulnerabilities(self) -> bool:
"""
Check whether this Bom has any declared vulnerabilities.
Returns:
`bool` - `True` if this Bom has at least one Vulnerability, `False` otherwise.
"""
return bool(self.vulnerabilities)
@property # type: ignore[misc]
@serializable.view(SchemaVersion1Dot4)
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'vulnerability')
@serializable.xml_sequence(8)
[docs]
def vulnerabilities(self) -> "SortedSet[Vulnerability]":
"""
Get all the Vulnerabilities in this BOM.
Returns:
Set of `Vulnerability`
"""
return self._vulnerabilities
@vulnerabilities.setter
def vulnerabilities(self, vulnerabilities: Iterable[Vulnerability]) -> None:
self._vulnerabilities = SortedSet(vulnerabilities)
@property # type: ignore[misc]
@serializable.xml_attribute()
[docs]
def version(self) -> int:
return self._version
@version.setter
def version(self, version: int) -> None:
self._version = version
@property # type: ignore[misc]
@serializable.view(SchemaVersion1Dot2)
@serializable.view(SchemaVersion1Dot3)
@serializable.view(SchemaVersion1Dot4)
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'dependency')
@serializable.xml_sequence(5)
[docs]
def dependencies(self) -> "SortedSet[Dependency]":
return self._dependencies
@dependencies.setter
def dependencies(self, dependencies: Iterable[Dependency]) -> None:
self._dependencies = SortedSet(dependencies)
[docs]
def register_dependency(self, target: Dependable, depends_on: Optional[Iterable[Dependable]] = None) -> None:
_d = next(filter(lambda _d: _d.ref == target.bom_ref, self.dependencies), None)
if _d and depends_on:
# Dependency Target already registered - but it might have new dependencies to add
_d.dependencies = _d.dependencies.union( # type: ignore
set(map(lambda _d: Dependency(ref=_d.bom_ref), depends_on)) if depends_on else []
)
elif not _d:
# First time we are seeing this target as a Dependency
self._dependencies.add(Dependency(
ref=target.bom_ref,
dependencies=map(lambda _dep: Dependency(ref=_dep.bom_ref), depends_on) if depends_on else []
))
# Ensure dependents are registered with no further dependents in the Dependency Graph as per CDX specification
for _d2 in depends_on if depends_on else []:
self.register_dependency(target=_d2, depends_on=None)
[docs]
def urn(self) -> str:
return f'urn:cdx:{self.serial_number}/{self.version}'
[docs]
def validate(self) -> bool:
"""
Perform data-model level validations to make sure we have some known data integrity prior to attempting output
of this `Bom`
Returns:
`bool`
"""
# 0. Make sure all Dependable have a Dependency entry
if self.metadata.component:
self.register_dependency(target=self.metadata.component)
for _c in self.components:
self.register_dependency(target=_c)
for _s in self.services:
self.register_dependency(target=_s)
# 1. Make sure dependencies are all in this Bom.
all_bom_refs = set(map(lambda c: c.bom_ref, self._get_all_components())) | set(
map(lambda s: s.bom_ref, self.services))
all_dependency_bom_refs = set().union(*(d.dependencies_as_bom_refs() for d in self.dependencies))
dependency_diff = all_dependency_bom_refs - all_bom_refs
if len(dependency_diff) > 0:
raise UnknownComponentDependencyException(
f'One or more Components have Dependency references to Components/Services that are not known in this '
f'BOM. They are: {dependency_diff}')
# 2. if root component is set: dependencies should exist for the Component this BOM is describing
if self.metadata.component and not any(map(
lambda d: d.ref == self.metadata.component.bom_ref and len(d.dependencies) > 0, # type: ignore[union-attr]
self.dependencies
)):
warnings.warn(
f'The Component this BOM is describing {self.metadata.component.purl} has no defined dependencies '
f'which means the Dependency Graph is incomplete - you should add direct dependencies to this '
f'"root" Component to complete the Dependency Graph data.',
UserWarning
)
return True
[docs]
def __eq__(self, other: object) -> bool:
if isinstance(other, Bom):
return hash(other) == hash(self)
return False
[docs]
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.vulnerabilities), tuple(self.dependencies)
))
[docs]
def __repr__(self) -> str:
return f'<Bom uuid={self.serial_number}, hash={hash(self)}>'