mirror of
https://github.com/openwrt/openwrt.git
synced 2026-10-12 06:57:57 +04:00
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>
198 lines
5.5 KiB
Python
Executable File
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))
|