mirror of
https://gitlab.com/buildroot.org/buildroot.git
synced 2026-10-20 07:41:47 -09:00
Add test for CycloneDX vulnerability generation.
If multiple packages ignore the same vulnerability but provide different
analyses, the generator must produce multiple vulnerability entries with
the same vulnerability ID but different analysis information.
This situation can occur when generating a CycloneDX SBOM from the
output of 'make show-info-all'. The resulting SBOM includes packages
that exist in multiple versions within Buildroot (gnupg for instance).
In such a case, one package version may ignore a vulnerability because
it is not affected, while another package version may address it through
a patch.
Therefore, multiple vulnerability entries must be generated, each with
its own analysis and reference information.
This adds a test case for this scenario.
Signed-off-by: Thomas Perale <thomas.perale@mind.be>
Reviewed-by: Quentin Schulz <quentin.schulz@cherry.de>
[Arnout: give next() a None default to return None instead of raise
StopIteration]
Signed-off-by: Arnout Vandecappelle <arnout@rnout.be>
(cherry picked from commit 03e9ac7d9c)
Signed-off-by: Raphaël Mélotte <raphael.melotte@mind.be>
310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""Unit tests for utils/generate-cyclonedx."""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
import infra
|
|
|
|
PATCH = "support/testing/tests/utils/test_generate_cyclonedx/0001-cve_upstream.patch"
|
|
SCHEMA_LICENSES = ["MIT", "Apache-2.0", "GPL-3.0-only"]
|
|
|
|
|
|
class TestGenerateCycloneDX(unittest.TestCase):
|
|
def setUp(self):
|
|
# Provide a fake SPDX schema so the script never hits the network.
|
|
self.schema_dir = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.schema_dir.cleanup)
|
|
|
|
cyclonedx_dir = Path(self.schema_dir.name) / "cyclonedx"
|
|
cyclonedx_dir.mkdir(parents=True)
|
|
schema_path = cyclonedx_dir / "spdx-1.6.2.schema.json"
|
|
schema_path.write_text(json.dumps({"enum": SCHEMA_LICENSES}))
|
|
|
|
self.env = os.environ.copy()
|
|
self.env["BR2_DL_DIR"] = self.schema_dir.name
|
|
self.script = infra.basepath("utils/generate-cyclonedx")
|
|
self.cwd = infra.basepath()
|
|
|
|
def _make_show_info(self) -> dict:
|
|
return {
|
|
"package-foo": {
|
|
"name": "foo",
|
|
"version": "1.2",
|
|
"type": "target",
|
|
"virtual": False,
|
|
"licenses": "MIT",
|
|
"cpe-id": "cpe:2.3:a:example:foo:1.2:*:*:*:*:*:*:*",
|
|
"patches": [PATCH],
|
|
"provides": ["package-virtual"],
|
|
"dependencies": ["skeleton-baz", "package-bar"],
|
|
"ignore_cves": ["CVE-2025-0001"],
|
|
"package_dir": "package/package-foo",
|
|
},
|
|
"skeleton-baz": {
|
|
"name": "skeleton-baz",
|
|
"version": "0.1",
|
|
"type": "target",
|
|
"virtual": False,
|
|
"licenses": "Apache-2.0",
|
|
"dependencies": [],
|
|
"package_dir": "package/skeleton-baz",
|
|
},
|
|
"package-bar": {
|
|
"name": "bar",
|
|
"version": "0.2",
|
|
"type": "target",
|
|
"virtual": False,
|
|
"licenses": "MIT",
|
|
"ignore_cves": ["CVE-2025-0002"],
|
|
"dependencies": ["package-virtual"],
|
|
"package_dir": "package/package-bar",
|
|
},
|
|
"host-tool": {
|
|
"name": "host-tool",
|
|
"version": "0.3",
|
|
"type": "host",
|
|
"virtual": False,
|
|
"licenses": "GPL-3.0-only",
|
|
"dependencies": [],
|
|
"package_dir": "package/host-tool",
|
|
},
|
|
"package-virtual": {
|
|
"name": "virtual-provider",
|
|
"virtual": True,
|
|
"type": "target",
|
|
"dependencies": ["package-foo"],
|
|
"package_dir": "package/package-virtual",
|
|
},
|
|
}
|
|
|
|
def _run_script(self, extra_args=(), show_info=None):
|
|
data = show_info if show_info is not None else self._make_show_info()
|
|
completed = subprocess.run(
|
|
[self.script, *extra_args],
|
|
cwd=self.cwd,
|
|
env=self.env,
|
|
input=json.dumps(data),
|
|
text=True,
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
return json.loads(completed.stdout)
|
|
|
|
def _find_component(self, result: dict, name: str) -> dict:
|
|
for component in result["components"]:
|
|
if component["bom-ref"] == name:
|
|
return component
|
|
self.fail(f"component {name} missing")
|
|
|
|
def _map_vulnerabilities(self, result: dict) -> dict:
|
|
vuln_map = defaultdict(list)
|
|
for vuln in result["vulnerabilities"]:
|
|
vuln_map[vuln["id"]].append(vuln)
|
|
return vuln_map
|
|
|
|
def test_default(self):
|
|
result = self._run_script()
|
|
|
|
self.assertEqual(len(result["components"]), 4)
|
|
self.assertIn("vulnerabilities", result)
|
|
vulnerabilities = self._map_vulnerabilities(result)
|
|
self.assertEqual(len(vulnerabilities), 2)
|
|
self.assertEqual(len(vulnerabilities["CVE-2025-0001"]), 1)
|
|
self.assertEqual(len(vulnerabilities["CVE-2025-0002"]), 1)
|
|
self.assertEqual(vulnerabilities["CVE-2025-0001"][0]["analysis"]["state"], "resolved_with_pedigree")
|
|
self.assertEqual(vulnerabilities["CVE-2025-0002"][0]["analysis"]["state"], "in_triage")
|
|
|
|
foo = self._find_component(result, "package-foo")
|
|
patch = foo["pedigree"]["patches"][0]
|
|
self.assertIn("text", patch["diff"])
|
|
self.assertIn("content", patch["diff"]["text"])
|
|
|
|
host = self._find_component(result, "host-tool")
|
|
self.assertEqual(host["properties"][0]["value"], "host")
|
|
|
|
names = {c["bom-ref"] for c in result["components"]}
|
|
self.assertIn("skeleton-baz", names)
|
|
self.assertIn("package-bar", names)
|
|
self.assertNotIn("package-virtual", names)
|
|
|
|
foo_deps = next(d for d in result["dependencies"] if d["ref"] == "package-foo")
|
|
self.assertEqual(foo_deps["dependsOn"], ["package-bar", "skeleton-baz"])
|
|
|
|
bar_deps = next(d for d in result["dependencies"] if d["ref"] == "package-bar")
|
|
self.assertEqual(bar_deps["dependsOn"], ["package-foo"])
|
|
|
|
project_deps = next(d for d in result["dependencies"] if d["ref"] == "buildroot")
|
|
self.assertEqual(project_deps["dependsOn"], ["host-tool", "package-foo"])
|
|
|
|
def test_virtual(self):
|
|
result = self._run_script(["--virtual"])
|
|
|
|
names = {c["bom-ref"] for c in result["components"]}
|
|
self.assertEqual(names, {"package-foo", "skeleton-baz", "host-tool", "package-virtual", "package-bar"})
|
|
|
|
foo_deps = next(d for d in result["dependencies"] if d["ref"] == "package-foo")
|
|
self.assertEqual(foo_deps["dependsOn"], ["package-bar", "skeleton-baz"])
|
|
|
|
def test_external_references(self):
|
|
info = self._make_show_info()
|
|
info["package-foo"]["downloads"] = [
|
|
{
|
|
"source": "foo-1.2.tar.gz",
|
|
"uris": [
|
|
"git+git://git.example.org/foo",
|
|
"svn+https://svn.example.org/foo",
|
|
"git+git@example.org:project/foo.git",
|
|
"https+https://sources.buildroot.net/foo",
|
|
"http|https+https://mirror.example.org/foo",
|
|
],
|
|
},
|
|
]
|
|
|
|
result = self._run_script(show_info=info)
|
|
foo = self._find_component(result, "package-foo")
|
|
|
|
self.assertIn("externalReferences", foo)
|
|
self.assertEqual(
|
|
foo["externalReferences"],
|
|
[
|
|
{
|
|
"type": "vcs",
|
|
"url": "git://git.example.org/foo",
|
|
"comment": "git repository",
|
|
},
|
|
{
|
|
"type": "vcs",
|
|
"url": "https://svn.example.org/foo",
|
|
"comment": "svn repository",
|
|
},
|
|
{
|
|
"type": "vcs",
|
|
"url": "ssh://git@example.org/project/foo.git",
|
|
"comment": "git repository",
|
|
},
|
|
{
|
|
"type": "source-distribution",
|
|
"url": "https://mirror.example.org/foo/foo-1.2.tar.gz",
|
|
}
|
|
],
|
|
)
|
|
|
|
def test_external_references_hashes(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
hash_file = Path(tmpdir) / "foo.hash"
|
|
hash_file.write_text(
|
|
"# source archive checksums\n"
|
|
"sha256 1111111111111111111111111111111111111111111111111111111111111111 foo-1.2.tar.gz\n"
|
|
"sha1 2222222222222222222222222222222222222222 foo-1.2.tar.gz\n"
|
|
"sha256 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa LICENSE\n"
|
|
)
|
|
|
|
info = self._make_show_info()
|
|
info["package-foo"]["hashes"] = [str(hash_file)]
|
|
info["package-foo"]["downloads"] = [
|
|
{
|
|
"source": "foo-1.2.tar.gz",
|
|
"uris": [
|
|
"git+git://git.example.org/foo",
|
|
"http|https+https://mirror.example.org/foo",
|
|
],
|
|
},
|
|
]
|
|
|
|
result = self._run_script(show_info=info)
|
|
|
|
foo = self._find_component(result, "package-foo")
|
|
self.assertEqual(
|
|
foo["externalReferences"],
|
|
[
|
|
{
|
|
"type": "vcs",
|
|
"url": "git://git.example.org/foo",
|
|
"comment": "git repository",
|
|
"hashes": [
|
|
{
|
|
"alg": "SHA-256",
|
|
"content": "1111111111111111111111111111111111111111111111111111111111111111",
|
|
},
|
|
{
|
|
"alg": "SHA-1",
|
|
"content": "2222222222222222222222222222222222222222",
|
|
},
|
|
]
|
|
},
|
|
{
|
|
"type": "source-distribution",
|
|
"url": "https://mirror.example.org/foo/foo-1.2.tar.gz",
|
|
"hashes": [
|
|
{
|
|
"alg": "SHA-256",
|
|
"content": "1111111111111111111111111111111111111111111111111111111111111111",
|
|
},
|
|
{
|
|
"alg": "SHA-1",
|
|
"content": "2222222222222222222222222222222222222222",
|
|
},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
|
|
def test_vulnerabilities_with_different_analysis(self):
|
|
"""
|
|
If multiple packages ignore the same vulnerability but provide
|
|
different analyses, the generator must produce multiple
|
|
vulnerability entries with the same vulnerability ID but different
|
|
analysis information.
|
|
|
|
This situation can occur when generating a CycloneDX SBOM from the
|
|
output of 'make show-info-all'. The resulting SBOM includes packages
|
|
that exist in multiple versions within Buildroot (gnupg for instance).
|
|
|
|
In such a case, one package version may ignore a vulnerability because
|
|
it is not affected, while another package version may address it
|
|
through a patch.
|
|
|
|
Therefore, multiple vulnerability entries must be generated, each with
|
|
its own analysis and reference information.
|
|
"""
|
|
|
|
info = {
|
|
"gnupg": {
|
|
"name": "gnupg",
|
|
"version": "1.4.23",
|
|
"type": "target",
|
|
"ignore_cves": ["CVE-2025-0001"],
|
|
"virtual": False,
|
|
},
|
|
"gnupg2": {
|
|
"name": "gnupg2",
|
|
"version": "2.5.20",
|
|
"type": "target",
|
|
"virtual": False,
|
|
"patches": [PATCH],
|
|
"ignore_cves": ["CVE-2025-0001"],
|
|
},
|
|
}
|
|
|
|
result = self._run_script(show_info=info)
|
|
self.assertEqual(len(result["vulnerabilities"]), 2)
|
|
|
|
vulnerabilities = self._map_vulnerabilities(result)
|
|
|
|
self.assertEqual(len(vulnerabilities["CVE-2025-0001"]), 2)
|
|
|
|
ignored = next((v for v in vulnerabilities["CVE-2025-0001"] if v["analysis"]["state"] == "in_triage"), None)
|
|
self.assertIsNotNone(ignored)
|
|
self.assertEqual(len(ignored["affects"]), 1)
|
|
self.assertEqual(ignored["affects"][0]["ref"], "gnupg")
|
|
|
|
resolved = next((v for v in vulnerabilities["CVE-2025-0001"] if v["analysis"]["state"] == "resolved_with_pedigree"), None)
|
|
self.assertIsNotNone(resolved)
|
|
self.assertEqual(len(resolved["affects"]), 1)
|
|
self.assertEqual(resolved["affects"][0]["ref"], "gnupg2")
|