From d7f1f97a282572fca42ab5ae7517018ccac3ddfd Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 09:46:09 +0200 Subject: [PATCH 1/7] Add M0.9 attestation verification --- scripts/qualify_attestations.sh | 101 ++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 scripts/qualify_attestations.sh diff --git a/scripts/qualify_attestations.sh b/scripts/qualify_attestations.sh new file mode 100644 index 0000000..eed2536 --- /dev/null +++ b/scripts/qualify_attestations.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${IMAGE:-ghcr.io/ploos-as/glowing-bear}" +VERSION="${VERSION:?VERSION is required}" + +if [[ "$VERSION" == v* ]]; then + VERSION="${VERSION#v}" +fi + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "release version is not semver-like: $VERSION" >&2 + exit 1 +fi + +primary="$IMAGE:$VERSION" +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +index_json="$workdir/index.json" +docker buildx imagetools inspect --raw "$primary" > "$index_json" + +platform_digests=() +while IFS= read -r digest; do + [[ -n "$digest" ]] && platform_digests+=("$digest") +done < <( + jq -r '.manifests[] + | select(.platform.os == "linux") + | select(.platform.architecture == "amd64" or .platform.architecture == "arm64") + | .digest' "$index_json" +) + +if [[ "${#platform_digests[@]}" -lt 2 ]]; then + echo "$primary does not expose both required Linux platform manifests" >&2 + exit 1 +fi + +attestation_rows="$workdir/attestations.tsv" +jq -r '.manifests[] + | select(.platform.os == "unknown" and .platform.architecture == "unknown") + | select(.annotations["vnd.docker.reference.type"] == "attestation-manifest") + | [.digest, .annotations["vnd.docker.reference.digest"]] | @tsv' \ + "$index_json" > "$attestation_rows" + +if [[ ! -s "$attestation_rows" ]]; then + echo "$primary has no BuildKit attestation manifests" >&2 + exit 1 +fi + +for platform_digest in "${platform_digests[@]}"; do + attestation_digest="$(awk -F '\t' -v d="$platform_digest" '$2 == d {print $1; exit}' "$attestation_rows")" + if [[ -z "$attestation_digest" ]]; then + echo "missing attestation manifest for platform digest $platform_digest" >&2 + exit 1 + fi + + manifest_json="$workdir/${attestation_digest#sha256:}.json" + docker buildx imagetools inspect --raw "$IMAGE@$attestation_digest" > "$manifest_json" + + linked_digest="$(jq -r '.annotations["vnd.docker.reference.digest"] // empty' "$manifest_json")" + reference_type="$(jq -r '.annotations["vnd.docker.reference.type"] // empty' "$manifest_json")" + + if [[ "$linked_digest" != "$platform_digest" ]]; then + echo "attestation $attestation_digest links to $linked_digest, expected $platform_digest" >&2 + exit 1 + fi + if [[ "$reference_type" != "attestation-manifest" ]]; then + echo "unexpected attestation reference type for $attestation_digest: $reference_type" >&2 + exit 1 + fi + + jq -e '[.layers[] | select(.mediaType == "application/vnd.in-toto+json")] | length >= 2' \ + "$manifest_json" >/dev/null || { + echo "attestation $attestation_digest does not contain the expected in-toto layers" >&2 + exit 1 + } + + jq -e '.layers[] + | select(.mediaType == "application/vnd.in-toto+json") + | select(.annotations["in-toto.io/predicate-type"] == "https://slsa.dev/provenance/v0.2") + | select(.size > 0) + | .digest | test("^sha256:[0-9a-f]{64}$")' \ + "$manifest_json" >/dev/null || { + echo "attestation $attestation_digest is missing a valid SLSA provenance descriptor" >&2 + exit 1 + } + + jq -e '.layers[] + | select(.mediaType == "application/vnd.in-toto+json") + | select(.annotations["in-toto.io/predicate-type"] == "https://spdx.dev/Document") + | select(.size > 0) + | .digest | test("^sha256:[0-9a-f]{64}$")' \ + "$manifest_json" >/dev/null || { + echo "attestation $attestation_digest is missing a valid SPDX SBOM descriptor" >&2 + exit 1 + } + + echo "Qualified SLSA provenance and SPDX SBOM for $platform_digest via $attestation_digest" +done + +echo "M0.9 attestation verification passed for $primary" From 89bda3b7b63fea7b41bba491c1b85a89edfe5db4 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 09:46:18 +0200 Subject: [PATCH 2/7] Add M0.9 attestation verification workflow --- .../workflows/attestation-verification.yml | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/attestation-verification.yml diff --git a/.github/workflows/attestation-verification.yml b/.github/workflows/attestation-verification.yml new file mode 100644 index 0000000..a53c50e --- /dev/null +++ b/.github/workflows/attestation-verification.yml @@ -0,0 +1,66 @@ +name: Attestation verification + +on: + pull_request: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: Existing release version to verify (for example 0.2.1 or v0.2.1) + required: true + default: '0.2.1' + type: string + +permissions: + contents: read + packages: read + +concurrency: + group: attestation-verification-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: ghcr.io/ploos-as/glowing-bear + +jobs: + validate-script: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Validate M0.9 script syntax + run: bash -n scripts/qualify_attestations.sh + + verify-attestations: + needs: validate-script + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Select release version + id: release + shell: bash + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + version="${{ inputs.version }}" + elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + version="${GITHUB_REF_NAME}" + else + version="0.2.1" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Verify published provenance and SBOM attestations + env: + VERSION: ${{ steps.release.outputs.version }} + run: bash scripts/qualify_attestations.sh From f8c550f1538e1ba266f44c3e507c641c61531e4e Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 09:46:29 +0200 Subject: [PATCH 3/7] Document M0.9 attestation verification --- docs/M0_9_ATTESTATION_VERIFICATION.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/M0_9_ATTESTATION_VERIFICATION.md diff --git a/docs/M0_9_ATTESTATION_VERIFICATION.md b/docs/M0_9_ATTESTATION_VERIFICATION.md new file mode 100644 index 0000000..3e8bea2 --- /dev/null +++ b/docs/M0_9_ATTESTATION_VERIFICATION.md @@ -0,0 +1,18 @@ +# M0.9 attestation verification + +M0.8 verifies that a published release exposes Buildx attestation manifests. M0.9 strengthens that contract by validating how those attestations are attached and what predicate types they advertise. + +For every required Linux platform manifest (`linux/amd64` and `linux/arm64`), M0.9 requires: + +- a BuildKit attestation manifest whose `vnd.docker.reference.digest` points to that exact platform digest; +- `vnd.docker.reference.type=attestation-manifest`; +- in-toto layers using media type `application/vnd.in-toto+json`; +- a non-empty SLSA provenance descriptor with predicate type `https://slsa.dev/provenance/v0.2`; +- a non-empty SPDX SBOM descriptor with predicate type `https://spdx.dev/Document`; +- sha256 layer digests for both descriptors. + +This closes the M0.8 gap where merely counting `unknown/unknown` manifests could not distinguish the required provenance/SBOM contract from unrelated attestations. + +The workflow runs on release tags and can also be run manually against an existing immutable release. Pull requests verify the currently published `0.2.1` release as a real registry-backed integration test while also syntax-checking the script. + +M0.9 does not claim cryptographic signer identity verification. Signature policy and keyless identity verification, if desired, belong in a later milestone rather than being conflated with attestation-content qualification. From 7b214dd5842c5fb07c4bd1896326f15f12ae8210 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 09:47:50 +0200 Subject: [PATCH 4/7] Fix M0.9 attestation descriptor scope --- scripts/qualify_attestations.sh | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/scripts/qualify_attestations.sh b/scripts/qualify_attestations.sh index eed2536..bbe2a65 100644 --- a/scripts/qualify_attestations.sh +++ b/scripts/qualify_attestations.sh @@ -35,15 +35,18 @@ if [[ "${#platform_digests[@]}" -lt 2 ]]; then exit 1 fi +# BuildKit stores the subject linkage on the attestation descriptor in the OCI +# index, not inside the attestation manifest itself. attestation_rows="$workdir/attestations.tsv" jq -r '.manifests[] | select(.platform.os == "unknown" and .platform.architecture == "unknown") | select(.annotations["vnd.docker.reference.type"] == "attestation-manifest") + | select((.annotations["vnd.docker.reference.digest"] // "") | test("^sha256:[0-9a-f]{64}$")) | [.digest, .annotations["vnd.docker.reference.digest"]] | @tsv' \ "$index_json" > "$attestation_rows" if [[ ! -s "$attestation_rows" ]]; then - echo "$primary has no BuildKit attestation manifests" >&2 + echo "$primary has no valid BuildKit attestation descriptors" >&2 exit 1 fi @@ -57,18 +60,6 @@ for platform_digest in "${platform_digests[@]}"; do manifest_json="$workdir/${attestation_digest#sha256:}.json" docker buildx imagetools inspect --raw "$IMAGE@$attestation_digest" > "$manifest_json" - linked_digest="$(jq -r '.annotations["vnd.docker.reference.digest"] // empty' "$manifest_json")" - reference_type="$(jq -r '.annotations["vnd.docker.reference.type"] // empty' "$manifest_json")" - - if [[ "$linked_digest" != "$platform_digest" ]]; then - echo "attestation $attestation_digest links to $linked_digest, expected $platform_digest" >&2 - exit 1 - fi - if [[ "$reference_type" != "attestation-manifest" ]]; then - echo "unexpected attestation reference type for $attestation_digest: $reference_type" >&2 - exit 1 - fi - jq -e '[.layers[] | select(.mediaType == "application/vnd.in-toto+json")] | length >= 2' \ "$manifest_json" >/dev/null || { echo "attestation $attestation_digest does not contain the expected in-toto layers" >&2 From ba8c0546044c1dab3b50d7a74d62aa7ce6f604a5 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 10:01:29 +0200 Subject: [PATCH 5/7] Read M0.9 attestation payloads via Buildx --- scripts/qualify_attestations.sh | 91 ++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 41 deletions(-) diff --git a/scripts/qualify_attestations.sh b/scripts/qualify_attestations.sh index bbe2a65..cef547e 100644 --- a/scripts/qualify_attestations.sh +++ b/scripts/qualify_attestations.sh @@ -20,23 +20,23 @@ trap 'rm -rf "$workdir"' EXIT index_json="$workdir/index.json" docker buildx imagetools inspect --raw "$primary" > "$index_json" -platform_digests=() -while IFS= read -r digest; do - [[ -n "$digest" ]] && platform_digests+=("$digest") -done < <( - jq -r '.manifests[] - | select(.platform.os == "linux") - | select(.platform.architecture == "amd64" or .platform.architecture == "arm64") - | .digest' "$index_json" -) - -if [[ "${#platform_digests[@]}" -lt 2 ]]; then - echo "$primary does not expose both required Linux platform manifests" >&2 - exit 1 -fi +platform_rows="$workdir/platforms.tsv" +jq -r '.manifests[] + | select(.platform.os == "linux") + | select(.platform.architecture == "amd64" or .platform.architecture == "arm64") + | [.platform.architecture, .digest] | @tsv' "$index_json" > "$platform_rows" + +for arch in amd64 arm64; do + if ! awk -F '\t' -v a="$arch" '$1 == a {found=1} END {exit !found}' "$platform_rows"; then + echo "$primary is missing linux/$arch" >&2 + exit 1 + fi +done # BuildKit stores the subject linkage on the attestation descriptor in the OCI -# index, not inside the attestation manifest itself. +# index. The attestation payload itself is exposed by Buildx through the +# Provenance and SBOM formatter objects; predicate metadata is part of that +# payload and is not required to be duplicated as a layer annotation. attestation_rows="$workdir/attestations.tsv" jq -r '.manifests[] | select(.platform.os == "unknown" and .platform.architecture == "unknown") @@ -50,43 +50,52 @@ if [[ ! -s "$attestation_rows" ]]; then exit 1 fi -for platform_digest in "${platform_digests[@]}"; do +while IFS=$'\t' read -r arch platform_digest; do attestation_digest="$(awk -F '\t' -v d="$platform_digest" '$2 == d {print $1; exit}' "$attestation_rows")" if [[ -z "$attestation_digest" ]]; then - echo "missing attestation manifest for platform digest $platform_digest" >&2 + echo "missing attestation manifest for linux/$arch digest $platform_digest" >&2 exit 1 fi - manifest_json="$workdir/${attestation_digest#sha256:}.json" + manifest_json="$workdir/${attestation_digest#sha256:}.manifest.json" docker buildx imagetools inspect --raw "$IMAGE@$attestation_digest" > "$manifest_json" - jq -e '[.layers[] | select(.mediaType == "application/vnd.in-toto+json")] | length >= 2' \ + jq -e '[.layers[] | select(.mediaType == "application/vnd.in-toto+json") | select(.size > 0) | .digest | select(test("^sha256:[0-9a-f]{64}$"))] | length >= 2' \ "$manifest_json" >/dev/null || { - echo "attestation $attestation_digest does not contain the expected in-toto layers" >&2 + echo "attestation $attestation_digest does not contain at least two valid in-toto layers" >&2 exit 1 } - jq -e '.layers[] - | select(.mediaType == "application/vnd.in-toto+json") - | select(.annotations["in-toto.io/predicate-type"] == "https://slsa.dev/provenance/v0.2") - | select(.size > 0) - | .digest | test("^sha256:[0-9a-f]{64}$")' \ - "$manifest_json" >/dev/null || { - echo "attestation $attestation_digest is missing a valid SLSA provenance descriptor" >&2 - exit 1 - } - - jq -e '.layers[] - | select(.mediaType == "application/vnd.in-toto+json") - | select(.annotations["in-toto.io/predicate-type"] == "https://spdx.dev/Document") - | select(.size > 0) - | .digest | test("^sha256:[0-9a-f]{64}$")' \ - "$manifest_json" >/dev/null || { - echo "attestation $attestation_digest is missing a valid SPDX SBOM descriptor" >&2 - exit 1 - } + provenance_json="$workdir/${arch}.provenance.json" + sbom_json="$workdir/${arch}.sbom.json" + + docker buildx imagetools inspect \ + --format '{{ json .Provenance }}' \ + "$IMAGE@$platform_digest" > "$provenance_json" + docker buildx imagetools inspect \ + --format '{{ json .SBOM }}' \ + "$IMAGE@$platform_digest" > "$sbom_json" + + jq -e ' + . != null and + ([.. | objects | .predicateType? // empty] + | any(. == "https://slsa.dev/provenance/v0.2" or startswith("https://slsa.dev/provenance/"))) + ' "$provenance_json" >/dev/null || { + echo "linux/$arch is missing readable SLSA provenance content" >&2 + cat "$provenance_json" >&2 || true + exit 1 + } + + jq -e ' + . != null and + ([.. | objects | .spdxVersion? // empty] | any(startswith("SPDX-"))) + ' "$sbom_json" >/dev/null || { + echo "linux/$arch is missing readable SPDX SBOM content" >&2 + cat "$sbom_json" >&2 || true + exit 1 + } - echo "Qualified SLSA provenance and SPDX SBOM for $platform_digest via $attestation_digest" -done + echo "Qualified SLSA provenance and SPDX SBOM for linux/$arch $platform_digest via $attestation_digest" +done < "$platform_rows" echo "M0.9 attestation verification passed for $primary" From d5fccd30076f731d57267b6d413f9dee3c9348f8 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 10:02:43 +0200 Subject: [PATCH 6/7] Inspect M0.9 payloads from release index --- scripts/qualify_attestations.sh | 69 ++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/scripts/qualify_attestations.sh b/scripts/qualify_attestations.sh index cef547e..c80bf1a 100644 --- a/scripts/qualify_attestations.sh +++ b/scripts/qualify_attestations.sh @@ -34,9 +34,9 @@ for arch in amd64 arm64; do done # BuildKit stores the subject linkage on the attestation descriptor in the OCI -# index. The attestation payload itself is exposed by Buildx through the -# Provenance and SBOM formatter objects; predicate metadata is part of that -# payload and is not required to be duplicated as a layer annotation. +# index. The descriptor binds each attestation manifest to its exact platform +# digest; Buildx exposes the decoded provenance/SBOM payloads from the parent +# multi-platform release reference. attestation_rows="$workdir/attestations.tsv" jq -r '.manifests[] | select(.platform.os == "unknown" and .platform.architecture == "unknown") @@ -66,36 +66,41 @@ while IFS=$'\t' read -r arch platform_digest; do exit 1 } - provenance_json="$workdir/${arch}.provenance.json" - sbom_json="$workdir/${arch}.sbom.json" - - docker buildx imagetools inspect \ - --format '{{ json .Provenance }}' \ - "$IMAGE@$platform_digest" > "$provenance_json" - docker buildx imagetools inspect \ - --format '{{ json .SBOM }}' \ - "$IMAGE@$platform_digest" > "$sbom_json" - - jq -e ' - . != null and - ([.. | objects | .predicateType? // empty] - | any(. == "https://slsa.dev/provenance/v0.2" or startswith("https://slsa.dev/provenance/"))) - ' "$provenance_json" >/dev/null || { - echo "linux/$arch is missing readable SLSA provenance content" >&2 - cat "$provenance_json" >&2 || true - exit 1 - } - - jq -e ' - . != null and - ([.. | objects | .spdxVersion? // empty] | any(startswith("SPDX-"))) - ' "$sbom_json" >/dev/null || { - echo "linux/$arch is missing readable SPDX SBOM content" >&2 - cat "$sbom_json" >&2 || true - exit 1 - } +done < "$platform_rows" + +provenance_json="$workdir/provenance.json" +sbom_json="$workdir/sbom.json" + +docker buildx imagetools inspect \ + --format '{{ json .Provenance }}' \ + "$primary" > "$provenance_json" +docker buildx imagetools inspect \ + --format '{{ json .SBOM }}' \ + "$primary" > "$sbom_json" + +jq -e ' + . != null and + ([.. | objects | .predicateType? // empty] + | any(. == "https://slsa.dev/provenance/v0.2" or startswith("https://slsa.dev/provenance/"))) +' "$provenance_json" >/dev/null || { + echo "$primary is missing readable SLSA provenance content" >&2 + cat "$provenance_json" >&2 || true + exit 1 +} + +jq -e ' + . != null and + ([.. | objects | .spdxVersion? // empty] | any(startswith("SPDX-"))) +' "$sbom_json" >/dev/null || { + echo "$primary is missing readable SPDX SBOM content" >&2 + cat "$sbom_json" >&2 || true + exit 1 +} - echo "Qualified SLSA provenance and SPDX SBOM for linux/$arch $platform_digest via $attestation_digest" +while IFS=$'\t' read -r arch platform_digest; do + attestation_digest="$(awk -F '\t' -v d="$platform_digest" '$2 == d {print $1; exit}' "$attestation_rows")" + echo "Qualified attestation linkage for linux/$arch $platform_digest via $attestation_digest" done < "$platform_rows" +echo "Qualified decoded SLSA provenance and SPDX SBOM payloads for $primary" echo "M0.9 attestation verification passed for $primary" From 1b9a376e1c43fb478d6e24d14a912ab5914dd8d3 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 10:57:13 +0200 Subject: [PATCH 7/7] Validate Buildx SLSA provenance structure --- scripts/qualify_attestations.sh | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/scripts/qualify_attestations.sh b/scripts/qualify_attestations.sh index c80bf1a..9210b96 100644 --- a/scripts/qualify_attestations.sh +++ b/scripts/qualify_attestations.sh @@ -35,7 +35,7 @@ done # BuildKit stores the subject linkage on the attestation descriptor in the OCI # index. The descriptor binds each attestation manifest to its exact platform -# digest; Buildx exposes the decoded provenance/SBOM payloads from the parent +# digest; Buildx exposes decoded provenance/SBOM payloads from the parent # multi-platform release reference. attestation_rows="$workdir/attestations.tsv" jq -r '.manifests[] @@ -65,7 +65,6 @@ while IFS=$'\t' read -r arch platform_digest; do echo "attestation $attestation_digest does not contain at least two valid in-toto layers" >&2 exit 1 } - done < "$platform_rows" provenance_json="$workdir/provenance.json" @@ -78,15 +77,20 @@ docker buildx imagetools inspect \ --format '{{ json .SBOM }}' \ "$primary" > "$sbom_json" -jq -e ' - . != null and - ([.. | objects | .predicateType? // empty] - | any(. == "https://slsa.dev/provenance/v0.2" or startswith("https://slsa.dev/provenance/"))) -' "$provenance_json" >/dev/null || { - echo "$primary is missing readable SLSA provenance content" >&2 - cat "$provenance_json" >&2 || true - exit 1 -} +for arch in amd64 arm64; do + jq -e --arg platform "linux/$arch" ' + .[$platform].SLSA + | type == "object" + and (.buildDefinition | type == "object") + and (.buildDefinition.buildType | type == "string" and length > 0) + and (.runDetails | type == "object") + and (.runDetails.builder.id | type == "string" and length > 0) + ' "$provenance_json" >/dev/null || { + echo "$primary is missing readable SLSA provenance content for linux/$arch" >&2 + cat "$provenance_json" >&2 || true + exit 1 + } +done jq -e ' . != null and