Skip to content

Commit ce054ba

Browse files
feat(release): attach an SBOM to every release image
A release said nothing about what is inside its container images, so a user could not tell which packages a cluster runs. SBOM=1 had been plumbed for it and left off. BuildKit attaches the SBOM as an attestation, which makes the pushed image an index of the image and its attestation manifest. The stitch took such an index for something other than one amd64 and one arm64 image, skipped it and still exited 0, and the multi-arch gate then failed the release, so the stitch now reads through the index and the stitched one keeps both attestations. The release build ran on the default docker driver, which cannot attach attestations, so it moves to the docker-container builder with the docker.io mirror, as the arm64 leg already did. The scanner is pinned by digest where --sbom=true pulls a floating tag. The Talos installer image is built by the imager and pushed by skopeo, so it carries no attestation. Assisted-by: LLM Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent d1d4e92 commit ce054ba

9 files changed

Lines changed: 197 additions & 34 deletions

File tree

‎.github/workflows/tags.yaml‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ jobs:
120120
env:
121121
DOCKER_CONFIG: ${{ runner.temp }}/.docker
122122
BUILDER: ${{ steps.buildx.outputs.name }}
123+
SBOM: '1'
123124
PLATFORM: linux/arm64
124125
CACHE_REGISTRY: iad.ocir.io/idyksih5sir9/cozystack
125126
# Read the arm64 cache build-arm64-nightly.yaml keeps warm; a release
@@ -290,11 +291,14 @@ jobs:
290291
env:
291292
DOCKER_CONFIG: ${{ runner.temp }}/.docker
292293

293-
# Only for the matchbox rebuild below: the default docker driver cannot
294-
# build an index, and `use: false` keeps make build on that driver.
295-
- name: Set up Buildx for matchbox (docker-container driver)
294+
# Every image carries an SBOM attestation, which the default docker driver
295+
# cannot attach, and the matchbox index below needs this driver too.
296+
# docker.io pulls go through the mirror in hack/buildkitd.toml, as the
297+
# arm64 leg's do. `use: false` keeps plain `docker build` on the default
298+
# driver; the builds name this one with BUILDER.
299+
- name: Set up Buildx (docker-container driver, docker.io mirror)
296300
if: steps.check_release.outputs.release_exists == 'false'
297-
id: matchbox_buildx
301+
id: buildx
298302
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
299303
with:
300304
driver: docker-container
@@ -315,17 +319,16 @@ jobs:
315319
# The arm64 leg skips the talos package, so matchbox gets no arm64
316320
# half to stitch. Its Dockerfile only copies files, so both arches
317321
# build here without QEMU and the stamped ref pins the index.
318-
make -C packages/core/talos image-matchbox PLATFORM=linux/amd64,linux/arm64 BUILDER="$MATCHBOX_BUILDER"
322+
make -C packages/core/talos image-matchbox PLATFORM=linux/amd64,linux/arm64
319323
env:
320-
MATCHBOX_BUILDER: ${{ steps.matchbox_buildx.outputs.name }}
321324
DOCKER_CONFIG: ${{ runner.temp }}/.docker
325+
BUILDER: ${{ steps.buildx.outputs.name }}
326+
SBOM: '1'
322327
# Cache manifests are registry-portable (oci-mediatypes=true) — read
323328
# them from OCIR while pushing images to GHCR. A missing :buildcache
324329
# ref 404s harmlessly into a cold build.
325330
CACHE_REGISTRY: iad.ocir.io/idyksih5sir9/cozystack
326-
# amd64 here; build-arm64 builds arm64 natively. This job also
327-
# builds on the default docker driver, which handles a
328-
# multi-platform image only on the containerd image store.
331+
# amd64 here; build-arm64 builds arm64 natively.
329332
PLATFORM: linux/amd64
330333
# Push the release tag as :IMAGE_TAG (vX.Y.Z-rc.N) and the component
331334
# versions as :<component-ver>. Deliberately NO PUBLISH_FLOATING: this

