diff --git a/DEVELOPERS b/DEVELOPERS index 73d4281d12..9a220359e7 100644 --- a/DEVELOPERS +++ b/DEVELOPERS @@ -3176,6 +3176,7 @@ F: package/xorcurses/ N: Thomas Perale F: package/go/ +F: utils/generate-cyclonedx.py N: Thomas Petazzoni F: arch/Config.in.arm diff --git a/utils/generate-cyclonedx b/utils/generate-cyclonedx new file mode 100755 index 0000000000..993d7154ef --- /dev/null +++ b/utils/generate-cyclonedx @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-or-later + +# This script converts the output of the show-info make target +# to CycloneDX format. +# +# Example usage: +# > make show-info | utils/generate-cyclonedx.py | jq '.' + + +import argparse +import bz2 +import gzip +import json +import os +from pathlib import Path +import urllib.request +import subprocess +import sys + +CYCLONEDX_VERSION = "1.6" +SPDX_SCHEMA_URL = f"https://raw.githubusercontent.com/CycloneDX/specification/{CYCLONEDX_VERSION}/schema/spdx.schema.json" + +brpath = Path(__file__).parent.parent + +cyclonedxpath = Path(os.getenv("BR2_DL_DIR", brpath / "dl")) / "cyclonedx" +SPDX_SCHEMA_PATH = cyclonedxpath / f"spdx-{CYCLONEDX_VERSION}.schema.json" + +BR2_VERSION_FULL = ( + subprocess.check_output( + ["make", "--no-print-directory", "-C", brpath, "print-version"] + ) + .decode() + .strip() +) + +SPDX_LICENSES = [] + +if not SPDX_SCHEMA_PATH.exists(): + # Download the CycloneDX SPDX schema JSON, and cache it locally + cyclonedxpath.mkdir(parents=True, exist_ok=True) + urllib.request.urlretrieve(SPDX_SCHEMA_URL, SPDX_SCHEMA_PATH) + +try: + with SPDX_SCHEMA_PATH.open() as f: + SPDX_LICENSES = json.load(f).get("enum", []) +except json.JSONDecodeError: + # In case of error the license will just not be matched to the SPDX names + # but the SBOM generation still work. + print(f"Failed to load the SPDX licenses file: {SPDX_SCHEMA_PATH}", file=sys.stderr) + + +def split_top_level_comma(subj): + """Split a string at comma's, but do not split at comma's in between parentheses. + + Args: + subj (str): String to be split. + + Returns: + list: A list of substrings + """ + counter = 0 + substring = "" + + for char in subj: + if char == "," and counter == 0: + yield substring + substring = "" + else: + if char == "(": + counter += 1 + elif char == ")": + counter -= 1 + substring += char + + yield substring + + +def cyclonedx_license(lic): + """Given the name of a license, create an individual entry in + CycloneDX format. In CycloneDX, the 'id' keyword is used for + names that are recognized as SPDX License abbreviations. All other + license names are placed under the 'name' keyword. + + Args: + lic (str): Name of the license + + Returns: + dict: An entry for the license in CycloneDX format. + """ + key = "id" if lic in SPDX_LICENSES else "name" + return { + key: lic, + } + + +def cyclonedx_licenses(lic_list): + """Create a licenses list formatted for a CycloneDX component + + Args: + lic_list (str): A comma separated list of license names. + + Returns: + dict: A dictionary with license information for the component, + in CycloneDX format. + """ + return { + "licenses": [ + {"license": cyclonedx_license(lic.strip())} for lic in split_top_level_comma(lic_list) + ] + } + + +def cyclonedx_patches(patch_list): + """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. + + Returns: + dict: Patch information in CycloneDX format. + """ + patch_contents = [] + 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() + } + }) + 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) + + f.close() + else: + # If the patch is not a file it's a tarball or diff url passed + # through the `_PATCH` variable. + patch_contents.append({ + "url": patch + }) + + return { + "pedigree": { + "patches": [{ + "type": "unofficial", + "diff": content + } for content in patch_contents] + }, + } + + +def cyclonedx_component(name, comp): + """Translate a component from the show-info output, to a component entry in CycloneDX format. + + Args: + name (str): Key used for the package in the show-info output. + comp (dict): Data about the package as a Python dictionary. + + Returns: + dict: Component information in CycloneDX format. + """ + return { + "bom-ref": name, + "type": "library", + **({ + "name": comp["name"], + } if "name" in comp else {}), + **({ + "version": comp["version"], + **(cyclonedx_licenses(comp["licenses"]) if "licenses" in comp else {}), + } if not comp["virtual"] else {}), + **({ + "cpe": comp["cpe-id"], + } if "cpe-id" in comp else {}), + **(cyclonedx_patches(comp["patches"]) if comp.get("patches") else {}), + "properties": [{ + "name": "BR_TYPE", + "value": comp["type"], + }], + } + + +def cyclonedx_dependency(ref, depends): + """Create JSON for dependency relationships between components. + + Args: + ref (str): reference to a component bom-ref. + depends (list): array of component bom-ref identifier to create the dependencies. + + Returns: + dict: Dependency information in CycloneDX format. + """ + return { + "ref": ref, + "dependsOn": depends, + } + + +def cyclonedx_vulnerabilities(show_info_dict): + """Create a JSON list of vulnerabilities ignored by buildroot and associate + the component for which they are solved. + + Args: + show_info_dict (dict): The JSON output of the show-info + command, parsed into a Python dictionary. + + Returns: + list: Solved vulnerabilities list in CycloneDX format. + """ + cves = {} + + for name, comp in show_info_dict.items(): + for cve in comp.get('ignore_cves', []): + cves.setdefault(cve, []).append(name) + + return [{ + "id": cve, + "analysis": { + "state": "in_triage", + "detail": f"The CVE '{cve}' has been marked as ignored by Buildroot" + }, + "affects": [ + {"ref": bomref} for bomref in components + ] + } for cve, components in cves.items()] + + +def br2_parse_deps_recursively(ref, show_info_dict, virtual=False, deps=[]): + """Parse dependencies from the show-info output. This function will + recursively collect all dependencies, and return a list where each dependency + is stated at most once. + The dependency on virtual package will collect the final dependency without + including the virtual one. + + Args: + ref (str): The identifier of the package for which the dependencies have + to be looked up. + show_info_dict (dict): The JSON output of the show-info + command, parsed into a Python dictionary. + + Kwargs: + deps (list): A list, to which dependencies will be appended. If set to None, + a new empty list will be created. Defaults to None. + + Returns: + list: A list of dependencies of the 'ref' package. + """ + for dep in show_info_dict.get(ref, {}).get("dependencies", []): + if dep not in deps: + if virtual or show_info_dict.get(dep, {}).get("virtual") is False: + deps.append(dep) + br2_parse_deps_recursively(dep, show_info_dict, virtual, deps) + + return deps + + +def main(): + parser = argparse.ArgumentParser( + description='''Create a CycloneDX SBoM for the Buildroot configuration. + Example usage: make show-info | utils/generate-cyclonedx.py | jq > sbom.json + ''' + ) + parser.add_argument("-i", "--in-file", nargs="?", type=argparse.FileType("r"), default=sys.stdin) + parser.add_argument("-o", "--out-file", nargs="?", type=argparse.FileType("w"), default=sys.stdout) + parser.add_argument("--virtual", default=False, action='store_true', + help="This option includes virtual packages to the CycloneDX output") + + args = parser.parse_args() + + show_info_dict = json.load(args.in_file) + + # Remove rootfs and virtual packages if not explicitly included + # from the cli arguments + filtered_show_info_dict = {k: v for k, v in show_info_dict.items() + if ("rootfs" not in v["type"]) and (args.virtual or v["virtual"] is False)} + + cyclonedx_dict = { + "bomFormat": "CycloneDX", + "$schema": f"http://cyclonedx.org/schema/bom-{CYCLONEDX_VERSION}.schema.json", + "specVersion": f"{CYCLONEDX_VERSION}", + "components": [ + cyclonedx_component(name, comp) for name, comp in filtered_show_info_dict.items() + ], + "dependencies": [ + cyclonedx_dependency("buildroot", list(filtered_show_info_dict)), + *[cyclonedx_dependency(ref, br2_parse_deps_recursively(ref, show_info_dict, args.virtual)) + for ref in filtered_show_info_dict], + ], + "vulnerabilities": cyclonedx_vulnerabilities(show_info_dict), + "metadata": { + "component": { + "bom-ref": "buildroot", + "name": "buildroot", + "type": "firmware", + "version": f"{BR2_VERSION_FULL}", + }, + }, + } + + args.out_file.write(json.dumps(cyclonedx_dict)) + + +if __name__ == "__main__": + main()