Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
66 changes: 1 addition & 65 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,14 @@
from collections.abc import Generator, Iterable
from datetime import datetime
from enum import Enum
from itertools import chain
from typing import TYPE_CHECKING, Optional, Union
from uuid import UUID, uuid4
from warnings import warn

import py_serializable as serializable
from sortedcontainers import SortedSet

from .._internal.compare import ComparableTuple as _ComparableTuple
from .._internal.time import get_now_utc as _get_now_utc
from ..exception.model import LicenseExpressionAlongWithOthersException, UnknownComponentDependencyException
from ..schema.deprecation import SchemaDeprecationWarning1Dot6
from ..schema.schema import (
SchemaVersion1Dot0,
Expand All @@ -48,7 +45,7 @@
from .contact import OrganizationalContact, OrganizationalEntity
from .definition import Definitions
from .dependency import Dependable, Dependency
from .license import License, LicenseExpression, LicenseRepository, _LicenseRepositorySerializationHelper
from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper
from .lifecycle import Lifecycle, LifecycleRepository, _LifecycleRepositoryHelper
from .service import Service
from .tool import Tool, ToolRepository, _ToolRepositoryHelper
Expand Down Expand Up @@ -804,67 +801,6 @@ def urn(self) -> str:
# idea: have 'serial_number' be a string, and use it instead of this method
return f'{_BOM_LINK_PREFIX}{self.serial_number}/{self.version}'

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`

.. deprecated:: next
Deprecated without any replacement.
"""
# !! deprecated function. have this as an part of the normalization process, like the BomRefDiscrimator
# 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.
component_bom_refs = set(map(lambda c: c.bom_ref, self._get_all_components())) | set(
map(lambda s: s.bom_ref, self.services))
dependency_bom_refs = set(chain(
(d.ref for d in self.dependencies),
chain.from_iterable(d.dependencies_as_bom_refs() for d in self.dependencies)
))
dependency_diff = dependency_bom_refs - component_bom_refs
if len(dependency_diff) > 0:
raise UnknownComponentDependencyException(
'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 and there are other components: dependencies should exist for the Component
# this BOM is describing
if self.metadata.component and len(self.components) > 0 and not any(map(
lambda d: d.ref == self.metadata.component.bom_ref and len(d.dependencies) > 0, # type:ignore[union-attr]
self.dependencies
)):
warn(
f'The Component this BOM is describing {self.metadata.component.purl} has no defined dependencies '
'which means the Dependency Graph is incomplete - you should add direct dependencies to this '
'"root" Component to complete the Dependency Graph data.',
category=UserWarning, stacklevel=1
)

# 3. If a LicenseExpression is set, then there must be no other license.
# see https://github.com/CycloneDX/specification/pull/205
elem: Union[BomMetaData, Component, Service]
for elem in chain( # type:ignore[assignment]
[self.metadata],
self.metadata.component.get_all_nested_components(include_self=True) if self.metadata.component else [],
chain.from_iterable(c.get_all_nested_components(include_self=True) for c in self.components),
self.services
):
if len(elem.licenses) > 1 and any(isinstance(li, LicenseExpression) for li in elem.licenses):
raise LicenseExpressionAlongWithOthersException(
f'Found LicenseExpression along with others licenses in: {elem!r}')

return True

def __comparable_tuple(self) -> _ComparableTuple:
return _ComparableTuple((
self.serial_number, self.version, self.metadata, _ComparableTuple(
Expand Down
16 changes: 16 additions & 0 deletions cyclonedx/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
from itertools import chain
from random import random
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, overload
from warnings import warn as _warn

from ..schema import OutputFormat, SchemaVersion
from ..validation.model import ModelValidationErrorSeverity, ModelValidator

if TYPE_CHECKING: # pragma: no cover
from ..model.bom import Bom
Expand Down Expand Up @@ -68,6 +70,20 @@ def get_bom(self) -> 'Bom':
def set_bom(self, bom: 'Bom') -> None:
self._bom = bom

def _prepare(self) -> None:
"""Normalize dependency graph and validate model integrity before serialization."""
bom = self._bom
if bom.metadata.component:
bom.register_dependency(target=bom.metadata.component)
for _c in bom.components:
bom.register_dependency(target=_c)
for _s in bom.services:
bom.register_dependency(target=_s)
for _err in ModelValidator().validate(bom):
if _err.severity is ModelValidationErrorSeverity.ERROR:
raise _err.data
_warn(str(_err.data), stacklevel=3)

@abstractmethod
def generate(self, force_regeneration: bool = False) -> None:
... # pragma: no cover
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx/output/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ def generate(self, force_regeneration: bool = False) -> None:
'specVersion': self.schema_version.to_version()
}
_view = SCHEMA_VERSIONS.get(self.schema_version_enum)
self._prepare()
bom = self.get_bom()
bom.validate()
with BomRefDiscriminator.from_bom(bom):
bom_json: dict[str, Any] = json_loads(
bom.as_json( # type:ignore[attr-defined]
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx/output/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ def generate(self, force_regeneration: bool = False) -> None:
return

_view = SCHEMA_VERSIONS[self.schema_version_enum]
self._prepare()
bom = self.get_bom()
bom.validate()
xmlns = self.get_target_namespace()
with BomRefDiscriminator.from_bom(bom):
self._bom_xml = '<?xml version="1.0" ?>\n' + xml_dumps( # type:ignore[call-overload]
Expand Down
108 changes: 104 additions & 4 deletions cyclonedx/validation/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,107 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.


# nothing here, yet.
# in the future this could be the place where model validation is done.
# like the current `model.bom.Bom.validate()`
# see also: https://github.com/CycloneDX/cyclonedx-python-lib/issues/455
__all__ = ['ModelValidator', 'ModelValidationError', 'ModelValidationErrorSeverity']

from collections.abc import Iterable
from enum import Enum
from itertools import chain
from typing import TYPE_CHECKING, Any, Union

from ..exception.model import LicenseExpressionAlongWithOthersException, UnknownComponentDependencyException
from . import ValidationError

if TYPE_CHECKING: # pragma: no cover
from ..model.bom import Bom, BomMetaData
from ..model.component import Component
from ..model.service import Service


class ModelValidationErrorSeverity(str, Enum):
"""Severity level of a :class:`ModelValidationError`."""

ERROR = 'error'
"""BOM is structurally invalid and cannot be serialized correctly."""

WARNING = 'warning'
"""BOM may have issues but can still be serialized; attention is recommended."""


class ModelValidationError(ValidationError):
"""Validation failed with this specific error.

Use :attr:`~data` to access the content.
Use :attr:`~severity` to determine how critical the issue is.
"""

def __init__(self, data: Any,
severity: ModelValidationErrorSeverity = ModelValidationErrorSeverity.ERROR) -> None:
super().__init__(data)
self.severity = severity


class ModelValidator:
"""Perform data-model level validations to make sure we have some known data integrity."""

def validate(self, bom: 'Bom') -> Iterable[ModelValidationError]:
"""Validate a :class:`~cyclonedx.model.bom.Bom` at the data-model level.

Yields :class:`ModelValidationError` instances — one per issue found.
Errors with :attr:`~ModelValidationErrorSeverity.ERROR` severity indicate structural
invalidity; errors with :attr:`~ModelValidationErrorSeverity.WARNING` severity are
advisory.

This method has no side-effects: it does not mutate the ``bom`` passed in.

:param bom: The :class:`~cyclonedx.model.bom.Bom` to validate.
:return: An iterable of :class:`ModelValidationError` for each issue found.
"""
from ..model.license import LicenseExpression

# Collect all components across the BOM, including nested ones.
all_components: set['Component'] = set(chain.from_iterable(
c.get_all_nested_components(include_self=True) for c in bom.components
))
if bom.metadata.component:
all_components.update(
bom.metadata.component.get_all_nested_components(include_self=True)
)

# 1. Make sure every bom_ref referenced in the dependency graph exists in this BOM.
all_dependable_bom_refs = {e.bom_ref for e in chain(all_components, bom.services)}
all_dependency_bom_refs = set(chain(
(d.ref for d in bom.dependencies),
chain.from_iterable(d.dependencies_as_bom_refs() for d in bom.dependencies),
))
dependency_diff = all_dependency_bom_refs - all_dependable_bom_refs
if dependency_diff:
yield ModelValidationError(UnknownComponentDependencyException(
'One or more Components have Dependency references to Components/Services that are not known in this '
f'BOM. They are: {dependency_diff}'
))

# 2. If the root component is set and there are other components, the root should declare
# at least one dependency — otherwise the Dependency Graph is incomplete.
# NOTE: guard on the component, not the BomRef — BomRef is falsy when value is None.
if bom.metadata.component is not None and len(bom.components) > 0 and not any(
len(d.dependencies) > 0
for d in bom.dependencies
if d.ref == bom.metadata.component.bom_ref
):
yield ModelValidationError(
UserWarning(
f'The Component this BOM is describing {bom.metadata.component.purl} has no defined '
'dependencies which means the Dependency Graph is incomplete - you should add direct '
'dependencies to this "root" Component to complete the Dependency Graph data.'
),
severity=ModelValidationErrorSeverity.WARNING,
)

# 3. If a LicenseExpression is set, then there must be no other license.
# see https://github.com/CycloneDX/specification/pull/205
elem: Union['BomMetaData', 'Component', 'Service']
for elem in chain([bom.metadata], all_components, bom.services): # type: ignore[assignment]
if len(elem.licenses) > 1 and any(isinstance(li, LicenseExpression) for li in elem.licenses):
yield ModelValidationError(LicenseExpressionAlongWithOthersException(
f'Found LicenseExpression along with others licenses in: {elem!r}'
))
9 changes: 6 additions & 3 deletions tests/test_model_bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle
from cyclonedx.model.tool import Tool
from cyclonedx.output.json import JsonV1Dot7
from cyclonedx.validation.model import ModelValidationErrorSeverity, ModelValidator
from tests import reorder
from tests._data.models import (
get_bom_component_licenses_invalid,
Expand Down Expand Up @@ -254,7 +255,9 @@ def test_bom_nested_components_issue_275(self) -> None:
bom = get_bom_for_issue_275_components()
self.assertIsInstance(bom.metadata.component, Component)
self.assertEqual(2, len(bom.components))
bom.validate()
errors = [e for e in ModelValidator().validate(bom)
if e.severity is ModelValidationErrorSeverity.ERROR]
self.assertFalse(errors)

@named_data(
['metadata_licenses', get_bom_metadata_licenses_invalid],
Expand All @@ -266,8 +269,8 @@ def test_bom_nested_components_issue_275(self) -> None:
)
def test_validate_with_invalid_license_constellation_throws(self, get_bom: Callable[[], Bom]) -> None:
bom = get_bom()
with self.assertRaises(LicenseExpressionAlongWithOthersException):
bom.validate()
error_types = [type(e.data) for e in ModelValidator().validate(bom)]
self.assertIn(LicenseExpressionAlongWithOthersException, error_types)

# def test_bom_nested_services_issue_275(self) -> None:
# """regression test for issue #275
Expand Down
9 changes: 7 additions & 2 deletions tests/test_real_world_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from unittest.mock import patch

from cyclonedx.model.bom import Bom
from cyclonedx.validation.model import ModelValidationErrorSeverity, ModelValidator
from tests import OWN_DATA_DIRECTORY


Expand All @@ -44,15 +45,19 @@ def test_regression_issue677(self, *_: Any, **__: Any) -> None:
json = json_loads(input_json.read())
bom = Bom.from_json(json)
self.assertEqual(4, len(bom.components))
bom.validate()
errors = [e for e in ModelValidator().validate(bom)
if e.severity is ModelValidationErrorSeverity.ERROR]
self.assertFalse(errors)

def test_regression_issue753(self, *_: Any, **__: Any) -> None:
# tests https://github.com/CycloneDX/cyclonedx-python-lib/issues/753
with open(join(OWN_DATA_DIRECTORY, 'json', '1.5', 'issue753.json')) as input_json:
json = json_loads(input_json.read())
bom = Bom.from_json(json)
self.assertEqual(2, len(bom.components))
bom.validate()
errors = [e for e in ModelValidator().validate(bom)
if e.severity is ModelValidationErrorSeverity.ERROR]
self.assertFalse(errors)

def test_regression_issue_850(self, *_: Any, **__: Any) -> None:
# tests https://github.com/CycloneDX/cyclonedx-python-lib/issues/850
Expand Down
Loading