‎docs/agents/image-refs.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ If a package stamps a reference somewhere the two globs do not cover, add that f
4747
- **A package that stamps a reference is in the root `Makefile` `build:` list.** `build-matrix.sh`, the main build and release preparation all iterate that one list, so a package outside it is never rebuilt by any path and its pin freezes permanently. `hack/build-matrix_test.bats` computes the set of packages whose Makefile runs `docker buildx build` and fails on any the list misses.
4848
- **Every consumer of references reads all three storage shapes**, via `hack/lib/image-refs.sh`.
4949
- **Scan wider than you rewrite.** A rewrite is deliberately narrow to avoid corrupting vendored defaults, so the check that follows it must not share that blind spot. `hack/promote-rewrite-tags.sh` rewrites the enumerated files, then greps the whole tree and fails if any reference to the release candidate survives.
50+
- **A pin can name an index that carries attestation manifests.** An image built with `SBOM=1` is pushed as an index of the image and its attestation manifest, and the ref pins that index, not a single-platform manifest. A consumer that reads a pin's manifest takes the entries with `platform.os == "linux"` as its platforms: an entry whose platform is `unknown/unknown` is an attestation, not a platform. `hack/stitch-multiarch.sh` and `hack/verify-multiarch.sh` read pins this way, and `skopeo copy --multi-arch all`, which the retag and the mirror run, copies the attestation with the image.
5051

5152
## What each consumer does
5253

@@ -58,9 +59,9 @@ If a package stamps a reference somewhere the two globs do not cover, add that f
5859
| `hack/promote-retag.sh` | all three shapes | copy each digest to its stable tag in the registry |
5960
| `hack/nightly-mirror.sh` | all three shapes | copy each digest to the public registry, then rewrite hosts in the same file set |
6061
| `hack/overlay-main-images.sh` | `values.yaml` + `*.tag` | overlay current-main references onto packages a PR did not rebuild |
61-
| `hack/stitch-multiarch.sh` | all three shapes | join each amd64 image and its `-arm64` twin into one index, repin the tree on it, then fail if any old digest survives |
62+
| `hack/stitch-multiarch.sh` | all three shapes | join each amd64 image and its `-arm64` twin into one index, each side an image or, built with `SBOM=1`, an index of the image and its attestation that the result keeps, repin the tree on it, then fail if any old digest survives |
6263
| `hack/retention-packages.sh` | all three shapes, in the published nightly tree | name the GHCR packages the retention workflow prunes (those linked to another repository are then skipped); a ref it cannot see is a package whose nightlies are never pruned |
63-
| `hack/verify-multiarch.sh` | all three shapes, first- and third-party | fail a release unless every pinned image is an index with `linux/amd64` and `linux/arm64`, bar the repositories in `hack/multiarch-allowlist` |
64+
| `hack/verify-multiarch.sh` | all three shapes, first- and third-party | fail a release unless every pinned image is an index with `linux/amd64` and `linux/arm64`, attestation entries not counted, bar the repositories in `hack/multiarch-allowlist` |
6465

6566
`hack/multiarch-allowlist` is the only bypass for that gate, and it is for images that cannot exist for arm64, not for images nobody has rebuilt yet. An entry is a repository with no tag or digest, so bumping an allowed image needs no edit, and it must carry a reason after `#` on the same line: the script refuses an entry without one. Allowed refs are still printed, under their own "allowed single-arch" heading, and in tree mode an entry that matches no ref that failed the check is reported as stale without changing the exit status, so it gets removed once the image is multi-arch or gone. `--refs-file` reports no stale entries, since a list of pulled images omits everything the run did not pull.
6667

‎docs/release.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ So a commit on a supported `release-X.Y` line ships when a maintainer promotes t
329329

