Skip to content

Commit c6ab369

Browse files
committed
feat(ISV-5784): support multiple CPEs
Signed-off-by: Martin Jediny <jedinym@proton.me>
1 parent edd05a8 commit c6ab369

File tree

2 files changed

+92
-24
lines changed

2 files changed

+92
-24
lines changed

sbom/create_product_sbom.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import uuid
1111
from datetime import datetime, timezone
1212
import argparse
13-
from typing import List
13+
from typing import List, Union
1414
from pathlib import Path
1515
import asyncio
1616

@@ -42,7 +42,7 @@ class ReleaseNotes(pdc.BaseModel):
4242

4343
product_name: str
4444
product_version: str
45-
cpe: str
45+
cpe: Union[str, List[str]] = pdc.Field(union_mode="left_to_right")
4646

4747

4848
class ReleaseData(pdc.BaseModel):
@@ -55,6 +55,19 @@ class ReleaseData(pdc.BaseModel):
5555

5656
def create_product_package(product_elem_id: str, release_notes: ReleaseNotes) -> Package:
5757
"""Create SPDX package corresponding to the product."""
58+
if isinstance(release_notes.cpe, str):
59+
cpes = [release_notes.cpe]
60+
else:
61+
cpes = release_notes.cpe
62+
63+
refs = [
64+
ExternalPackageRef(
65+
category=ExternalPackageRefCategory.SECURITY,
66+
reference_type="cpe22Type",
67+
locator=cpe,
68+
)
69+
for cpe in cpes
70+
]
5871

5972
return Package(
6073
spdx_id=product_elem_id,
@@ -64,13 +77,7 @@ def create_product_package(product_elem_id: str, release_notes: ReleaseNotes) ->
6477
supplier=Actor(ActorType.ORGANIZATION, "Red Hat"),
6578
license_declared=SpdxNoAssertion(),
6679
files_analyzed=False,
67-
external_references=[
68-
ExternalPackageRef(
69-
category=ExternalPackageRefCategory.SECURITY,
70-
reference_type="cpe22Type",
71-
locator=release_notes.cpe,
72-
)
73-
],
80+
external_references=refs,
7481
)
7582

7683

@@ -168,14 +175,18 @@ def create_sbom(release_notes: ReleaseNotes, snapshot: Snapshot) -> Document:
168175
)
169176

170177

171-
def main() -> None:
178+
def parse_release_notes(raw_json: str) -> ReleaseNotes:
179+
return ReleaseData.model_validate_json(raw_json).release_notes
180+
181+
182+
def main() -> None: # pragma: nocover
172183
"""
173184
Script entrypoint.
174185
"""
175186
parser = argparse.ArgumentParser(
176187
prog="create-product-sbom",
177188
description="Create product-level SBOM from merged data file"
178-
"and mapped snapshot spec.",
189+
" and mapped snapshot spec.",
179190
)
180191
parser.add_argument(
181192
"--data-path",
@@ -203,14 +214,15 @@ def main() -> None:
203214
snapshot = asyncio.run(sbomlib.make_snapshot(args.snapshot_path))
204215
with open(args.data_path, "r", encoding="utf-8") as fp:
205216
raw_json = fp.read()
206-
release_notes = ReleaseData.model_validate_json(raw_json).release_notes
217+
release_notes = parse_release_notes(raw_json)
207218

208219
sbom = create_sbom(release_notes, snapshot)
209220

210221
write_file(document=sbom, file_name=str(args.output_path), validate=True)
211222
except Exception: # pylint: disable=broad-except
212223
logger.exception("Creation of the product-level SBOM failed.")
224+
raise
213225

214226

215-
if __name__ == "__main__":
227+
if __name__ == "__main__": # pragma: nocover
216228
main()

sbom/test_create_product_sbom.py

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from io import StringIO
22
import json
3-
from typing import List
3+
from typing import List, Union
44
from collections import namedtuple
55

66
import pytest
77
from packageurl import PackageURL
88
from spdx_tools.spdx.writer.json.json_writer import write_document_to_stream
99

10-
from sbom.create_product_sbom import ReleaseNotes, create_sbom
10+
from sbom.create_product_sbom import ReleaseNotes, create_sbom, parse_release_notes
1111
from sbom.sbomlib import Component, Image, IndexImage, Snapshot
1212

1313
Digests = namedtuple("Digests", ["single_arch", "multi_arch"])
@@ -17,15 +17,59 @@
1717
)
1818

