diff --git a/build-ps/build-binary.sh b/build-ps/build-binary.sh index d1b5c879889..86121dd602b 100755 --- a/build-ps/build-binary.sh +++ b/build-ps/build-binary.sh @@ -818,9 +818,31 @@ fi mkdir -p "$TARGETDIR/usr/local/minimal" cp -r "$TARGETDIR/usr/local/$PRODUCT_FULL_NAME" "$TARGETDIR/usr/local/minimal/$PRODUCT_FULL_NAME-minimal" + # Emitted after link so lib/private holds every bundled host library. + # A copy is staged in TARGETDIR so the caller can collect it. + gen_tarball_sbom() { + local dest=$1 + local label=$2 + [ "${SBOM:-0}" = "1" ] || return 0 + sh "$SOURCEDIR/build-ps/sbom/gen-sbom.sh" \ + --pkg "percona-xtradb-cluster" \ + --version "${MYSQL_VERSION}-${PERCONA_SERVER_EXTENSION}" \ + --root "$SOURCEDIR" \ + --pins "$SOURCEDIR/build-ps/sbom/submodule-pins.txt" \ + --artifact tarball \ + --scan-libs "${dest}/lib/private" \ + --dest "${dest}/sbom" + mkdir -p "${TARGETDIR}/sbom" + for _sf in "${dest}"/sbom/*; do + [ -f "$_sf" ] || continue + cp "$_sf" "${TARGETDIR}/sbom/${label}.$(basename "$_sf")" + done + } + # NORMAL TARBALL cd "$TARGETDIR/usr/local/$PRODUCT_FULL_NAME" link + gen_tarball_sbom "$TARGETDIR/usr/local/$PRODUCT_FULL_NAME" "$PRODUCT_FULL_NAME" # MIN TARBALL if [[ $CMAKE_BUILD_TYPE != "Debug" ]]; then @@ -829,6 +851,7 @@ fi rm -rf percona-xtradb-cluster-tests 2> /dev/null find . -type f -exec file '{}' \; | grep ': ELF ' | cut -d':' -f1 | xargs strip --strip-unneeded link + gen_tarball_sbom "$TARGETDIR/usr/local/minimal/$PRODUCT_FULL_NAME-minimal" "$PRODUCT_FULL_NAME-minimal" fi ) diff --git a/build-ps/debian/rules b/build-ps/debian/rules index 7a3dd0a96cd..d3aa7a72a95 100755 --- a/build-ps/debian/rules +++ b/build-ps/debian/rules @@ -39,6 +39,7 @@ DEB_UPSTREAM_VERSION_MAJOR_MINOR := $(shell echo $(DEB_UPSTREAM_VERSION) | sed - DEB_HOST_MULTIARCH ?= $(shell dpkg-architecture -qDEB_HOST_MULTIARCH) EXPORTED_SOURCE_TARBALL = debian/percona-xtradb-cluster-source-8.0.tar.gz +SBOM_UTILS = build-ps/sbom DISTRIBUTION = $(shell lsb_release -i -s) DISTRELEASE = $(shell lsb_release -c -s) @@ -258,6 +259,21 @@ override_dh_install: mkdir -p debian/percona-xtradb-cluster-server-debug/usr/lib/mysql/private/debug cp ./builddir/library_output_directory/libprotobuf-lite.so* debian/percona-xtradb-cluster-server-debug/usr/lib/mysql/private/debug patchelf --debug --set-rpath $(rpathdebug) debian/percona-xtradb-cluster-server-debug/usr/lib/mysql/plugin/debug/group_replication.so +ifneq (,$(filter pkg.pxc.sbom,$(DEB_BUILD_PROFILES))) + @test -n "$(DEB_VERSION)" || \ + { echo "SBOM: dpkg-parsechangelog produced no Version" >&2; exit 1; } + @test -d debian/percona-xtradb-cluster-server || \ + { echo "SBOM: debian/percona-xtradb-cluster-server is not a staging dir" >&2; exit 1; } + sh $(SBOM_UTILS)/check-components.sh --root . + install -d -m 0755 debian/percona-xtradb-cluster-server/usr/share/percona-xtradb-cluster/sbom + sh $(SBOM_UTILS)/gen-sbom.sh \ + --pkg percona-xtradb-cluster \ + --version $(DEB_VERSION) \ + --root . \ + --pins $(SBOM_UTILS)/submodule-pins.txt \ + --artifact package \ + --dest debian/percona-xtradb-cluster-server/usr/share/percona-xtradb-cluster/sbom +endif @echo "RULES.$@" ifeq ($(OS_VERSION), 18.04) diff --git a/build-ps/percona-xtradb-cluster.spec b/build-ps/percona-xtradb-cluster.spec index af0a96bbe5b..64a5201857e 100644 --- a/build-ps/percona-xtradb-cluster.spec +++ b/build-ps/percona-xtradb-cluster.spec @@ -1109,6 +1109,18 @@ install -d $RBR%{_libdir}/mysql find $RBR -name 'core.[0-9]*' -type f -delete || true +%if 0%{?with_sbom} +sh $MBD/build-ps/sbom/check-components.sh --root $MBD +install -d -m 0755 $RBR%{_datadir}/percona-xtradb-cluster/sbom +sh $MBD/build-ps/sbom/gen-sbom.sh \ + --pkg percona-xtradb-cluster \ + --version %{mysql_version}-%{percona_server_version} \ + --root $MBD \ + --pins $MBD/build-ps/sbom/submodule-pins.txt \ + --artifact package \ + --dest $RBR%{_datadir}/percona-xtradb-cluster/sbom +%endif + ############################################################################## # Post processing actions, i.e. when installed ############################################################################## @@ -1607,6 +1619,10 @@ fi %files -n percona-xtradb-cluster-server %defattr(-,root,root,0755) +%if 0%{?with_sbom} +%{_datadir}/percona-xtradb-cluster/sbom +%endif + %if %{defined license_files_server} %doc %{license_files_server} %endif diff --git a/build-ps/pxc_builder.sh b/build-ps/pxc_builder.sh index 71443075c57..06a6f405928 100755 --- a/build-ps/pxc_builder.sh +++ b/build-ps/pxc_builder.sh @@ -23,6 +23,7 @@ Usage: $0 [OPTIONS] --bin_release BIN version( default = 1) --debug Build debug tarball --enable_pgo PGO (Profile-Guided Optimization) build (default = 1, set to 0 to disable) + --sbom If it is 1 SBOMs are generated and shipped (default = 0) --help) usage ;; Example $0 --builddir=/tmp/PXC9x --get_sources=1 --build_src_rpm=1 --build_rpm=1 EOF @@ -61,7 +62,8 @@ parse_arguments() { --no_clone=*) NO_CLONE="$val" ;; --debug=*) DEBUG="$val" ;; --enable_pgo=*) ENABLE_PGO="$val" ;; - --help) usage ;; + --sbom=*) SBOM="$val" ;; + --help) usage ;; *) if test -n "$pick_args" then @@ -237,6 +239,19 @@ get_sources(){ rsync -av ${WORKDIR}/percona-xtradb-cluster/extra/coredumper/ ${PXCDIR}/extra/coredumper --exclude .git rsync -av ${WORKDIR}/percona-xtradb-cluster/extra/libkmip/ ${PXCDIR}/extra/libkmip --exclude .git + # the rsync above drops .git, so record the submodule pins while they are + # still resolvable; the SBOM tooling reads this file instead of git + if [ ${SBOM} = 1 ]; then + mkdir -p ${PXCDIR}/build-ps/sbom + git -C ${WORKDIR}/percona-xtradb-cluster submodule status --recursive \ + | awk '{ sub(/^[-+U]/, "", $1); print $2 "|" $1 }' \ + > ${PXCDIR}/build-ps/sbom/submodule-pins.txt + if [ ! -s ${PXCDIR}/build-ps/sbom/submodule-pins.txt ]; then + echo "ERROR: --sbom=1 but no submodule pins could be recorded" + exit 1 + fi + fi + sed -i 's:ROUTER_RUNTIMEDIR:/var/run/mysqlrouter/:g' ${PXCDIR}/packaging/rpm-common/* cd ${PXCDIR}/packaging/rpm-common || exit if [ ! -f mysqlrouter.service ]; then @@ -274,6 +289,27 @@ get_sources(){ sed -i 's:boostorg\.jfrog\.io/artifactory/main/release/.*/source:downloads.percona.com/downloads/packaging/boost:g' cmake/boost.cmake popd # + if [ ${SBOM} = 1 ]; then + if ! sh ${PXCDIR}/build-ps/sbom/check-components.sh --root ${PXCDIR}; then + echo "ERROR: --sbom=1 but the component registry does not match the tree" + exit 1 + fi + if ! sh ${PXCDIR}/build-ps/sbom/gen-sbom.sh \ + --pkg percona-xtradb-cluster \ + --version ${MYSQL_VERSION}-${MYSQL_RELEASE} \ + --root ${PXCDIR} \ + --pins ${PXCDIR}/build-ps/sbom/submodule-pins.txt \ + --artifact source \ + --dest ${PXCDIR}/sbom; then + echo "ERROR: --sbom=1 but SBOM generation failed" + exit 1 + fi + if [ -z "$(ls -A ${PXCDIR}/sbom 2>/dev/null)" ]; then + echo "ERROR: --sbom=1 but no SBOM was produced for the source tarball" + exit 1 + fi + fi + # tar --owner=0 --group=0 --exclude=.bzr --exclude=.git -czf ${PXCDIR}.tar.gz ${PXCDIR} rm -fr ${PXCDIR} cat pxc-9x.properties @@ -829,10 +865,14 @@ build_rpm(){ if [ "${ENABLE_PGO}" = "0" ]; then PGO_DEFINE="--define \"without_pgo 1\"" fi + SBOM_DEFINE="" + if [ "${SBOM}" = "1" ]; then + SBOM_DEFINE="--define \"with_sbom 1\"" + fi if [ ${ARCH} = x86_64 ]; then - rpmbuild --define '"_topdir ${WORKDIR}/rpmbuild"' --define '"dist ${OS_NAME}"' --define '"rpm_version $MYSQL_RELEASE.$RPM_RELEASE"' --define '"rel $RPM_RELEASE"' --define '"galera_revision ${GALERA_REVNO}"' --define '"with_mecab ${MECAB_INSTALL_DIR}/usr"' ${PGO_DEFINE} --rebuild rpmbuild/SRPMS/${SRCRPM} + rpmbuild --define '"_topdir ${WORKDIR}/rpmbuild"' --define '"dist ${OS_NAME}"' --define '"rpm_version $MYSQL_RELEASE.$RPM_RELEASE"' --define '"rel $RPM_RELEASE"' --define '"galera_revision ${GALERA_REVNO}"' --define '"with_mecab ${MECAB_INSTALL_DIR}/usr"' ${PGO_DEFINE} ${SBOM_DEFINE} --rebuild rpmbuild/SRPMS/${SRCRPM} else - rpmbuild --define '"_topdir ${WORKDIR}/rpmbuild"' --define '"dist ${OS_NAME}"' --define '"rpm_version $MYSQL_RELEASE.$RPM_RELEASE"' --define '"rel $RPM_RELEASE"' --define '"galera_revision ${GALERA_REVNO}"' --define '"with_tokudb 0"' --define '"with_rocksdb 0"' --define '"with_mecab ${MECAB_INSTALL_DIR}/usr"' ${PGO_DEFINE} --rebuild rpmbuild/SRPMS/${SRCRPM} + rpmbuild --define '"_topdir ${WORKDIR}/rpmbuild"' --define '"dist ${OS_NAME}"' --define '"rpm_version $MYSQL_RELEASE.$RPM_RELEASE"' --define '"rel $RPM_RELEASE"' --define '"galera_revision ${GALERA_REVNO}"' --define '"with_tokudb 0"' --define '"with_rocksdb 0"' --define '"with_mecab ${MECAB_INSTALL_DIR}/usr"' ${PGO_DEFINE} ${SBOM_DEFINE} --rebuild rpmbuild/SRPMS/${SRCRPM} fi return_code=$? if [ $return_code != 0 ]; then @@ -1029,7 +1069,16 @@ build_deb(){ if [ "${ENABLE_PGO}" = "0" ]; then export DEB_NO_PGO=1 fi - GALERA_REVNO="${GALERA_REVNO}" SCONS_ARGS=' strict_build_flags=0' MAKE_JFLAG=-j4 dpkg-buildpackage -rfakeroot -uc -us -b + if [ "${SBOM}" = "1" ]; then + DEB_BUILD_PROFILES="${DEB_BUILD_PROFILES:+${DEB_BUILD_PROFILES} }pkg.pxc.sbom" \ + GALERA_REVNO="${GALERA_REVNO}" SCONS_ARGS=' strict_build_flags=0' MAKE_JFLAG=-j4 dpkg-buildpackage -rfakeroot -uc -us -b + else + GALERA_REVNO="${GALERA_REVNO}" SCONS_ARGS=' strict_build_flags=0' MAKE_JFLAG=-j4 dpkg-buildpackage -rfakeroot -uc -us -b + fi + if [ $? != 0 ]; then + echo "ERROR: dpkg-buildpackage failed" + exit 1 + fi # cd ${WORKSPACE} || exit rm -fv *.dsc *.orig.tar.gz *.debian.tar.gz *.changes @@ -1215,10 +1264,26 @@ build_tarball(){ mkdir -p ${WORKDIR}/${DIRNAME} mkdir -p ${CURDIR}/${DIRNAME} rm -f $BUILD_NUMBER/percona-xtrabackup* || true + if [ -z "$(ls -A $BUILD_NUMBER/*.tar.gz 2>/dev/null)" ]; then + echo "ERROR: build-binary.sh produced no tarballs" + exit 1 + fi cp $BUILD_NUMBER/*.tar.gz ${WORKDIR}/../${DIRNAME} cp $BUILD_NUMBER/*.tar.gz ${WORKDIR}/${DIRNAME} cp $BUILD_NUMBER/*.tar.gz ${CURDIR}/${DIRNAME} + if [ ${SBOM} = 1 ]; then + mkdir -p ${WORKDIR}/sbom + mkdir -p ${CURDIR}/sbom + if [ -n "$(ls -A $BUILD_NUMBER/sbom 2>/dev/null)" ]; then + cp $BUILD_NUMBER/sbom/* ${WORKDIR}/sbom/ + cp $BUILD_NUMBER/sbom/* ${CURDIR}/sbom/ + else + echo "ERROR: --sbom=1 but build-binary.sh produced no SBOMs" + exit 1 + fi + fi + } #main @@ -1253,7 +1318,9 @@ MYSQL_RELEASE=1 WSREP_VERSION=31.33 PRODUCT_FULL=Percona-XtraDB-Cluster-9.1.0-31.33 BOOST_PACKAGE_NAME=boost_1_59_0 +SBOM=0 parse_arguments PICK-ARGS-FROM-ARGV "$@" +export SBOM check_workdir get_system diff --git a/build-ps/sbom/check-components.sh b/build-ps/sbom/check-components.sh new file mode 100755 index 00000000000..c31d8c8a094 --- /dev/null +++ b/build-ps/sbom/check-components.sh @@ -0,0 +1,184 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT="." +REGISTRY="${SCRIPT_DIR}/components.txt" +PINS="" + +KNOWN_UNCERTAIN="" + +while [ $# -gt 0 ]; do + case "$1" in + --root) ROOT=${2:?}; shift 2 ;; + --registry) REGISTRY=${2:?}; shift 2 ;; + --pins) PINS=${2:?}; shift 2 ;; + *) echo "check-components: unknown argument: $1" >&2; exit 2 ;; + esac +done + +[ -f "$REGISTRY" ] || { echo "check-components: registry not found: $REGISTRY" >&2; exit 2; } +[ -d "$ROOT/extra" ] || { echo "check-components: no extra/ under root: $ROOT" >&2; exit 2; } +[ -z "$PINS" ] || [ -f "$PINS" ] || { echo "check-components: pins file not found: $PINS" >&2; exit 2; } + +if [ -z "$PINS" ] && [ -f "$ROOT/build-ps/sbom/submodule-pins.txt" ]; then + PINS="$ROOT/build-ps/sbom/submodule-pins.txt" +fi + +ERRORS=0 +UNVERIFIED=0 +err() { echo "check-components: ERROR: $*" >&2; ERRORS=$((ERRORS + 1)); } +warn() { echo "check-components: WARNING: $*" >&2; } + +ROWS=$(mktemp "${TMPDIR:-/tmp}/ps-reg.XXXXXX") +SUBS=$(mktemp "${TMPDIR:-/tmp}/ps-sub.XXXXXX") +trap 'rm -f "$ROWS" "$SUBS"' EXIT + +awk -F'|' ' + { sub(/#.*/, "") } + /^[[:space:]]*$/ { next } + { + for (i = 1; i <= NF; i++) gsub(/^[ \t]+|[ \t]+$/, "", $i) + if ($1 == "") next + print $1 "|" $2 "|" $4 "|" $6 "|" $7 + } +' "$REGISTRY" > "$ROWS" + +if [ -f "$ROOT/.gitmodules" ]; then + awk '/^[[:space:]]*path[[:space:]]*=/ { + sub(/^[^=]*=[[:space:]]*/, ""); gsub(/[[:space:]]+$/, ""); print + }' "$ROOT/.gitmodules" > "$SUBS" +else + : > "$SUBS" +fi + +is_submodule() { + while IFS= read -r _s; do + [ "$_s" = "$1" ] && return 0 + done < "$SUBS" + return 1 +} + +resolve_commit() { + _path=$1 + if [ -n "$PINS" ] && [ -f "$PINS" ]; then + _c=$(awk -F'|' -v p="$_path" '$1 == p { print $2; exit }' "$PINS") + if [ -n "$_c" ]; then printf '%s' "$_c"; return 0; fi + fi + if command -v git >/dev/null 2>&1 && [ -e "$ROOT/$_path/.git" ]; then + _c=$(git -C "$ROOT/$_path" rev-parse HEAD 2>/dev/null) || _c="" + if [ -n "$_c" ]; then printf '%s' "$_c"; return 0; fi + fi + return 1 +} + +is_sha() { + printf '%s' "$1" | grep -qE '^[0-9a-f]{40}$' +} + +norm() { printf '%s' "$1" | tr 'A-Z' 'a-z' | tr -cd 'a-z0-9'; } + +while IFS='|' read -r name version ships path vsrc; do + [ -n "$name" ] || continue + + case " $KNOWN_UNCERTAIN " in + *" $name "*) [ "$ships" = "uncertain" ] && warn "$name: ships=uncertain (allowlisted; resolve and set yes/no/tarball)" ;; + *) [ "$ships" = "uncertain" ] && err "$name: ships=uncertain and not allowlisted" ;; + esac + + case "$ships" in + yes|tarball|no) ;; + *) err "$name: invalid ships value '$ships' (expected yes, tarball, no or uncertain)" ;; + esac + + if [ "$ships" != "no" ] && [ -z "$version" ]; then + err "$name: ships=$ships with an empty version (use 'unknown' if genuinely unversioned)" + fi + + if [ "$path" != "-" ]; then + if [ ! -e "$ROOT/$path" ]; then + if is_submodule "$path"; then + err "$name: $path missing - run 'git submodule update --init $path'" + else + err "$name: path does not exist: $path" + fi + continue + fi + if is_submodule "$path" && [ -z "$(ls -A "$ROOT/$path" 2>/dev/null)" ]; then + err "$name: $path is an empty submodule - run 'git submodule update --init $path'" + continue + fi + fi + + [ "$version" = "unknown" ] && continue + + if [ "$path" != "-" ] && is_submodule "$path" && is_sha "$version"; then + actual=$(resolve_commit "$path") || actual="" + if [ -z "$actual" ]; then + err "$name: version is a commit but the submodule pin could not be resolved (no pins file and no .git)" + elif [ "$actual" != "$version" ]; then + err "$name: registry version '$version' does not match submodule pin '$actual'" + fi + fi + + case "$vsrc" in + dir) + if [ "$path" = "-" ]; then + err "$name: version-src=dir requires a path" + continue + fi + nv=$(norm "$version") + nb=$(norm "$(basename "$path")") + case "$nb" in + *"$nv"*) ;; + *) err "$name: version '$version' not found in path '$path'" ;; + esac + ;; + file:*) + vfile=${vsrc#file:} + if [ ! -f "$ROOT/$vfile" ]; then + err "$name: version-src file does not exist: $vfile" + continue + fi + if ! grep -qF -- "$version" "$ROOT/$vfile"; then + err "$name: version '$version' not found in $vfile" + fi + ;; + none) + UNVERIFIED=$((UNVERIFIED + 1)) + ;; + *) + err "$name: invalid version-src '$vsrc' (expected dir, file: or none)" + ;; + esac +done < "$ROWS" + +for d in "$ROOT"/extra/*/; do + [ -d "$d" ] || continue + dname=$(basename "$d") + if ! awk -F'|' -v d="extra/${dname}" ' + $4 == d || index($4, d "/") == 1 { found = 1 } + END { exit found ? 0 : 1 }' "$ROWS"; then + err "extra/${dname} has no registry entry (add it, with ships=yes, tarball or no)" + fi +done + +while IFS= read -r sub; do + [ -n "$sub" ] || continue + if ! awk -F'|' -v d="$sub" ' + $4 == d { found = 1 } + END { exit found ? 0 : 1 }' "$ROWS"; then + err "submodule $sub has no registry entry" + fi +done < "$SUBS" + +if [ "$UNVERIFIED" -gt 0 ]; then + echo "check-components: ${UNVERIFIED} component(s) have no automated version verification" +fi + +if [ "$ERRORS" -gt 0 ]; then + echo "check-components: FAILED with ${ERRORS} error(s)" >&2 + exit 1 +fi +echo "check-components: registry consistent with tree" diff --git a/build-ps/sbom/components.txt b/build-ps/sbom/components.txt new file mode 100644 index 00000000000..da0e645b6f0 --- /dev/null +++ b/build-ps/sbom/components.txt @@ -0,0 +1,74 @@ +# Third-party component registry for Percona XtraDB Cluster SBOM generation. +# +# Vendored C/C++ trees carry no package manifest, so no scanner can derive this +# inventory. This file is authoritative. check-components.sh validates it +# against the source tree on every build and fails on drift. +# +# Format (pipe-separated, surrounding space ignored, '#' starts a comment): +# +# name | version | license | ships | linkage | path | version-src | notes +# +# ships yes present in every shipped artifact +# tarball present only in the binary tarball +# no present in the source tree but not built or not shipped +# uncertain needs a decision; an error unless allowlisted +# linkage shared-lib | static | header-only | data | source +# path tree path used for validation; '-' if not applicable +# version-src dir version must appear in the final path component +# file: version must appear literally in that file +# none no automated verification is possible +# license SPDX identifier or expression +# +# Components at a path listed in .gitmodules are resolved to their pinned commit +# at build time; that commit is emitted alongside the version. +# +# Only ships=yes and ships=tarball rows are emitted, filtered by artifact. +# +# Scope note: the packages also bundle prebuilt Percona XtraBackup trees under +# /usr/bin/pxc_extra (pxb-8.4, pxb-9.5, pxb-9.6), fetched at build time by pxc_builder.sh +# rather than present in this source tree. They are Percona products carrying +# their own SBOMs and are deliberately not enumerated here. + +# --- shipped: cluster replication (PXC-specific) ----------------------------- +galera | 4.27 | GPL-2.0-only | yes | shared-lib | percona-xtradb-cluster-galera | none | submodule; ships libgalera_smm.so in the -server package; version from GALERA_VERSION (MAJOR.MINOR) +wsrep-lib | 1.0.0 | GPL-2.0-or-later | yes | static | wsrep-lib | none | submodule; version from include/wsrep/version.hpp (separate MAJOR/MINOR/PATCH defines, no literal) +wsrep-API | 26.1 | GPL-2.0-only | yes | header-only | wsrep-lib/wsrep-API/v26 | file:wsrep-lib/wsrep-API/v26/wsrep_api.h | nested submodule of both galera and wsrep-lib at the same commit 7fe08ed3; version is WSREP_INTERFACE_VERSION +asio | 1.14.1 | BSL-1.0 | yes | header-only | percona-xtradb-cluster-galera/asio | file:percona-xtradb-cluster-galera/asio/asio/version.hpp | vendored inside galera; used by the gcomm transport + +# --- shipped: bundled libraries installed as files --------------------------- +protobuf | 24.4 | BSD-3-Clause | yes | shared-lib | extra/protobuf/protobuf-24.4 | dir | ships under lib/mysql/private and lib/mysqlrouter/private +utf8_range | unknown | MIT | yes | static | extra/protobuf/protobuf-24.4/third_party/utf8_range | none | vendored inside bundled protobuf and compiled into it; no independent version marker upstream +abseil-cpp | 20250814.1 | Apache-2.0 | yes | shared-lib | extra/abseil/abseil-cpp-20250814.1 | dir | slaved to WITH_PROTOBUF=bundled; ships libabsl_* +icu | 77.1 | Unicode-3.0 | yes | shared-lib | extra/icu/icu-release-77-1 | dir | ships as the icu-data-files package; no LICENSE in tree, headers cite unicode.org/copyright.html + +# --- shipped: bundled libraries linked into the binaries --------------------- +zlib | 1.3.2 | Zlib | yes | static | extra/zlib/zlib-1.3.2 | dir | -DWITH_ZLIB=bundled in the RPM and DEB recipes +zstd | 1.5.7 | BSD-3-Clause | yes | static | extra/zstd/zstd-1.5.7 | dir | -DWITH_ZSTD=bundled; COPYING GPL-2.0 covers the CLI, which is not built +lz4 | 1.10.0 | BSD-2-Clause | yes | static | extra/lz4/lz4-1.10.0 | dir | only lib/ is compiled; programs/ GPL-2.0 not built +xxhash | 0.8.3 | BSD-2-Clause | yes | static | extra/xxhash/xxHash-0.8.3 | dir | unconditional; no LICENSE in tree, BSD-2 asserted from header +libbacktrace | 793921876c981 | BSD-3-Clause | yes | static | extra/libbacktrace/sha793921876c981 | dir | WITH_EXT_BACKTRACE defaults ON on Linux; version is the pinned commit; no LICENSE in tree +libedit | 20240808-3.1 | BSD-3-Clause | yes | static | extra/libedit/libedit-20240808-3.1 | dir | -DWITH_EDITLINE=bundled +libcno | 208939f540957a35b337dacdd5c5e34d51821bd2 | MIT | yes | static | extra/libcno/libcno-208939f540957a35b337dacdd5c5e34d51821bd2 | dir | unconditional; static into the router HTTP libs +picohttpparser | unknown | MIT OR Artistic-1.0-Perl | yes | static | extra/libcno/libcno-208939f540957a35b337dacdd5c5e34d51821bd2/picohttpparser | none | vendored inside libcno and compiled into it; no LICENSE file, dual license asserted from the header +libcbor | 0.11.0 | MIT | yes | static | extra/libcbor/libcbor-0.11.0 | dir | slaved to WITH_FIDO=bundled +libfido2 | 1.15.0 | BSD-2-Clause | yes | static | extra/libfido2/libfido2-1.15.0 | dir | -DWITH_FIDO=bundled on DEB and on RPM where add_fido_plugins is set +libkmip | 82988b2de6a0687791a3ae549fc2bd5cbeb457ab | Apache-2.0 OR BSD-3-Clause | yes | static | extra/libkmip | none | submodule at upstream tag kmipclient_v0.2.2; version is the pinned commit +coredumper | 8f2623b6d34bfc4e691bdf67b4a0d37c77513ac7 | BSD-3-Clause | yes | static | extra/coredumper | none | submodule; version is the pinned commit +opensslpp | 1.0.0 | GPL-2.0-only | yes | static | extra/opensslpp | file:extra/opensslpp/CMakeLists.txt | Percona-authored; WITH_ENCRYPTION_UDF=ON; no LICENSE in tree + +# --- shipped: server telemetry component, new in 9.x ------------------------- +opentelemetry-cpp | 1.23.0 | Apache-2.0 | yes | static | extra/opentelemetry-cpp/opentelemetry-cpp-1.23.0 | dir | linked into components/telemetry when OPENTELEMETRY_CPP_AVAILABLE (bundled protobuf 24.4 and system curl clear the version floors) +opentelemetry-proto | 1.7.0 | Apache-2.0 | yes | static | extra/opentelemetry-proto/opentelemetry-proto-1.7.0 | dir | linked into the telemetry component +json | 3.12.0 | MIT | yes | header-only | extra/json/json-3.12.0 | dir | nlohmann json; header-only dependency of opentelemetry-cpp + +# --- shipped: header-only, compiled into the binaries ------------------------ +boost | 1.87.0 | BSL-1.0 | yes | header-only | extra/boost/boost_1_87_0 | dir | always bundled, boost.cmake hardcodes the tree; trimmed subset; no LICENSE in tree +rapidjson | 1.1.0 | MIT | yes | header-only | extra/rapidjson | none | -DWITH_RAPIDJSON=bundled; version is only in preprocessor defines, not as a literal +unordered_dense | 4.4.0 | MIT | yes | header-only | extra/unordered_dense/unordered_dense-4.4.0 | dir | unconditional + +# --- not shipped ------------------------------------------------------------- +curl | 8.14.1 | curl | no | source | extra/curl/curl-8.14.1 | dir | -DWITH_CURL=system on RPM; never passed on DEB or tarball, so the UNIX default (system) applies. PXC bundles no curl +tirpc | 1.3.5 | BSD-3-Clause | no | source | extra/tirpc/libtirpc-1.3.5 | dir | bundled only with a custom OpenSSL path on RHEL8/9; no PXC recipe passes one, so the system libtirpc is used +googletest | 1.17.0 | BSD-3-Clause | no | source | extra/googletest/googletest-1.17.0 | dir | compiled for unit tests but no test artifact is packaged +gperftools | 2.15 | BSD-3-Clause | no | source | extra/gperftools/gperftools-2.15 | dir | WITH_TCMALLOC defaults OFF and is never passed +doxygen-awesome | 2.2.0 | MIT | no | data | extra/doxygen-awesome/2.2.0 | dir | documentation CSS only; no CMake reference diff --git a/build-ps/sbom/gen-sbom.sh b/build-ps/sbom/gen-sbom.sh new file mode 100755 index 00000000000..34e470f27c6 --- /dev/null +++ b/build-ps/sbom/gen-sbom.sh @@ -0,0 +1,410 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +PKG="" +VER="" +DEST="" +ROOT="." +REGISTRY="${SCRIPT_DIR}/components.txt" +ARTIFACT="package" +SCAN_LIBS="" +PINS="" +EXCLUDE="" + +usage() { + echo "usage: $0 --pkg NAME --version VER --dest DIR" >&2 + echo " [--root DIR] [--registry FILE] [--pins FILE]" >&2 + echo " [--artifact package|tarball|source] [--scan-libs DIR]" >&2 + echo " [--exclude NAME[,NAME...]]" >&2 + exit 2 +} + +die() { echo "gen-sbom: ERROR: $*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --pkg) PKG=${2:?}; shift 2 ;; + --version) VER=${2:?}; shift 2 ;; + --dest) DEST=${2:?}; shift 2 ;; + --root) ROOT=${2:?}; shift 2 ;; + --registry) REGISTRY=${2:?}; shift 2 ;; + --pins) PINS=${2:?}; shift 2 ;; + --artifact) ARTIFACT=${2:?}; shift 2 ;; + --scan-libs) SCAN_LIBS=${2:?}; shift 2 ;; + --exclude) EXCLUDE=${2:?}; shift 2 ;; + -h|--help) usage ;; + *) echo "gen-sbom: unknown argument: $1" >&2; usage ;; + esac +done + +[ -n "$PKG" ] || usage +[ -n "$VER" ] || usage +[ -n "$DEST" ] || usage +[ -f "$REGISTRY" ] || die "registry not found: $REGISTRY" +[ -z "$PINS" ] || [ -f "$PINS" ] || die "pins file not found: $PINS" +[ -z "$SCAN_LIBS" ] || [ -d "$SCAN_LIBS" ] || die "scan-libs directory not found: $SCAN_LIBS" + +if [ -z "$PINS" ] && [ -f "$ROOT/build-ps/sbom/submodule-pins.txt" ]; then + PINS="$ROOT/build-ps/sbom/submodule-pins.txt" +fi + +case "$ARTIFACT" in + package|tarball|source) ;; + *) die "invalid --artifact '$ARTIFACT' (expected package, tarball or source)" ;; +esac + +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 | cut -d' ' -f1; } +else + die "no sha256sum or shasum available" +fi + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/ps-sbom.XXXXXX") +trap 'rm -rf "$WORK"' EXIT + +COMPONENTS="${WORK}/components" +ROWS="${WORK}/rows" +SUBS="${WORK}/subs" + +tr '\t' ' ' < "$REGISTRY" | tr -d '\000-\010\013\014\016-\037\177' > "${WORK}/registry.clean" + +awk -F'|' ' + { sub(/#.*/, "") } + /^[[:space:]]*$/ { next } + { + for (i = 1; i <= NF; i++) gsub(/^[ \t]+|[ \t]+$/, "", $i) + if ($1 == "") next + print $1 "|" $2 "|" $3 "|" $4 "|" $5 "|" $6 + } +' "${WORK}/registry.clean" > "$ROWS" + +if [ -f "$ROOT/.gitmodules" ]; then + awk '/^[[:space:]]*path[[:space:]]*=/ { + sub(/^[^=]*=[[:space:]]*/, ""); gsub(/[[:space:]]+$/, ""); print + }' "$ROOT/.gitmodules" > "$SUBS" +else + : > "$SUBS" +fi + +resolve_commit() { + _path=$1 + if [ -n "$PINS" ] && [ -f "$PINS" ]; then + _c=$(awk -F'|' -v p="$_path" '$1 == p { print $2; exit }' "$PINS") + if [ -n "$_c" ]; then printf '%s' "$_c"; return 0; fi + fi + if command -v git >/dev/null 2>&1 && [ -e "$ROOT/$_path/.git" ]; then + _c=$(git -C "$ROOT/$_path" rev-parse HEAD 2>/dev/null) || _c="" + if [ -n "$_c" ]; then printf '%s' "$_c"; return 0; fi + fi + return 1 +} + +is_submodule() { + while IFS= read -r _s; do + [ "$_s" = "$1" ] && return 0 + done < "$SUBS" + return 1 +} + +: > "$COMPONENTS" +UNPINNED="" +EXCLUDED="" + +while IFS='|' read -r name version license ships linkage path; do + [ -n "$name" ] || continue + + case "$ships" in + yes) ;; + tarball) [ "$ARTIFACT" = "tarball" ] || continue ;; + *) continue ;; + esac + + case ",${EXCLUDE}," in + *",${name},"*) EXCLUDED="${EXCLUDED} ${name}"; continue ;; + esac + + [ -n "$version" ] || die "$name: ships=$ships with an empty version" + + commit="" + if [ "$path" != "-" ] && is_submodule "$path"; then + commit=$(resolve_commit "$path") || commit="" + [ -n "$commit" ] || UNPINNED="${UNPINNED} ${name}" + fi + + [ -n "$license" ] || license="NOASSERTION" + + printf '%s|%s|%s|%s|generic|vendored|%s\n' \ + "$name" "$version" "$license" "$linkage" "$commit" >> "$COMPONENTS" +done < "$ROWS" + +if [ -s "$COMPONENTS" ]; then :; else + die "no components selected for artifact '$ARTIFACT' - registry is empty or over-filtered" +fi + +if [ -n "$EXCLUDED" ]; then + echo "gen-sbom: excluded by caller (resolved to a system library):${EXCLUDED}" +fi + +if [ -n "$UNPINNED" ]; then + echo "gen-sbom: WARNING: no commit resolved for submodule component(s):${UNPINNED}" >&2 + echo "gen-sbom: WARNING: pass --pins, or place submodule-pins.txt under /build-ps/sbom/" >&2 +fi + +row_is_sane() { + [ "$(printf '%s' "$1" | awk -F'|' 'END { print NF }')" = "3" ] || return 1 + case $1 in + *' '*) return 1 ;; + esac + printf '%s' "$1" | awk -F'|' '{ exit ($1 == "" || $2 == "") ? 1 : 0 }' +} + +resolve_owner() { + _p=$1 + if command -v rpm >/dev/null 2>&1; then + _r=$(rpm -qf --qf '%{NAME}|%{VERSION}-%{RELEASE}|%{LICENSE}\n' "$_p" 2>/dev/null | head -1) || _r="" + case $_r in + *'|'*'|'*) if row_is_sane "$_r"; then printf '%s|rpm' "$_r"; return 0; fi ;; + esac + fi + if command -v dpkg-query >/dev/null 2>&1; then + _pk=$(dpkg -S "$_p" 2>/dev/null \ + | grep -v '^diversion by ' \ + | head -1 | cut -d: -f1) || _pk="" + if [ -n "$_pk" ]; then + _v=$(dpkg-query -W -f='${Version}\n' "$_pk" 2>/dev/null | head -1) || _v="" + [ -n "$_v" ] || _v="unknown" + printf '%s|%s|NOASSERTION|deb' "$_pk" "$_v" + return 0 + fi + fi + return 1 +} + +SCANNED=0 +RESOLVED=0 + +if [ -n "$SCAN_LIBS" ]; then + HOSTMAP="${WORK}/hostmap" + ldconfig -p 2>/dev/null | awk '/=>/ { print $NF }' | while IFS= read -r _p; do + _rp=$(readlink -f "$_p" 2>/dev/null) || continue + [ -f "$_rp" ] || continue + printf '%s|%s\n' "$(basename "$_rp")" "$_rp" + done | sort -u > "$HOSTMAP" || true + + for f in "$SCAN_LIBS"/*; do + [ -L "$f" ] && continue + [ -f "$f" ] || continue + SCANNED=$((SCANNED + 1)) + b=$(basename "$f") + hp=$(awk -F'|' -v b="$b" '$1 == b { print $2; exit }' "$HOSTMAP" 2>/dev/null) || hp="" + [ -n "$hp" ] || continue + owner=$(resolve_owner "$hp") || continue + RESOLVED=$((RESOLVED + 1)) + oname=$(printf '%s' "$owner" | cut -d'|' -f1) + over=$(printf '%s' "$owner" | cut -d'|' -f2) + olic=$(printf '%s' "$owner" | cut -d'|' -f3) + otype=$(printf '%s' "$owner" | cut -d'|' -f4) + [ -n "$olic" ] || olic="NOASSERTION" + if ! awk -F'|' -v n="$oname" '$1 == n { found = 1 } END { exit found ? 0 : 1 }' "$COMPONENTS"; then + printf '%s|%s|%s|shared-lib|%s|host|\n' \ + "$oname" "$over" "$olic" "$otype" >> "$COMPONENTS" + fi + done + + echo "gen-sbom: scanned ${SCANNED} bundled libraries, resolved ${RESOLVED} to host packages" + if [ "$SCANNED" -gt 0 ] && [ "$RESOLVED" -eq 0 ]; then + echo "gen-sbom: WARNING: no bundled library resolved to a host package;" \ + "is ldconfig available and is the rpm or dpkg database readable?" >&2 + fi +fi + +UUID=$(printf '%s|%s|%s|%s' "$PKG" "$VER" "$ARTIFACT" "$(cat "$COMPONENTS")" | sha256 | awk '{ + h = $0 + printf "%s-%s-4%s-a%s-%s", + substr(h, 1, 8), substr(h, 9, 4), substr(h, 14, 3), + substr(h, 18, 3), substr(h, 21, 12) +}') + +if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then + CREATED=$(date -u -d "@${SOURCE_DATE_EPOCH}" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ + || date -u -r "${SOURCE_DATE_EPOCH}" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || CREATED="" +fi +[ -n "${CREATED:-}" ] || CREATED=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + +DUPS=$(cut -d'|' -f1 "$COMPONENTS" | sort | uniq -d) +[ -z "$DUPS" ] || die "duplicate component names would collide as SPDX identifiers: $(echo $DUPS)" + +mkdir -p "$DEST" + +AWK_LIB=' +function jesc(s, i, c, o) { + o = "" + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "\\") o = o "\\\\" + else if (c == "\"") o = o "\\\"" + else if (c == "\n") o = o "\\n" + else if (c == "\r") o = o "\\r" + else if (c == "\t") o = o "\\t" + else if (c < " ") o = o sprintf("\\u%04x", ORD[c]) + else o = o c + } + return o +} +function penc(s, i, c, o) { + o = "" + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c ~ /[A-Za-z0-9._~:-]/) o = o c + else o = o sprintf("%%%02X", ORD[c]) + } + return o +} +function purl(type, name, version) { + return "pkg:" type "/" penc(name) "@" penc(version) +} +BEGIN { for (i = 0; i < 256; i++) ORD[sprintf("%c", i)] = i } +' + +awk -F'|' -v pkg="$PKG" -v ver="$VER" -v uuid="$UUID" -v created="$CREATED" \ + -v artifact="$ARTIFACT" "$AWK_LIB"' +{ + name[NR] = $1; vers[NR] = $2; lic[NR] = $3 + link[NR] = $4; ptype[NR] = $5; orig[NR] = $6; commit[NR] = $7 + n = NR +} +END { + printf "{\n" + printf " \"spdxVersion\": \"SPDX-2.3\",\n" + printf " \"dataLicense\": \"CC0-1.0\",\n" + printf " \"SPDXID\": \"SPDXRef-DOCUMENT\",\n" + printf " \"name\": \"%s-%s\",\n", jesc(pkg), jesc(ver) + printf " \"documentNamespace\": \"https://percona.com/spdxdocs/%s-%s-%s\",\n", jesc(pkg), jesc(ver), uuid + printf " \"creationInfo\": {\n" + printf " \"created\": \"%s\",\n", created + printf " \"creators\": [\"Organization: Percona LLC\", \"Tool: gen-sbom.sh\"]\n" + printf " },\n" + printf " \"packages\": [\n" + printf " {\n" + printf " \"SPDXID\": \"SPDXRef-Package-root\",\n" + printf " \"name\": \"%s\",\n", jesc(pkg) + printf " \"versionInfo\": \"%s\",\n", jesc(ver) + printf " \"downloadLocation\": \"NOASSERTION\",\n" + printf " \"filesAnalyzed\": false,\n" + printf " \"licenseConcluded\": \"GPL-2.0-only\",\n" + printf " \"licenseDeclared\": \"GPL-2.0-only\",\n" + printf " \"copyrightText\": \"NOASSERTION\",\n" + printf " \"supplier\": \"Organization: Percona LLC\"\n" + printf " }" + for (i = 1; i <= n; i++) { + printf ",\n {\n" + printf " \"SPDXID\": \"SPDXRef-Component-%d\",\n", i + printf " \"name\": \"%s\",\n", jesc(name[i]) + printf " \"versionInfo\": \"%s\",\n", jesc(vers[i]) + printf " \"downloadLocation\": \"NOASSERTION\",\n" + printf " \"filesAnalyzed\": false,\n" + printf " \"licenseConcluded\": \"NOASSERTION\",\n" + printf " \"licenseDeclared\": \"%s\",\n", jesc(lic[i]) + printf " \"copyrightText\": \"NOASSERTION\",\n" + printf " \"externalRefs\": [{\n" + printf " \"referenceCategory\": \"PACKAGE-MANAGER\",\n" + printf " \"referenceType\": \"purl\",\n" + printf " \"referenceLocator\": \"%s\"\n", purl(ptype[i], name[i], vers[i]) + printf " }],\n" + if (commit[i] != "") + printf " \"sourceInfo\": \"linkage=%s origin=%s commit=%s\",\n", jesc(link[i]), jesc(orig[i]), jesc(commit[i]) + else + printf " \"sourceInfo\": \"linkage=%s origin=%s\",\n", jesc(link[i]), jesc(orig[i]) + printf " \"comment\": \"artifact=%s\"\n", jesc(artifact) + printf " }" + } + printf "\n ],\n" + printf " \"relationships\": [\n" + printf " {\"spdxElementId\": \"SPDXRef-DOCUMENT\", \"relatedSpdxElement\": \"SPDXRef-Package-root\", \"relationshipType\": \"DESCRIBES\"}" + for (i = 1; i <= n; i++) { + printf ",\n {\"spdxElementId\": \"SPDXRef-Package-root\", \"relatedSpdxElement\": \"SPDXRef-Component-%d\", \"relationshipType\": \"CONTAINS\"}", i + } + printf "\n ]\n" + printf "}\n" +} +' "$COMPONENTS" > "${DEST}/${PKG}.spdx.json" + +awk -F'|' -v pkg="$PKG" -v ver="$VER" -v uuid="$UUID" -v created="$CREATED" \ + -v artifact="$ARTIFACT" "$AWK_LIB"' +{ + name[NR] = $1; vers[NR] = $2; lic[NR] = $3 + link[NR] = $4; ptype[NR] = $5; orig[NR] = $6; commit[NR] = $7 + n = NR +} +END { + printf "{\n" + printf " \"bomFormat\": \"CycloneDX\",\n" + printf " \"specVersion\": \"1.5\",\n" + printf " \"serialNumber\": \"urn:uuid:%s\",\n", uuid + printf " \"version\": 1,\n" + printf " \"metadata\": {\n" + printf " \"timestamp\": \"%s\",\n", created + printf " \"tools\": [{\"vendor\": \"Percona\", \"name\": \"gen-sbom.sh\"}],\n" + printf " \"component\": {\n" + printf " \"type\": \"application\",\n" + printf " \"bom-ref\": \"root\",\n" + printf " \"name\": \"%s\",\n", jesc(pkg) + printf " \"version\": \"%s\",\n", jesc(ver) + printf " \"supplier\": {\"name\": \"Percona LLC\"},\n" + printf " \"licenses\": [{\"license\": {\"id\": \"GPL-2.0-only\"}}]\n" + printf " }\n" + printf " },\n" + printf " \"components\": [\n" + for (i = 1; i <= n; i++) { + printf " {\n" + printf " \"type\": \"library\",\n" + printf " \"bom-ref\": \"component-%d\",\n", i + printf " \"name\": \"%s\",\n", jesc(name[i]) + printf " \"version\": \"%s\",\n", jesc(vers[i]) + printf " \"scope\": \"required\",\n" + printf " \"licenses\": [{\"license\": {\"name\": \"%s\"}}],\n", jesc(lic[i]) + printf " \"purl\": \"%s\",\n", purl(ptype[i], name[i], vers[i]) + printf " \"properties\": [\n" + printf " {\"name\": \"percona:linkage\", \"value\": \"%s\"},\n", jesc(link[i]) + printf " {\"name\": \"percona:origin\", \"value\": \"%s\"},\n", jesc(orig[i]) + if (commit[i] != "") + printf " {\"name\": \"percona:commit\", \"value\": \"%s\"},\n", jesc(commit[i]) + printf " {\"name\": \"percona:artifact\", \"value\": \"%s\"}\n", jesc(artifact) + printf " ]\n" + printf " }%s\n", (i < n ? "," : "") + } + printf " ],\n" + printf " \"dependencies\": [\n" + printf " {\"ref\": \"root\", \"dependsOn\": [" + for (i = 1; i <= n; i++) printf "%s\"component-%d\"", (i > 1 ? ", " : ""), i + printf "]}\n" + printf " ]\n" + printf "}\n" +} +' "$COMPONENTS" > "${DEST}/${PKG}.cdx.json" + +{ + echo "Percona Server for MySQL - third-party components" + echo "Package: ${PKG}" + echo "Version: ${VER}" + echo "Artifact: ${ARTIFACT}" + echo "Created: ${CREATED}" + echo + printf '%-22s %-26s %-14s %s\n' "COMPONENT" "VERSION" "ORIGIN" "LICENSE" + awk -F'|' '{ printf "%-22s %-26s %-14s %s\n", $1, $2, $6, $3 }' "$COMPONENTS" +} > "${DEST}/${PKG}.sbom.txt" + +awk -F'|' '{ print $3 }' "$COMPONENTS" | sort -u > "${DEST}/${PKG}.licenses.txt" + +chmod 0755 "$DEST" +for f in "${DEST}/${PKG}.spdx.json" "${DEST}/${PKG}.cdx.json" \ + "${DEST}/${PKG}.sbom.txt" "${DEST}/${PKG}.licenses.txt"; do + chmod 0644 "$f" +done + +echo "gen-sbom: wrote $(wc -l < "$COMPONENTS") components to ${DEST} (artifact=${ARTIFACT})" diff --git a/build-ps/sbom/validate-sbom.sh b/build-ps/sbom/validate-sbom.sh new file mode 100755 index 00000000000..ea89f26c591 --- /dev/null +++ b/build-ps/sbom/validate-sbom.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +set -uo pipefail + +REQUIRED_COMPONENTS="protobuf zlib zstd lz4 icu abseil-cpp libkmip coredumper boost galera wsrep-lib" +MIN_COMPONENTS=15 + +ERRORS=0 +err() { echo "validate-sbom: ERROR: $*" >&2; ERRORS=$((ERRORS + 1)); } +ok() { echo "validate-sbom: OK: $*"; } + +[ $# -gt 0 ] || { echo "usage: $0 ARTIFACT [ARTIFACT...]" >&2; exit 2; } + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/ps-vsbom.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +extract() { + local art=$1 dest=$2 + mkdir -p "$dest" + case "$art" in + *.rpm) (cd "$dest" && rpm2cpio "$art" | cpio -idmu --quiet) ;; + *.deb) dpkg-deb -x "$art" "$dest" ;; + *.tar.gz) tar xzf "$art" -C "$dest" ;; + *) [ -d "$art" ] && cp -r "$art/." "$dest/" ;; + esac +} + +check_json() { + local f=$1 + if command -v python3 >/dev/null 2>&1; then + python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$f" 2>/dev/null + else + return 0 + fi +} + +idx=0 +for art in "$@"; do + idx=$((idx + 1)) + [ -e "$art" ] || { err "artifact does not exist: $art"; continue; } + + name=$(basename "$art") + errors_before=$ERRORS + + dest="${TMP}/$(printf '%03d' "$idx")_$(echo "$name" | tr -c 'A-Za-z0-9._-' '_')" + rm -rf "$dest" + extract "$art" "$dest" + + spdx=$(find "$dest" -name '*.spdx.json' -type f 2>/dev/null | head -1) + cdx=$(find "$dest" -name '*.cdx.json' -type f 2>/dev/null | head -1) + txt=$(find "$dest" -name '*.sbom.txt' -type f 2>/dev/null | head -1) + lic=$(find "$dest" -name '*.licenses.txt' -type f 2>/dev/null | head -1) + + if [ -z "$spdx" ]; then + err "$name: no .spdx.json found" + continue + fi + [ -n "$cdx" ] || err "$name: no .cdx.json found" + [ -n "$txt" ] || err "$name: no .sbom.txt found" + [ -n "$lic" ] || err "$name: no .licenses.txt found" + + check_json "$spdx" || err "$name: .spdx.json is not valid JSON" + [ -z "$cdx" ] || check_json "$cdx" || err "$name: .cdx.json is not valid JSON" + + count=$(grep -o '"SPDXID": "SPDXRef-Component-' "$spdx" 2>/dev/null | wc -l | tr -d ' ') + count=${count:-0} + if [ "$count" -lt "$MIN_COMPONENTS" ]; then + err "$name: only ${count} components in SPDX, expected at least ${MIN_COMPONENTS}" + fi + + for c in $REQUIRED_COMPONENTS; do + if ! grep -q "\"name\": \"${c}\"" "$spdx"; then + err "$name: required component missing from SPDX: ${c}" + fi + done + + if [ "$ERRORS" -eq "$errors_before" ]; then + ok "$name: ${count} components, all required present" + fi +done + +if [ "$ERRORS" -gt 0 ]; then + echo "validate-sbom: FAILED with ${ERRORS} error(s)" >&2 + exit 1 +fi +echo "validate-sbom: all artifacts carry a valid SBOM"