|
16 | 16 | # Copyright (c) OWASP Foundation. All Rights Reserved. |
17 | 17 |
|
18 | 18 |
|
19 | | -__all__ = ['ModelValidator', 'ModelValidationError'] |
| 19 | +__all__ = ['ModelValidator', 'ModelValidationError', 'ModelValidationErrorSeverity'] |
20 | 20 |
|
21 | | -import warnings |
22 | 21 | from collections.abc import Iterable |
| 22 | +from enum import Enum |
23 | 23 | from itertools import chain |
24 | | -from typing import TYPE_CHECKING, Set, Union |
| 24 | +from typing import TYPE_CHECKING, Any, Union |
25 | 25 |
|
26 | 26 | from ..exception.model import LicenseExpressionAlongWithOthersException, UnknownComponentDependencyException |
27 | 27 | from . import ValidationError |
28 | 28 |
|
29 | | -# REMOVED: from ..model.license import LicenseExpression |
30 | | - |
31 | 29 | if TYPE_CHECKING: # pragma: no cover |
32 | 30 | from ..model.bom import Bom, BomMetaData |
33 | 31 | from ..model.component import Component |
34 | 32 | from ..model.service import Service |
35 | 33 |
|
36 | 34 |
|
| 35 | +class ModelValidationErrorSeverity(str, Enum): |
| 36 | + """Severity level of a :class:`ModelValidationError`.""" |
| 37 | + |
| 38 | + ERROR = 'error' |
| 39 | + """BOM is structurally invalid and cannot be serialized correctly.""" |
| 40 | + |
| 41 | + WARNING = 'warning' |
| 42 | + """BOM may have issues but can still be serialized; attention is recommended.""" |
| 43 | + |
| 44 | + |
37 | 45 | class ModelValidationError(ValidationError): |
38 | 46 | """Validation failed with this specific error. |
39 | 47 |
|
40 | 48 | Use :attr:`~data` to access the content. |
| 49 | + Use :attr:`~severity` to determine how critical the issue is. |
41 | 50 | """ |
42 | | - pass |
| 51 | + |
| 52 | + def __init__(self, data: Any, |
| 53 | + severity: ModelValidationErrorSeverity = ModelValidationErrorSeverity.ERROR) -> None: |
| 54 | + super().__init__(data) |
| 55 | + self.severity = severity |
43 | 56 |
|
44 | 57 |
|
45 | 58 | class ModelValidator: |
46 | 59 | """Perform data-model level validations to make sure we have some known data integrity.""" |
47 | 60 |
|
48 | 61 | def validate(self, bom: 'Bom') -> Iterable[ModelValidationError]: |
49 | | - """ |
50 | | - Perform data-model level validations to make sure we have some known data integrity |
51 | | - prior to attempting output of a `Bom`. |
| 62 | + """Validate a :class:`~cyclonedx.model.bom.Bom` at the data-model level. |
52 | 63 |
|
53 | | - :param bom: The `Bom` to validate. |
54 | | - :return: An iterable of `ModelValidationError` if any issues are found. |
| 64 | + Yields :class:`ModelValidationError` instances — one per issue found. |
| 65 | + Errors with :attr:`~ModelValidationErrorSeverity.ERROR` severity indicate structural |
| 66 | + invalidity; errors with :attr:`~ModelValidationErrorSeverity.WARNING` severity are |
| 67 | + advisory. |
| 68 | +
|
| 69 | + This method has no side-effects: it does not mutate the ``bom`` passed in. |
| 70 | +
|
| 71 | + :param bom: The :class:`~cyclonedx.model.bom.Bom` to validate. |
| 72 | + :return: An iterable of :class:`ModelValidationError` for each issue found. |
55 | 73 | """ |
56 | | - # 1. Make sure dependencies are all in this Bom. |
| 74 | + from ..model.license import LicenseExpression |
| 75 | + |
| 76 | + # Collect all components across the BOM, including nested ones. |
57 | 77 | all_components: set['Component'] = set(chain.from_iterable( |
58 | | - c.get_all_nested_components(include_self=True) for c in bom.components)) |
| 78 | + c.get_all_nested_components(include_self=True) for c in bom.components |
| 79 | + )) |
59 | 80 | if bom.metadata.component: |
60 | | - all_components.add(bom.metadata.component) |
| 81 | + all_components.update( |
| 82 | + bom.metadata.component.get_all_nested_components(include_self=True) |
| 83 | + ) |
61 | 84 |
|
| 85 | + # 1. Make sure every bom_ref referenced in the dependency graph exists in this BOM. |
62 | 86 | all_dependable_bom_refs = {e.bom_ref for e in chain(all_components, bom.services)} |
63 | | - all_dependency_bom_refs = set(chain.from_iterable(d.dependencies_as_bom_refs() for d in bom.dependencies)) |
| 87 | + all_dependency_bom_refs = set(chain( |
| 88 | + (d.ref for d in bom.dependencies), |
| 89 | + chain.from_iterable(d.dependencies_as_bom_refs() for d in bom.dependencies), |
| 90 | + )) |
64 | 91 | dependency_diff = all_dependency_bom_refs - all_dependable_bom_refs |
65 | | - if len(dependency_diff) > 0: |
| 92 | + if dependency_diff: |
66 | 93 | yield ModelValidationError(UnknownComponentDependencyException( |
67 | 94 | 'One or more Components have Dependency references to Components/Services that are not known in this ' |
68 | | - f'BOM. They are: {dependency_diff}')) |
69 | | - |
70 | | - # 2. if root component is set: dependencies should exist for the Component this BOM is describing |
71 | | - meta_bom_ref = bom.metadata.component.bom_ref if bom.metadata.component else None |
72 | | - if meta_bom_ref and len(bom.components) > 0 and not any( |
73 | | - len(d.dependencies) > 0 for d in bom.dependencies if d.ref == meta_bom_ref |
| 95 | + f'BOM. They are: {dependency_diff}' |
| 96 | + )) |
| 97 | + |
| 98 | + # 2. If the root component is set and there are other components, the root should declare |
| 99 | + # at least one dependency — otherwise the Dependency Graph is incomplete. |
| 100 | + # NOTE: guard on the component, not the BomRef — BomRef is falsy when value is None. |
| 101 | + if bom.metadata.component is not None and len(bom.components) > 0 and not any( |
| 102 | + len(d.dependencies) > 0 |
| 103 | + for d in bom.dependencies |
| 104 | + if d.ref == bom.metadata.component.bom_ref |
74 | 105 | ): |
75 | | - warnings.warn( |
76 | | - f'The Component this BOM is describing {bom.metadata.component.purl} has no defined dependencies ' |
77 | | - 'which means the Dependency Graph is incomplete - you should add direct dependencies to this ' |
78 | | - '"root" Component to complete the Dependency Graph data.', |
79 | | - category=UserWarning, stacklevel=2 |
| 106 | + yield ModelValidationError( |
| 107 | + UserWarning( |
| 108 | + f'The Component this BOM is describing {bom.metadata.component.purl} has no defined ' |
| 109 | + 'dependencies which means the Dependency Graph is incomplete - you should add direct ' |
| 110 | + 'dependencies to this "root" Component to complete the Dependency Graph data.' |
| 111 | + ), |
| 112 | + severity=ModelValidationErrorSeverity.WARNING, |
80 | 113 | ) |
81 | 114 |
|
82 | 115 | # 3. If a LicenseExpression is set, then there must be no other license. |
83 | 116 | # see https://github.com/CycloneDX/specification/pull/205 |
84 | | - from ..model.license import LicenseExpression |
85 | 117 | elem: Union['BomMetaData', 'Component', 'Service'] |
86 | 118 | for elem in chain([bom.metadata], all_components, bom.services): # type: ignore[assignment] |
87 | 119 | if len(elem.licenses) > 1 and any(isinstance(li, LicenseExpression) for li in elem.licenses): |
88 | 120 | yield ModelValidationError(LicenseExpressionAlongWithOthersException( |
89 | | - f'Found LicenseExpression along with others licenses in: {elem!r}')) |
| 121 | + f'Found LicenseExpression along with others licenses in: {elem!r}' |
| 122 | + )) |
0 commit comments