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 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. diff --git a/scripts/qualify_attestations.sh b/scripts/qualify_attestations.sh new file mode 100644 index 0000000..9210b96 --- /dev/null +++ b/scripts/qualify_attestations.sh @@ -0,0 +1,110 @@ +#!/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_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. The descriptor binds each attestation manifest to its exact platform +# digest; Buildx exposes 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") + | 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 valid BuildKit attestation descriptors" >&2 + exit 1 +fi + +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 linux/$arch digest $platform_digest" >&2 + exit 1 + fi + + 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") | select(.size > 0) | .digest | select(test("^sha256:[0-9a-f]{64}$"))] | length >= 2' \ + "$manifest_json" >/dev/null || { + 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" +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" + +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 + ([.. | 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 +} + +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"