Skip to content

Commit a8e7565

Browse files
committed
sigstore: sign and verify BuildKit attestation manifests
Signed-off-by: CrazyMax <[email protected]>
1 parent 364d8e8 commit a8e7565

File tree

7 files changed

+1386
-4
lines changed

7 files changed

+1386
-4
lines changed

__tests__/.fixtures/cosign/sign-output1.txt

Lines changed: 300 additions & 0 deletions
Large diffs are not rendered by default.

__tests__/.fixtures/cosign/sign-output2.txt

Lines changed: 408 additions & 0 deletions
Large diffs are not rendered by default.

__tests__/.fixtures/cosign/sign-output3.txt

Lines changed: 329 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
2025/10/31 13:57:03 --> GET https://index.docker.io/v2/
2+
2025/10/31 13:57:03 GET /v2/ HTTP/1.1
3+
Host: index.docker.io
4+
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
5+
Accept-Encoding: gzip
6+
7+
8+
2025/10/31 13:57:03 <-- 401 https://index.docker.io/v2/ (191.948348ms)
9+
2025/10/31 13:57:03 HTTP/2.0 401 Unauthorized
10+
Content-Length: 87
11+
Content-Type: application/json
12+
Date: Fri, 31 Oct 2025 13:57:03 GMT
13+
Docker-Distribution-Api-Version: registry/2.0
14+
Strict-Transport-Security: max-age=31536000
15+
Www-Authenticate: ***"https://auth.docker.io/token",service="registry.docker.io"
16+
17+
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}
18+
19+
2025/10/31 13:57:03 --> GET https://auth.docker.io/token?scope=repository%3Acrazymax%2Fgithub-builder-test%3Apull&service=registry.docker.io [body redacted: basic token response contains credentials]
20+
2025/10/31 13:57:03 GET /token?scope=repository%3Acrazymax%2Fgithub-builder-test%3Apull&service=registry.docker.io HTTP/1.1
21+
Host: auth.docker.io
22+
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
23+
Authorization: <redacted>
24+
Accept-Encoding: gzip
25+
26+
27+
2025/10/31 13:57:03 <-- 200 https://auth.docker.io/token?scope=repository%3Acrazymax%2Fgithub-builder-test%3Apull&service=registry.docker.io (180.01561ms) [body redacted: basic token response contains credentials]
28+
2025/10/31 13:57:03 HTTP/2.0 200 OK
29+
Connection: close
30+
Content-Type: application/json
31+
Date: Fri, 31 Oct 2025 13:57:03 GMT
32+
Strict-Transport-Security: max-age=31536000
33+
X-Trace-Id: 8d63fbce36baf5f2a0c5f2542efa7a7a
34+
X-Trace-Sampled: false
35+
36+
37+
2025/10/31 13:57:03 --> GET https://index.docker.io/v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0
38+
2025/10/31 13:57:03 GET /v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0 HTTP/1.1
39+
Host: index.docker.io
40+
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
41+
Accept: application/vnd.oci.image.index.v1+json
42+
Authorization: <redacted>
43+
Accept-Encoding: gzip
44+
45+
46+
2025/10/31 13:57:03 <-- 200 https://index.docker.io/v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0 (84.160823ms)
47+
2025/10/31 13:57:03 HTTP/2.0 200 OK
48+
Content-Length: 89
49+
Content-Type: application/vnd.oci.image.index.v1+json
50+
Date: Fri, 31 Oct 2025 13:57:03 GMT
51+
Docker-Distribution-Api-Version: registry/2.0
52+
Strict-Transport-Security: max-age=31536000
53+
54+
{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[]}
55+
56+
2025/10/31 13:57:03 --> GET https://index.docker.io/v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0
57+
2025/10/31 13:57:03 GET /v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0 HTTP/1.1
58+
Host: index.docker.io
59+
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
60+
Accept: application/vnd.oci.image.index.v1+json
61+
Authorization: <redacted>
62+
Accept-Encoding: gzip
63+
64+
65+
2025/10/31 13:57:03 <-- 200 https://index.docker.io/v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0 (95.303988ms)
66+
2025/10/31 13:57:03 HTTP/2.0 200 OK
67+
Content-Length: 89
68+
Content-Type: application/vnd.oci.image.index.v1+json
69+
Date: Fri, 31 Oct 2025 13:57:03 GMT
70+
Docker-Distribution-Api-Version: registry/2.0
71+
Strict-Transport-Security: max-age=31536000
72+
73+
{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[]}
74+
75+
2025/10/31 13:57:03 --> GET https://index.docker.io/v2/crazymax/github-builder-test/manifests/sha256-6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0.sig
76+
2025/10/31 13:57:03 GET /v2/crazymax/github-builder-test/manifests/sha256-6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0.sig HTTP/1.1
77+
Host: index.docker.io
78+
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
79+
Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json
80+
Authorization: <redacted>
81+
Accept-Encoding: gzip
82+
83+
84+
2025/10/31 13:57:03 <-- 404 https://index.docker.io/v2/crazymax/github-builder-test/manifests/sha256-6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0.sig (66.155995ms)
85+
2025/10/31 13:57:03 HTTP/2.0 404 Not Found
86+
Content-Length: 169
87+
Content-Type: application/json
88+
Date: Fri, 31 Oct 2025 13:57:03 GMT
89+
Docker-Distribution-Api-Version: registry/2.0
90+
Docker-Ratelimit-Source: d2fd3209-1e2e-451f-b428-29c5bbf3b4b7
91+
Strict-Transport-Security: max-age=31536000
92+
93+
{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown","detail":"unknown tag=sha256-6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0.sig"}]}
94+
95+
Error: no signatures found
96+
error during command execution: no signatures found

__tests__/cosign/cosign.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@
1515
*/
1616

1717
import {describe, expect, it, jest, test} from '@jest/globals';
18+
import fs from 'fs';
19+
import path from 'path';
1820
import * as semver from 'semver';
1921

2022
import {Exec} from '../../src/exec';
2123
import {Cosign} from '../../src/cosign/cosign';
24+
import {Sigstore} from '../../src/sigstore/sigstore';
25+
import {bundleFromJSON, bundleToJSON} from '@sigstore/bundle';
26+
27+
const fixturesDir = path.join(__dirname, '..', '.fixtures');
2228

2329
describe('isAvailable', () => {
2430
it('checks Cosign is available', async () => {
@@ -61,3 +67,26 @@ describe('versionSatisfies', () => {
6167
expect(await cosign.versionSatisfies(range, version)).toBe(expected);
6268
});
6369
});
70+
71+
describe('parseCommandOutput', () => {
72+
// prettier-ignore
73+
test.each([
74+
[path.join(fixturesDir, 'cosign', 'sign-output1.txt')],
75+
[path.join(fixturesDir, 'cosign', 'sign-output2.txt')],
76+
[path.join(fixturesDir, 'cosign', 'sign-output3.txt')],
77+
])('parsing %p', async (fixturePath: string) => {
78+
const signResult = Cosign.parseCommandOutput(fs.readFileSync(fixturePath, 'utf-8'));
79+
expect(signResult).toBeDefined();
80+
expect(signResult.bundle).toBeDefined();
81+
});
82+
83+
// prettier-ignore
84+
test.each([
85+
[path.join(fixturesDir, 'cosign', 'verify-output-err1.txt')],
86+
])('parsing %p', async (fixturePath: string) => {
87+
const signResult = Cosign.parseCommandOutput(fs.readFileSync(fixturePath, 'utf-8'));
88+
expect(signResult).toBeDefined();
89+
expect(signResult.bundle).toBeUndefined();
90+
expect(signResult.errors).toBeDefined();
91+
});
92+
});

src/cosign/cosign.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,28 @@
1515
*/
1616

1717
import * as core from '@actions/core';
18+
import {BUNDLE_V03_MEDIA_TYPE, SerializedBundle} from '@sigstore/bundle';
1819

1920
import {Exec} from '../exec';
2021
import * as semver from 'semver';
22+
import {MEDIATYPE_EMPTY_JSON_V1} from '../types/oci/mediatype';
2123

2224
export interface CosignOpts {
2325
binPath?: string;
2426
}
2527

28+
export interface CosignCommandResult {
29+
bundle?: SerializedBundle;
30+
signatureManifestDigest?: string;
31+
errors?: Array<CosignCommandError>;
32+
}
33+
34+
export interface CosignCommandError {
35+
code: string;
36+
message: string;
37+
detail: string;
38+
}
39+
2640
export class Cosign {
2741
private readonly binPath: string;
2842
private _version: string;
@@ -88,4 +102,59 @@ export class Cosign {
88102
core.debug(`Cosign.versionSatisfies ${ver} statisfies ${range}: ${res}`);
89103
return res;
90104
}
105+
106+
public static parseCommandOutput(logs: string): CosignCommandResult {
107+
let signatureManifestDigest: string | undefined;
108+
let signatureManifestFallbackDigest: string | undefined;
109+
let bundlePayload: SerializedBundle | undefined;
110+
let errors: Array<CosignCommandError> | undefined;
111+
112+
for (const rawLine of logs.split(/\r?\n/)) {
113+
const line = rawLine.trim();
114+
if (!line.startsWith('{') || !line.endsWith('}')) {
115+
continue;
116+
}
117+
118+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
119+
let obj: any;
120+
try {
121+
obj = JSON.parse(line);
122+
} catch {
123+
continue;
124+
}
125+
126+
if (obj && Array.isArray(obj.errors) && obj.errors.length > 0) {
127+
errors = obj.errors;
128+
}
129+
130+
// signature manifest digest
131+
if (!signatureManifestDigest && obj && Array.isArray(obj.manifests) && obj.manifests.length > 0) {
132+
const m0 = obj.manifests[0];
133+
if (m0?.artifactType === BUNDLE_V03_MEDIA_TYPE && typeof m0.digest === 'string') {
134+
signatureManifestDigest = m0.digest;
135+
} else if (m0?.artifactType === MEDIATYPE_EMPTY_JSON_V1 && typeof m0.digest === 'string') {
136+
signatureManifestFallbackDigest = m0.digest;
137+
}
138+
}
139+
140+
// signature payload
141+
if (!bundlePayload && obj && obj.mediaType === BUNDLE_V03_MEDIA_TYPE) {
142+
bundlePayload = obj as SerializedBundle;
143+
}
144+
145+
if (bundlePayload && signatureManifestDigest) {
146+
break;
147+
}
148+
}
149+
150+
if (!errors && !bundlePayload) {
151+
throw new Error(`Cannot find signature bundle from cosign command output: ${logs}`);
152+
}
153+
154+
return {
155+
bundle: bundlePayload,
156+
signatureManifestDigest: signatureManifestDigest || signatureManifestFallbackDigest,
157+
errors: errors
158+
};
159+
}
91160
}

0 commit comments

Comments
 (0)