mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:20:43 +00:00
ci: verify docker release attestations
This commit is contained in:
92
.github/workflows/docker-release.yml
vendored
92
.github/workflows/docker-release.yml
vendored
@@ -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<<EOF"
|
||||
printf "%s\n" "${multi_refs[@]}" "${slim_multi_refs[@]}"
|
||||
echo "EOF"
|
||||
echo "amd64<<EOF"
|
||||
printf "%s\n" "${amd64_refs[@]}"
|
||||
echo "EOF"
|
||||
echo "arm64<<EOF"
|
||||
printf "%s\n" "${arm64_refs[@]}"
|
||||
echo "EOF"
|
||||
} >> "$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[@]}"
|
||||
|
||||
202
scripts/verify-docker-attestations.mjs
Normal file
202
scripts/verify-docker-attestations.mjs
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
103
test/scripts/verify-docker-attestations.test.ts
Normal file
103
test/scripts/verify-docker-attestations.test.ts
Normal file
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user