Skip to content
Draft
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
52 changes: 10 additions & 42 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -815,56 +815,24 @@ def validate(self) -> bool:
`bool`

.. deprecated:: next
Deprecated without any replacement.
Use :class:`cyclonedx.validation.model.ModelValidator` instead.
"""
# !! deprecated function. have this as an part of the normalization process, like the BomRefDiscrimator
# 0. Make sure all Dependable have a Dependency entry
from ..validation.model import ModelValidator
warn('`Bom.validate()` is deprecated. Use `cyclonedx.validation.model.ModelValidator` instead.',
category=DeprecationWarning, stacklevel=2)

# Maintain backward compatibility: perform side effects (normalization)
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}')

errors = ModelValidator().validate(self)
first_error = next(iter(errors), None)
if first_error:
raise first_error.data
return True

def __comparable_tuple(self) -> _ComparableTuple:
Expand Down
75 changes: 71 additions & 4 deletions cyclonedx/validation/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,74 @@
# 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']

import warnings
from collections.abc import Iterable
from itertools import chain
from typing import TYPE_CHECKING, Set, Union

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

# REMOVED: from ..model.license import LicenseExpression

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


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

Use :attr:`~data` to access the content.
"""
pass


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

def validate(self, bom: 'Bom') -> Iterable[ModelValidationError]:
"""
Perform data-model level validations to make sure we have some known data integrity
prior to attempting output of a `Bom`.

:param bom: The `Bom` to validate.
:return: An iterable of `ModelValidationError` if any issues are found.
"""
# 1. Make sure dependencies are all in this Bom.
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.add(bom.metadata.component)

all_dependable_bom_refs = {e.bom_ref for e in chain(all_components, bom.services)}
all_dependency_bom_refs = set(chain.from_iterable(d.dependencies_as_bom_refs() for d in bom.dependencies))
dependency_diff = all_dependency_bom_refs - all_dependable_bom_refs
if len(dependency_diff) > 0:
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 root component is set: dependencies should exist for the Component this BOM is describing
meta_bom_ref = bom.metadata.component.bom_ref if bom.metadata.component else None
if meta_bom_ref and len(bom.components) > 0 and not any(
len(d.dependencies) > 0 for d in bom.dependencies if d.ref == meta_bom_ref
):
warnings.warn(
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.',
category=UserWarning, stacklevel=2
)

# 3. If a LicenseExpression is set, then there must be no other license.
# see https://github.com/CycloneDX/specification/pull/205
from ..model.license import LicenseExpression
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}'))
76 changes: 76 additions & 0 deletions tests/test_validation_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# This file is part of CycloneDX Python Library
#
# 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 unittest import TestCase

from cyclonedx.exception.model import LicenseExpressionAlongWithOthersException, UnknownComponentDependencyException
from cyclonedx.model.bom import Bom
from cyclonedx.model.component import Component
from cyclonedx.model.dependency import Dependency
from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression
from cyclonedx.validation.model import ModelValidator


class TestModelValidator(TestCase):
def test_validate_multiple_errors(self) -> None:
bom = Bom()
# Error 1: Component with multiple licenses including expression
comp = Component(name='test', version='1.0', bom_ref='test-comp')
comp.licenses.update([
DisjunctiveLicense(id='MIT'),
LicenseExpression(value='Apache-2.0 OR MIT')
])
bom.components.add(comp)

# Error 2: Unknown dependency reference
bom.dependencies.add(Dependency('test-comp', dependencies=[Dependency('non-existent-ref')]))

validator = ModelValidator()
errors = list(validator.validate(bom))

self.assertEqual(len(errors), 2)
error_types = [type(e.data) for e in errors]
self.assertIn(UnknownComponentDependencyException, error_types)
self.assertIn(LicenseExpressionAlongWithOthersException, error_types)

def test_validate_clean_bom(self) -> None:
bom = Bom()
bom.metadata.component = Component(name='root', version='1.0', bom_ref='root')
validator = ModelValidator()
errors = list(validator.validate(bom))
self.assertEqual(len(errors), 0)

def test_bom_validate_deprecated_behavior(self) -> None:
bom = Bom()
bom.metadata.component = Component(name='root', version='1.0', bom_ref='root')

# Verify side effect: register_dependency is called by Bom.validate
self.assertEqual(len(bom.dependencies), 0)
with self.assertWarns(DeprecationWarning):
bom.validate()
self.assertEqual(len(bom.dependencies), 1)
self.assertEqual(next(iter(bom.dependencies)).ref.value, 'root')

def test_model_validator_no_side_effects(self) -> None:
bom = Bom()
bom.metadata.component = Component(name='root', version='1.0', bom_ref='root')

# Verify NO side effect: ModelValidator should not call register_dependency
self.assertEqual(len(bom.dependencies), 0)
validator = ModelValidator()
list(validator.validate(bom))
self.assertEqual(len(bom.dependencies), 0)