Files
openwrt/scripts/make-sbom.py
T
John Crispin 712ff7ca89 build: create reproducible CycloneDX SBOMs
Both SBOM generators produce a different file on every run, so the
published .bom.cdx.json of an image and of a package feed never match a
rebuild.

dump_cyclonedxsbom_json() in scripts/package-metadata.pl has three
causes. It draws the serial number from rand(), it takes the timestamp
from the wall clock, and encode_json() follows the Perl hash order, which
changes with the hash seed of each run. The last one alone reorders every
key in the file.

scripts/make-sbom.py has the first two causes, through uuid.uuid4() and
datetime.utcnow().

Take the timestamp from SOURCE_DATE_EPOCH, which the build exports. Derive
the serial number from the timestamp and the component list, so it stays a
valid RFC 4122 identifier and still changes when the content changes. Sort
the keys of the Perl output.

Tested with the package index and the image manifest of an ipq40xx build.
Two runs with the same SOURCE_DATE_EPOCH are now identical, two runs with
different values differ, and the scripts exit with an error when the value
is not a number. Both files differ between two runs without the change.

Signed-off-by: John Crispin <john@phrozen.org>
2026-09-22 18:46:30 +02:00

198 lines
5.5 KiB
Python
Executable File

#!/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 os
import sys
import uuid
def build_time() -> datetime.datetime:
epoch = os.environ.get("SOURCE_DATE_EPOCH")
if not epoch:
return datetime.datetime.now(datetime.timezone.utc)
try:
seconds = int(epoch)
except ValueError:
print("SOURCE_DATE_EPOCH is not a number", file=sys.stderr)
raise SystemExit(1)
return datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc)
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 = build_time().strftime("%Y-%m-%dT%H:%M:%SZ")
serial: uuid.UUID = uuid.uuid5(
uuid.NAMESPACE_URL, timestamp + json.dumps(components, sort_keys=True)
)
cyclonedx: dict = {
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": "urn:uuid:" + str(serial),
"version": 1,
"metadata": {
"timestamp": timestamp,
},
"components": components,
}
print(json.dumps(cyclonedx, indent=2))