Skip to content

Commit

Permalink
Merge pull request #210 from CycloneDX/feat/support-bom-dependencies
Browse files Browse the repository at this point in the history
feat: add support for Dependency Graph in Model and output serialisation (JSON and XML)
  • Loading branch information
madpah authored Apr 20, 2022
2 parents 67ecfac + 2551545 commit 938169c
Show file tree
Hide file tree
Showing 88 changed files with 1,465 additions and 105 deletions.
6 changes: 6 additions & 0 deletions cyclonedx/exception/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ class NoPropertiesProvidedException(CycloneDxModelException):
pass


class UnknownComponentDependencyException(CycloneDxModelException):
"""
Exception raised when a dependency has been noted for a Component that is NOT a Component BomRef in this Bom.
"""


class UnknownHashTypeException(CycloneDxModelException):
"""
Exception raised when we are unable to determine the type of hash from a composite hash string.
Expand Down
34 changes: 33 additions & 1 deletion cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
#
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.

import warnings
from datetime import datetime, timezone
from typing import Iterable, Optional, Set
from uuid import UUID, uuid4

from ..exception.model import UnknownComponentDependencyException
from ..parser import BaseParser
from . import ExternalReference, LicenseChoice, OrganizationalContact, OrganizationalEntity, Property, ThisTool, Tool
from .component import Component
Expand Down Expand Up @@ -363,6 +364,37 @@ def has_vulnerabilities(self) -> bool:
"""
return any(c.has_vulnerabilities() for c in self.components)

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`
"""

# 1. Make sure dependencies are all in this Bom.
all_bom_refs = set([self.metadata.component.bom_ref] if self.metadata.component else []) | set(
map(lambda c: c.bom_ref, self.components)) | set(map(lambda s: s.bom_ref, self.services))

all_dependency_bom_refs = set().union(*(c.dependencies for c in self.components))
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. Dependencies should exist for the Component this BOM is describing, if one is set
if self.metadata.component and not self.metadata.component.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 Component'
f'to complete the Dependency Graph data.',
UserWarning
)

return True

def __eq__(self, other: object) -> bool:
if isinstance(other, Bom):
return hash(other) == hash(self)
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx/model/bom_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def __hash__(self) -> int:
return hash(self.value)

def __repr__(self) -> str:
return f'<BomRef {self.value}'
return f'<BomRef {self.value}>'

def __str__(self) -> str:
return self.value
15 changes: 15 additions & 0 deletions cyclonedx/model/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,7 @@ def __init__(self, *, name: str, component_type: ComponentType = ComponentType.L
if not licenses:
self.licenses = {LicenseChoice(license_expression=license_str)}

self.__dependencies: Set[BomRef] = set()
self.__vulnerabilites: Set[Vulnerability] = set()

@property
Expand Down Expand Up @@ -1094,6 +1095,20 @@ def release_notes(self) -> Optional[ReleaseNotes]:
def release_notes(self, release_notes: Optional[ReleaseNotes]) -> None:
self._release_notes = release_notes

@property
def dependencies(self) -> Set[BomRef]:
"""
Set of `BomRef` that this Component depends on.
Returns:
Set of `BomRef`
"""
return self.__dependencies

@dependencies.setter
def dependencies(self, dependencies: Iterable[BomRef]) -> None:
self.__dependencies = set(dependencies)

def add_vulnerability(self, vulnerability: Vulnerability) -> None:
"""
Add a Vulnerability to this Component.
Expand Down
47 changes: 47 additions & 0 deletions cyclonedx/model/dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 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.

from typing import Iterable, Optional, Set

from .bom_ref import BomRef


class Dependency:
"""
This is our internal representation of a Dependency for a Component.
.. note::
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_dependencyType
"""

def __init__(self, *, ref: BomRef, depends_on: Optional[Iterable[BomRef]] = None) -> None:
self._ref = ref
self.depends_on = set(depends_on or [])

@property
def ref(self) -> BomRef:
return self._ref

@property
def depends_on(self) -> Set[BomRef]:
return self._depends_on

@depends_on.setter
def depends_on(self, depends_on: Iterable[BomRef]) -> None:
self._depends_on = set(depends_on)
53 changes: 34 additions & 19 deletions cyclonedx/output/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import json
from abc import abstractmethod
from typing import Any, Dict, List, Optional, Union, cast
from typing import Any, Dict, Iterable, List, Optional, Union

from ..exception.output import FormatNotSupportedException
from ..model.bom import Bom
Expand Down Expand Up @@ -56,28 +56,43 @@ def generate(self, force_regeneration: bool = False) -> None:
if self.generated and not force_regeneration:
return

bom = self.get_bom()
bom.validate()

schema_uri: Optional[str] = self._get_schema_uri()
if not schema_uri:
raise FormatNotSupportedException(
f'JSON is not supported by CycloneDX in schema version {self.schema_version.to_version()}'
)

vulnerabilities: Dict[str, List[Dict[Any, Any]]] = {"vulnerabilities": []}
if self.get_bom().components:
for component in cast(List[Component], self.get_bom().components):
for vulnerability in component.get_vulnerabilities():
vulnerabilities['vulnerabilities'].append(
json.loads(json.dumps(vulnerability, cls=CycloneDxJSONEncoder))
)

bom_json = json.loads(json.dumps(self.get_bom(), cls=CycloneDxJSONEncoder))
f'JSON is not supported by CycloneDX in schema version {self.schema_version.to_version()}')

extras = {}
if self.bom_supports_dependencies():
dep_components: Iterable[Component] = bom.components
if bom.metadata.component:
dep_components = [bom.metadata.component, *dep_components]
dependencies = []
for component in dep_components:
dependencies.append({
'ref': str(component.bom_ref),
'dependsOn': [*map(str, component.dependencies)]
})
if dependencies:
extras["dependencies"] = dependencies
del dep_components

if self.bom_supports_vulnerabilities():
vulnerabilities: List[Dict[Any, Any]] = []
if bom.components:
for component in bom.components:
for vulnerability in component.get_vulnerabilities():
vulnerabilities.append(
json.loads(json.dumps(vulnerability, cls=CycloneDxJSONEncoder))
)
if vulnerabilities:
extras["vulnerabilities"] = vulnerabilities

bom_json = json.loads(json.dumps(bom, cls=CycloneDxJSONEncoder))
bom_json = json.loads(self._specialise_output_for_schema_version(bom_json=bom_json))
if self.bom_supports_vulnerabilities() and vulnerabilities['vulnerabilities']:
self._json_output = json.dumps(
{**self._create_bom_element(), **bom_json, **vulnerabilities}
)
else:
self._json_output = json.dumps({**self._create_bom_element(), **bom_json})
self._json_output = json.dumps({**self._create_bom_element(), **bom_json, **extras})

self.generated = True

Expand Down
9 changes: 9 additions & 0 deletions cyclonedx/output/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ def bom_supports_services(self) -> bool:
def bom_supports_external_references(self) -> bool:
return True

def bom_supports_dependencies(self) -> bool:
return True

def services_supports_properties(self) -> bool:
return True

Expand Down Expand Up @@ -223,6 +226,9 @@ def pedigree_supports_patches(self) -> bool:
def services_supports_release_notes(self) -> bool:
return False

def bom_supports_dependencies(self) -> bool:
return False

def bom_supports_vulnerabilities(self) -> bool:
return False

Expand Down Expand Up @@ -278,6 +284,9 @@ def bom_supports_services(self) -> bool:
def bom_supports_external_references(self) -> bool:
return False

def bom_supports_dependencies(self) -> bool:
return False

def services_supports_properties(self) -> bool:
return False

Expand Down
57 changes: 37 additions & 20 deletions cyclonedx/output/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.

import warnings
from typing import Optional, Set
from typing import Iterable, Optional, Set
from xml.etree import ElementTree

from ..model import (
Expand Down Expand Up @@ -67,14 +67,17 @@ def generate(self, force_regeneration: bool = False) -> None:
elif self.generated:
return

bom = self.get_bom()
bom.validate()

if self.bom_supports_metadata():
self._add_metadata_element()

components_element = ElementTree.SubElement(self._root_bom_element, 'components')

has_vulnerabilities: bool = False
if self.get_bom().components:
for component in self.get_bom().components:

components_element = ElementTree.SubElement(self._root_bom_element, 'components')
if bom.components:
for component in bom.components:
component_element = self._add_component_element(component=component)
components_element.append(component_element)
if self.bom_supports_vulnerabilities_via_extension() and component.has_vulnerabilities():
Expand All @@ -94,22 +97,35 @@ def generate(self, force_regeneration: bool = False) -> None:
elif component.has_vulnerabilities():
has_vulnerabilities = True

if self.bom_supports_services():
if self.get_bom().services:
services_element = ElementTree.SubElement(self._root_bom_element, 'services')
for service in self.get_bom().services:
services_element.append(self._add_service_element(service=service))

if self.bom_supports_external_references():
if self.get_bom().external_references:
self._add_external_references_to_element(
ext_refs=self.get_bom().external_references,
element=self._root_bom_element
)
if self.bom_supports_services() and bom.services:
services_element = ElementTree.SubElement(self._root_bom_element, 'services')
for service in bom.services:
services_element.append(self._add_service_element(service=service))

if self.bom_supports_external_references() and bom.external_references:
self._add_external_references_to_element(
ext_refs=bom.external_references,
element=self._root_bom_element
)

if self.bom_supports_dependencies() and (bom.metadata.component or bom.components):
dep_components: Iterable[Component] = bom.components
if bom.metadata.component:
dep_components = [bom.metadata.component, *dep_components]
dependencies_element = ElementTree.SubElement(self._root_bom_element, 'dependencies')
for component in dep_components:
dependency_element = ElementTree.SubElement(dependencies_element, 'dependency', {
'ref': str(component.bom_ref)
})
for dependency in component.dependencies:
ElementTree.SubElement(dependency_element, 'dependency', {
'ref': str(dependency)
})
del dep_components

if self.bom_supports_vulnerabilities() and has_vulnerabilities:
vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities')
for component in self.get_bom().components:
for component in bom.components:
for vulnerability in component.get_vulnerabilities():
vulnerabilities_element.append(
self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability)
Expand All @@ -126,13 +142,14 @@ def get_target_namespace(self) -> str:

# Builder Methods
def _create_bom_element(self) -> ElementTree.Element:
bom = self.get_bom()
root_attributes = {
'xmlns': self.get_target_namespace(),
'version': '1',
'serialNumber': self.get_bom().get_urn_uuid()
'serialNumber': bom.get_urn_uuid()
}

if self.bom_supports_vulnerabilities_via_extension() and self.get_bom().has_vulnerabilities():
if self.bom_supports_vulnerabilities_via_extension() and bom.has_vulnerabilities():
root_attributes['xmlns:v'] = Xml.VULNERABILITY_EXTENSION_NAMESPACE
ElementTree.register_namespace('v', Xml.VULNERABILITY_EXTENSION_NAMESPACE)

Expand Down
22 changes: 16 additions & 6 deletions docs/modelling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,8 @@ Vulnerabilities are supported by the Model as of version 0.3.0.
**Note:** Known vulnerabilities associated with Components can be sourced from various data sources, but this library
will not source them for you. Perhaps look at `Jake`_ if you're interested in this.

Examples
--------

From a Parser
~~~~~~~~~~~~~
Example BOM using a Parser
--------------------------

**Note:** Concreate parser implementations were moved out of this library and into `CycloneDX Python`_ as of version
``1.0.0``.
Expand All @@ -40,6 +37,19 @@ From a Parser
parser = EnvironmentParser()
bom = Bom.from_parser(parser=parser)
Example BOM created programmatically
------------------------------------

.. note::

It is recommended that you have a good understanding of the `CycloneDX Schema`_ before attempting to create a BOM
programmatically with this library.


For the most up-to-date in-depth examples, look at our `Unit Tests`_.


.. _CycloneDX Python: https://github.com/CycloneDX/cyclonedx-python
.. _Jake: https://pypi.org/project/jake
.. _Jake: https://pypi.org/project/jake
.. _CycloneDX Schema: https://cyclonedx.org/docs/latest
.. _Unit Tests: https://github.com/CycloneDX/cyclonedx-python-lib/tree/main/tests
2 changes: 1 addition & 1 deletion docs/schema-support.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ supported in prior versions of the CycloneDX schema.
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.externalReferences`` | Yes | |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.dependencies`` | No | |
| ``bom.dependencies`` | Yes | Since ``2.3.0`` |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.compositions`` | No | |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
Expand Down
Loading

0 comments on commit 938169c

Please sign in to comment.