From 54d2f7cce1ad456464aaeb171aa3d93576fcf98a Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 13 Oct 2026 10:32:08 -0300 Subject: [PATCH] release: a CycloneDX SBOM of the machine, inside the tarball, beside it and attested S2 of the supply-chain plan. hack/release writes what the release is made of from what the builds recorded: the apk databases QEMU and e2fsprogs were statically linked in (copied out of each builder by its extract stage, so the compile is not touched), the image's dpkg list, versions.yaml's pins and every shipped file by its hash, merged under machine.env inside the tarball and under the tarball beside it, and fails on a component with no version. The release attests the SBOM over the tarball as cyclonedx; workflows_test requires it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 13 +++++++++- docs/releasing.md | 9 +++++++ e2fsprogs/Dockerfile | 3 +++ e2fsprogs/Taskfile.yml | 2 +- go.mod | 4 ++- go.sum | 20 +++++++++++++-- hack/release | 48 +++++++++++++++++++++++++++++++++++ qemu/Dockerfile | 4 +++ qemu/Taskfile.yml | 2 +- workflows/workflows_test.go | 2 +- 10 files changed, 100 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6dc9be..c045310 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,7 +212,7 @@ jobs: IDENTITY: ${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }} run: | set -euo pipefail - for f in _output/release/*.tar.gz _output/release/*.tar.gz.sha256 _output/release/machine.env; do + for f in _output/release/*.tar.gz _output/release/*.tar.gz.sha256 _output/release/machine.env _output/release/*.cdx.json; do cosign sign-blob --yes --bundle "${f}.sigstore.json" "${f}" > /dev/null cosign attest-blob --yes --type slsaprovenance1 --predicate _output/provenance.json \ --bundle "${f}.provenance.sigstore.json" "${f}" > /dev/null @@ -222,7 +222,17 @@ jobs: --certificate-identity "${IDENTITY}" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com "${f}" done + # The tarball's SBOM (hack/release), attested over it: what it is made of, by the + # workflow that built it. The same SBOM is inside it, at usr/share/spin-stack/sbom. + for f in _output/release/*.tar.gz; do + cosign attest-blob --yes --type cyclonedx --predicate "${f%.tar.gz}.cdx.json" \ + --bundle "${f}.cdx.sigstore.json" "${f}" > /dev/null + cosign verify-blob-attestation --type cyclonedx --bundle "${f}.cdx.sigstore.json" \ + --certificate-identity "${IDENTITY}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "${f}" + done go tool attestgate files -require provenance _output/release + go tool attestgate files -require cdx -except '*.sha256,machine.env,*.cdx.json' _output/release # Tagged here when the run generated its own version, so that the next run's count # of "tags already taken today" is right and the tarball has a commit behind it. @@ -246,6 +256,7 @@ jobs: _output/release/*.tar.gz _output/release/*.tar.gz.sha256 _output/release/machine.env + _output/release/*.cdx.json _output/release/*.sigstore.json # What every combination of the machine's features costs, booted from the tarball just diff --git a/docs/releasing.md b/docs/releasing.md index baa5899..bd5e693 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -51,6 +51,15 @@ cosign verify-blob-attestation --type slsaprovenance1 --bundle f.provenance.sigs ``` A run started by hand ran on `refs/heads/main`, and that is the ref its certificate names. + +What the release is made of is its CycloneDX SBOM, written by `hack/release` from what the builds +recorded - the apk databases QEMU and e2fsprogs were statically linked in (each build's +`/lib/apk/db/installed`, extracted to `_output/sbom/`), the image's `packages.txt`, the pins in +`versions.yaml`, and each shipped file by its hash - and checked with `go tool sbom check`: a +component with no version fails the release. It is inside the tarball at +`usr/share/spin-stack/sbom/machine.cdx.json`, beside it as `.cdx.json`, and attested +over the tarball as `.cdx.sigstore.json` (`cosign verify-blob-attestation --type +cyclonedx`, with the identity above). `workflows/workflows_test.go` holds every workflow to attesting before it publishes. ## The feature matrix diff --git a/e2fsprogs/Dockerfile b/e2fsprogs/Dockerfile index 0ed42a8..1b6896b 100644 --- a/e2fsprogs/Dockerfile +++ b/e2fsprogs/Dockerfile @@ -77,3 +77,6 @@ COPY --from=builder /build/e2fsck/e2fsck /bin/e2fsck COPY --from=builder /build/misc/dumpe2fs /bin/dumpe2fs COPY --from=builder /build/debugfs/debugfs /bin/debugfs COPY --from=builder /build/misc/mke2fs.conf /e2fsprogs/mke2fs.conf +# The packages the builder installed, the static libraries linked among them, for the SBOM +# hack/release writes: a copy out of the finished builder, so nothing the compile reads changes. +COPY --from=builder /lib/apk/db/installed /sbom/e2fsprogs.apk diff --git a/e2fsprogs/Taskfile.yml b/e2fsprogs/Taskfile.yml index 92bd2a4..be9edf6 100644 --- a/e2fsprogs/Taskfile.yml +++ b/e2fsprogs/Taskfile.yml @@ -8,7 +8,7 @@ tasks: build: desc: "Build e2fsprogs statically: mkfs.ext4, e2fsck, dumpe2fs, debugfs and the mke2fs.conf they are made with." cmds: - - rm -rf {{.OUTPUT_ABS}}/bin/mkfs.ext4 {{.OUTPUT_ABS}}/bin/e2fsck {{.OUTPUT_ABS}}/bin/dumpe2fs {{.OUTPUT_ABS}}/bin/debugfs {{.OUTPUT_ABS}}/e2fsprogs + - rm -rf {{.OUTPUT_ABS}}/bin/mkfs.ext4 {{.OUTPUT_ABS}}/bin/e2fsck {{.OUTPUT_ABS}}/bin/dumpe2fs {{.OUTPUT_ABS}}/bin/debugfs {{.OUTPUT_ABS}}/e2fsprogs {{.OUTPUT_ABS}}/sbom/e2fsprogs.apk - mkdir -p {{.OUTPUT_ABS}} - | docker buildx build \ diff --git a/go.mod b/go.mod index 106a7c1..294f72c 100644 --- a/go.mod +++ b/go.mod @@ -8,16 +8,18 @@ tool ( github.com/spin-stack/go-tools/cmd/mutate github.com/spin-stack/go-tools/cmd/provenance github.com/spin-stack/go-tools/cmd/refs + github.com/spin-stack/go-tools/cmd/sbom github.com/spin-stack/go-tools/cmd/testquality github.com/spin-stack/go-tools/cmd/versions ) require ( - github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405 + github.com/spin-stack/go-tools v0.0.0-20261013132702-621f429d5b97 golang.org/x/sys v0.48.0 ) require ( + github.com/CycloneDX/cyclonedx-go v0.12.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.41.0 // indirect golang.org/x/sync v0.23.0 // indirect diff --git a/go.sum b/go.sum index e6ec9b8..dfe3956 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,27 @@ +github.com/CycloneDX/cyclonedx-go v0.12.0 h1:/7Jum36UA6V043tQZ/fE3jf+Nf9gn/qxUFfd7QReMy8= +github.com/CycloneDX/cyclonedx-go v0.12.0/go.mod h1:V2577HhxDDCDLYfkm55WJrz16nHTfyQZwcWUBSG7Z28= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= -github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405 h1:NwRkSsLhvJIZx8eiqCnNTuSxOxldtBvNOXxKG0FeNwo= -github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405/go.mod h1:GgzYh3wr8jXxFMripzZZEkTmS8n8hYZm+u37cEQSbfo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spin-stack/go-tools v0.0.0-20261013132702-621f429d5b97 h1:9O9xGhCP1Ij0ydUba0gqmS1pvGIIvW5iytBzq6RYIvA= +github.com/spin-stack/go-tools v0.0.0-20261013132702-621f429d5b97/go.mod h1:cKPqYf5z2CcqsABqoifumRkiEEYSCBlRCwVNP+3Hz5A= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/terminalstatic/go-xsd-validate v0.1.8 h1:UVrTCy1j3DhwaYTTUF+QYO/Nan13S0tf+Jwi+p45Bf0= +github.com/terminalstatic/go-xsd-validate v0.1.8/go.mod h1:1kb47fi2c6onlf+B7UrrQ9VYraOhcYwFm3iG+J6F4Zo= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= diff --git a/hack/release b/hack/release index 43b6e15..0d740a8 100755 --- a/hack/release +++ b/hack/release @@ -218,9 +218,57 @@ SOURCES echo "Sources:" echo " ok: SOURCES" +# What the release is made of, as a scanner reads it: CycloneDX, one component per thing shipped +# and the version it was built from. Read from what the builds themselves recorded - the apk +# databases the static binaries were linked in, the image's dpkg database, versions.yaml's pins - +# never from a list kept here. A component with no version fails the release (sbom check): a +# scanner cannot match it against anything. The time in it is the commit's, so a release built +# twice from one commit carries the same bytes. +echo "SBOM:" +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || echo 0)}" +export SOURCE_DATE_EPOCH +PARTS="${OUTPUT_DIR}/release/sbom" +mkdir -p "${PARTS}" "${SHARE}/sbom" +sbom() { go tool sbom "$@"; } +for db in qemu e2fsprogs; do + [ -f "${OUTPUT_DIR}/sbom/${db}.apk" ] || { echo "👹 missing ${db}'s apk database: ${OUTPUT_DIR}/sbom/${db}.apk" >&2; echo " run: task build" >&2; exit 1; } +done +# The packages each static binary links, by the -static or -dev package that carries the .a, +# with musl's libc and gcc's libgcc: the rest of each database built them and is formulation. +sbom apk -name qemu-libraries -version "${QEMU_VERSION}" -distro alpine \ + -only glib-static,pcre2-static,libffi-dev,pixman-static,zlib-static,libseccomp-static,libcap-ng-static,liburing-dev,libaio-dev,gettext-static,musl-dev,gcc \ + -out "${PARTS}/qemu-libraries.cdx.json" "${OUTPUT_DIR}/sbom/qemu.apk" +sbom apk -name e2fsprogs-libraries -version "${E2FSPROGS_VERSION}" -distro alpine \ + -only libarchive-static,acl-static,zlib-static,xz-static,zstd-static,bzip2-static,lz4-static,expat-static,openssl-libs-static,musl-dev,gcc \ + -out "${PARTS}/e2fsprogs-libraries.cdx.json" "${OUTPUT_DIR}/sbom/e2fsprogs.apk" +sbom dpkg -name base-image -version "${VERSION}" -distro ubuntu -out "${PARTS}/base-image.cdx.json" "${SHARE}/packages.txt" +sbom pins -name sources -version "${VERSION}" -out "${PARTS}/sources.cdx.json" qemu qboot e2fsprogs kernel ubuntu task git-lfs buildkit +# The files themselves, by their hashes: the firmware is the QEMU tarball's own build of it. +for f in "${SHARE}"/bin/qemu-system-x86_64 "${SHARE}"/bin/qemu-system-x86_64-tcg "${SHARE}"/bin/qemu-img \ + "${SHARE}"/qemu/bios.bin "${SHARE}"/qemu/bios-256k.bin "${SHARE}"/qemu/pvh.bin "${SHARE}"/qemu/kvmvapic.bin "${SHARE}"/qemu/efi-virtio.rom; do + sbom file -name "$(basename "$f")" -version "${QEMU_VERSION}" -out "${PARTS}/$(basename "$f").cdx.json" "$f" +done +for f in mkfs.ext4 debugfs e2fsck dumpe2fs; do + sbom file -name "$f" -version "${E2FSPROGS_VERSION}" -out "${PARTS}/$f.cdx.json" "${SHARE}/bin/$f" +done +sbom file -name qboot.bin -version "${QBOOT_COMMIT}" -out "${PARTS}/qboot.bin.cdx.json" "${SHARE}/qemu/qboot.bin" +sbom file -name vmlinux -version "${KERNEL_VERSION}" -out "${PARTS}/vmlinux.cdx.json" "${SHARE}/kernel/vmlinux" +sbom file -name rootfs.qcow2 -version "${VERSION}" -out "${PARTS}/rootfs.qcow2.cdx.json" "${SHARE}/image/rootfs.qcow2" +# The machine, as the manifest above describes it; inside the tarball, where whatever lays the +# release keeps it. +sbom merge -name spin-machine -version "${VERSION}" -subject "${SHARE}/machine.env" \ + -out "${SHARE}/sbom/machine.cdx.json" "${PARTS}"/*.cdx.json +sbom check "${SHARE}/sbom/machine.cdx.json" > /dev/null +echo " ok: usr/share/spin-stack/sbom/machine.cdx.json" + TARBALL="${OUTPUT_DIR}/release/${RELEASE_NAME}.tar.gz" tar -czf "${TARBALL}" -C "${OUTPUT_DIR}/release" "${RELEASE_NAME}" sha256sum "${TARBALL}" | sed "s|${OUTPUT_DIR}/release/||" > "${TARBALL}.sha256" +# And beside it, of the tarball by its hash: what the release's attestation is made of. +sbom merge -name "${RELEASE_NAME}.tar.gz" -version "${VERSION}" -subject "${TARBALL}" \ + -out "${TARBALL%.tar.gz}.cdx.json" "${SHARE}/sbom/machine.cdx.json" +sbom check "${TARBALL%.tar.gz}.cdx.json" > /dev/null +rm -rf "${PARTS}" # The manifest again, beside the tarball rather than inside it, so that it can be published # as an asset of its own and read back without the 1 GB around it. diff --git a/qemu/Dockerfile b/qemu/Dockerfile index 530c56c..e7b79a7 100644 --- a/qemu/Dockerfile +++ b/qemu/Dockerfile @@ -517,3 +517,7 @@ COPY --from=builder /opt/qemu-tcg/bin/qemu-system-x86_64 /bin/qemu-system-x86_64 COPY --from=builder /opt/qemu/bin/qemu-img /bin/qemu-img COPY --from=builder /build/pc-bios/ /qemu/ COPY --from=qboot /build/qboot.bin /qemu/qboot.bin +# The packages the builder installed, as apk recorded them: the static libraries the binaries +# link are among them, by the version that was linked, and hack/release's SBOM reads them here. +# A copy out of the finished builder, so nothing the compile depends on changes. +COPY --from=builder /lib/apk/db/installed /sbom/qemu.apk diff --git a/qemu/Taskfile.yml b/qemu/Taskfile.yml index 0aa0274..60cbd92 100644 --- a/qemu/Taskfile.yml +++ b/qemu/Taskfile.yml @@ -24,7 +24,7 @@ tasks: # a firmware blob this build no longer produces stays on disk, indistinguishable from # one it does. That is how a 331 MB firmware tree outlived the build that stopped # shipping it, and how a qemu-nbd would outlive the decision to stop shipping one. - - rm -rf {{.OUTPUT_ABS}}/bin/qemu-* {{.OUTPUT_ABS}}/qemu + - rm -rf {{.OUTPUT_ABS}}/bin/qemu-* {{.OUTPUT_ABS}}/qemu {{.OUTPUT_ABS}}/sbom/qemu.apk - mkdir -p {{.OUTPUT_ABS}} - | docker buildx build \ diff --git a/workflows/workflows_test.go b/workflows/workflows_test.go index 93a0255..d84e44c 100644 --- a/workflows/workflows_test.go +++ b/workflows/workflows_test.go @@ -13,7 +13,7 @@ import ( // Every step that publishes runs after an unconditional provenance attestation in its job; the // ones that look like an upload and ship nothing anyone installs are named here, each with why. func TestEveryUploadIsAttested(t *testing.T) { - g := attestgate.Gate{Root: "..", NotPublished: map[string]string{ + g := attestgate.Gate{Root: "..", RequireSBOM: true, NotPublished: map[string]string{ "ci.yml/gate/Upload Go coverage HTML": "a coverage page for a person, kept seven days", "kernel.yml/build/Upload the kernel": "a lane's kernel for a person to fetch; a release builds its own", "lab.yml/lab/Keep the log": "a measurement's log, for a person",