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: change model to put Vulnerability at Bom level, not Component level #263

Merged
merged 10 commits into from
Jul 11, 2022
2 changes: 1 addition & 1 deletion .github/workflows/poetry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
build-and-test:
name: Test (${{ matrix.os }} py${{ matrix.python-version }} ${{ matrix.toxenv-factor }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
timeout-minutes: 15
env:
REPORTS_ARTIFACT: tests-reports
strategy:
Expand Down
16 changes: 8 additions & 8 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ repos:
hooks:
- id: system
name: mypy
entry: poetry run tox -e mypy
entry: poetry run tox -e mypy-locked
pass_filenames: false
language: system
- repo: local
hooks:
- id: system
name: isort
entry: poetry run isort cyclonedx tests
pass_filenames: false
language: system
# - repo: local
madpah marked this conversation as resolved.
Show resolved Hide resolved
# hooks:
# - id: system
# name: isort
# entry: poetry run isort
# pass_filenames: false
# language: system
- repo: local
hooks:
- id: system
Expand Down
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,21 @@
----

This CycloneDX module for Python can generate valid CycloneDX bill-of-material document containing an aggregate of all
project dependencies.
project dependencies. CycloneDX is a lightweight BOM specification that is easily created, human-readable, and simple
to parse.

This module is not designed for standalone use.
**This module is not designed for standalone use.**

If you're looking for a CycloneDX tool to run to generate (SBOM) software bill-of-materials documents, why not checkout
[CycloneDX Python][cyclonedx-python].

Additionally, the following tool can be used as well (and this library was written to help improve it) [Jake][jake].
As of version `3.0.0`, the internal data model was adjusted to allow CycloneDX VEX documents to be produced as per
[official examples](https://cyclonedx.org/capabilities/bomlink/#linking-external-vex-to-bom-inventory) linking a VEX
documents to a separate BOM document.

Additionally, you can use this module yourself in your application to programmatically generate SBOMs.
If you're looking for a CycloneDX tool to run to generate (SBOM) software bill-of-materials documents, why not checkout
[CycloneDX Python][cyclonedx-python] or [Jake][jake].

CycloneDX is a lightweight BOM specification that is easily created, human-readable, and simple to parse.
Alternatively, you can use this module yourself in your application to programmatically generate CycloneDX BOMs.

View our documentation [here](https://cyclonedx-python-library.readthedocs.io/).
View the documentation [here](https://cyclonedx-python-library.readthedocs.io/).

## Python Support

Expand Down
43 changes: 39 additions & 4 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#
# 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
Expand All @@ -26,8 +27,10 @@
from ..exception.model import UnknownComponentDependencyException
from ..parser import BaseParser
from . import ExternalReference, LicenseChoice, OrganizationalContact, OrganizationalEntity, Property, ThisTool, Tool
from .bom_ref import BomRef
from .component import Component
from .service import Service
from .vulnerability import Vulnerability


class BomMetaData:
Expand Down Expand Up @@ -242,6 +245,7 @@ def __init__(self, *, components: Optional[Iterable[Component]] = None,
self.components = components or [] # type: ignore
self.services = services or [] # type: ignore
self.external_references = external_references or [] # type: ignore
self.vulnerabilities = SortedSet()

@property
def uuid(self) -> UUID:
Expand Down Expand Up @@ -356,15 +360,46 @@ def external_references(self) -> "SortedSet[ExternalReference]":
def external_references(self, external_references: Iterable[ExternalReference]) -> None:
self._external_references = SortedSet(external_references)

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

def has_vulnerabilities(self) -> bool:
"""
Check whether this Bom has any declared vulnerabilities.

Returns:
`bool` - `True` if at least one `cyclonedx.model.component.Component` has at least one Vulnerability,
`False` otherwise.
`bool` - `True` if this Bom has at least one Vulnerability, `False` otherwise.
"""
return bool(self.vulnerabilities)

@property
def vulnerabilities(self) -> "SortedSet[Vulnerability]":
"""
Get all the Vulnerabilities in this BOM.

Returns:
Set of `Vulnerability`
"""
return any(c.has_vulnerabilities() for c in self.components)
return self._vulnerabilities

@vulnerabilities.setter
def vulnerabilities(self, vulnerabilities: Iterable[Vulnerability]) -> None:
self._vulnerabilities = SortedSet(vulnerabilities)

def validate(self) -> bool:
"""
Expand All @@ -389,7 +424,7 @@ def validate(self) -> bool:
# 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'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
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 @@ -44,7 +44,7 @@ def value(self, value: str) -> None:

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

def __lt__(self, other: Any) -> bool:
Expand Down
33 changes: 0 additions & 33 deletions cyclonedx/model/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
from .bom_ref import BomRef
from .issue import IssueType
from .release_note import ReleaseNotes
from .vulnerability import Vulnerability


class Commit:
Expand Down Expand Up @@ -763,7 +762,6 @@ def __init__(self, *, name: str, component_type: ComponentType = ComponentType.L
self.licenses = [LicenseChoice(license_expression=license_str)] # type: ignore

self.__dependencies: "SortedSet[BomRef]" = SortedSet()
self.__vulnerabilites: "SortedSet[Vulnerability]" = SortedSet()

@property
def type(self) -> ComponentType:
Expand Down Expand Up @@ -1128,37 +1126,6 @@ def dependencies(self) -> "SortedSet[BomRef]":
def dependencies(self, dependencies: Iterable[BomRef]) -> None:
self.__dependencies = SortedSet(dependencies)

def add_vulnerability(self, vulnerability: Vulnerability) -> None:
"""
Add a Vulnerability to this Component.

Args:
vulnerability:
`cyclonedx.model.vulnerability.Vulnerability` instance to add to this Component.

Returns:
None
"""
self.__vulnerabilites.add(vulnerability)

def get_vulnerabilities(self) -> "SortedSet[Vulnerability]":
"""
Get all the Vulnerabilities for this Component.

Returns:
Set of `Vulnerability`
"""
return self.__vulnerabilites

def has_vulnerabilities(self) -> bool:
"""
Does this Component have any vulnerabilities?

Returns:
`True` if this Component has 1 or more vulnerabilities, `False` otherwise.
"""
return bool(self.get_vulnerabilities())

def get_pypi_url(self) -> str:
if self.version:
return f'https://pypi.org/project/{self.name}/{self.version}'
Expand Down
15 changes: 4 additions & 11 deletions cyclonedx/output/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,6 @@ def generate(self, force_regeneration: bool = False) -> None:
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))
self._json_output = json.dumps({**self._create_bom_element(), **bom_json, **extras})
Expand Down Expand Up @@ -133,6 +122,10 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str
and 'hashes' in bom_json['externalReferences'][i].keys():
del bom_json['externalReferences'][i]['hashes']

# Remove Vulnerabilities if not supported
if not self.bom_supports_vulnerabilities() and 'vulnerabilities' in bom_json.keys():
del bom_json['vulnerabilities']

return json.dumps(bom_json)

def output_as_string(self) -> str:
Expand Down
46 changes: 22 additions & 24 deletions cyclonedx/output/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,29 +75,28 @@ def generate(self, force_regeneration: bool = False) -> None:
if self.bom_supports_metadata():
self._add_metadata_element()

has_vulnerabilities: bool = False

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():
# Vulnerabilities are only possible when bom-ref is supported by the main CycloneDX schema version
vulnerabilities = ElementTree.SubElement(component_element, 'v:vulnerabilities')
for vulnerability in component.get_vulnerabilities():
if component.bom_ref:
vulnerabilities.append(
Xml._get_vulnerability_as_xml_element_pre_1_3(bom_ref=component.bom_ref,
vulnerability=vulnerability)
)
else:
warnings.warn(
f'Unable to include Vulnerability {str(vulnerability)} in generated BOM as the '
f'Component it relates to ({str(component)}) but it has no bom-ref.'
)
elif component.has_vulnerabilities():
has_vulnerabilities = True
if self.bom_supports_vulnerabilities_via_extension():
component_vulnerabilities = bom.get_vulnerabilities_for_bom_ref(bom_ref=component.bom_ref)
if component_vulnerabilities:
# Vulnerabilities are only possible when bom-ref is supported by the main CycloneDX schema
# version
vulnerabilities = ElementTree.SubElement(component_element, 'v:vulnerabilities')
for vulnerability in component_vulnerabilities:
if component.bom_ref:
vulnerabilities.append(
Xml._get_vulnerability_as_xml_element_pre_1_3(bom_ref=component.bom_ref,
vulnerability=vulnerability)
)
else:
warnings.warn(
f'Unable to include Vulnerability {str(vulnerability)} in generated BOM as the '
f'Component it relates to ({str(component)}) but it has no bom-ref.'
)

if self.bom_supports_services() and bom.services:
services_element = ElementTree.SubElement(self._root_bom_element, 'services')
Expand Down Expand Up @@ -125,13 +124,12 @@ def generate(self, force_regeneration: bool = False) -> None:
})
del dep_components

if self.bom_supports_vulnerabilities() and has_vulnerabilities:
if self.bom_supports_vulnerabilities() and bom.vulnerabilities:
vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities')
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)
)
for vulnerability in bom.vulnerabilities:
vulnerabilities_element.append(
self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability)
)

self.generated = True

Expand Down
6 changes: 5 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ CycloneDX is a lightweight BOM specification that is easily created, human-reada
This CycloneDX module for Python can generate valid CycloneDX bill-of-material document containing an aggregate of all
project dependencies.

As of version ``3.0.0``, the internal data model was adjusted to allow CycloneDX VEX documents to be produced as per
`official examples`_ linking VEX to a separate BOM.

This module is not designed for standalone use (i.e. it is not executable on it’s own). If you’re looking for a
CycloneDX tool to run to generate (SBOM) software bill-of-materials documents, why not checkout:

Expand All @@ -44,4 +47,5 @@ programmatically generate SBOMs.

.. _CycloneDX Python: https://pypi.org/project/cyclonedx-bom/
.. _Jake: https://pypi.org/project/jake
.. _CycloneDX Tool Center: https://cyclonedx.org/tool-center/
.. _CycloneDX Tool Center: https://cyclonedx.org/tool-center/
.. _official examples: https://cyclonedx.org/capabilities/bomlink/#linking-external-vex-to-bom-inventory
1 change: 1 addition & 0 deletions docs/schema-support.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ supported in prior versions of the CycloneDX schema.
| ``bom.properties`` | No | See `schema specification bug 130`_ |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.vulnerabilities`` | Yes | Note: Prior to CycloneDX 1.4, these were present under ``bom.components`` via a schema extension. |
| | | Note: As of ``cyclonedx-python-lib`` ``>3.0.0``, Vulnerability are modelled differently |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.signature`` | No | |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
Expand Down
8 changes: 6 additions & 2 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@
from jsonschema import ValidationError, validate as json_validate

if sys.version_info >= (3, 8):
from importlib.metadata import version
from importlib.metadata import PackageNotFoundError, version
else:
from importlib_metadata import version

cyclonedx_lib_name: str = 'cyclonedx-python-lib'
cyclonedx_lib_version: str = version(cyclonedx_lib_name)
cyclonedx_lib_version: str = 'DEV'
try:
cyclonedx_lib_version: str = version(cyclonedx_lib_name)
except PackageNotFoundError:
pass
single_uuid: str = 'urn:uuid:{}'.format(uuid4())
schema_directory = os.path.join(os.path.dirname(__file__), '../cyclonedx/schema')

Expand Down
12 changes: 7 additions & 5 deletions tests/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
from decimal import Decimal
from typing import List, Optional, TypeVar

from packageurl import PackageURL
# See https://github.com/package-url/packageurl-python/issues/65
from packageurl import PackageURL # type: ignore

from cyclonedx.model import (
AttachedText,
Expand Down Expand Up @@ -163,7 +164,8 @@ def get_bom_with_component_setuptools_complete() -> Bom:
def get_bom_with_component_setuptools_with_vulnerability() -> Bom:
bom = Bom()
component = get_component_setuptools_simple()
vulnerability = Vulnerability(
bom.components.add(component)
bom.vulnerabilities.add(Vulnerability(
bom_ref='my-vuln-ref-1', id='CVE-2018-7489', source=get_vulnerability_source_nvd(),
references=[
VulnerabilityReference(id='SOME-OTHER-ID', source=VulnerabilitySource(
Expand Down Expand Up @@ -215,9 +217,9 @@ def get_bom_with_component_setuptools_with_vulnerability() -> Bom:
)]
)
]
)
component.add_vulnerability(vulnerability=vulnerability)
bom.components.add(component)
))
# component.add_vulnerability(vulnerability=vulnerability)

return bom


Expand Down
Loading