From b61954919c2c517246326cba0026f6604ee949e0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 22:40:35 +0100 Subject: [PATCH] ci: verify docker release attestations --- .github/workflows/docker-release.yml | 92 ++++++++ scripts/verify-docker-attestations.mjs | 202 ++++++++++++++++++ .../verify-docker-attestations.test.ts | 103 +++++++++ 3 files changed, 397 insertions(+) create mode 100644 scripts/verify-docker-attestations.mjs create mode 100644 test/scripts/verify-docker-attestations.test.ts diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 5081b9da4af..3bc355f0dba 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -405,3 +405,95 @@ jobs: docker buildx imagetools create "${args[@]}" \ "${AMD64_SLIM_DIGEST}" \ "${ARM64_SLIM_DIGEST}" + + verify-attestations: + needs: [create-manifest] + if: ${{ always() && needs.create-manifest.result == 'success' }} + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Set up Docker Builder + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve image refs + id: refs + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + IS_MANUAL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }} + run: | + set -euo pipefail + multi_refs=() + slim_multi_refs=() + amd64_refs=() + arm64_refs=() + if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then + multi_refs+=("${IMAGE}:main") + slim_multi_refs+=("${IMAGE}:main-slim") + amd64_refs+=("${IMAGE}:main-amd64" "${IMAGE}:main-slim-amd64") + arm64_refs+=("${IMAGE}:main-arm64" "${IMAGE}:main-slim-arm64") + fi + if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then + version="${SOURCE_REF#refs/tags/v}" + multi_refs+=("${IMAGE}:${version}") + slim_multi_refs+=("${IMAGE}:${version}-slim") + amd64_refs+=("${IMAGE}:${version}-amd64" "${IMAGE}:${version}-slim-amd64") + arm64_refs+=("${IMAGE}:${version}-arm64" "${IMAGE}:${version}-slim-arm64") + if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then + multi_refs+=("${IMAGE}:latest") + slim_multi_refs+=("${IMAGE}:slim") + fi + fi + if [[ ${#multi_refs[@]} -eq 0 || ${#amd64_refs[@]} -eq 0 || ${#arm64_refs[@]} -eq 0 ]]; then + echo "::error::No Docker image refs resolved for ref ${SOURCE_REF}" + exit 1 + fi + { + echo "multi<> "$GITHUB_OUTPUT" + + - name: Verify Docker attestations + shell: bash + env: + MULTI_REFS: ${{ steps.refs.outputs.multi }} + AMD64_REFS: ${{ steps.refs.outputs.amd64 }} + ARM64_REFS: ${{ steps.refs.outputs.arm64 }} + run: | + set -euo pipefail + mapfile -t multi_refs <<< "${MULTI_REFS}" + mapfile -t amd64_refs <<< "${AMD64_REFS}" + mapfile -t arm64_refs <<< "${ARM64_REFS}" + + node scripts/verify-docker-attestations.mjs \ + --platform linux/amd64 \ + --platform linux/arm64 \ + "${multi_refs[@]}" + node scripts/verify-docker-attestations.mjs \ + --platform linux/amd64 \ + "${amd64_refs[@]}" + node scripts/verify-docker-attestations.mjs \ + --platform linux/arm64 \ + "${arm64_refs[@]}" diff --git a/scripts/verify-docker-attestations.mjs b/scripts/verify-docker-attestations.mjs new file mode 100644 index 00000000000..5c2df08027b --- /dev/null +++ b/scripts/verify-docker-attestations.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +const ATTESTATION_REFERENCE_TYPE = "attestation-manifest"; +const REQUIRED_PREDICATES = ["https://spdx.dev/Document", "https://slsa.dev/provenance/v1"]; + +export function imageRefForDigest(imageRef, digest) { + const atIndex = imageRef.indexOf("@"); + if (atIndex >= 0) { + return `${imageRef.slice(0, atIndex)}@${digest}`; + } + const lastSlash = imageRef.lastIndexOf("/"); + const tagIndex = imageRef.indexOf(":", lastSlash + 1); + const base = tagIndex >= 0 ? imageRef.slice(0, tagIndex) : imageRef; + return `${base}@${digest}`; +} + +export function parsePlatform(value) { + const [os, architecture, variant] = value.split("/"); + if (!os || !architecture || value.split("/").length > 3) { + throw new Error(`Invalid platform ${JSON.stringify(value)}. Expected os/architecture.`); + } + return { architecture, os, variant }; +} + +function formatPlatform(platform) { + return platform.variant + ? `${platform.os}/${platform.architecture}/${platform.variant}` + : `${platform.os}/${platform.architecture}`; +} + +function platformMatches(actual, expected) { + return ( + actual?.os === expected.os && + actual?.architecture === expected.architecture && + (expected.variant ? actual?.variant === expected.variant : true) + ); +} + +function parseJson(raw, label) { + try { + return JSON.parse(raw); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse ${label}: ${reason}`, { cause: error }); + } +} + +export function collectDockerAttestationErrors(params) { + const { + imageRef, + index, + inspectAttestation, + requiredPlatforms, + requiredPredicates = REQUIRED_PREDICATES, + } = params; + const errors = []; + const manifests = Array.isArray(index?.manifests) ? index.manifests : []; + if (manifests.length === 0) { + return [`${imageRef}: expected an image index with manifest descriptors`]; + } + + for (const platform of requiredPlatforms) { + const platformLabel = formatPlatform(platform); + const imageManifest = manifests.find((entry) => platformMatches(entry.platform, platform)); + if (!imageManifest?.digest) { + errors.push(`${imageRef}: missing image manifest for ${platformLabel}`); + continue; + } + + const attestationDescriptors = manifests.filter( + (entry) => + entry?.annotations?.["vnd.docker.reference.type"] === ATTESTATION_REFERENCE_TYPE && + entry?.annotations?.["vnd.docker.reference.digest"] === imageManifest.digest && + typeof entry.digest === "string" && + entry.digest.length > 0, + ); + if (attestationDescriptors.length === 0) { + errors.push(`${imageRef}: missing attestation manifest for ${platformLabel}`); + continue; + } + + const predicates = new Set(); + for (const descriptor of attestationDescriptors) { + const attestation = inspectAttestation(descriptor.digest); + if (attestation?.artifactType !== "application/vnd.docker.attestation.manifest.v1+json") { + errors.push( + `${imageRef}: ${platformLabel} attestation ${descriptor.digest} has unexpected artifactType ${JSON.stringify( + attestation?.artifactType, + )}`, + ); + } + for (const layer of attestation?.layers ?? []) { + const predicate = layer?.annotations?.["in-toto.io/predicate-type"]; + if (typeof predicate === "string") { + predicates.add(predicate); + } + } + } + + for (const predicate of requiredPredicates) { + if (!predicates.has(predicate)) { + errors.push(`${imageRef}: ${platformLabel} missing predicate ${predicate}`); + } + } + } + + return errors; +} + +function inspectRaw(imageRef) { + return execFileSync("docker", ["buildx", "imagetools", "inspect", "--raw", imageRef], { + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function parseArgs(argv) { + const imageRefs = []; + const requiredPlatforms = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--platform") { + const value = argv[i + 1]; + if (!value) { + throw new Error("--platform requires a value"); + } + requiredPlatforms.push(parsePlatform(value)); + i += 1; + continue; + } + if (arg === "--help" || arg === "-h") { + return { help: true, imageRefs, requiredPlatforms }; + } + if (arg?.startsWith("-")) { + throw new Error(`Unknown option: ${arg}`); + } + imageRefs.push(arg); + } + return { help: false, imageRefs, requiredPlatforms }; +} + +function printHelp() { + console.log( + `Usage: node scripts/verify-docker-attestations.mjs --platform linux/amd64 --platform linux/arm64 IMAGE...`, + ); +} + +async function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.help) { + printHelp(); + return; + } + if (parsed.imageRefs.length === 0) { + throw new Error("At least one image reference is required."); + } + if (parsed.requiredPlatforms.length === 0) { + throw new Error("At least one --platform is required."); + } + + const allErrors = []; + for (const imageRef of parsed.imageRefs) { + const index = parseJson(inspectRaw(imageRef), `${imageRef} index`); + const errors = collectDockerAttestationErrors({ + imageRef, + index, + requiredPlatforms: parsed.requiredPlatforms, + inspectAttestation(digest) { + return parseJson( + inspectRaw(imageRefForDigest(imageRef, digest)), + `${imageRef} attestation ${digest}`, + ); + }, + }); + if (errors.length === 0) { + console.log( + `Verified Docker attestations for ${imageRef}: ${parsed.requiredPlatforms + .map(formatPlatform) + .join(", ")}`, + ); + } + allErrors.push(...errors); + } + + if (allErrors.length > 0) { + for (const error of allErrors) { + console.error(`[docker-attestations] ${error}`); + } + process.exit(1); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/test/scripts/verify-docker-attestations.test.ts b/test/scripts/verify-docker-attestations.test.ts new file mode 100644 index 00000000000..181cf6ff8f8 --- /dev/null +++ b/test/scripts/verify-docker-attestations.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + collectDockerAttestationErrors, + imageRefForDigest, + parsePlatform, +} from "../../scripts/verify-docker-attestations.mjs"; + +const imageDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +const attestationDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + +function createIndex() { + return { + schemaVersion: 2, + mediaType: "application/vnd.oci.image.index.v1+json", + manifests: [ + { + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: imageDigest, + size: 482, + platform: { architecture: "amd64", os: "linux" }, + }, + { + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: attestationDigest, + size: 1110, + annotations: { + "vnd.docker.reference.digest": imageDigest, + "vnd.docker.reference.type": "attestation-manifest", + }, + platform: { architecture: "unknown", os: "unknown" }, + }, + ], + }; +} + +function createAttestation( + predicates = ["https://spdx.dev/Document", "https://slsa.dev/provenance/v1"], +) { + return { + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + artifactType: "application/vnd.docker.attestation.manifest.v1+json", + layers: predicates.map((predicate) => ({ + mediaType: "application/vnd.in-toto+json", + digest: imageDigest, + size: 1, + annotations: { + "in-toto.io/predicate-type": predicate, + }, + })), + }; +} + +describe("verify-docker-attestations", () => { + it("resolves digest refs from tagged image refs", () => { + expect(imageRefForDigest("ghcr.io/openclaw/openclaw:2026.4.26", imageDigest)).toBe( + `ghcr.io/openclaw/openclaw@${imageDigest}`, + ); + expect(imageRefForDigest("localhost:5000/openclaw:main", imageDigest)).toBe( + `localhost:5000/openclaw@${imageDigest}`, + ); + }); + + it("accepts an image index with SBOM and provenance predicates", () => { + const errors = collectDockerAttestationErrors({ + imageRef: "ghcr.io/openclaw/openclaw:test", + index: createIndex(), + requiredPlatforms: [parsePlatform("linux/amd64")], + inspectAttestation: () => createAttestation(), + }); + + expect(errors).toEqual([]); + }); + + it("reports missing attestation manifests", () => { + const index = createIndex(); + index.manifests = index.manifests.slice(0, 1); + + const errors = collectDockerAttestationErrors({ + imageRef: "ghcr.io/openclaw/openclaw:test", + index, + requiredPlatforms: [parsePlatform("linux/amd64")], + inspectAttestation: () => createAttestation(), + }); + + expect(errors).toEqual([ + "ghcr.io/openclaw/openclaw:test: missing attestation manifest for linux/amd64", + ]); + }); + + it("reports missing SBOM or provenance predicates", () => { + const errors = collectDockerAttestationErrors({ + imageRef: "ghcr.io/openclaw/openclaw:test", + index: createIndex(), + requiredPlatforms: [parsePlatform("linux/amd64")], + inspectAttestation: () => createAttestation(["https://spdx.dev/Document"]), + }); + + expect(errors).toEqual([ + "ghcr.io/openclaw/openclaw:test: linux/amd64 missing predicate https://slsa.dev/provenance/v1", + ]); + }); +});