Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions scripts/verify-release-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,28 @@ void describe("subprocess capture", () => {
});
});

/**
* Real `cosign verify-attestation` output, captured from cosign v3.0.6 — the version
* `setup-release-security-tools` installs — attesting `--type spdxjson` over a local registry and
* verifying it back. The published release framing is identical: verifying
* `ghcr.io/hephaestus-build/webapp@sha256:ff26dd20…` against the release identity prints one
* unwrapped 1.4 MB envelope on stdout and its banner on stderr, which is the shape the v0.75.0
* release read as an array and rejected. The predicates are `attested("one")` and `attested("two")`.
*/
const ONE_ATTESTATION = `{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsICJzdWJqZWN0IjpbeyJuYW1lIjoiMTI3LjAuMC4xOjU3MTEvc2luZ2xlIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI5MmIxZDFjYWU1ZjIzNTgxMjE4NDQxNWU2M2Q5YjI0NDY0MTE2YzU4ZDNiYTNjNDYwYjFlYjAyNDdmMGY0NmUzIn19XSwgInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50IiwgInByZWRpY2F0ZSI6eyJTUERYSUQiOiJTUERYUmVmLURPQ1VNRU5UIiwgIm5hbWUiOiJvbmUiLCAic3BkeFZlcnNpb24iOiJTUERYLTIuMyJ9fQ==","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEUCIQCighBWsxdqA3466CSv1Pz4ny3CAAVUk1L8eBM/95AR/wIgSB1yUick8Sa98l+0ANTS+nI77MX0JPbZrqLupZEpqB4="}]}
`;

const TWO_ATTESTATIONS = `{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsICJzdWJqZWN0IjpbeyJuYW1lIjoiMTI3LjAuMC4xOjU3MTEvbGFiIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI5MmIxZDFjYWU1ZjIzNTgxMjE4NDQxNWU2M2Q5YjI0NDY0MTE2YzU4ZDNiYTNjNDYwYjFlYjAyNDdmMGY0NmUzIn19XSwgInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50IiwgInByZWRpY2F0ZSI6eyJTUERYSUQiOiJTUERYUmVmLURPQ1VNRU5UIiwgIm5hbWUiOiJvbmUiLCAic3BkeFZlcnNpb24iOiJTUERYLTIuMyJ9fQ==","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEUCIQC1HEpwfNfuYsx4Bu6KFtT3vWy/rxoRMBIGcVaF8Li1LAIgKI+2OWmCsLqK1ROi7EWyKdaJkaMCpt4CYimpqpRrQ40="}]}
{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsICJzdWJqZWN0IjpbeyJuYW1lIjoiMTI3LjAuMC4xOjU3MTEvbGFiIiwgImRpZ2VzdCI6eyJzaGEyNTYiOiI5MmIxZDFjYWU1ZjIzNTgxMjE4NDQxNWU2M2Q5YjI0NDY0MTE2YzU4ZDNiYTNjNDYwYjFlYjAyNDdmMGY0NmUzIn19XSwgInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50IiwgInByZWRpY2F0ZSI6eyJTUERYSUQiOiJTUERYUmVmLURPQ1VNRU5UIiwgIm5hbWUiOiJ0d28iLCAic3BkeFZlcnNpb24iOiJTUERYLTIuMyJ9fQ==","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEUCIHwf0z545E7IF1LwfbK5UujrpJWZm8K1LtmX9aAUfg2EAiEAgUIsdk5MALmyjqjj4zzns6P3ae7B/EgFTJIwwdQd2eA="}]}
`;

/** The durable SBOM those attestations were made from, in the key order it was written in. */
const attested = (name: string) => ({
SPDXID: "SPDXRef-DOCUMENT",
spdxVersion: "SPDX-2.3",
name,
});

void describe("release evidence bindings", () => {
void it("binds license and OCI index evidence to the immutable subject", () => {
const amd64 = subject("linux/amd64");
Expand All @@ -202,11 +224,34 @@ void describe("release evidence bindings", () => {
});

void it("requires a Cosign payload whose predicate exactly matches the durable SBOM", () => {
const sbom = { packages: [{ name: "example", version: "1" }] };
const payload = (predicate: unknown): string =>
Buffer.from(JSON.stringify({ predicate })).toString("base64");
assert.equal(attestationContainsSbom([{ payload: payload(sbom) }], sbom), true);
assert.equal(attestationContainsSbom([{ payload: payload({ packages: [] }) }], sbom), false);
assert.equal(attestationContainsSbom([{ payload: "not-base64-json" }], sbom), false);
assert.equal(attestationContainsSbom(ONE_ATTESTATION, attested("one")), true);
assert.equal(attestationContainsSbom(ONE_ATTESTATION, attested("two")), false);
// Cosign re-serializes the predicate with its keys sorted, so the durable document and the
// attested one differ byte for byte and match value for value.
assert.notEqual(
JSON.stringify(attested("one")),
JSON.stringify({ SPDXID: "SPDXRef-DOCUMENT", name: "one", spdxVersion: "SPDX-2.3" }),
);
});

void it("reads every framing cosign prints its verified attestations in", () => {
// The framing that failed the release: one attestation is one bare envelope, not an array.
assert.equal(attestationContainsSbom(ONE_ATTESTATION, attested("one")), true);
// Two attestations on one subject are newline-delimited, still unwrapped.
assert.equal(attestationContainsSbom(TWO_ATTESTATIONS, attested("two")), true);
// And a future cosign that wraps them in an array, pretty-printed or not, reads the same.
const envelopes: unknown[] = TWO_ATTESTATIONS.trim()
.split("\n")
.map((line) => JSON.parse(line) as unknown);
assert.equal(attestationContainsSbom(JSON.stringify(envelopes), attested("two")), true);
assert.equal(
attestationContainsSbom(JSON.stringify(envelopes, null, 2), attested("two")),
true,
);
});

void it("refuses to pass or to skip an attestation it cannot read", () => {
for (const unreadable of ["", " \n", '{"payload":"not-base64-json"}', "[]", '["envelope"]'])
assert.throws(() => attestationContainsSbom(unreadable, attested("one")));
});
});
87 changes: 66 additions & 21 deletions scripts/verify-release-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,58 @@ export function indexContainsSubject(value: unknown, subject: Subject): boolean
});
}

