From 3cee8d92b653b6f1c629a9268b264d21d0e529a2 Mon Sep 17 00:00:00 2001 From: Bernd Ritter Date: Mon, 27 Apr 2026 17:34:37 +0200 Subject: [PATCH] Creating an sbom file (cyclonedx 1.7) during image build process. refs gardenlinux/glvd2#7 Signed-off-by: Bernd Ritter On-behalf-of: SAP --- builder/Makefile | 2 +- builder/dpkg_to_cyclonedx | 189 ++++++++++++++++++++++++++++++ builder/image.manifest | 1 + builder/image.sbom | 20 ++++ builder/make_get_artifact_rules | 2 +- builder/make_list_build_artifacts | 2 +- 6 files changed, 213 insertions(+), 3 deletions(-) create mode 100755 builder/dpkg_to_cyclonedx create mode 100755 builder/image.sbom diff --git a/builder/Makefile b/builder/Makefile index cada2a7..2bf2bc5 100644 --- a/builder/Makefile +++ b/builder/Makefile @@ -106,5 +106,5 @@ $(foreach artifact_rule,$(shell ./make_get_artifact_rules),$(eval $(call artifac ln -f -s -r '$<' '.build/$*' # prevents match anything rule from applying to Makefile and image/convert scripts -Makefile image image.release image.manifest image.sourcemanifest image.requirements $(shell find features -name 'convert.*' -o -name image -o -name 'image.*'): +Makefile image image.release image.manifest image.sbom image.sourcemanifest image.requirements $(shell find features -name 'convert.*' -o -name image -o -name 'image.*'): true diff --git a/builder/dpkg_to_cyclonedx b/builder/dpkg_to_cyclonedx new file mode 100755 index 0000000..b0b5aed --- /dev/null +++ b/builder/dpkg_to_cyclonedx @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +Generate a CycloneDX 1.5 SBOM (JSON) from installed dpkg packages. +Usage: + dpkg_to_cyclonedx [--input file.csv] [--output sbom.json] +""" + +import subprocess +import argparse +import uuid +import sys +import platform +import json + +from typing import Any + +from datetime import datetime, timezone +from pathlib import Path +from dataclasses import dataclass + +VERSION="1.0.0" + +DPKG_FIELDS = "\t".join([ + "${Package}", + "${Version}", + "${Architecture}", + "${Homepage}", + "${Maintainer}", + "${source:Package}", + "${source:Version}", +]) + +@dataclass +class Package: + """fields see https://man7.org/linux/man-pages/man1/dpkg-query.1.html""" + package: str + version: str + arch: str + homepage: str + maintainer: str + srcPackage: str + srcVersion: str + +@dataclass +class BuilderMetadata: + cname: str + version: str + arch: str + features: str + timestamp: int + +def read_packages(input_file: Path|None) -> list[Package]: + """Execute dpkg-query to evaluate packages""" + output: str = "" + + if input_file: + with open(input_file, 'r') as f: + output = f.read() + else: + result = subprocess.run(["dpkg-query", "--show", f"--show-fields='{DPKG_FIELDS}\n'"], capture_output=True, text=True, check=True) + + if result.returncode != 0: + raise ValueError("dpkg-query failed with rc=%d", [result.returncode]) + + output = result.stdout + + return parse_dpkg(output) + +def parse_dpkg(output: str) -> list[Package]: + """Parse output to list of packages""" + packages: list[Package] = [] + + for line in output.splitlines(): + parts = line.split("\t") # default delimiter with dpkg-query + if len(parts) != 7: + continue + + package = Package(package=parts[0], version=parts[1], arch=parts[2], homepage=parts[3], maintainer=parts[4], srcPackage=parts[5], srcVersion=parts[6]) + packages.append(package) + + return packages + +def pkg_to_component(pkg: Package) -> dict[str, Any]: + purl = f"pkg:deb/gardenlinux/{pkg.srcPackage}@{pkg.srcVersion}?arch={pkg.arch}" + + # TODO: add swid + component = { + "type": "library", + "bom-ref": f"{pkg.package}@{pkg.version}", + "name": pkg.srcPackage, + "version": pkg.srcVersion, + "description": "", + "licenses": [], + "purl": purl, + "hashes": [], + "cpe": "", + "externalReferences": [] + } + + if pkg.homepage: + homepageRef = { + "type": "website", + "url": pkg.homepage + } + component["externalReferences"].append(homepageRef) + + return component + +def build_sbom(packages: list[Package], builderMetadata: BuilderMetadata) -> dict[str, Any]: + """Assemble the full CycloneDX 1.5 BOM document. See https://github.com/CycloneDX/specification/blob/master/schema/bom-1.7.schema.json""" + now = datetime.now(timezone.utc).isoformat() + serial = f"urn:uuid:{uuid.uuid4()}" + + components = [pkg_to_component(p) for p in packages] + + # TODO: add swid + bom = { + "bomFormat": "CycloneDX", + "specVersion": "1.7", + "serialNumber": serial, + "version": 1, + "metadata": { + "timestamp": now, + "tools": [ + { + "vendor": "GardenLinux", + "name": sys.argv[0], + "version": VERSION + } + ], + "component": { + "type": "operating-system", + "name": "GardenLinux", + "version": builderMetadata.version, + "description": builderMetadata.cname, + "cpe": "", + "properties": [ + { + "name": "cname", + "value": builderMetadata.cname + }, + { + "name": "arch", + "value": builderMetadata.arch + }, + { + "name": "features", + "value": builderMetadata.features + }, + { + "name": "build timestamp", + "value": datetime.fromtimestamp(builderMetadata.timestamp).strftime('%Y-%m-%dT%H:%M:%SZ') + } + ] + }, + "lifecycles": [ + { + "phase": "post-build" + } + ] + }, + "components": components + } + + return bom + +def dpkg_to_cyclonedx(input_file: Path|None, builderMetadata: BuilderMetadata) -> dict[str, Any]: + """read packages and convert output to cyclonedx""" + packages = read_packages(input_file) + return build_sbom(packages=packages, builderMetadata=builderMetadata) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate CycloneDX SBOM from dpkg") + parser.add_argument("--input", default=None, help="Use an csv file as input instead of dpkg-query output") + parser.add_argument("--output", default="sbom.json", help="Output file (default: sbom.json)") + parser.add_argument("--builder_features", default="", help="GardenLinux image features present in the image at hand") + parser.add_argument("--builder_cname", default="", help="Cname") + parser.add_argument("--builder_arch", default="", help="Architecture") + parser.add_argument("--builder_version", default="", help="Version") + parser.add_argument("--builder_unixtimestamp", type=int, default="0", help="Unix-Timestamp of build time") + args = parser.parse_args() + + builderMetadata = BuilderMetadata(cname=args.builder_cname, version=args.builder_version, arch=args.builder_arch, features=args.builder_features, timestamp=args.builder_unixtimestamp) + + sbom = dpkg_to_cyclonedx(input_file=Path(args.input), builderMetadata=builderMetadata) + with open(Path(args.output), "w", encoding="utf-8") as f: + json.dump(sbom, f, indent=2, ensure_ascii=False) + + print(f"created sbom with {len(sbom["components"])} packages") diff --git a/builder/image.manifest b/builder/image.manifest index f5d949f..6de62cf 100755 --- a/builder/image.manifest +++ b/builder/image.manifest @@ -8,6 +8,7 @@ tar --extract --xattrs --xattrs-include '*' --directory "$chroot_dir" < "$1" mount --rbind --make-rslave /proc "$chroot_dir/proc" +# build manifest file #shellcheck disable=SC2016 chroot "$chroot_dir" dpkg-query --show --showformat='${binary:Package} ${Version}\n' > "$2" diff --git a/builder/image.sbom b/builder/image.sbom new file mode 100755 index 0000000..c558850 --- /dev/null +++ b/builder/image.sbom @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -eufo pipefail + +chroot_dir="$(mktemp -d)" +mount -t tmpfs tmpfs "$chroot_dir" +tar --extract --xattrs --xattrs-include '*' --directory "$chroot_dir" < "$1" + +mount --rbind --make-rslave /proc "$chroot_dir/proc" + +# build cyclonedx sbom +tmpfile="$(mktemp --suffix '.dpkg.csv')" +#shellcheck disable=SC2016 +chroot "$chroot_dir" dpkg-query --show --showformat='${binary:Package}\t${Version}\t${Architecture}\t${Homepage}\t${Maintainer}\t${source:Package}\t${source:Version}\n' > "$tmpfile" +./dpkg_to_cyclonedx --input "$tmpfile" --output "$2" --builder_features "$BUILDER_FEATURES" --builder_cname "$BUILDER_CNAME" --builder_arch "$BUILDER_ARCH" --builder_version "$BUILDER_VERSION" --builder_unixtimestamp "$BUILDER_TIMESTAMP" + +umount -l "$chroot_dir/proc" + +umount "$chroot_dir" +rmdir "$chroot_dir" diff --git a/builder/make_get_artifact_rules b/builder/make_get_artifact_rules index 6073ad6..43c29cd 100755 --- a/builder/make_get_artifact_rules +++ b/builder/make_get_artifact_rules @@ -3,7 +3,7 @@ set -euo pipefail shopt -s nullglob -extensions=(release manifest sourcemanifest requirements raw) +extensions=(release manifest sbom sourcemanifest requirements raw) for feature in "features/"*; do for i in "$feature/"{image,convert}.*; do diff --git a/builder/make_list_build_artifacts b/builder/make_list_build_artifacts index c75e993..cfd409a 100755 --- a/builder/make_list_build_artifacts +++ b/builder/make_list_build_artifacts @@ -7,7 +7,7 @@ cname="$1" IFS=',' read -r -a features < <(./parse_features --allow-frankenstein --feature-dir features --cname "$cname" features) -artifacts=(".build/$cname-$COMMIT.tar" ".build/$cname-$COMMIT.release" ".build/$cname-$COMMIT.manifest" ".build/$cname-$COMMIT.sourcemanifest" ".build/$cname-$COMMIT.requirements") +artifacts=(".build/$cname-$COMMIT.tar" ".build/$cname-$COMMIT.release" ".build/$cname-$COMMIT.manifest" ".build/$cname-$COMMIT.sbom" ".build/$cname-$COMMIT.sourcemanifest" ".build/$cname-$COMMIT.requirements") for feature in "${features[@]}"; do for i in "features/$feature/"{image,convert}.*; do