]> git.ipfire.org Git - thirdparty/openwrt.git/commitdiff
build: add CycloneDX SBOM processing to apk
authorFlorian Eckert <fe@dev.tdt.de>
Tue, 30 Sep 2025 12:49:52 +0000 (14:49 +0200)
committerRobert Marko <robimarko@gmail.com>
Sun, 17 May 2026 10:21:09 +0000 (12:21 +0200)
Currently, there is no SBOM generation in imagebuilder when the package
system 'apk' is used. This commit adds this feature back. This already
worked for the package system 'opkg'.

Furthermore, generating the SBOM using perl is not reproducible if the
input data has not changed. A different file is always generated. This is
not the case with Python. For this reason, Python is now used to generate
the SBOM for the imagebuilder.

The script has already been prepared so that it can also process the opkg
package system for generating the SBOM.

Signed-off-by: Florian Eckert <fe@dev.tdt.de>
package/Makefile
scripts/make-sbom.py [new file with mode: 0755]

index b5581f9859d0b4a8ef8a4c24c44eed75908171c3..e0b60891fec88964b4543c5c590fdcdeb0cd0566 100644 (file)
@@ -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 (executable)
index 0000000..331dffc
--- /dev/null
@@ -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))