1919

20-
def verify_cpe(sbom, cpe: str) -> None:
20+
@pytest.mark.parametrize(
21+
["data", "expected_rn"],
22+
[
23+
pytest.param(
24+
{
25+
"unrelated": "field",
26+
"releaseNotes": {
27+
"product_name": "Product",
28+
"product_version": "1.0",
29+
"cpe": "cpe",
30+
},
31+
},
32+
ReleaseNotes(
33+
product_name="Product",
34+
product_version="1.0",
35+
cpe="cpe",
36+
),
37+
id="cpe-single",
38+
),
39+
pytest.param(
40+
{
41+
"unrelated": "field",
42+
"releaseNotes": {
43+
"product_name": "Product",
44+
"product_version": "1.0",
45+
"cpe": ["cpe1", "cpe2"],
46+
},
47+
},
48+
ReleaseNotes(
49+
product_name="Product",
50+
product_version="1.0",
51+
cpe=["cpe1", "cpe2"],
52+
),
53+
id="cpe-list",
54+
),
55+
],
56+
)
57+
def test_parse_release_notes(data: dict, expected_rn: ReleaseNotes) -> None:
58+
actual = parse_release_notes(json.dumps(data))
59+
assert expected_rn == actual
60+
61+
62+
def verify_cpe(sbom, expected_cpe: Union[str, List[str]]) -> None:
2163
"""
22-
Verify that the CPE externalRef is in the first package.
64+
Verify that all CPE externalRefs are in the first package.
2365
"""
24-
assert {
25-
"referenceCategory": "SECURITY",
26-
"referenceLocator": cpe,
27-
"referenceType": "cpe22Type",
28-
} in sbom["packages"][0]["externalRefs"]
66+
all_cpes = expected_cpe if isinstance(expected_cpe, list) else [expected_cpe]
67+
for cpe in all_cpes:
68+
assert {
69+
"referenceCategory": "SECURITY",
70+
"referenceLocator": cpe,
71+
"referenceType": "cpe22Type",
72+
} in sbom["packages"][0]["externalRefs"]
2973

3074

3175
def verify_purls(sbom, expected: List[str]) -> None:
@@ -98,6 +142,19 @@ def verify_package_licenses(sbom) -> None:
98142
assert package["licenseDeclared"] == "NOASSERTION"
99143

100144

145+
@pytest.mark.parametrize(
146+
"cpe",
147+
[
148+
pytest.param("cpe:/a:redhat:discovery:1.0::el9", id="cpe-single"),
149+
pytest.param(
150+
[
151+
"cpe:/a:redhat:discovery:1.0::el9",
152+
"cpe:/a:redhat:discovery:1.0::el10",
153+
],
154+
id="cpe-list",
155+
),
156+
],
157+
)
101158
@pytest.mark.parametrize(
102159
["snapshot", "purls"],
103160
[
@@ -176,12 +233,11 @@ def verify_package_licenses(sbom) -> None:
176233
),
177234
],
178235
)
179-
def test_create_sbom(snapshot, purls):
236+
def test_create_sbom(snapshot: Snapshot, purls: List[str], cpe: Union[str, List[str]]):
180237
"""
181238
Create an SBOM from release notes and a snapshot and verify that the
182239
expected properties hold.
183240
"""
184-
cpe = "cpe:/a:redhat:discovery:1.0::el9"
185241
release_notes = ReleaseNotes(
186242
product_name="Product",
187243
product_version="1.0",

0 commit comments

Comments
 (0)