330330
A release cut from a tree that has [`hack/sign-release-assets.sh`](../hack/sign-release-assets.sh) carries three files beside its other assets, which [`hack/upload-assets.sh`](../hack/upload-assets.sh) has that script write before it uploads anything. Releases cut before it have none of them, and neither does a maintenance line until the script is backported to it (see below).
331331

332-
- `cozystack-source-sbom.spdx.json` is an SPDX SBOM, made with syft, of the committed source tree (the Go modules, npm lockfiles, GitHub Actions references and other manifests syft reads in it) and of the `cozypkg` binaries in the `cozypkg-*.tar.gz` assets. It does not describe what runs in the cluster: neither the container images that `packages/` pins by digest, whose SBOMs are [#2937](https://github.com/cozystack/cozystack/issues/2937) (item A2 in [#3278](https://github.com/cozystack/cozystack/issues/3278)), nor the Talos ISO, disk, kernel and initramfs images. Its name says what it covers, so that `cozystack-sbom` stays free for an SBOM of the release as a whole.
332+
- `cozystack-source-sbom.spdx.json` is an SPDX SBOM, made with syft, of the committed source tree (the Go modules, npm lockfiles, GitHub Actions references and other manifests syft reads in it) and of the `cozypkg` binaries in the `cozypkg-*.tar.gz` assets. It does not describe what runs in the cluster: the container images carry their own SBOMs, see [SBOM of the container images](#sbom-of-the-container-images), and the Talos installer image, ISO, disk, kernel and initramfs images have none. Its name says what it covers, so that `cozystack-sbom` stays free for an SBOM of the release as a whole.
333333
- `cozystack-checksums.txt` holds the SHA-256 of every other asset and of the SBOM.
334334
- `cozystack-checksums.txt.sigstore.json` is a Sigstore bundle with a keyless cosign signature over the checksums. Its certificate names the workflow that published the release: `tags.yaml` for a pre-release, `promote-rc.yaml` for a stable one.
335335

@@ -351,6 +351,10 @@ The script runs the same `cosign verify-blob` before it returns, so a signature
351351

352352
`promote-rc.yaml` runs the `hack/upload-assets.sh` of the rc tree, not the dispatch ref's. A stable release is therefore signed only when its rc was tagged from a tree that has `hack/sign-release-assets.sh`, and a maintenance line starts carrying signatures once that script is backported to its `release-X.Y` branch.
353353

354+
### SBOM of the container images
355+
356+
The container images a release pins by digest each carry an SPDX SBOM of the packages in them, which BuildKit attaches at build time as an attestation (`SBOM=1` in `tags.yaml`, scanner and flags in [`hack/common-envs.mk`](../hack/common-envs.mk)). The attestation is an entry of the image's index, so the digest the release pins names both, and promotion and the nightly mirror copy it with the image. Read it with `docker buildx imagetools inspect <image>@<digest> --format '{{ json .SBOM }}'`, which prints one document per platform. The attestations are not signed. A release cut before `SBOM=1` was set has none, and neither has the Talos installer image, which the imager builds and `skopeo` pushes, nor the third-party images `packages/` vendors by digest.
357+
354358
## Nightly builds
355359

356360
A nightly is an **installable copy of `main` on GHCR — not a rebuild and not a release**. [`build-main.yaml`](../.github/workflows/build-main.yaml) already builds every push to `main` into the CI registry (OCIR); the nightly promotes that build to the public release registry (GHCR) and proves it installs.

‎docs/security/self-assessment.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This document is a security self-assessment for [Cozystack](https://github.com/c
1010
| Software | <https://github.com/cozystack/cozystack> |
1111
| Security Provider | No. Cozystack is a cloud platform, not a security product; security is a supporting property, not its primary function. |
1212
| Languages | Go, Helm, shell |
13-
| SBOM | Not published at this time. Image builds can emit a CycloneDX SBOM attestation (`SBOM=1` in `hack/common-envs.mk`), but this is disabled by default; SBOM generation on release is a planned improvement. |
13+
| SBOM | Not published at this time. Image builds can emit an SPDX SBOM attestation (`SBOM=1` in `hack/common-envs.mk`), but this is disabled by default; SBOM generation on release is a planned improvement. |
1414

1515
### Security links
1616

‎hack/common-envs.mk‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,20 @@ PLATFORM ?= $(if $(filter 1,$(LOAD)),,$(DEFAULT_PLATFORMS))
6666
BUILDX_EXTRA_ARGS ?=
6767
COZYSTACK_VERSION = $(patsubst v%,%,$(shell git describe --tags --match 'v*'))
6868

69-
# SBOM=1 attaches a CycloneDX SBOM as a buildx build attestation (--sbom=true).
70-
# Requires the docker-container buildx driver and registry referrers/attestation
71-
# support. Kept OFF by default until an OCIR referrers/attestation probe confirms
72-
# support — otherwise the attestation push fails the build. See #2937.
69+
# SBOM=1 attaches an SPDX SBOM to every image as a BuildKit attestation. The
70+
# pushed ref then names an index of the image and its attestation manifest, not
71+
# the image itself, which hack/stitch-multiarch.sh and hack/verify-multiarch.sh
72+
# both handle. Attestations need the docker-container buildx driver. The scanner
73+
# is pinned by digest like every other build input, where --sbom=true would pull
74+
# a floating tag; bump the version and the digest together.
7375
SBOM ?= 0
76+
SBOM_SCANNER ?= docker.io/docker/buildkit-syft-scanner:1.12.0@sha256:ae4f3b554449e7e25548e7d8ccc029d17357348e30c6e3df01b92bc93654d6a9
77+
# A variable, because the commas would split the arguments of $(if) below.
78+
SBOM_ATTEST := --attest type=sbom,generator=$(SBOM_SCANNER)
7479

7580
BUILDX_ARGS := --provenance=false --push=$(PUSH) --load=$(LOAD) \
7681
--label org.opencontainers.image.source=https://github.com/cozystack/cozystack \
77-
$(if $(filter 1,$(SBOM)),--sbom=true) \
82+
$(if $(filter 1,$(SBOM)),$(SBOM_ATTEST)) \
7883
$(if $(strip $(BUILDER)),--builder=$(BUILDER)) \
7984
$(if $(strip $(PLATFORM)),--platform=$(PLATFORM)) \
8085
$(BUILDX_EXTRA_ARGS)

‎hack/common-envs_test.bats‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,20 @@ load test_helper
144144
echo "$out" | grep -q -- '--push=0 --load=1'
145145
}
146146

147+
@test "SBOM=1 attaches an SPDX attestation from a scanner pinned by digest, and the default builds none" {
148+
# An image built with an attestation is an index, not a plain manifest, which
149+
# the stitch and the gate have to read, so it stays off unless a build asks.
150+
# --sbom=true would pull the scanner by a floating tag.
151+
out=$(make -n -C packages/system/cozystack-controller image IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0 BUILDER=b)
152+
if echo "$out" | grep -q -- '--attest\|--sbom'; then echo "FAIL: an attestation without SBOM=1"; false; fi
153+
out=$(make -n -C packages/system/cozystack-controller image IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0 BUILDER=b SBOM=1)
154+
echo "$out" | grep -qE -- '--attest type=sbom,generator=[^ ]+@sha256:[0-9a-f]{64} ' || { echo "FAIL: no digest-pinned scanner"; false; }
155+
if echo "$out" | grep -q -- '--sbom'; then echo "FAIL: --sbom=true pulls a floating scanner tag"; false; fi
156+
# The environment reaches buildx as well, which is how the release workflow sets it.
157+
out=$(SBOM=1 make -n -C packages/system/cozystack-controller image IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0 BUILDER=b)
158+
echo "$out" | grep -q -- '--attest type=sbom,'
159+
}
160+
147161
@test "talos publishes nothing to the registry with PUSH=0" {
148162
out=$(make -n -B -C packages/core/talos image-talos PUSH=0 PUBLISH_VERSIONED=1 PUBLISH_FLOATING=1 IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0)
149163
echo "$out" | grep -q 'docker://'

‎hack/stitch-multiarch.sh‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
# image as <image-tag>-arm64
1111
#
1212
# The amd64 build and the arm64 leg run on separate native runners, so each
13-
# pushes a single-platform image. For every ref <registry>/<repo>[:<t>]@A in
13+
# pushes a single-platform image. Built with SBOM=1 that image is an index of
14+
# itself and its attestation manifest, and the index made below carries both
15+
# attestations. For every ref <registry>/<repo>[:<t>]@A in
1416
# the tree, whatever tag <t> it carries, with <tag> the <image-tag>:
1517
#
1618
# 1. B is the digest of <repo>:<tag>-arm64. No such image is not an error:
@@ -109,10 +111,18 @@ manifest_digest() {
109111
}
110112

111113
# image_arch <repo@digest> prints the architecture of a single-platform image,
112-
# or nothing for an index or a non-image artifact. A registry error returns
113-
# non-zero, like manifest_digest.
114+
# or nothing for an index of several platforms or a non-image artifact. An
115+
# image built with SBOM=1 is an index of one linux image and its attestation
116+
# manifest, and counts as that image. A registry error returns non-zero, like
117+
# manifest_digest.
114118
image_arch() {
115119
_ia_raw="$(sk inspect --raw "docker://$1")" || return 1
120+
_ia_plat="$(printf '%s' "$_ia_raw" | jq -r '
121+
if .manifests then
122+
[.manifests[] | select(.platform.os == "linux") | .platform.architecture]
123+
| if length == 1 then .[0] else empty end
124+
else empty end')"
125+
if [ -n "$_ia_plat" ]; then printf '%s\n' "$_ia_plat"; return 0; fi
116126
printf '%s' "$_ia_raw" | jq -r '.config.mediaType // empty' \
117127
| grep -qE 'image\.config|container\.image' || return 0
118128
_ia_cfg="$(sk inspect --config "docker://$1")" || return 1
@@ -232,10 +242,15 @@ while read -r repo a reftags <&3; do
232242
else
233243
[ -n "$cur" ] || { echo "::error::${repo}:${tag} does not exist, though the tree pins it" >&2; exit 1; }
234244
raw="$(sk inspect --raw "docker://${repo}@${cur}")"
245+
# An A built with SBOM=1 is itself an index, and the stitched one lists its
246+
# linux image rather than A.
247+
a_raw="$(sk inspect --raw "docker://${repo}@${a}")"
248+
a_members="$(printf '%s' "$a_raw" | jq -c --arg a "$a" \
249+
'if .manifests then [.manifests[] | select(.platform.os == "linux") | .digest] else [$a] end')"
235250
# Its arm64 half need not be this run's B: a rerun rebuilds the arm64
236251
# image, which need not reproduce it byte for byte.
237-
if ! printf '%s' "$raw" | jq -e --arg a "$a" \
238-
'(.manifests // []) as $m | any($m[]; .digest == $a) and any($m[]; .platform.architecture == "arm64")' >/dev/null; then
252+
if ! printf '%s' "$raw" | jq -e --argjson ms "$a_members" \
253+
'(.manifests // []) as $m | ($ms | length) > 0 and all($ms[]; . as $d | any($m[]; .digest == $d)) and any($m[]; .platform.architecture == "arm64")' >/dev/null; then
239254
echo "::error::${repo}:${tag} points at ${cur}, which is neither the digest the tree pins (${a}) nor an index of it and an arm64 image" >&2
240255
exit 1
241256
fi

0 commit comments

Comments
 (0)