export function attestationContainsSbom(value: unknown, sbom: unknown): boolean {
if (!Array.isArray(value)) throw new Error("Cosign attestation result is malformed");
return value.some((entry) => {
if (!record(entry) || typeof entry.payload !== "string") return false;
try {
const envelope: unknown = JSON.parse(Buffer.from(entry.payload, "base64").toString("utf8"));
return record(envelope) && isDeepStrictEqual(envelope.predicate, sbom);
} catch {
return false;
}
});
/**
* Cosign writes its verification banner to stderr and its result to stdout as a *stream* of JSON
* documents rather than as one document. `verify-attestation` prints one DSSE envelope per verified
* attestation, newline-delimited and unwrapped, so a subject carrying a single SBOM attestation
* yields a bare object — reading that as an array is what failed the v0.75.0 release. `verify`
* prints an array of claims instead. Cosign documents neither framing, so read all three shapes: a
* cosign upgrade that reframes its output must not be able to fail a release.
*/
function parseCosignDocuments(stdout: string): unknown[] {
const text = stdout.trim();
// A pretty-printed document spans lines and a stream of documents does not, so neither framing
// can be told from the text alone — parse it whole, then per line. The fallback throws on its
// own failure, so a capture that is genuinely unreadable still stops the release.
let documents: unknown[];
try {
documents = [JSON.parse(text) as unknown];
} catch {
documents = text
.split("\n")
.filter((line) => line.trim())
.map((line) => JSON.parse(line) as unknown);
}
const [only] = documents;
const result = documents.length === 1 && Array.isArray(only) ? only : documents;
if (result.length === 0) throw new Error("cosign verified nothing: it wrote no result");
return result;
}

/**
* The in-toto statement one DSSE envelope carries. Cosign prints only envelopes it has already
* verified cryptographically, so one it prints that cannot be decoded is a broken capture rather
* than a failed attestation, and stops the release instead of being skipped past.
*/
function attestedStatement(envelope: unknown): JsonObject {
if (!record(envelope) || typeof envelope.payload !== "string")
throw new Error("cosign attestation is not a DSSE envelope");
const statement: unknown = JSON.parse(Buffer.from(envelope.payload, "base64").toString("utf8"));
if (!record(statement)) throw new Error("cosign attestation payload is not an in-toto statement");
return statement;
}

/**
* Whether the attestations cosign verified for one subject carry exactly the SBOM this release
* recorded. `cosign attest --type spdxjson` stores the predicate file verbatim — the predicate
* published for `ghcr.io/hephaestus-build/webapp` is the syft document in the bundle, key for key —
* so the two are compared whole. Cosign re-serializes the predicate with its keys sorted, which is
* why this is a deep equality and not a string comparison.
*/
export function attestationContainsSbom(stdout: string, sbom: unknown): boolean {
return parseCosignDocuments(stdout).some((envelope) =>
isDeepStrictEqual(attestedStatement(envelope).predicate, sbom),
);
}

export function validateManifest(
Expand Down Expand Up @@ -262,16 +303,20 @@ export function verifyReleaseEvidence(
if (!indexContainsSubject(index, subject))
throw new Error(`${subject.image} index does not contain ${subject.platform} digest`);
if (mode === "verify-signatures" && subject.provenance === "first-party") {
const attestations = readJsonFromCommand("cosign", [
"verify-attestation",
"--type",
"spdxjson",
"--certificate-identity",
releaseCertificateIdentity(release, process.env),
"--certificate-oidc-issuer",
"https://token.actions.githubusercontent.com",
reference,
]);
const attestations = command(
"cosign",
[
"verify-attestation",
"--type",
"spdxjson",
"--certificate-identity",
releaseCertificateIdentity(release, process.env),
"--certificate-oidc-issuer",
"https://token.actions.githubusercontent.com",
reference,
],
true,
);
if (!attestationContainsSbom(attestations, readJson(`${prefix}.spdx.json`)))
throw new Error(`${subject.image} SBOM attestation does not match its durable evidence`);
}
Expand Down
Loading