From 7c5de1a2fdc68d7cec5308cdbbb499142c6b3697 Mon Sep 17 00:00:00 2001 From: Brad House Date: Fri, 5 Jun 2026 00:32:08 +0000 Subject: [PATCH] build: generate per-image SBOMs (CycloneDX 1.6 + SPDX 2.3) Emit a Software Bill of Materials alongside every image describing the third-party software compiled from source and installed into that machine's rootfs/initramfs, plus the boot/runtime components (kernel, uClibc-ng, GCC runtime, bootloader). scripts/gen-sbom.py is machine-agnostic: it introspects the build system (make --eval) to enumerate the enabled SYSROOTDIR-installing fragments and their final versions/tarballs/source URLs, pins each by sha256, records applied patches as CycloneDX pedigree, and detects licenses from the already-extracted sources via askalono (with deterministic overrides in conf/sbom/license-overrides.json for genuinely multi-license or hard-to-detect components). CycloneDX 1.6 is the source of truth; SPDX 2.3 is produced from it via cyclonedx-cli (run in invariant-globalization mode so it needs no ICU package). make/sbom.make wires `make MACHINE= sbom` and folds the SBOM into `all`, so it is always produced. The required tools (askalono, cyclonedx-cli, and grype for the vulnerability scan) are pinned + sha256-verified by scripts/install-sbom-tools.sh; when they are not already on PATH the build provisions them into a writable build-local prefix (build/sbom-tools/bin) without root, so a full-fidelity SBOM is produced in any environment. If provisioning fails (e.g. no network) the generator degrades gracefully to a CycloneDX-only, NOASSERTION SBOM. scripts/sbom-vuln-scan.py scans the SBOM with grype, suppresses CVEs already fixed by ONIE patches via OpenVEX, and renders a Markdown table (report only -- always exits 0). The CI workflow runs `make sbom-vuln-scan` after the build and publishes the table to the job log and the run summary. README.sbom documents the feature. Signed-off-by: Brad House --- .github/workflows/build-onie.yml | 16 + README.rst | 7 + README.sbom | 128 ++++++ build-config/Makefile | 3 +- build-config/conf/sbom/cpe-overrides.json | 26 ++ build-config/conf/sbom/license-overrides.json | 11 + build-config/make/sbom.make | 84 ++++ build-config/scripts/gen-sbom.py | 366 ++++++++++++++++ build-config/scripts/install-sbom-tools.sh | 129 ++++++ build-config/scripts/sbom-vuln-scan.py | 395 ++++++++++++++++++ 10 files changed, 1164 insertions(+), 1 deletion(-) create mode 100644 README.sbom create mode 100644 build-config/conf/sbom/cpe-overrides.json create mode 100644 build-config/conf/sbom/license-overrides.json create mode 100644 build-config/make/sbom.make create mode 100644 build-config/scripts/gen-sbom.py create mode 100755 build-config/scripts/install-sbom-tools.sh create mode 100755 build-config/scripts/sbom-vuln-scan.py diff --git a/.github/workflows/build-onie.yml b/.github/workflows/build-onie.yml index f5b94c5e7..3196ac3dd 100644 --- a/.github/workflows/build-onie.yml +++ b/.github/workflows/build-onie.yml @@ -158,6 +158,22 @@ jobs: make MACHINE=kvm_x86_64 shim-self-sign && \ make -j"$(nproc)" MACHINE=kvm_x86_64 all recovery-iso demo' + # Scan the SBOM (produced by `make all`) for known vulnerabilities and + # publish the table to the job log and the run summary. This is a + # report, not a gate: sbom-vuln-scan.py always exits 0 (CVEs already + # fixed by ONIE patches are suppressed via OpenVEX), so it never fails + # the build. The SBOM tools (incl. grype) were provisioned by the SBOM + # step of `make all`. + - name: SBOM vulnerability scan + run: | + docker run --rm \ + -v "${PWD}:/onie" \ + onie-build-env \ + bash -lc 'cd build-config && make MACHINE=kvm_x86_64 sbom-vuln-scan' + for f in build/images/*.sbom.vulns.md; do + [ -f "$f" ] || continue + cat "$f" >> "$GITHUB_STEP_SUMMARY" + done - name: Upload kvm recovery image uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/README.rst b/README.rst index 8d20f7e64..98d7fd193 100644 --- a/README.rst +++ b/README.rst @@ -15,6 +15,13 @@ Building ONIE The recommended way to set up an ONIE build environment is to use a Docker image, as described in the ONIE Documentation under `Preparing An ONIE Build Environment `_. +Software Bill of Materials +========================== + +Every build emits a Software Bill of Materials (CycloneDX 1.6 and SPDX 2.3) +describing the third-party software shipped in the image. See `README.sbom +`_ for details. + ****************************** Mailing List and Collaboration ****************************** diff --git a/README.sbom b/README.sbom new file mode 100644 index 000000000..0a644c10f --- /dev/null +++ b/README.sbom @@ -0,0 +1,128 @@ +============================================ +Software Bill of Materials (SBOM) for ONIE +============================================ + +Every ONIE image is built with a Software Bill of Materials describing the +third-party software it ships. The SBOM is generated automatically as part of +``make all`` and written next to the image, so a fresh build always produces a +matching SBOM. + +What the SBOM describes +======================= + +The SBOM lists the third-party software that is **compiled from source and +installed into that machine's image** -- nothing that is merely a build-time +host tool. Concretely, for the machine being built it covers: + +- every package whose make fragment installs into the image sysroot + (``$(SYSROOTDIR)``) -- e.g. busybox, e2fsprogs, lvm2, util-linux, dropbear, + ... ; and +- the boot / runtime components that are not ordinary sysroot packages: the + Linux kernel, the uClibc-ng C library, the GCC runtime, and the bootloader + (shim / U-Boot). + +Build-only tooling (the crosstool-NG cross compiler, binutils, gdb, autoconf, +ncurses, the SBOM tools themselves, ...) is intentionally **excluded** -- it is +not present in the shipped image. + +For each component the SBOM records the name, the exact version actually built, +the upstream (canonical, non-mirror) source URL, the source tarball's SHA-256, +the list of ONIE patches applied (as CycloneDX *pedigree*), and the SPDX +license. + +Output +====== + +Two files are written to ``build/images/`` per machine:: + + .sbom.cdx.json CycloneDX 1.6 (source of truth) + .sbom.spdx.json SPDX 2.3 (derived via cyclonedx-cli) + +CycloneDX 1.6 is the authoritative document; the SPDX 2.3 file is converted +from it. This matches the formats published by the SONiC project. + +How it is invoked +================= + +The SBOM is part of the default ``all`` target, so an ordinary build emits it:: + + make -j$(nproc) MACHINE=kvm_x86_64 all + +To (re)generate just the SBOM for an already-built image:: + + make MACHINE=kvm_x86_64 sbom + +To remove the SBOM artifacts:: + + make MACHINE=kvm_x86_64 sbom-clean + +The generator is ``build-config/scripts/gen-sbom.py``. It is fully +machine-agnostic: it introspects the build system to discover the enabled +packages and their final versions, and detects licenses from the package +sources, which are already extracted on disk by the time the sysroot is +assembled. There is nothing machine-specific to maintain. + +Tooling +======= + +``gen-sbom.py`` uses three external tools: + +============== ============================================= ========== +Tool Purpose Required? +============== ============================================= ========== +askalono SPDX license detection from package sources for licenses +cyclonedx-cli CycloneDX -> SPDX 2.3 conversion for SPDX output +grype vulnerability scan (optional helper, below) optional +============== ============================================= ========== + +If these tools are not already on ``PATH``, the build provisions them +automatically -- pinned to specific versions and verified against hard-coded +SHA-256 hashes -- into a build-local prefix (``build/sbom-tools/bin``). This +requires no root and works in any build environment, so a full-fidelity SBOM is +produced whether you build in the ONIE Docker environment, natively, or in CI. + +The provisioning is performed by ``build-config/scripts/install-sbom-tools.sh``, +which you can also run by hand to pre-populate the tools, optionally choosing +the install directory:: + + # system-wide (needs root) + sudo build-config/scripts/install-sbom-tools.sh + + # or into a chosen prefix, no root required + build-config/scripts/install-sbom-tools.sh /path/to/bin + +**Offline / air-gapped builds:** the tools are fetched over the network the +first time they are needed. Pre-install them with the script above (while +online) so the build finds them on ``PATH`` and downloads nothing. + +**Graceful degradation:** if the tools are unavailable and cannot be +provisioned (e.g. no network), the build still succeeds and still emits a +CycloneDX SBOM -- but license fields fall back to ``NOASSERTION`` and the SPDX +file is skipped. A warning is printed in that case. + +License detection and overrides +=============================== + +Licenses are detected automatically by askalono scanning the +``COPYING`` / ``LICENSE`` / ``COPYRIGHT`` files in each extracted source tree. +A handful of components are genuinely multi-licensed or have no single +top-level license file where automatic detection is ambiguous; those carry a +declared SPDX expression in:: + + build-config/conf/sbom/license-overrides.json + +The override is keyed by make-fragment name and always wins over detection. +Add an entry there if a component reports ``NOASSERTION`` but its license is +known. + +Vulnerability scanning (optional) +================================= + +An optional helper scans the generated SBOM against the grype vulnerability +database, suppressing CVEs that ONIE has already fixed by patch (recorded as +OpenVEX):: + + make MACHINE=kvm_x86_64 sbom-vuln-scan + +This writes ``.sbom.vulns.json`` to ``build/images/``. It is +not part of ``all`` -- run it explicitly when you want a vulnerability report. diff --git a/build-config/Makefile b/build-config/Makefile index 4aa83d247..808645601 100644 --- a/build-config/Makefile +++ b/build-config/Makefile @@ -508,6 +508,7 @@ endif include make/images.make include make/demo.make +include make/sbom.make # By default do not enable building firmware updates FIRMWARE_UPDATE_ENABLE ?= no @@ -535,7 +536,7 @@ download: $(DOWNLOAD) $(Q) echo "=== Finished making $@ ===" PHONY += all -all: $(KERNEL) $(UBOOT) $(SYSROOT) $(IMAGE) +all: $(KERNEL) $(UBOOT) $(SYSROOT) $(IMAGE) $(SBOM_STAMP) $(Q) echo "=== Finished making onie-$(PLATFORM) $(LSB_RELEASE_TAG) ===" PHONY += demo diff --git a/build-config/conf/sbom/cpe-overrides.json b/build-config/conf/sbom/cpe-overrides.json new file mode 100644 index 000000000..e835d8c9c --- /dev/null +++ b/build-config/conf/sbom/cpe-overrides.json @@ -0,0 +1,26 @@ +{ + "_comment": [ + "Maps an ONIE SBOM component name (lowercased, as emitted by gen-sbom.py) to", + "the NVD CPE 2.3 'part:vendor:product' that grype must match on. ONIE builds", + "from upstream source and carries only pkg:generic PURLs, so grype matches", + "components to CVEs solely via CPE against NVD. The NVD vendor:product rarely", + "equals the package name, so the wrong/absent CPE silently yields a false-clean", + "(zero-finding) scan. Components NOT listed here default to 'a::'.", + "Mappings were derived from the grype vulnerability DB (highest-CVE canonical", + "vendor:product per product). The CPE version is filled in per build." + ], + "linux": "o:linux:linux_kernel", + "util-linux": "a:kernel:util-linux", + "grub": "a:gnu:grub2", + "parted": "a:gnu:parted", + "gcc-runtime": "a:gnu:gcc", + "dropbear": "a:dropbear_ssh_project:dropbear_ssh", + "e2fsprogs": "a:e2fsprogs_project:e2fsprogs", + "uclibc-ng": "a:uclibc-ng_project:uclibc-ng", + "dosfstools": "a:dosfstools_project:dosfstools", + "kexec-tools": "a:kexec-tools_project:kexec-tools", + "shim": "a:redhat:shim", + "lvm2": "a:redhat:lvm2", + "dmidecode": "a:nongnu:dmidecode", + "lzo": "a:oberhumer:lzo2" +} diff --git a/build-config/conf/sbom/license-overrides.json b/build-config/conf/sbom/license-overrides.json new file mode 100644 index 000000000..75cf0c7c8 --- /dev/null +++ b/build-config/conf/sbom/license-overrides.json @@ -0,0 +1,11 @@ +{ + "_comment": "Declared SPDX license (or expression) for components whose source is genuinely multi-license or has no single top-level license file, where automatic detection is ambiguous. Keyed by make-fragment name.", + "e2fsprogs": "GPL-2.0-or-later AND LGPL-2.0-or-later AND BSD-3-Clause AND MIT", + "lvm2": "GPL-2.0-only AND LGPL-2.1-only", + "util-linux": "GPL-2.0-or-later AND LGPL-2.1-or-later AND BSD-3-Clause AND BSD-4-Clause-UC AND MIT", + "gcc-runtime": "GPL-3.0-or-later WITH GCC-exception-3.1", + "uclibc-ng": "LGPL-2.1-or-later", + "dropbear": "MIT", + "kernel": "GPL-2.0-only WITH Linux-syscall-note", + "shim": "BSD-2-Clause-Patent" +} diff --git a/build-config/make/sbom.make b/build-config/make/sbom.make new file mode 100644 index 000000000..0e763e7f5 --- /dev/null +++ b/build-config/make/sbom.make @@ -0,0 +1,84 @@ +#------------------------------------------------------------------------------- +# +# Copyright (C) 2026 Brad House +# +# SPDX-License-Identifier: GPL-2.0 +# +#------------------------------------------------------------------------------- +# +# Generate a Software Bill of Materials (SBOM) for the image. +# +# scripts/gen-sbom.py derives the third-party software COMPILED FROM SOURCE and +# INSTALLED INTO this MACHINE's image (the SYSROOTDIR-installed packages plus the +# kernel / uClibc-ng / GCC runtime / bootloader) from the build system, and emits +# CycloneDX 1.6 (then SPDX 2.3 via cyclonedx-cli). It depends on the assembled +# sysroot so every package source is already extracted (for license detection) +# and every version is final. The SBOM is built as part of `all`, so it is +# always produced alongside the image. +# +# The generator relies on a few external tools (askalono for license +# detection, cyclonedx-cli for the SPDX conversion, grype for the optional +# vulnerability scan). If they are not already on PATH, the build provisions +# them -- pinned + sha256-verified -- into a build-local prefix +# ($(SBOM_TOOLS_DIR)) without needing root, so a full-fidelity SBOM is +# produced in any environment. If provisioning fails (e.g. no network), the +# generator degrades gracefully (NOASSERTION licenses, CycloneDX-only). + +SBOM_CDX = $(IMAGEDIR)/$(MACHINE_PREFIX).sbom.cdx.json +SBOM_SPDX = $(IMAGEDIR)/$(MACHINE_PREFIX).sbom.spdx.json +SBOM_STAMP = $(STAMPDIR)/sbom +SBOM_TOOLS_DIR = $(BUILDDIR)/sbom-tools/bin +SBOM_INSTALL_TOOLS = $(SCRIPTDIR)/install-sbom-tools.sh + +PHONY += sbom sbom-clean sbom-vuln-scan + +sbom: $(SBOM_STAMP) +$(SBOM_STAMP): $(SYSROOT_COMPLETE_STAMP) + $(Q) rm -f $@ && eval $(PROFILE_STAMP) + $(Q) echo "==== Generating SBOM for $(MACHINE) ====" + $(Q) export PATH="$(SBOM_TOOLS_DIR):$$PATH" ; \ + command -v askalono >/dev/null 2>&1 && command -v cyclonedx-cli >/dev/null 2>&1 || { \ + echo "==== Provisioning SBOM tools into $(SBOM_TOOLS_DIR) ====" ; \ + "$(SBOM_INSTALL_TOOLS)" "$(SBOM_TOOLS_DIR)" || \ + echo " WARNING: SBOM tool provisioning failed; SBOM will be degraded (NOASSERTION licenses, CycloneDX only)" ; } ; \ + python3 $(SCRIPTDIR)/gen-sbom.py \ + --machine $(MACHINE) \ + --output $(SBOM_CDX) \ + --spdx-output $(SBOM_SPDX) + $(Q) touch $@ + +# Optional: scan the already-generated SBOM for known vulnerabilities (grype), +# with CVEs already fixed by ONIE patches suppressed via OpenVEX. +# +# This is pure post-processing of the SBOM artifact, so it deliberately does NOT +# depend on $(SBOM_STAMP) (and therefore not on $(SYSROOT_COMPLETE_STAMP)). +# Depending on the stamp made a standalone `make sbom-vuln-scan` re-assemble the +# whole rootfs: the sysroot stamp is invalidated on every parse by the rootconf +# -cnewer guard in images.make, so a second invocation (e.g. the CI scan step, +# run after `make all` already produced the SBOM) rebuilt busybox/e2fsprogs/... +# before scanning. Decoupled, the scan just consumes $(SBOM_CDX); if it is +# missing we fail with a clear message instead of silently rebuilding the image. +sbom-vuln-scan: + $(Q) test -f $(SBOM_CDX) || { \ + echo "ERROR: SBOM not found: $(SBOM_CDX)" >&2 ; \ + echo " Generate it first: make MACHINE=$(MACHINE) sbom (or 'make all')." >&2 ; \ + exit 1 ; } + $(Q) export PATH="$(SBOM_TOOLS_DIR):$$PATH" ; \ + command -v grype >/dev/null 2>&1 || "$(SBOM_INSTALL_TOOLS)" "$(SBOM_TOOLS_DIR)" || true ; \ + python3 $(SCRIPTDIR)/sbom-vuln-scan.py \ + --machine $(MACHINE) \ + --sbom $(SBOM_CDX) \ + --output $(IMAGEDIR)/$(MACHINE_PREFIX).sbom.vulns.json + +sbom-clean: + $(Q) rm -f $(SBOM_STAMP) $(SBOM_CDX) $(SBOM_SPDX) \ + $(IMAGEDIR)/$(MACHINE_PREFIX).sbom.vulns.json \ + $(IMAGEDIR)/$(MACHINE_PREFIX).sbom.vulns.vex.json \ + $(IMAGEDIR)/$(MACHINE_PREFIX).sbom.vulns.md + $(Q) echo "=== Finished making $@ for $(PLATFORM)" + +#------------------------------------------------------------------------------- +# +# Local Variables: +# mode: makefile-gmake +# End: diff --git a/build-config/scripts/gen-sbom.py b/build-config/scripts/gen-sbom.py new file mode 100644 index 000000000..d93b97429 --- /dev/null +++ b/build-config/scripts/gen-sbom.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2026 Brad House +# +# SPDX-License-Identifier: GPL-2.0 +# +# Generate a CycloneDX 1.6 SBOM describing the third-party software that is +# COMPILED FROM SOURCE and INSTALLED INTO an ONIE image for a given MACHINE. +# +# ONIE has no package database -- every component is built from an upstream +# source tarball pinned in build-config/make/.make. This tool therefore +# derives the component list and metadata from the build system itself: +# +# * "enabled for this MACHINE" == the *_VERSION make variables that are +# defined, because build-config/Makefile only `include`s a package +# fragment when its *_ENABLE is yes (so undefined => not built). +# * "installed into the image" == the package's fragment writes into +# $(SYSROOTDIR) (the rootfs that becomes the ONIE initramfs). Pure +# build-time tooling (the crosstool-NG toolchain + its companions, the +# host pesign, ...) never touches SYSROOTDIR and is excluded. +# * boot/runtime components that live in the image but install outside +# SYSROOTDIR -- the kernel, uClibc-ng, the GCC runtime libs, and the +# bootloader (shim/grub or u-boot) -- are added explicitly. +# +# Per-component metadata comes from the .make variables (version, tarball, +# source URL), the SHA-256 of the actual downloaded tarball (so the SBOM +# attests the built artifact regardless of the repo's integrity-pin format), +# the applied patch series (patches//series), and the SPDX license +# detected from the already-extracted source tree (askalono) with a curated +# override map for the genuinely multi-license packages. + +import argparse +import json +import hashlib +import os +import re +import shutil +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +BUILD_CONFIG = os.path.dirname(HERE) # build-config/ +ONIE_ROOT = os.path.dirname(BUILD_CONFIG) # repo root +MAKEDIR = os.path.join(BUILD_CONFIG, "make") +UPSTREAMDIR = os.path.join(ONIE_ROOT, "upstream") +PATCHDIR = os.path.join(ONIE_ROOT, "patches") +OVERRIDES = os.path.join(BUILD_CONFIG, "conf", "sbom", "license-overrides.json") +CPE_OVERRIDES = os.path.join(BUILD_CONFIG, "conf", "sbom", "cpe-overrides.json") + +# Make-variable prefixes that have a _VERSION/_TARBALL but are build-time only +# (the cross toolchain and its companion tools) -- never shipped in the image. +BUILD_ONLY_PREFIXES = { + "CROSSTOOL_NG", "GCC", "BINUTILS", "GDB", "GMP", "ISL", "MPFR", "MPC", + "MAKE", "M4", "AUTOCONF", "AUTOMAKE", "LIBTOOL", "NCURSES", "GETTEXT", + "LIBICONV", "DUMA", "LTRACE", "STRACE", "PESIGN", "GNU_EFI", + "XTOOLS", "XTOOLS_LINUX", +} + + +def run(cmd, **kw): + return subprocess.run(cmd, check=True, capture_output=True, text=True, **kw) + + +def make_dump(machine): + """Return {PREFIX: {version, tarball, urls[]}} for every *_VERSION the + build defines for MACHINE (i.e. every enabled package + toolchain bit).""" + sep = "\x1f" + eval_expr = ( + "onie-sbom-dump: ; @$(foreach v,$(sort $(filter %%_VERSION,$(.VARIABLES)))," + "$(info $(v:_VERSION=)%s$($(v))%s$($(v:_VERSION=_TARBALL))%s" + "$($(v:_VERSION=_TARBALL_URLS))%s$($(v:_VERSION=_DIR))))" + % (sep, sep, sep, sep) + ) + # Drop parent make's jobserver env so this introspection sub-make is clean. + env = {k: v for k, v in os.environ.items() + if k not in ("MAKEFLAGS", "MFLAGS", "MAKELEVEL")} + out = run(["make", "MACHINE=%s" % machine, "--eval=" + eval_expr, + "onie-sbom-dump"], cwd=BUILD_CONFIG, env=env).stdout + pkgs = {} + for line in out.splitlines(): + if sep not in line: + continue + parts = line.split(sep) + prefix = parts[0].strip() + if not prefix: + continue + pkgs[prefix] = { + "version": parts[1].strip() if len(parts) > 1 else "", + "tarball": parts[2].strip() if len(parts) > 2 else "", + "url": parts[3].strip() if len(parts) > 3 else "", + "dir": parts[4].strip() if len(parts) > 4 else "", + } + return pkgs + + +def shipped_fragment_prefixes(): + """Prefixes of packages whose fragment installs into $(SYSROOTDIR) (the + shipped rootfs). Prefix is taken from the fragment's *_TARBALL variable + (disambiguates e.g. UTILLINUX vs UTILLINUX_MAJOR).""" + infra = {"sysroot", "images", "signing-keys", "demo"} + prefixes = {} + for fn in os.listdir(MAKEDIR): + if not fn.endswith(".make") or fn[:-5] in infra: + continue + path = os.path.join(MAKEDIR, fn) + text = open(path, encoding="utf-8", errors="replace").read() + if "SYSROOTDIR" not in text: + continue + m = re.search(r"^([A-Z][A-Z0-9_]*)_TARBALL\b", text, re.MULTILINE) + if m: + prefixes[m.group(1)] = fn[:-5] + return prefixes + + +def sha256_for(tarball, downloaddir=None): + """SHA-256 of the source tarball. + + Hash the actual downloaded artifact so the SBOM attests what was really + built, independent of the repo's integrity-pin format (the build verifies + downloads against upstream/.sha1 or .sha256 depending on the tree + state; the SBOM should not care which). Fall back to the upstream/.sha256 + pin file if the artifact is not on disk, then give up. + """ + if downloaddir: + path = os.path.join(downloaddir, tarball) + if os.path.isfile(path): + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + f = os.path.join(UPSTREAMDIR, tarball + ".sha256") + if os.path.isfile(f): + return open(f).read().split()[0] + return None + + +def make_var(machine, var): + """Resolve a single make variable's value for MACHINE.""" + env = {k: v for k, v in os.environ.items() + if k not in ("MAKEFLAGS", "MFLAGS", "MAKELEVEL")} + out = run(["make", "MACHINE=%s" % machine, + "--eval=onie-sbom-var: ; @echo $(%s)" % var, + "onie-sbom-var"], cwd=BUILD_CONFIG, env=env).stdout + return out.strip() + + +def patches_for(fragment): + """Applied patch filenames from patches//series (pedigree).""" + series = os.path.join(PATCHDIR, fragment, "series") + if not os.path.isfile(series): + return [] + pats = [] + for line in open(series): + line = line.strip() + if line and not line.startswith("#"): + pats.append(line.split()[0]) + return pats + + +def detect_license(fragment, srcdir, overrides): + """SPDX id for a component: curated override wins; else askalono run over + the license files in the package's extracted source dir; else NOASSERTION.""" + if fragment in overrides: + return overrides[fragment], "override" + if not srcdir or not shutil.which("askalono"): + return "NOASSERTION", "undetermined" + if not os.path.isabs(srcdir): + srcdir = os.path.normpath(os.path.join(BUILD_CONFIG, srcdir)) + if not os.path.isdir(srcdir): + return "NOASSERTION", "no-source" + best, score = None, 0.0 + for root, dirs, files in os.walk(srcdir): + if root[len(srcdir):].count(os.sep) > 1: # top-level + one subdir (LICENSES/) + dirs[:] = [] + continue + for fn in files: + if not re.match(r"(?i)(copying|licen[cs]e|copyright)", fn): + continue + p = subprocess.run(["askalono", "--format", "json", "id", + os.path.join(root, fn)], capture_output=True, text=True) + try: + res = json.loads(p.stdout).get("result") + except ValueError: + res = None + if res: + lic = res.get("license", {}).get("name") + sc = res.get("score", 0.0) + if lic and sc > score: + best, score = lic, sc + if best and score >= 0.9: + return best, "askalono(%.2f)" % score + return "NOASSERTION", "low-confidence" + + +def license_entry(lic): + """A CycloneDX licenses[] entry: SPDX expression, single id, or name.""" + if lic == "NOASSERTION": + return {"license": {"name": "NOASSERTION"}} + if any(op in lic for op in (" AND ", " OR ", " WITH ")): + return {"expression": lic} + return {"license": {"id": lic}} + + +def canonical_source_url(urls, tarball): + """Pick the canonical upstream base URL (not the ONIE mirror cache) from a + space-separated _TARBALL_URLS list and join it with the tarball name.""" + words = [u for u in urls.split() if u] + bases = [u for u in words if "mirror.opencompute.org" not in u] or words + if not bases: + return "" + base = bases[0] + return base.rstrip("/") + "/" + tarball if tarball else base + + +def purl(name, version, url, sha256): + q = [] + if url: + q.append("download_url=" + url) + if sha256: + q.append("checksum=sha256:" + sha256) + p = "pkg:generic/%s@%s" % (name, version) + if q: + p += "?" + "&".join(q) + return p + + +def cpe_version(v): + """Normalize an ONIE version to an NVD-CPE-comparable form: drop a leading + 'v' and turn underscore separators into dots (e.g. lvm2 '2_02_105' -> + '2.02.105', btrfs-progs 'v4.9.1' -> '4.9.1').""" + v = (v or "").strip() + if v[:1].lower() == "v" and v[1:2].isdigit(): + v = v[1:] + v = v.replace("_", ".") + return v or "*" + + +def cpe_for(name, version, overrides): + """CPE 2.3 string for a component. + + Our components carry only a pkg:generic PURL, which grype cannot map to a + CVE -- it matches source-built packages to NVD via CPE. Without a CPE the + scan silently finds *nothing* (a false-clean report). The NVD vendor:product + rarely equals the ONIE package name (linux -> linux:linux_kernel, grub -> + gnu:grub2, util-linux -> kernel:util-linux, dropbear -> + dropbear_ssh_project:dropbear_ssh, ...), so a curated 'part:vendor:product' + override map (conf/sbom/cpe-overrides.json) wins; otherwise we emit the + syft-style default 'a::'.""" + key = name.lower() + pvp = overrides.get(key) or "a:%s:%s" % (key, key) + part, vendor, product = pvp.split(":") + return "cpe:2.3:%s:%s:%s:%s:*:*:*:*:*:*:*" % ( + part, vendor, product, cpe_version(version)) + + +def main(): + ap = argparse.ArgumentParser(description="Generate an ONIE image SBOM (CycloneDX 1.6)") + ap.add_argument("--machine", required=True) + ap.add_argument("--output", required=True, help="output CycloneDX 1.6 JSON path") + ap.add_argument("--spdx-output", help="also emit SPDX 2.3 JSON here (cyclonedx-cli)") + args = ap.parse_args() + + overrides = {} + if os.path.isfile(OVERRIDES): + overrides = json.load(open(OVERRIDES)) + + cpe_overrides = {} + if os.path.isfile(CPE_OVERRIDES): + cpe_overrides = {k.lower(): v for k, v in json.load(open(CPE_OVERRIDES)).items() + if not k.startswith("_")} + + enabled = make_dump(args.machine) + shipped_pref = shipped_fragment_prefixes() + downloaddir = make_var(args.machine, "DOWNLOADDIR") + + components = [] + warnings = [] + + def add(prefix, fragment, name, version, tarball, url): + sha = sha256_for(tarball, downloaddir) if tarball else None + src = canonical_source_url(url, tarball) + lic, how = detect_license(fragment, enabled.get(prefix, {}).get("dir", ""), overrides) + if lic == "NOASSERTION": + warnings.append("license undetermined: %s" % name) + comp = { + "type": "library", + "name": name, + "version": version, + "purl": purl(name, version, src, sha), + "cpe": cpe_for(name, version, cpe_overrides), + "licenses": [license_entry(lic)], + "properties": [{"name": "onie:fragment", "value": fragment}, + {"name": "onie:license_source", "value": how}], + } + if tarball and src: + ext = {"url": src, "type": "distribution", "comment": tarball} + if sha: + ext["hashes"] = [{"alg": "SHA-256", "content": sha}] + comp["externalReferences"] = [ext] + pats = patches_for(fragment) + if pats: + comp["pedigree"] = {"patches": [{"type": "unofficial", + "diff": {"url": "patches/%s/%s" % (fragment, p)}} + for p in pats]} + components.append(comp) + + # 1) shipped rootfs packages = enabled prefixes that install to SYSROOTDIR + for prefix, frag in sorted(shipped_pref.items()): + if prefix in enabled and enabled[prefix]["tarball"]: + e = enabled[prefix] + add(prefix, frag, frag, e["version"], e["tarball"], e["url"]) + + # 2) boot/runtime components (in the image, installed outside SYSROOTDIR) + if "LINUX" in enabled: + # kernel: tarball encodes LINUX_RELEASE (e.g. linux-6.18.34.tar.xz) + t = enabled["LINUX"]["tarball"] + ver = re.sub(r"^linux-|\.tar\..*$", "", t) or enabled["LINUX"]["version"] + add("LINUX", "kernel", "linux", ver, t, enabled["LINUX"]["url"]) + if "XTOOLS_LIBC" in enabled: + v = enabled["XTOOLS_LIBC"]["version"] + add("XTOOLS_LIBC", "uclibc-ng", "uClibc-ng", v, + "uClibc-ng-%s.tar.xz" % v, "") + if "GCC" in enabled: # GCC runtime libs (libgcc/libstdc++) ship + add("GCC", "gcc-runtime", "gcc-runtime", enabled["GCC"]["version"], "", "") + for boot in ("SHIM", "UBOOT"): + if boot in enabled and enabled[boot]["tarball"]: + e = enabled[boot] + frag = "shim" if boot == "SHIM" else "u-boot" + add(boot, frag, frag, e["version"], e["tarball"], e["url"]) + + sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "operating-system", + "name": "onie-%s" % args.machine, + }, + "tools": [{"name": "gen-sbom.py", "vendor": "ONIE"}], + }, + "components": sorted(components, key=lambda c: c["name"]), + } + with open(args.output, "w") as f: + json.dump(sbom, f, indent=2) + f.write("\n") + sys.stderr.write("Wrote %s: %d components\n" % (args.output, len(components))) + if args.spdx_output: + if shutil.which("cyclonedx-cli"): + # cyclonedx-cli is a .NET tool; run it in invariant-globalization + # mode so it does not require an ICU package to be installed (the + # JSON conversion is locale-independent). + cdx_env = dict(os.environ, DOTNET_SYSTEM_GLOBALIZATION_INVARIANT="1") + subprocess.run(["cyclonedx-cli", "convert", "--input-file", args.output, + "--output-file", args.spdx_output, + "--output-format", "spdxjson"], check=True, env=cdx_env) + sys.stderr.write("Wrote %s (SPDX 2.3)\n" % args.spdx_output) + else: + sys.stderr.write(" WARN: cyclonedx-cli not found; skipped SPDX output\n") + for w in warnings: + sys.stderr.write(" WARN: %s\n" % w) + + +if __name__ == "__main__": + main() diff --git a/build-config/scripts/install-sbom-tools.sh b/build-config/scripts/install-sbom-tools.sh new file mode 100755 index 000000000..9008fa2cb --- /dev/null +++ b/build-config/scripts/install-sbom-tools.sh @@ -0,0 +1,129 @@ +#!/bin/bash + +# Copyright (C) 2026 Brad House +# +# SPDX-License-Identifier: GPL-2.0 + +# +# Download, verify (sha256) and install the SBOM/license tooling (askalono, +# cyclonedx-cli, grype) used by scripts/gen-sbom.py. Modeled after SONiC's +# install_sbom_tool.sh: versions are pinned and every download is verified +# against a hardcoded SHA-256 hash. Any download failure or hash mismatch is +# fatal (exit 1). Re-running is idempotent: a tool already present at the +# pinned version is skipped. +# +# The install directory defaults to /usr/local/bin (system-wide, needs root), +# but can be overridden so the build can provision the tools into a writable +# build-local prefix without root: +# +# install-sbom-tools.sh [INSTALL_DIR] +# SBOM_TOOLS_DIR=/path/to/bin install-sbom-tools.sh +# +# The ONIE build (make/sbom.make) invokes it this way automatically when the +# tools are not already on PATH. +# +# Requires: curl, unzip, tar. +# + +set -euo pipefail + +INSTALL_DIR="${1:-${SBOM_TOOLS_DIR:-/usr/local/bin}}" +mkdir -p "$INSTALL_DIR" + +# -- Pinned versions --------------------------------------------------------- +ASKALONO_VERSION="0.5.0" +CYCLONEDX_VERSION="0.32.0" +GRYPE_VERSION="0.113.0" + +# -- Pinned download URLs ---------------------------------------------------- +ASKALONO_URL="https://github.com/jpeddicord/askalono/releases/download/${ASKALONO_VERSION}/askalono-Linux.zip" +CYCLONEDX_URL="https://github.com/CycloneDX/cyclonedx-cli/releases/download/v${CYCLONEDX_VERSION}/cyclonedx-linux-x64" +GRYPE_URL="https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/grype_${GRYPE_VERSION}_linux_amd64.tar.gz" + +# -- Pinned SHA-256 hashes --------------------------------------------------- +ASKALONO_SHA256="8a007355b700137b48e7e39aab5d083acb9a0926f20c064ab4476b1624390e52" +CYCLONEDX_SHA256="454879e6a4a405c8a13bff49b8982adcb0596f3019b26b0811c66e4d7f0783e1" +GRYPE_SHA256="d87059d11616446a5dd817a47c21823676b4b24bdaac529695ca404a32ba339d" + +# verify_sha256 +verify_sha256() { + local file="$1" + local expected="$2" + local actual + + actual=$(sha256sum "$file" | awk '{print $1}') + if [ "$actual" != "$expected" ]; then + echo "Error: sha256 mismatch for $file" >&2 + echo " expected: $expected" >&2 + echo " actual: $actual" >&2 + exit 1 + fi +} + +# download +download() { + local url="$1" + local dest="$2" + + echo "Downloading $url" + if ! curl -fsSL -o "$dest" "$url"; then + echo "Error: failed to download $url" >&2 + exit 1 + fi +} + +install_askalono() { + if [ -x "${INSTALL_DIR}/askalono" ] && \ + "${INSTALL_DIR}/askalono" --version 2>/dev/null | grep -q "${ASKALONO_VERSION}"; then + echo "askalono ${ASKALONO_VERSION} already installed, skipping" + return 0 + fi + + local tmpdir + tmpdir=$(mktemp -d) + download "$ASKALONO_URL" "${tmpdir}/askalono-Linux.zip" + verify_sha256 "${tmpdir}/askalono-Linux.zip" "$ASKALONO_SHA256" + unzip -o -q "${tmpdir}/askalono-Linux.zip" -d "$tmpdir" + install -m 0755 "${tmpdir}/askalono" "${INSTALL_DIR}/askalono" + rm -rf "$tmpdir" + echo "Installed askalono ${ASKALONO_VERSION} to ${INSTALL_DIR}/askalono" +} + +install_cyclonedx() { + if [ -x "${INSTALL_DIR}/cyclonedx-cli" ] && \ + "${INSTALL_DIR}/cyclonedx-cli" --version 2>/dev/null | grep -q "${CYCLONEDX_VERSION}"; then + echo "cyclonedx-cli ${CYCLONEDX_VERSION} already installed, skipping" + return 0 + fi + + local tmpdir + tmpdir=$(mktemp -d) + download "$CYCLONEDX_URL" "${tmpdir}/cyclonedx-cli" + verify_sha256 "${tmpdir}/cyclonedx-cli" "$CYCLONEDX_SHA256" + install -m 0755 "${tmpdir}/cyclonedx-cli" "${INSTALL_DIR}/cyclonedx-cli" + rm -rf "$tmpdir" + echo "Installed cyclonedx-cli ${CYCLONEDX_VERSION} to ${INSTALL_DIR}/cyclonedx-cli" +} + +install_grype() { + if [ -x "${INSTALL_DIR}/grype" ] && \ + "${INSTALL_DIR}/grype" version 2>/dev/null | grep -q "${GRYPE_VERSION}"; then + echo "grype ${GRYPE_VERSION} already installed, skipping" + return 0 + fi + + local tmpdir + tmpdir=$(mktemp -d) + download "$GRYPE_URL" "${tmpdir}/grype.tar.gz" + verify_sha256 "${tmpdir}/grype.tar.gz" "$GRYPE_SHA256" + tar -xzf "${tmpdir}/grype.tar.gz" -C "$tmpdir" grype + install -m 0755 "${tmpdir}/grype" "${INSTALL_DIR}/grype" + rm -rf "$tmpdir" + echo "Installed grype ${GRYPE_VERSION} to ${INSTALL_DIR}/grype" +} + +install_askalono +install_cyclonedx +install_grype + +echo "SBOM tooling installation complete" diff --git a/build-config/scripts/sbom-vuln-scan.py b/build-config/scripts/sbom-vuln-scan.py new file mode 100755 index 000000000..41a49e5ea --- /dev/null +++ b/build-config/scripts/sbom-vuln-scan.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2026 Brad House +# +# SPDX-License-Identifier: GPL-2.0 +# +# Scan a CycloneDX SBOM (see gen-sbom.py) for known vulnerabilities using +# grype, and suppress CVEs that ONIE has already fixed locally via a patch. +# +# ONIE has no package database -- it builds every component from an upstream +# source tarball and applies a series of local patches from patches//. +# When a patch fixes a CVE, the upstream version that grype keys off of is +# still "vulnerable" as far as the vulnerability database is concerned, even +# though the shipped binary is fixed. To avoid drowning in false positives we +# mine the patch tree for CVE references, emit an OpenVEX document declaring +# those CVEs "fixed", and cross-reference grype's output against the mined set. +# +# Modeled on SONiC's sbom_vuln_scan.py + sbom_extract_vex_from_patches.py +# (sonic-buildimage PR #27455), adapted to ONIE's patches/ layout. +# +# Usage: +# sbom-vuln-scan.py --sbom --output +# [--vex-output ] +# +# This is a report generator: it always exits 0. If grype is unavailable the +# VEX document is still written and the report records that grype was missing. + +import argparse +import json +import os +import re +import subprocess +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +PATCHES_DIR = os.path.join(REPO_ROOT, "patches") + +CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE) + + +def mine_patched_cves(patches_dir=PATCHES_DIR): + """Walk patches// and patches/// looking for CVE ids in + both patch filenames and patch file contents. + + Returns a list of dicts: {"cve_id", "patch_path", "package"} de-duplicated + on (cve_id, patch_path). cve_id is normalized to upper case. patch_path is + relative to the repo root. package is the top-level patches/ dir name. + """ + found = {} + + if not os.path.isdir(patches_dir): + return [] + + # Top-level packages: patches/ + for pkg in sorted(os.listdir(patches_dir)): + pkg_dir = os.path.join(patches_dir, pkg) + if not os.path.isdir(pkg_dir): + continue + + # Candidate dirs to scan files in: patches/ and patches// + scan_dirs = [pkg_dir] + for sub in sorted(os.listdir(pkg_dir)): + sub_dir = os.path.join(pkg_dir, sub) + if os.path.isdir(sub_dir): + scan_dirs.append(sub_dir) + + for d in scan_dirs: + for entry in sorted(os.listdir(d)): + fpath = os.path.join(d, entry) + if not os.path.isfile(fpath): + continue + + cves = set() + + # 1) CVE in the filename + for m in CVE_RE.findall(entry): + cves.add(m.upper()) + + # 2) CVE in the file contents + try: + with open(fpath, "r", encoding="utf-8", errors="replace") as fh: + for m in CVE_RE.findall(fh.read()): + cves.add(m.upper()) + except (OSError, IOError): + pass + + rel = os.path.relpath(fpath, REPO_ROOT) + for cve in cves: + key = (cve, rel) + if key not in found: + found[key] = { + "cve_id": cve, + "patch_path": rel, + "package": pkg, + } + + return list(found.values()) + + +def load_sbom_components(sbom_path): + """Return a map of lowercased component name -> purl from a CycloneDX SBOM.""" + name_to_purl = {} + try: + with open(sbom_path, "r", encoding="utf-8") as fh: + doc = json.load(fh) + except (OSError, IOError, ValueError): + return name_to_purl + + for comp in doc.get("components", []) or []: + name = comp.get("name") + purl = comp.get("purl") + if name and purl: + name_to_purl.setdefault(name.lower(), purl) + return name_to_purl + + +def build_vex(patched, name_to_purl): + """Build an OpenVEX v0.2.0 document from the mined patched CVEs. + + One statement per mined (cve, patch) hit. When the patched package name + maps to an SBOM component PURL, a products[] entry is attached; otherwise + products is omitted. + """ + statements = [] + for idx, item in enumerate(patched): + cve = item["cve_id"] + pkg = item["package"] + patch_path = item["patch_path"] + + stmt = { + "@id": "https://openvex.dev/statements/onie/%s/%d" % (cve, idx), + "vulnerability": {"name": cve}, + "status": "fixed", + "impact_statement": "Fixed by ONIE patch %s" % patch_path, + } + + purl = name_to_purl.get(pkg.lower()) + if purl: + stmt["products"] = [{"@id": purl}] + + statements.append(stmt) + + return { + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://openvex.dev/docs/onie/patched-cves", + "author": "ONIE build-config/scripts/sbom-vuln-scan.py", + "version": 1, + "statements": statements, + } + + +def run_grype(sbom_path): + """Run grype against the SBOM. Returns parsed JSON dict on success, or None + if grype is missing or fails.""" + try: + proc = subprocess.run( + ["grype", "sbom:%s" % sbom_path, "-o", "json"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except (OSError, FileNotFoundError): + return None + + if proc.returncode != 0: + return None + + try: + return json.loads(proc.stdout.decode("utf-8", errors="replace")) + except ValueError: + return None + + +def parse_grype_matches(grype_json): + """Normalize grype JSON into a list of match dicts.""" + out = [] + for match in grype_json.get("matches", []) or []: + vuln = match.get("vulnerability", {}) or {} + artifact = match.get("artifact", {}) or {} + + fix = vuln.get("fix", {}) or {} + fix_versions = fix.get("versions", []) or [] + + out.append({ + "id": vuln.get("id", ""), + "package": artifact.get("name", ""), + "severity": vuln.get("severity", ""), + "installed_version": artifact.get("version", ""), + "fixed_version": ", ".join(fix_versions) if fix_versions else "", + }) + return out + + +# Most-severe first; unknown/blank sorts last. +SEVERITY_ORDER = { + "critical": 0, "high": 1, "medium": 2, "low": 3, "negligible": 4, +} + + +def _sev_key(sev): + return SEVERITY_ORDER.get((sev or "").lower(), 8) + + +def render_markdown(report, machine=None): + """Render the vulnerability report as a GitHub-flavored Markdown table.""" + s = report.get("summary", {}) + out = [] + title = "SBOM Vulnerability Scan" + if machine: + title += " — %s" % machine + out.append("## %s" % title) + out.append("") + if not report.get("grype_available", False): + out.append("> :warning: grype was not available; no vulnerability scan " + "was performed.") + out.append("") + out.append("| Metric | Count |") + out.append("| --- | ---: |") + out.append("| Total findings | %d |" % s.get("total", 0)) + out.append("| Suppressed (fixed by ONIE patch) | %d |" + % s.get("suppressed_by_vex", 0)) + out.append("| No fix available (won't-fix / disputed upstream) | %d |" + % s.get("no_fix_available", 0)) + out.append("| **Actionable (fix available)** | **%d** |" + % s.get("actionable", 0)) + out.append("") + + actionable = report.get("actionable", []) or [] + out.append("### Actionable vulnerabilities") + out.append("") + out.append("_Only findings with an upstream fix are listed; the " + "%d without a published fix are counted above but omitted " + "(no action available)._" % s.get("no_fix_available", 0)) + out.append("") + if actionable: + out.append("| Severity | ID | Package | Installed | Fixed in |") + out.append("| --- | --- | --- | --- | --- |") + for a in sorted(actionable, + key=lambda x: (_sev_key(x.get("severity")), + x.get("package", ""))): + out.append("| %s | %s | %s | %s | %s |" % ( + a.get("severity") or "-", a.get("id") or "-", + a.get("package") or "-", a.get("installed_version") or "-", + a.get("fixed_version") or "-")) + else: + out.append("None. :white_check_mark:") + out.append("") + + suppressed = report.get("suppressed", []) or [] + if suppressed: + out.append("
Suppressed — already fixed by ONIE " + "patches (%d)" % len(suppressed)) + out.append("") + out.append("| ID | Package | Fixed by patch |") + out.append("| --- | --- | --- |") + for sup in sorted(suppressed, key=lambda x: x.get("package", "")): + patch = (sup.get("reason", "") or "").replace( + "fixed by ONIE patch ", "") + out.append("| %s | %s | %s |" % ( + sup.get("id") or "-", sup.get("package") or "-", patch or "-")) + out.append("") + out.append("
") + out.append("") + + return "\n".join(out) + "\n" + + +def finalize(report, args): + """Write the JSON report + a Markdown table, and echo the table to stdout.""" + with open(args.output, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, sort_keys=False) + fh.write("\n") + + md = render_markdown(report, machine=args.machine) + if args.markdown: + with open(args.markdown, "w", encoding="utf-8") as fh: + fh.write(md) + sys.stdout.write(md) + + +def main(): + ap = argparse.ArgumentParser( + description="Scan a CycloneDX SBOM for vulnerabilities (grype) and " + "suppress CVEs already patched by ONIE (OpenVEX).") + ap.add_argument("--sbom", required=True, + help="Path to the CycloneDX JSON SBOM to scan.") + ap.add_argument("--output", required=True, + help="Path to write the vulnerability report JSON.") + ap.add_argument("--vex-output", default=None, + help="Path to write the OpenVEX JSON (default: alongside " + "--output as .vex.json).") + ap.add_argument("--markdown", default=None, + help="Path to write a Markdown vulnerability table (default: " + "alongside --output as .md). The table is " + "always echoed to stdout regardless.") + ap.add_argument("--machine", default=None, + help="Machine name, used only in the report title.") + args = ap.parse_args() + + base = os.path.splitext(args.output)[0] + if args.vex_output: + vex_output = args.vex_output + else: + vex_output = base + ".vex.json" + if not args.markdown: + args.markdown = base + ".md" + + # 1) Mine patched CVEs from the patch tree. + patched = mine_patched_cves() + patched_cve_to_patch = {} + for item in patched: + patched_cve_to_patch.setdefault(item["cve_id"], item["patch_path"]) + + # 2) Build + write the OpenVEX document. + name_to_purl = load_sbom_components(args.sbom) + vex = build_vex(patched, name_to_purl) + with open(vex_output, "w", encoding="utf-8") as fh: + json.dump(vex, fh, indent=2, sort_keys=False) + fh.write("\n") + + # 3) Run grype. + grype_json = run_grype(args.sbom) + + if grype_json is None: + report = { + "scanned": args.sbom, + "grype_available": False, + "note": "grype was not available or failed; no vulnerability " + "scan was performed. OpenVEX written to %s" % vex_output, + "summary": {"total": 0, "suppressed_by_vex": 0, "actionable": 0}, + "suppressed": [], + "actionable": [], + } + finalize(report, args) + sys.stderr.write( + "sbom-vuln-scan: grype unavailable; wrote VEX (%d patched CVEs) " + "and empty report to %s\n" % (len(patched_cve_to_patch), args.output)) + return 0 + + # 4) Cross-reference grype matches against the mined patched-CVE set, then + # split the rest by whether an upstream fix actually exists: + # * suppressed -- already fixed by an ONIE patch (VEX) + # * actionable -- a fixed version is available upstream -> we can act + # * no_fix -- no fixed version published. This usually means + # upstream has triaged it as won't-fix / disputed / + # not-exploitable, so there is nothing for us to do. + # We report it as a statistic only, not as an + # actionable item (listing it is noise). + matches = parse_grype_matches(grype_json) + suppressed = [] + actionable = [] + no_fix = [] + for m in matches: + patch_path = patched_cve_to_patch.get((m["id"] or "").upper()) + if patch_path: + entry = dict(m) + entry["reason"] = "fixed by ONIE patch %s" % patch_path + suppressed.append(entry) + elif m["fixed_version"]: + actionable.append(m) + else: + no_fix.append(m) + + def slim(items): + return [{"id": x["id"], "package": x["package"], "severity": x["severity"], + "installed_version": x["installed_version"], + "fixed_version": x["fixed_version"]} for x in items] + + # 5) Write the report. + report = { + "scanned": args.sbom, + "grype_available": True, + "summary": { + "total": len(matches), + "suppressed_by_vex": len(suppressed), + "no_fix_available": len(no_fix), + "actionable": len(actionable), + }, + "suppressed": suppressed, + # Kept in the JSON for auditability; intentionally NOT listed in the + # Markdown report (statistic only -- see no_fix_available above). + "no_fix": slim(no_fix), + "actionable": slim(actionable), + } + finalize(report, args) + + sys.stderr.write( + "sbom-vuln-scan: %d findings, %d suppressed by VEX, %d no-fix, " + "%d actionable (VEX: %s, report: %s)\n" % ( + len(matches), len(suppressed), len(no_fix), len(actionable), + vex_output, args.output)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())