mirror of
https://git.busybox.net/buildroot
synced 2025-12-20 01:10:56 +08:00
utils/generate-cyclonedx: add support for 'resolved_with_pedigree'
The CycloneDX specification for vulnerabilities defines four analysis
states ([1]) for cases where a vulnerability does not affect a component:
* resolved
* resolved_with_pedigree
* not_affected
* false_positive
Currently, the metadatas present in Buildroot does not allow an accurate
mapping of ignored CVEs to the appropriate CycloneDX vulnerability
categories. As a result, all ignored CVEs are currently marked as
'in_triage' by default.
This default analysis was established during the introduction of the
'generate-cyclonedx' script. The reasoning at the time was that SBOM
consumers might want to re-evaluate ignored vulnerabilities, as the
Buildroot infrastructure could not reliably determine their actual
state.
This patch adds support for automatically marking vulnerabilities as
'resolved_with_pedigree' when a Buildroot patch includes a 'CVE:''
tag in its header referencing the CVE identifier.
The 'CVE:' tag appears alongside the already required 'Upstream:', if
the patch address a security vulnerability and may be repeated if a
patch addresses multiple vulnerabilities.
If a vulnerability is addressed by multiple patches, each patch will need to
reference the vulnerability identifier.
For details on how CycloneDX handles 'resolved_with_pedigree', see
[1][2].
As an example, the CVE-2025-3198 from the binutils package will result
in the following pedigree for the binutils component:
```
{
"type": "unofficial",
"diff": {
"text": {
"content": "..."
}
},
"resolves": [
{
"type": "security",
"name": "CVE-2025-3198"
}
]
},
```
The `resolves` property is an array of issue the pedigree resolves. If
multiple are addressed by the same patch, then multiple identifier will be
present in this array.
In the listed vulnerabilities the entry for the CVE-2025-3198 looks like
this:
```
{
"id": "CVE-2025-3198",
"analysis": {
"state": "resolved_with_pedigree",
"detail": "The CVE 'CVE-2025-3198' has been marked as ignored by Buildroot"
},
"affects": [
{
"ref": "binutils"
}
]
}
```
[1] https://cyclonedx.org/docs/1.6/json/#vulnerabilities_items_analysis_state
[2] https://cyclonedx.org/docs/1.6/json/#components_items_pedigree_patches_items_resolves
Signed-off-by: Thomas Perale <thomas.perale@mind.be>
Signed-off-by: Peter Korsgaard <peter@korsgaard.com>
This commit is contained in:
committed by
Peter Korsgaard
parent
8f940235c0
commit
9415529923
@@ -17,6 +17,8 @@ from pathlib import Path
|
||||
import urllib.request
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
|
||||
|
||||
CYCLONEDX_VERSION = "1.6"
|
||||
SPDX_SCHEMA_URL = f"https://raw.githubusercontent.com/CycloneDX/specification/{CYCLONEDX_VERSION}/schema/spdx.schema.json"
|
||||
@@ -34,6 +36,11 @@ BR2_VERSION_FULL = (
|
||||
.strip()
|
||||
)
|
||||
|
||||
# Set of vulnerabilities that were addressed by a patch present in buildroot
|
||||
# tree. This set is used to set the analysis of the ignored CVEs to
|
||||
# 'resolved_with_pedigree'.
|
||||
VULN_WITH_PEDIGREE = set()
|
||||
|
||||
SPDX_LICENSES = []
|
||||
|
||||
if not SPDX_SCHEMA_PATH.exists():
|
||||
@@ -111,12 +118,91 @@ def cyclonedx_licenses(lic_list):
|
||||
}
|
||||
|
||||
|
||||
def cyclonedx_patches(patch_list):
|
||||
def extract_cves_from_header(header: str) -> list[str]:
|
||||
"""Extract CVE identifiers from the patch header.
|
||||
|
||||
Args:
|
||||
header (str): Content of the header of a patch.
|
||||
|
||||
Returns:
|
||||
list: Array of CVE identifier present in a patch header passed as
|
||||
argument.
|
||||
"""
|
||||
PATCH_CVE_HEADER = "CVE: "
|
||||
return [
|
||||
line.partition(PATCH_CVE_HEADER)[2].strip()
|
||||
for line in header.splitlines()
|
||||
if line.startswith(PATCH_CVE_HEADER)
|
||||
]
|
||||
|
||||
|
||||
def patch_retrieve_header(content: str) -> str:
|
||||
"""Read the content of a patch and split the header from the content.
|
||||
|
||||
Args:
|
||||
content (str): Patch content.
|
||||
|
||||
Returns:
|
||||
str: Patch header content.
|
||||
"""
|
||||
DIFF_LINE_REGEX = re.compile(r"^diff\s+(?:--git|-[-\w]+)\s+(\S+)\s+(\S+)$")
|
||||
INDEX_LINE_REGEX = re.compile(r"^Index:\s+(\S+)$")
|
||||
|
||||
lines = content.split('\n')
|
||||
|
||||
header = []
|
||||
for i, line in enumerate(lines):
|
||||
if DIFF_LINE_REGEX.match(line):
|
||||
# diff --git a/configure.ac b/configure.ac
|
||||
# index 1234..1234 100644
|
||||
# --- a/configure.ac
|
||||
# +++ b/configure.ac
|
||||
break
|
||||
elif INDEX_LINE_REGEX.match(line):
|
||||
# Index: <filename>
|
||||
# --- <filename>
|
||||
# +++ <filename>
|
||||
if i < len(lines) - 2 and lines[i + 1].startswith("===") and lines[i + 2].startswith("---"):
|
||||
break
|
||||
elif line.startswith("---"):
|
||||
# Some patches don't have a 'diff' tag just the --- +++ tuple.
|
||||
# Check next line is starting with '+++'
|
||||
# ex: package/berkeleydb/0001-cwd-db_config.patch
|
||||
if i < len(lines) - 2 and lines[i + 1].startswith("+++") and lines[i + 2].startswith("@@"):
|
||||
break
|
||||
else:
|
||||
header.append(line)
|
||||
|
||||
return '\n'.join(header)
|
||||
|
||||
|
||||
def read_patch_file(patch_path: Path) -> str:
|
||||
"""Read the content of a patch file, handling compression.
|
||||
|
||||
Args:
|
||||
patch_path (Path): Patch path.
|
||||
|
||||
Returns:
|
||||
str: Patch content.
|
||||
"""
|
||||
if patch_path.suffix == ".gz":
|
||||
f = gzip.open(patch_path, mode="rt")
|
||||
elif patch_path.suffix == ".bz":
|
||||
f = bz2.open(patch_path, mode="rt")
|
||||
else:
|
||||
f = open(patch_path)
|
||||
|
||||
content = f.read()
|
||||
f.close()
|
||||
return content
|
||||
|
||||
|
||||
def cyclonedx_patches(patch_list: list[str]):
|
||||
"""Translate a list of patches from the show-info JSON to a list of
|
||||
patches in CycloneDX format.
|
||||
|
||||
Args:
|
||||
patch_list (dict): Information about the patches as a Python dictionary.
|
||||
patch_list (list): Array of patch relative paths for a given component.
|
||||
|
||||
Returns:
|
||||
dict: Patch information in CycloneDX format.
|
||||
@@ -125,38 +211,51 @@ def cyclonedx_patches(patch_list):
|
||||
for patch in patch_list:
|
||||
patch_path = brpath / patch
|
||||
if patch_path.exists():
|
||||
f = None
|
||||
if patch.endswith('.gz'):
|
||||
f = gzip.open(patch_path, mode="rt")
|
||||
elif patch.endswith('.bz'):
|
||||
f = bz2.open(patch_path, mode="rt")
|
||||
else:
|
||||
f = open(patch_path)
|
||||
|
||||
try:
|
||||
patch_contents.append({
|
||||
"text": {
|
||||
"content": f.read()
|
||||
}
|
||||
})
|
||||
content = read_patch_file(patch_path)
|
||||
except Exception:
|
||||
# If the patch can't be read it won't be added to
|
||||
# the resulting SBOM.
|
||||
print(f"Failed to handle patch: {patch}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
f.close()
|
||||
header = patch_retrieve_header(content)
|
||||
|
||||
issue = {}
|
||||
cves = extract_cves_from_header(header)
|
||||
if cves:
|
||||
VULN_WITH_PEDIGREE.update(cves)
|
||||
issue = {
|
||||
"resolves": [
|
||||
{
|
||||
"type": "security",
|
||||
"name": cve
|
||||
} for cve in cves
|
||||
]
|
||||
}
|
||||
|
||||
patch_contents.append({
|
||||
"diff": {
|
||||
"text": {
|
||||
"content": content
|
||||
}
|
||||
},
|
||||
**issue
|
||||
})
|
||||
else:
|
||||
# If the patch is not a file it's a tarball or diff url passed
|
||||
# through the `<pkg-name>_PATCH` variable.
|
||||
patch_contents.append({
|
||||
"url": patch
|
||||
"diff": {
|
||||
"url": patch
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"pedigree": {
|
||||
"patches": [{
|
||||
"type": "unofficial",
|
||||
"diff": content
|
||||
**content
|
||||
} for content in patch_contents]
|
||||
},
|
||||
}
|
||||
@@ -229,7 +328,7 @@ def cyclonedx_vulnerabilities(show_info_dict):
|
||||
return [{
|
||||
"id": cve,
|
||||
"analysis": {
|
||||
"state": "in_triage",
|
||||
"state": "resolved_with_pedigree" if cve in VULN_WITH_PEDIGREE else "in_triage",
|
||||
"detail": f"The CVE '{cve}' has been marked as ignored by Buildroot"
|
||||
},
|
||||
"affects": [
|
||||
|
||||
Reference in New Issue
Block a user