Skip to content

Commit 52b2403

Browse files
saquibsaifeeclaude
andcommitted
feat(validation): add ModelValidationErrorSeverity and fix ModelValidator
- Add ModelValidationErrorSeverity enum (ERROR/WARNING) for typed severity - Fix nested components under metadata.component not being license-checked - Fix top-level dependency d.ref not validated against known BOM components - Convert incomplete-dependency-graph warning to typed ModelValidationError with WARNING severity instead of Python warnings.warn() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Saquib Saifee <saquibsaifee2@gmail.com>
1 parent d726148 commit 52b2403

1 file changed

Lines changed: 62 additions & 29 deletions

File tree

cyclonedx/validation/model.py

Lines changed: 62 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,74 +16,107 @@
1616
# Copyright (c) OWASP Foundation. All Rights Reserved.
1717

1818

19-
__all__ = ['ModelValidator', 'ModelValidationError']
19+
__all__ = ['ModelValidator', 'ModelValidationError', 'ModelValidationErrorSeverity']
2020

21-
import warnings
2221
from collections.abc import Iterable
22+
from enum import Enum
2323
from itertools import chain
24-
from typing import TYPE_CHECKING, Set, Union
24+
from typing import TYPE_CHECKING, Any, Union
2525

2626
from ..exception.model import LicenseExpressionAlongWithOthersException, UnknownComponentDependencyException
2727
from . import ValidationError
2828

29-
# REMOVED: from ..model.license import LicenseExpression
30-
3129
if TYPE_CHECKING: # pragma: no cover
3230
from ..model.bom import Bom, BomMetaData
3331
from ..model.component import Component
3432
from ..model.service import Service
3533

3634

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+
3745
class ModelValidationError(ValidationError):
3846
"""Validation failed with this specific error.
3947
4048
Use :attr:`~data` to access the content.
49+
Use :attr:`~severity` to determine how critical the issue is.
4150
"""
42-
pass
51+
52+
def __init__(self, data: Any,
53+
severity: ModelValidationErrorSeverity = ModelValidationErrorSeverity.ERROR) -> None:
54+
super().__init__(data)
55+
self.severity = severity
4356

4457

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

4861
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.
5263
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.
5573
"""
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.
5777
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+
))
5980
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+
)
6184

85+
# 1. Make sure every bom_ref referenced in the dependency graph exists in this BOM.
6286
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+
))
6491
dependency_diff = all_dependency_bom_refs - all_dependable_bom_refs
65-
if len(dependency_diff) > 0:
92+
if dependency_diff:
6693
yield ModelValidationError(UnknownComponentDependencyException(
6794
'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
74105
):
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,
80113
)
81114

82115
# 3. If a LicenseExpression is set, then there must be no other license.
83116
# see https://github.com/CycloneDX/specification/pull/205
84-
from ..model.license import LicenseExpression
85117
elem: Union['BomMetaData', 'Component', 'Service']
86118
for elem in chain([bom.metadata], all_components, bom.services): # type: ignore[assignment]
87119
if len(elem.licenses) > 1 and any(isinstance(li, LicenseExpression) for li in elem.licenses):
88120
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

Comments
 (0)