diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 08e838773..45b29cdd4 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -815,10 +815,13 @@ 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: @@ -826,45 +829,10 @@ def validate(self) -> bool: 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: diff --git a/cyclonedx/validation/model.py b/cyclonedx/validation/model.py index 1f8b60610..db781e8b9 100644 --- a/cyclonedx/validation/model.py +++ b/cyclonedx/validation/model.py @@ -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}')) diff --git a/tests/test_validation_model.py b/tests/test_validation_model.py new file mode 100644 index 000000000..f0f318d7f --- /dev/null +++ b/tests/test_validation_model.py @@ -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)