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",