Skip to content

Commit 16843b2

Browse files
authored
fix: bom.validate() detects invalid license constellations (#452)
If a LicenseExpression is set, then there must be no other license. fixes #453 Signed-off-by: Jan Kowalleck <[email protected]>
1 parent 9d49280 commit 16843b2

File tree

5 files changed

+122
-21
lines changed

5 files changed

+122
-21
lines changed

cyclonedx/exception/model.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,21 @@ class UnknownComponentDependencyException(CycloneDxModelException):
7373
"""
7474
Exception raised when a dependency has been noted for a Component that is NOT a Component BomRef in this Bom.
7575
"""
76+
pass
7677

7778

7879
class UnknownHashTypeException(CycloneDxModelException):
7980
"""
8081
Exception raised when we are unable to determine the type of hash from a composite hash string.
8182
"""
8283
pass
84+
85+
86+
class LicenseExpressionAlongWithOthersException(CycloneDxModelException):
87+
"""
88+
Exception raised when a LicenseExpression was detected along with other licenses.
89+
If a LicenseExpression exists, than it must stand alone.
90+
91+
See https://github.com/CycloneDX/specification/pull/205
92+
"""
93+
pass

cyclonedx/model/bom.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
1919
import warnings
2020
from datetime import datetime
21-
from typing import TYPE_CHECKING, Iterable, Optional, Set
21+
from itertools import chain
22+
from typing import TYPE_CHECKING, Iterable, Optional, Set, Union
2223
from uuid import UUID, uuid4
2324

2425
import serializable
2526
from sortedcontainers import SortedSet
2627

2728
from cyclonedx.serialization import UrnUuidHelper
2829

29-
from ..exception.model import UnknownComponentDependencyException
30+
from ..exception.model import LicenseExpressionAlongWithOthersException, UnknownComponentDependencyException
3031
from ..parser import BaseParser
3132
from ..schema.schema import (
3233
SchemaVersion1Dot0,
@@ -573,6 +574,19 @@ def validate(self) -> bool:
573574
UserWarning
574575
)
575576

577+
# 3. If a LicenseExpression is set, then there must be no other license.
578+
# see https://github.com/CycloneDX/specification/pull/205
579+
elem: Union[BomMetaData, Component, Service]
580+
for elem in chain( # type: ignore[assignment]
581+
[self.metadata],
582+
self.metadata.component.get_all_nested_components(include_self=True) if self.metadata.component else [],
583+
chain.from_iterable(c.get_all_nested_components(include_self=True) for c in self.components),
584+
self.services
585+
):
586+
if len(elem.licenses) > 1 and any(li.expression for li in elem.licenses):
587+
raise LicenseExpressionAlongWithOthersException(
588+
f'Found LicenseExpression along with others licenses in: {elem!r}')
589+
576590
return True
577591

578592
def __eq__(self, other: object) -> bool:

tests/data.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,59 @@ def get_vulnerability_source_owasp() -> VulnerabilitySource:
640640
return VulnerabilitySource(name='OWASP', url=XsUri('https://owasp.org'))
641641

642642

643+
def get_bom_metadata_licenses_invalid() -> Bom:
644+
return Bom(metadata=BomMetaData(licenses=[
645+
LicenseChoice(expression='Apache-2.0 OR MIT'),
646+
LicenseChoice(license=License(id='MIT')),
647+
]))
648+
649+
650+
def get_invalid_license_repository() -> List[LicenseChoice]:
651+
"""
652+
license expression and a license -- this is an invalid constellation according to schema
653+
see https://github.com/CycloneDX/specification/pull/205
654+
"""
655+
return [
656+
LicenseChoice(expression='Apache-2.0 OR MIT'),
657+
LicenseChoice(license=License(id='GPL-2.0-only')),
658+
]
659+
660+
661+
def get_component_licenses_invalid() -> Component:
662+
return Component(name='foo', type=ComponentType.LIBRARY,
663+
licenses=get_invalid_license_repository())
664+
665+
666+
def get_bom_metadata_component_licenses_invalid() -> Bom:
667+
comp = get_component_licenses_invalid()
668+
return Bom(metadata=BomMetaData(component=comp),
669+
dependencies=[Dependency(comp.bom_ref)])
670+
671+
672+
def get_bom_metadata_component_nested_licenses_invalid() -> Bom:
673+
comp = Component(name='bar', type=ComponentType.LIBRARY,
674+
components=[get_component_licenses_invalid()])
675+
return Bom(metadata=BomMetaData(component=comp),
676+
dependencies=[Dependency(comp.bom_ref)])
677+
678+
679+
def get_bom_component_licenses_invalid() -> Bom:
680+
return Bom(components=[get_component_licenses_invalid()])
681+
682+
683+
def get_bom_component_nested_licenses_invalid() -> Bom:
684+
return Bom(components=[
685+
Component(name='bar', type=ComponentType.LIBRARY,
686+
components=[get_component_licenses_invalid()])
687+
])
688+
689+
690+
def get_bom_service_licenses_invalid() -> Bom:
691+
return Bom(services=[
692+
Service(name='foo', licenses=get_invalid_license_repository())
693+
])
694+
695+
643696
T = TypeVar('T')
644697

645698

tests/test_model_bom.py

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,11 @@
11
# encoding: utf-8
2-
3-
# This file is part of CycloneDX Python Lib
4-
#
5-
# Licensed under the Apache License, Version 2.0 (the "License");
6-
# you may not use this file except in compliance with the License.
7-
# You may obtain a copy of the License at
8-
#
9-
# http://www.apache.org/licenses/LICENSE-2.0
10-
#
11-
# Unless required by applicable law or agreed to in writing, software
12-
# distributed under the License is distributed on an "AS IS" BASIS,
13-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14-
# See the License for the specific language governing permissions and
15-
# limitations under the License.
16-
#
17-
# SPDX-License-Identifier: Apache-2.0
18-
# Copyright (c) OWASP Foundation. All Rights Reserved.
19-
2+
from typing import Callable
203
from unittest import TestCase
214
from uuid import uuid4
225

6+
from ddt import ddt, named_data
7+
8+
from cyclonedx.exception.model import LicenseExpressionAlongWithOthersException
239
from cyclonedx.model import (
2410
License,
2511
LicenseChoice,
@@ -33,12 +19,35 @@
3319
from cyclonedx.model.bom_ref import BomRef
3420
from cyclonedx.model.component import Component, ComponentType
3521
from tests.data import (
22+
get_bom_component_licenses_invalid,
23+
get_bom_component_nested_licenses_invalid,
3624
get_bom_for_issue_275_components,
25+
get_bom_metadata_component_licenses_invalid,
26+
get_bom_metadata_component_nested_licenses_invalid,
27+
get_bom_metadata_licenses_invalid,
28+
get_bom_service_licenses_invalid,
3729
get_bom_with_component_setuptools_with_vulnerability,
3830
get_component_setuptools_simple,
3931
get_component_setuptools_simple_no_version,
4032
)
4133

34+
# This file is part of CycloneDX Python Lib
35+
#
36+
# Licensed under the Apache License, Version 2.0 (the "License");
37+
# you may not use this file except in compliance with the License.
38+
# You may obtain a copy of the License at
39+
#
40+
# http://www.apache.org/licenses/LICENSE-2.0
41+
#
42+
# Unless required by applicable law or agreed to in writing, software
43+
# distributed under the License is distributed on an "AS IS" BASIS,
44+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
45+
# See the License for the specific language governing permissions and
46+
# limitations under the License.
47+
#
48+
# SPDX-License-Identifier: Apache-2.0
49+
# Copyright (c) OWASP Foundation. All Rights Reserved.
50+
4251

4352
class TestBomMetaData(TestCase):
4453

@@ -96,6 +105,7 @@ def test_basic_bom_metadata(self) -> None:
96105
self.assertTrue(tools[1] in metadata.tools)
97106

98107

108+
@ddt
99109
class TestBom(TestCase):
100110

101111
def test_bom_metadata_tool_this_tool(self) -> None:
@@ -168,6 +178,19 @@ def test_bom_nested_components_issue_275(self) -> None:
168178
self.assertEqual(2, len(bom.components))
169179
bom.validate()
170180

181+
@named_data(
182+
['metadata_licenses', get_bom_metadata_licenses_invalid],
183+
['metadata_component_licenses', get_bom_metadata_component_licenses_invalid],
184+
['metadata_component_nested_licenses', get_bom_metadata_component_nested_licenses_invalid],
185+
['component_licenses', get_bom_component_licenses_invalid],
186+
['component_nested_licenses', get_bom_component_nested_licenses_invalid],
187+
['service_licenses', get_bom_service_licenses_invalid],
188+
)
189+
def test_validate_with_invalid_license_constellation_throws(self, get_bom: Callable[[], Bom]) -> None:
190+
bom = get_bom()
191+
with self.assertRaises(LicenseExpressionAlongWithOthersException):
192+
bom.validate()
193+
171194
# def test_bom_nested_services_issue_275(self) -> None:
172195
# """regression test for issue #275
173196
# see https://github.com/CycloneDX/cyclonedx-python-lib/issues/275

tests/test_validation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,5 @@ def test_as_expected(self, of: OutputFormat, sv: SchemaVersion) -> None:
5252
)
5353
@unpack
5454
def test_fails_on_wrong_args(self, of: OutputFormat, sv: SchemaVersion, raisesRegex: Tuple) -> None:
55-
with self.assertRaisesRegexp(*raisesRegex):
55+
with self.assertRaisesRegex(*raisesRegex):
5656
get_validator(of, sv)

0 commit comments

Comments
 (0)