diff --git a/package/Makefile b/package/Makefile index b5581f9859..e0b60891fe 100644 --- a/package/Makefile +++ b/package/Makefile @@ -143,6 +143,16 @@ ifneq ($(CONFIG_USE_APK),) $(STAGING_DIR_HOST)/bin/apk adbdump --format json packages.adb | \ $(SCRIPT_DIR)/make-index-json.py -f apk -a "$(ARCH_PACKAGES)" - > index.json; \ done +ifdef CONFIG_JSON_CYCLONEDX_SBOM + @echo Creating CycloneDX package SBOMs... + @for d in $(PACKAGE_SUBDIRS); do \ + [ -d $$d ] && \ + cd $$d || continue; \ + [ -f packages.adb ] || continue; \ + $(STAGING_DIR_HOST)/bin/apk adbdump --format json packages.adb | \ + $(SCRIPT_DIR)/make-sbom.py - -f apk > Packages.bom.cdx.json || true; \ + done +endif else @for d in $(PACKAGE_SUBDIRS); do ( \ mkdir -p $$d; \ diff --git a/scripts/make-sbom.py b/scripts/make-sbom.py new file mode 100755 index 0000000000..331dffc211 --- /dev/null +++ b/scripts/make-sbom.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Parse the native package index files into a json file for use by +# downstream tools. +# +""" + +import datetime +import email.parser +import json +import uuid + + +def parse_args(): + from argparse import ArgumentParser + + parser = ArgumentParser() + # fmt: off + + parser.add_argument(dest="source", + help="File name for input, '-' for stdin") + parser.add_argument("-f", "--source-format", required=True, + choices=['apk', 'opkg'], + help=("Required source format of" + " input: 'apk' or 'opkg'")) + parser.add_argument("-m", "--manifest", + help=("File includes the packages to" + " be included in the output")) + + # fmt: on + args = parser.parse_args() + return args + + +def get_apk_sbom(text: str, installed: set) -> list: + packages: dict = json.loads(text) + components: list = [] + + type_allowed: dict = { + "kernel": "operating-system", + "firmware": "firmware", + "libs": "library" + } + + for package in packages["packages"]: + element: dict = {} + + # required + if 'name' in package: + name: str = package['name'] + element.update({"name": name}) + if installed: + if name not in installed: + continue + + if 'version' in package: + element.update({"version": package["version"]}) + + for tag in package.get("tags", []): + if tag.startswith("openwrt:cpe="): + cpe: str = tag.split("=")[-1] + element.update({"cpe": cpe}) + + # required + type_category: str = '' + + for tag in package.get("tags", []): + if tag.startswith("openwrt:section="): + category: str = tag.split("=")[-1] + if type_allowed.get(category): + type_category = type_allowed.get(category) + if type_category: + element.update({"type": type_category}) + else: + element.update({"type": "application"}) + + if 'license' in package: + licenses: list = [] + for license in package["license"].split(): + licenses.append({"license": {"name": license}}) + element.update({"licenses": licenses}) + + components.append(element) + + return components + + +def get_opkg_sbom(text: str, installed: set) -> list: + components: list = [] + + type_allowed: dict = { + "kernel": "operating-system", + "firmware": "firmware", + "libs": "library" + } + + parser: email.parser.Parser = email.parser.Parser() + chunks: list[str] = text.strip().split("\n\n") + for chunk in chunks: + element: dict = {} + package: dict = parser.parsestr(chunk, headersonly=True) + + # required + if 'Package' in package: + name: str = package['Package'] + element.update({"name": name}) + if installed: + if name not in installed: + continue + + if 'Version' in package: + element.update({"version": package['Version']}) + + if 'CPE-ID' in package: + element.update({"cpe": package['CPE-ID']}) + + # required + if 'Section' in package: + type_category: str = '' + if type_allowed.get(package['Section']): + type_category = type_allowed.get(package['Section']) + if type_category: + element.update({"type": type_category}) + else: + element.update({"type": "application"}) + + if 'license' in package: + licenses: list = [] + for license in package["license"].split(): + licenses.append({"license": {"name": license}}) + element.update({"licenses": licenses}) + + if element: + components.append(element) + + return components + + +if __name__ == "__main__": + import sys + + args = parse_args() + + input = sys.stdin if args.source == "-" else open(args.source, "r") + with input: + text: str = input.read() + + # Read manifest file (installed packages) + packages: set = set() + if args.manifest: + with open(args.manifest, 'r') as file: + for line in file: + packages.add(line.split(' - ')[0].strip()) + + components: list = [] + if args.source_format == "apk": + components = get_apk_sbom(text, packages) + elif args.source_format == "opkg": + components = get_opkg_sbom(text, packages) + else: + print("Source format unknown") + raise SystemExit + + timestamp: str = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + cyclonedx: dict = { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:" + str(uuid.uuid4()), + "version": "1", + "metadata": { + "timestamp": timestamp, + }, + "components": components, + } + + print(json.dumps(cyclonedx, indent=2))