@kungfu-tech/buildchain 3.0.2-alpha.4 → 3.0.2-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/actions/github-artifact-attestation/README.md +10 -0
- package/actions/promote-buildchain-ref/README.md +7 -0
- package/bin/buildchain.mjs +5 -0
- package/bin/internal/trust-release-cli.mjs +74 -3
- package/dist/site/artifact-schemas.json +5 -1
- package/dist/site/buildchain-contract.json +79 -28
- package/dist/site/buildchain-site.json +108 -25
- package/dist/site/capability-registry.json +8 -7
- package/dist/site/cli-registry.json +12 -0
- package/dist/site/controller-registry.json +40 -4
- package/dist/site/kfd-claims.json +203 -18
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +19 -5
- package/dist/site/node-api-registry.json +22 -9
- package/dist/site/page-registry.json +89 -15
- package/dist/site/public-surface-audit.json +125 -15
- package/dist/site/publication-authority-registry.json +27 -2
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-model.json +2 -1
- package/dist/site/release-passport-check-manifest.json +1 -0
- package/dist/site/release-provenance.json +1 -0
- package/dist/site/schemas/release-passport-v1.schema.json +6 -0
- package/dist/site/site-manifest.json +17 -9
- package/dist/site/workflow-registry.json +84 -7
- package/docs/MAP.md +3 -1
- package/docs/binary-distribution.md +7 -0
- package/docs/cli.md +9 -0
- package/docs/github-artifact-attestation.md +219 -0
- package/docs/release-passport.md +13 -0
- package/docs/reusable-build-surface.md +6 -0
- package/package.json +2 -1
- package/packages/core/buildchain-contract.js +6 -0
- package/packages/core/buildchain-kfd-claims.js +5 -0
- package/packages/core/buildchain-publication-authority.js +1 -0
- package/packages/core/github-artifact-attestation.js +642 -0
- package/packages/core/index.js +19 -0
- package/packages/core/publication-authority.js +1 -1
- package/packages/core/release-passport-contract.js +2 -0
- package/packages/core/release-passport.js +51 -0
- package/scripts/check-inventory.mjs +4 -0
- package/scripts/create-github-artifact-attestation-policy.mjs +62 -0
- package/scripts/generate-site-bundle.mjs +12 -0
- package/scripts/publish-github-artifact-attestation-evidence.mjs +201 -0
- package/scripts/release-candidate-resolver.mjs +12 -0
- package/scripts/stage-github-artifact-attestation-inputs.mjs +65 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const GITHUB_ARTIFACT_ATTESTATION_POLICY_CONTRACT =
|
|
6
|
+
"buildchain.github-artifact-attestation-policy/v1";
|
|
7
|
+
export const GITHUB_ARTIFACT_ATTESTATION_PREDICATE_CONTRACT =
|
|
8
|
+
"buildchain.github-artifact-attestation-predicate/v1";
|
|
9
|
+
export const GITHUB_ARTIFACT_ATTESTATION_EVIDENCE_CONTRACT =
|
|
10
|
+
"buildchain.github-artifact-attestation-evidence/v1";
|
|
11
|
+
export const GITHUB_ARTIFACT_ATTESTATION_VERIFICATION_CONTRACT =
|
|
12
|
+
"buildchain.github-artifact-attestation-verification/v1";
|
|
13
|
+
export const GITHUB_ARTIFACT_ATTESTATION_PREDICATE_TYPE =
|
|
14
|
+
"https://buildchain.libkungfu.dev/attestations/github-artifact/v1";
|
|
15
|
+
export const GITHUB_ARTIFACT_ATTESTATION_WORKFLOW =
|
|
16
|
+
".github/workflows/github-artifact-attestation.yml";
|
|
17
|
+
|
|
18
|
+
const SHA256 = /^sha256:([0-9a-f]{64})$/;
|
|
19
|
+
const COMMIT = /^[0-9a-f]{40}$/;
|
|
20
|
+
const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
21
|
+
const REQUIRED_PERMISSIONS = Object.freeze([
|
|
22
|
+
"actions:read",
|
|
23
|
+
"artifact-metadata:write",
|
|
24
|
+
"attestations:write",
|
|
25
|
+
"contents:read",
|
|
26
|
+
"id-token:write",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function object(value, label) {
|
|
30
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
31
|
+
throw new Error(`${label} must be a JSON object`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function string(value, label) {
|
|
37
|
+
const normalized = String(value || "").trim();
|
|
38
|
+
if (!normalized) throw new Error(`${label} must be a non-empty string`);
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sha256(value, label) {
|
|
43
|
+
const normalized = string(value, label).toLowerCase();
|
|
44
|
+
if (!SHA256.test(normalized)) throw new Error(`${label} must be sha256:<64-lowercase-hex>`);
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function commit(value, label) {
|
|
49
|
+
const normalized = string(value, label).toLowerCase();
|
|
50
|
+
if (!COMMIT.test(normalized)) throw new Error(`${label} must be an exact 40-hex commit SHA`);
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function repository(value, label) {
|
|
55
|
+
const normalized = string(value, label);
|
|
56
|
+
if (!REPOSITORY.test(normalized)) throw new Error(`${label} must be owner/repository`);
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function relativeFile(value, label) {
|
|
61
|
+
const normalized = string(value, label).replace(/\\/g, "/");
|
|
62
|
+
if (
|
|
63
|
+
normalized.startsWith("/") ||
|
|
64
|
+
normalized.split("/").includes("..") ||
|
|
65
|
+
normalized.endsWith("/")
|
|
66
|
+
) {
|
|
67
|
+
throw new Error(`${label} must be a safe relative file path`);
|
|
68
|
+
}
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function stableJson(value) {
|
|
73
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
74
|
+
if (value && typeof value === "object") {
|
|
75
|
+
return `{${Object.keys(value)
|
|
76
|
+
.sort()
|
|
77
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
|
78
|
+
.join(",")}}`;
|
|
79
|
+
}
|
|
80
|
+
return JSON.stringify(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function githubArtifactAttestationSha256Buffer(value) {
|
|
84
|
+
return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function githubArtifactAttestationSha256File(filePath) {
|
|
88
|
+
return githubArtifactAttestationSha256Buffer(fs.readFileSync(filePath));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function githubArtifactAttestationSemanticRoot(value) {
|
|
92
|
+
return githubArtifactAttestationSha256Buffer(stableJson(value));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeSubject(value) {
|
|
96
|
+
const subject = object(value, "policy.subject");
|
|
97
|
+
const name = string(subject.name, "policy.subject.name").replace(/\\/g, "/");
|
|
98
|
+
if (name === "." || name === ".." || name.includes("/")) {
|
|
99
|
+
throw new Error("policy.subject.name must be a safe file name");
|
|
100
|
+
}
|
|
101
|
+
const size = Number(subject.size);
|
|
102
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
103
|
+
throw new Error("policy.subject.size must be a non-negative safe integer");
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
name,
|
|
107
|
+
path: relativeFile(subject.path, "policy.subject.path"),
|
|
108
|
+
size,
|
|
109
|
+
digest: sha256(subject.digest, "policy.subject.digest"),
|
|
110
|
+
kind: "file",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeCaller(value) {
|
|
115
|
+
const caller = object(value, "policy.caller");
|
|
116
|
+
return {
|
|
117
|
+
repository: repository(caller.repository, "policy.caller.repository"),
|
|
118
|
+
sourceSha: commit(caller.sourceSha, "policy.caller.sourceSha"),
|
|
119
|
+
sourceTreeSha: commit(caller.sourceTreeSha, "policy.caller.sourceTreeSha"),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeSigner(value) {
|
|
124
|
+
const signer = object(value, "policy.signer");
|
|
125
|
+
const signerRepository = repository(signer.repository, "policy.signer.repository");
|
|
126
|
+
const workflowPath = relativeFile(signer.workflowPath, "policy.signer.workflowPath");
|
|
127
|
+
if (signerRepository !== "kungfu-systems/buildchain") {
|
|
128
|
+
throw new Error("policy.signer.repository must be kungfu-systems/buildchain");
|
|
129
|
+
}
|
|
130
|
+
if (workflowPath !== GITHUB_ARTIFACT_ATTESTATION_WORKFLOW) {
|
|
131
|
+
throw new Error(`policy.signer.workflowPath must be ${GITHUB_ARTIFACT_ATTESTATION_WORKFLOW}`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
repository: signerRepository,
|
|
135
|
+
workflowPath,
|
|
136
|
+
workflowDigest: commit(signer.workflowDigest, "policy.signer.workflowDigest"),
|
|
137
|
+
runner: "github-hosted-ubuntu",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeBuild(value) {
|
|
142
|
+
const build = object(value, "policy.build");
|
|
143
|
+
const platform = string(build.platform, "policy.build.platform");
|
|
144
|
+
if (!/^linux(?:-|$)/.test(platform)) {
|
|
145
|
+
throw new Error("policy.build.platform must identify a Linux platform");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
platform,
|
|
149
|
+
platformManifestDigest: sha256(
|
|
150
|
+
build.platformManifestDigest,
|
|
151
|
+
"policy.build.platformManifestDigest",
|
|
152
|
+
),
|
|
153
|
+
runnerReceiptRoot: sha256(build.runnerReceiptRoot, "policy.build.runnerReceiptRoot"),
|
|
154
|
+
buildchainRuntimeSha: commit(build.buildchainRuntimeSha, "policy.build.buildchainRuntimeSha"),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function normalizeGitHubArtifactAttestationPolicy(value) {
|
|
159
|
+
const policy = object(value, "policy");
|
|
160
|
+
if (policy.contract !== GITHUB_ARTIFACT_ATTESTATION_POLICY_CONTRACT) {
|
|
161
|
+
throw new Error(`policy.contract must be ${GITHUB_ARTIFACT_ATTESTATION_POLICY_CONTRACT}`);
|
|
162
|
+
}
|
|
163
|
+
if (policy.predicateType !== GITHUB_ARTIFACT_ATTESTATION_PREDICATE_TYPE) {
|
|
164
|
+
throw new Error(`policy.predicateType must be ${GITHUB_ARTIFACT_ATTESTATION_PREDICATE_TYPE}`);
|
|
165
|
+
}
|
|
166
|
+
const permissions = [...new Set((policy.permissions || []).map(String))].sort();
|
|
167
|
+
if (stableJson(permissions) !== stableJson(REQUIRED_PERMISSIONS)) {
|
|
168
|
+
throw new Error(`policy.permissions must equal ${REQUIRED_PERMISSIONS.join(", ")}`);
|
|
169
|
+
}
|
|
170
|
+
const signer = normalizeSigner(policy.signer);
|
|
171
|
+
const build = normalizeBuild(policy.build);
|
|
172
|
+
assertEqual(
|
|
173
|
+
build.runnerReceiptRoot,
|
|
174
|
+
build.platformManifestDigest,
|
|
175
|
+
"policy runner receipt root and platform manifest digest",
|
|
176
|
+
);
|
|
177
|
+
return {
|
|
178
|
+
contract: GITHUB_ARTIFACT_ATTESTATION_POLICY_CONTRACT,
|
|
179
|
+
predicateType: GITHUB_ARTIFACT_ATTESTATION_PREDICATE_TYPE,
|
|
180
|
+
subject: normalizeSubject(policy.subject),
|
|
181
|
+
caller: normalizeCaller(policy.caller),
|
|
182
|
+
signer,
|
|
183
|
+
build,
|
|
184
|
+
permissions,
|
|
185
|
+
permissionReasons: {
|
|
186
|
+
"actions:read": "download the exact retained build artifacts from the declared workflow run",
|
|
187
|
+
"artifact-metadata:write": "required by actions/attest v4 to register artifact metadata",
|
|
188
|
+
"attestations:write": "publish the signed GitHub artifact attestation",
|
|
189
|
+
"contents:read": "load the exact Buildchain attester runtime without consumer checkout",
|
|
190
|
+
"id-token:write": "mint the ephemeral GitHub OIDC identity used by Sigstore",
|
|
191
|
+
},
|
|
192
|
+
claims: {
|
|
193
|
+
kind: "artifact-attestation-and-provenance",
|
|
194
|
+
excludes: [
|
|
195
|
+
"embedded-elf-signing",
|
|
196
|
+
"apt-repository-signing",
|
|
197
|
+
"rpm-package-signing",
|
|
198
|
+
"vulnerability-freedom",
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function createGitHubArtifactAttestationPolicy(value) {
|
|
205
|
+
return normalizeGitHubArtifactAttestationPolicy({
|
|
206
|
+
contract: GITHUB_ARTIFACT_ATTESTATION_POLICY_CONTRACT,
|
|
207
|
+
predicateType: GITHUB_ARTIFACT_ATTESTATION_PREDICATE_TYPE,
|
|
208
|
+
permissions: REQUIRED_PERMISSIONS,
|
|
209
|
+
...value,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function readJson(filePath, label) {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
216
|
+
} catch (error) {
|
|
217
|
+
throw new Error(`${label} is not readable JSON: ${error.message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function stripSha256(value) {
|
|
222
|
+
return String(value || "").replace(/^sha256:/, "");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function assertEqual(actual, expected, label) {
|
|
226
|
+
if (actual !== expected) throw new Error(`${label} mismatch: expected ${expected}, got ${actual}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function findManifestFile(manifest, subjectPath) {
|
|
230
|
+
const files = Array.isArray(manifest.files) ? manifest.files : [];
|
|
231
|
+
const entry = files.find((candidate) => String(candidate.path || "").replace(/\\/g, "/") === subjectPath);
|
|
232
|
+
if (!entry) throw new Error(`platform manifest does not contain subject path ${subjectPath}`);
|
|
233
|
+
return entry;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function findPassportPolicy(passport, expectedPolicy) {
|
|
237
|
+
const policies = Array.isArray(passport.githubArtifactAttestations)
|
|
238
|
+
? passport.githubArtifactAttestations
|
|
239
|
+
: [];
|
|
240
|
+
const match = policies
|
|
241
|
+
.map(normalizeGitHubArtifactAttestationPolicy)
|
|
242
|
+
.find((candidate) => candidate.subject.name === expectedPolicy.subject.name);
|
|
243
|
+
if (!match) {
|
|
244
|
+
throw new Error(`release passport does not require attestation for ${expectedPolicy.subject.name}`);
|
|
245
|
+
}
|
|
246
|
+
assertEqual(stableJson(match), stableJson(expectedPolicy), "release passport attestation policy");
|
|
247
|
+
return match;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function prepareGitHubArtifactAttestation({
|
|
251
|
+
subjectPath,
|
|
252
|
+
platformManifestPath,
|
|
253
|
+
releasePassportPath,
|
|
254
|
+
policy,
|
|
255
|
+
expectedBuildchainRef = "",
|
|
256
|
+
expectedCallerRepository = "",
|
|
257
|
+
expectedSourceSha = "",
|
|
258
|
+
} = {}) {
|
|
259
|
+
const normalizedPolicy = normalizeGitHubArtifactAttestationPolicy(policy);
|
|
260
|
+
if (expectedBuildchainRef) {
|
|
261
|
+
assertEqual(
|
|
262
|
+
normalizedPolicy.signer.workflowDigest,
|
|
263
|
+
commit(expectedBuildchainRef, "expectedBuildchainRef"),
|
|
264
|
+
"policy signer workflow digest",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (expectedCallerRepository) {
|
|
268
|
+
assertEqual(
|
|
269
|
+
normalizedPolicy.caller.repository,
|
|
270
|
+
repository(expectedCallerRepository, "expectedCallerRepository"),
|
|
271
|
+
"policy caller repository",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (expectedSourceSha) {
|
|
275
|
+
assertEqual(
|
|
276
|
+
normalizedPolicy.caller.sourceSha,
|
|
277
|
+
commit(expectedSourceSha, "expectedSourceSha"),
|
|
278
|
+
"policy caller source SHA",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const resolvedSubject = path.resolve(string(subjectPath, "subjectPath"));
|
|
282
|
+
const resolvedManifest = path.resolve(string(platformManifestPath, "platformManifestPath"));
|
|
283
|
+
const resolvedPassport = path.resolve(string(releasePassportPath, "releasePassportPath"));
|
|
284
|
+
if (!fs.statSync(resolvedSubject).isFile()) throw new Error("subjectPath must be a file");
|
|
285
|
+
|
|
286
|
+
const subjectSize = fs.statSync(resolvedSubject).size;
|
|
287
|
+
const subjectDigest = githubArtifactAttestationSha256File(resolvedSubject);
|
|
288
|
+
assertEqual(subjectSize, normalizedPolicy.subject.size, "subject size");
|
|
289
|
+
assertEqual(subjectDigest, normalizedPolicy.subject.digest, "subject digest");
|
|
290
|
+
|
|
291
|
+
const manifestDigest = githubArtifactAttestationSha256File(resolvedManifest);
|
|
292
|
+
assertEqual(
|
|
293
|
+
manifestDigest,
|
|
294
|
+
normalizedPolicy.build.platformManifestDigest,
|
|
295
|
+
"platform manifest digest",
|
|
296
|
+
);
|
|
297
|
+
const manifest = object(readJson(resolvedManifest, "platform manifest"), "platform manifest");
|
|
298
|
+
if (manifest.contract !== "kungfu-buildchain-artifact") {
|
|
299
|
+
throw new Error("platform manifest contract must be kungfu-buildchain-artifact");
|
|
300
|
+
}
|
|
301
|
+
assertEqual(manifest.platform?.id, normalizedPolicy.build.platform, "platform manifest platform");
|
|
302
|
+
assertEqual(manifest.git?.repository, normalizedPolicy.caller.repository, "platform manifest repository");
|
|
303
|
+
assertEqual(String(manifest.git?.sha || "").toLowerCase(), normalizedPolicy.caller.sourceSha, "platform manifest source SHA");
|
|
304
|
+
const manifestFile = findManifestFile(manifest, normalizedPolicy.subject.path);
|
|
305
|
+
assertEqual(Number(manifestFile.size), subjectSize, "platform manifest subject size");
|
|
306
|
+
assertEqual(
|
|
307
|
+
`sha256:${String(manifestFile.sha256 || "").toLowerCase()}`,
|
|
308
|
+
subjectDigest,
|
|
309
|
+
"platform manifest subject digest",
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const passport = object(readJson(resolvedPassport, "release passport"), "release passport");
|
|
313
|
+
if (passport.contract !== "kungfu-buildchain-release-passport") {
|
|
314
|
+
throw new Error("release passport contract must be kungfu-buildchain-release-passport");
|
|
315
|
+
}
|
|
316
|
+
findPassportPolicy(passport, normalizedPolicy);
|
|
317
|
+
assertEqual(
|
|
318
|
+
String(passport.release?.sourceSha || "").toLowerCase(),
|
|
319
|
+
normalizedPolicy.caller.sourceSha,
|
|
320
|
+
"release passport source SHA",
|
|
321
|
+
);
|
|
322
|
+
assertEqual(
|
|
323
|
+
String(passport.release?.builtSourceTreeSha || passport.release?.sourceTreeSha || "").toLowerCase(),
|
|
324
|
+
normalizedPolicy.caller.sourceTreeSha,
|
|
325
|
+
"release passport source tree SHA",
|
|
326
|
+
);
|
|
327
|
+
const passportRoot = githubArtifactAttestationSha256File(resolvedPassport);
|
|
328
|
+
const predicate = {
|
|
329
|
+
contract: GITHUB_ARTIFACT_ATTESTATION_PREDICATE_CONTRACT,
|
|
330
|
+
subject: normalizedPolicy.subject,
|
|
331
|
+
caller: normalizedPolicy.caller,
|
|
332
|
+
signer: normalizedPolicy.signer,
|
|
333
|
+
build: normalizedPolicy.build,
|
|
334
|
+
releasePassport: {
|
|
335
|
+
name: path.basename(resolvedPassport),
|
|
336
|
+
digest: passportRoot,
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
return {
|
|
340
|
+
contract: "buildchain.github-artifact-attestation-preparation/v1",
|
|
341
|
+
policy: normalizedPolicy,
|
|
342
|
+
subjectPath: resolvedSubject,
|
|
343
|
+
platformManifestPath: resolvedManifest,
|
|
344
|
+
releasePassportPath: resolvedPassport,
|
|
345
|
+
predicateType: GITHUB_ARTIFACT_ATTESTATION_PREDICATE_TYPE,
|
|
346
|
+
predicate,
|
|
347
|
+
predicateRoot: githubArtifactAttestationSemanticRoot(predicate),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function walkRegularFiles(root) {
|
|
352
|
+
const resolvedRoot = path.resolve(root);
|
|
353
|
+
if (!fs.existsSync(resolvedRoot)) return [];
|
|
354
|
+
const pending = [resolvedRoot];
|
|
355
|
+
const files = [];
|
|
356
|
+
while (pending.length > 0) {
|
|
357
|
+
const current = pending.pop();
|
|
358
|
+
const stat = fs.lstatSync(current);
|
|
359
|
+
if (stat.isSymbolicLink()) continue;
|
|
360
|
+
if (stat.isDirectory()) {
|
|
361
|
+
for (const entry of fs.readdirSync(current).sort().reverse()) {
|
|
362
|
+
pending.push(path.join(current, entry));
|
|
363
|
+
}
|
|
364
|
+
} else if (stat.isFile()) {
|
|
365
|
+
files.push(current);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return files;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function uniqueDigestMatch(paths, expectedDigest, label, suffix = "") {
|
|
372
|
+
const normalizedSuffix = String(suffix || "").replace(/\\/g, "/");
|
|
373
|
+
const digestMatches = paths.filter((candidate) => (
|
|
374
|
+
githubArtifactAttestationSha256File(candidate) === expectedDigest
|
|
375
|
+
));
|
|
376
|
+
const suffixMatches = normalizedSuffix
|
|
377
|
+
? digestMatches.filter((candidate) => (
|
|
378
|
+
candidate.replace(/\\/g, "/").endsWith(`/${normalizedSuffix}`)
|
|
379
|
+
|| path.basename(candidate) === normalizedSuffix
|
|
380
|
+
))
|
|
381
|
+
: digestMatches;
|
|
382
|
+
const matches = normalizedSuffix ? suffixMatches : digestMatches;
|
|
383
|
+
if (matches.length !== 1) {
|
|
384
|
+
throw new Error(`${label} must resolve to exactly one digest-matching file, got ${matches.length}`);
|
|
385
|
+
}
|
|
386
|
+
return matches[0];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function stageGitHubArtifactAttestationInputs({
|
|
390
|
+
policy,
|
|
391
|
+
subjectRoots = [],
|
|
392
|
+
platformManifestPaths = [],
|
|
393
|
+
releasePassportPath,
|
|
394
|
+
outputDir = ".buildchain/github-artifact-attestation-input",
|
|
395
|
+
} = {}) {
|
|
396
|
+
const normalizedPolicy = normalizeGitHubArtifactAttestationPolicy(policy);
|
|
397
|
+
const subjectCandidates = subjectRoots.flatMap(walkRegularFiles);
|
|
398
|
+
const subjectPath = uniqueDigestMatch(
|
|
399
|
+
subjectCandidates,
|
|
400
|
+
normalizedPolicy.subject.digest,
|
|
401
|
+
"attestation subject",
|
|
402
|
+
normalizedPolicy.subject.path,
|
|
403
|
+
);
|
|
404
|
+
const manifestCandidates = platformManifestPaths
|
|
405
|
+
.flatMap((candidate) => {
|
|
406
|
+
const resolved = path.resolve(candidate);
|
|
407
|
+
return fs.existsSync(resolved) && fs.lstatSync(resolved).isDirectory()
|
|
408
|
+
? walkRegularFiles(resolved)
|
|
409
|
+
: [resolved];
|
|
410
|
+
})
|
|
411
|
+
.filter((candidate) => fs.existsSync(candidate) && fs.lstatSync(candidate).isFile());
|
|
412
|
+
const platformManifestPath = uniqueDigestMatch(
|
|
413
|
+
manifestCandidates,
|
|
414
|
+
normalizedPolicy.build.platformManifestDigest,
|
|
415
|
+
"platform manifest",
|
|
416
|
+
);
|
|
417
|
+
const preparation = prepareGitHubArtifactAttestation({
|
|
418
|
+
subjectPath,
|
|
419
|
+
platformManifestPath,
|
|
420
|
+
releasePassportPath,
|
|
421
|
+
policy: normalizedPolicy,
|
|
422
|
+
});
|
|
423
|
+
const resolvedOutput = path.resolve(outputDir);
|
|
424
|
+
const staged = {
|
|
425
|
+
subject: path.join(resolvedOutput, "subject", normalizedPolicy.subject.name),
|
|
426
|
+
platformManifest: path.join(resolvedOutput, "platform-manifest", "manifest.json"),
|
|
427
|
+
releasePassport: path.join(resolvedOutput, "release-passport", "buildchain.release.json"),
|
|
428
|
+
policy: path.join(resolvedOutput, "policy", "policy.json"),
|
|
429
|
+
};
|
|
430
|
+
for (const target of Object.values(staged)) fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
431
|
+
fs.copyFileSync(subjectPath, staged.subject);
|
|
432
|
+
fs.copyFileSync(platformManifestPath, staged.platformManifest);
|
|
433
|
+
fs.copyFileSync(path.resolve(releasePassportPath), staged.releasePassport);
|
|
434
|
+
fs.writeFileSync(staged.policy, `${JSON.stringify(normalizedPolicy, null, 2)}\n`);
|
|
435
|
+
return {
|
|
436
|
+
contract: "buildchain.github-artifact-attestation-input/v1",
|
|
437
|
+
outputDir: resolvedOutput,
|
|
438
|
+
policy: normalizedPolicy,
|
|
439
|
+
policyJson: JSON.stringify(normalizedPolicy),
|
|
440
|
+
preparation,
|
|
441
|
+
paths: staged,
|
|
442
|
+
relativePaths: {
|
|
443
|
+
subject: path.posix.join("subject", normalizedPolicy.subject.name),
|
|
444
|
+
platformManifest: "platform-manifest/manifest.json",
|
|
445
|
+
releasePassport: "release-passport/buildchain.release.json",
|
|
446
|
+
policy: "policy/policy.json",
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function decodeBundleStatement(bundle) {
|
|
452
|
+
const envelope = bundle?.dsseEnvelope || bundle?.dsse_envelope;
|
|
453
|
+
if (!envelope?.payload) throw new Error("attestation bundle is missing dsseEnvelope.payload");
|
|
454
|
+
try {
|
|
455
|
+
return JSON.parse(Buffer.from(envelope.payload, "base64").toString("utf8"));
|
|
456
|
+
} catch (error) {
|
|
457
|
+
throw new Error(`attestation bundle payload is invalid: ${error.message}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function validateStatement(statement, preparation) {
|
|
462
|
+
assertEqual(statement?.predicateType, preparation.predicateType, "attestation predicate type");
|
|
463
|
+
const subjects = Array.isArray(statement?.subject) ? statement.subject : [];
|
|
464
|
+
const subject = subjects.find((entry) => entry.name === preparation.policy.subject.name);
|
|
465
|
+
if (!subject) throw new Error("attestation statement is missing the expected subject");
|
|
466
|
+
assertEqual(
|
|
467
|
+
String(subject.digest?.sha256 || "").toLowerCase(),
|
|
468
|
+
stripSha256(preparation.policy.subject.digest),
|
|
469
|
+
"attestation subject digest",
|
|
470
|
+
);
|
|
471
|
+
assertEqual(stableJson(statement.predicate), stableJson(preparation.predicate), "attestation predicate");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function createGitHubArtifactAttestationEvidence({
|
|
475
|
+
preparation,
|
|
476
|
+
attestationId,
|
|
477
|
+
attestationUrl,
|
|
478
|
+
bundlePath,
|
|
479
|
+
workflow = {},
|
|
480
|
+
} = {}) {
|
|
481
|
+
const prepared = object(preparation, "preparation");
|
|
482
|
+
const resolvedBundle = path.resolve(string(bundlePath, "bundlePath"));
|
|
483
|
+
const bundle = readJson(resolvedBundle, "attestation bundle");
|
|
484
|
+
validateStatement(decodeBundleStatement(bundle), prepared);
|
|
485
|
+
const evidence = {
|
|
486
|
+
contract: GITHUB_ARTIFACT_ATTESTATION_EVIDENCE_CONTRACT,
|
|
487
|
+
subject: prepared.policy.subject,
|
|
488
|
+
caller: prepared.policy.caller,
|
|
489
|
+
signer: prepared.policy.signer,
|
|
490
|
+
build: prepared.policy.build,
|
|
491
|
+
releasePassport: prepared.predicate.releasePassport,
|
|
492
|
+
attestation: {
|
|
493
|
+
id: string(attestationId, "attestationId"),
|
|
494
|
+
url: string(attestationUrl, "attestationUrl"),
|
|
495
|
+
predicateType: prepared.predicateType,
|
|
496
|
+
predicateRoot: prepared.predicateRoot,
|
|
497
|
+
bundle: {
|
|
498
|
+
name: path.basename(resolvedBundle),
|
|
499
|
+
digest: githubArtifactAttestationSha256File(resolvedBundle),
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
workflow: {
|
|
503
|
+
repository: repository(workflow.repository, "workflow.repository"),
|
|
504
|
+
runId: string(workflow.runId, "workflow.runId"),
|
|
505
|
+
runAttempt: string(workflow.runAttempt, "workflow.runAttempt"),
|
|
506
|
+
job: string(workflow.job, "workflow.job"),
|
|
507
|
+
url: string(workflow.url, "workflow.url"),
|
|
508
|
+
},
|
|
509
|
+
verificationPolicy: {
|
|
510
|
+
repository: prepared.policy.caller.repository,
|
|
511
|
+
signerWorkflow: `${prepared.policy.signer.repository}/${prepared.policy.signer.workflowPath}`,
|
|
512
|
+
signerDigest: prepared.policy.signer.workflowDigest,
|
|
513
|
+
sourceDigest: prepared.policy.caller.sourceSha,
|
|
514
|
+
predicateType: prepared.predicateType,
|
|
515
|
+
denySelfHostedRunners: true,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
...evidence,
|
|
520
|
+
evidenceRoot: githubArtifactAttestationSemanticRoot(evidence),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function createGitHubArtifactAttestationVerificationPlan({
|
|
525
|
+
artifactPath,
|
|
526
|
+
bundlePath,
|
|
527
|
+
evidence,
|
|
528
|
+
} = {}) {
|
|
529
|
+
const value = object(evidence, "evidence");
|
|
530
|
+
if (value.contract !== GITHUB_ARTIFACT_ATTESTATION_EVIDENCE_CONTRACT) {
|
|
531
|
+
throw new Error(`evidence.contract must be ${GITHUB_ARTIFACT_ATTESTATION_EVIDENCE_CONTRACT}`);
|
|
532
|
+
}
|
|
533
|
+
const policy = object(value.verificationPolicy, "evidence.verificationPolicy");
|
|
534
|
+
return {
|
|
535
|
+
command: "gh",
|
|
536
|
+
args: [
|
|
537
|
+
"attestation",
|
|
538
|
+
"verify",
|
|
539
|
+
path.resolve(string(artifactPath, "artifactPath")),
|
|
540
|
+
"--repo",
|
|
541
|
+
repository(policy.repository, "verificationPolicy.repository"),
|
|
542
|
+
"--signer-workflow",
|
|
543
|
+
string(policy.signerWorkflow, "verificationPolicy.signerWorkflow"),
|
|
544
|
+
"--signer-digest",
|
|
545
|
+
commit(policy.signerDigest, "verificationPolicy.signerDigest"),
|
|
546
|
+
"--source-digest",
|
|
547
|
+
commit(policy.sourceDigest, "verificationPolicy.sourceDigest"),
|
|
548
|
+
"--predicate-type",
|
|
549
|
+
string(policy.predicateType, "verificationPolicy.predicateType"),
|
|
550
|
+
"--bundle",
|
|
551
|
+
path.resolve(string(bundlePath, "bundlePath")),
|
|
552
|
+
"--deny-self-hosted-runners",
|
|
553
|
+
"--format",
|
|
554
|
+
"json",
|
|
555
|
+
],
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function verificationStatement(result) {
|
|
560
|
+
return result?.verificationResult?.statement || result?.verification_result?.statement;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function verifyGitHubArtifactAttestationEvidence({
|
|
564
|
+
artifactPath,
|
|
565
|
+
platformManifestPath,
|
|
566
|
+
releasePassportPath,
|
|
567
|
+
bundlePath,
|
|
568
|
+
evidence,
|
|
569
|
+
verificationResults,
|
|
570
|
+
} = {}) {
|
|
571
|
+
const issues = [];
|
|
572
|
+
try {
|
|
573
|
+
const value = object(evidence, "evidence");
|
|
574
|
+
if (value.contract !== GITHUB_ARTIFACT_ATTESTATION_EVIDENCE_CONTRACT) {
|
|
575
|
+
throw new Error(`evidence.contract must be ${GITHUB_ARTIFACT_ATTESTATION_EVIDENCE_CONTRACT}`);
|
|
576
|
+
}
|
|
577
|
+
const preimage = structuredClone(value);
|
|
578
|
+
delete preimage.evidenceRoot;
|
|
579
|
+
assertEqual(
|
|
580
|
+
value.evidenceRoot,
|
|
581
|
+
githubArtifactAttestationSemanticRoot(preimage),
|
|
582
|
+
"evidence root",
|
|
583
|
+
);
|
|
584
|
+
assertEqual(githubArtifactAttestationSha256File(artifactPath), value.subject.digest, "artifact digest");
|
|
585
|
+
assertEqual(fs.statSync(artifactPath).size, value.subject.size, "artifact size");
|
|
586
|
+
assertEqual(
|
|
587
|
+
githubArtifactAttestationSha256File(platformManifestPath),
|
|
588
|
+
value.build.platformManifestDigest,
|
|
589
|
+
"platform manifest digest",
|
|
590
|
+
);
|
|
591
|
+
assertEqual(
|
|
592
|
+
githubArtifactAttestationSha256File(releasePassportPath),
|
|
593
|
+
value.releasePassport.digest,
|
|
594
|
+
"release passport digest",
|
|
595
|
+
);
|
|
596
|
+
assertEqual(
|
|
597
|
+
githubArtifactAttestationSha256File(bundlePath),
|
|
598
|
+
value.attestation.bundle.digest,
|
|
599
|
+
"attestation bundle digest",
|
|
600
|
+
);
|
|
601
|
+
const policy = normalizeGitHubArtifactAttestationPolicy({
|
|
602
|
+
contract: GITHUB_ARTIFACT_ATTESTATION_POLICY_CONTRACT,
|
|
603
|
+
predicateType: value.attestation.predicateType,
|
|
604
|
+
subject: value.subject,
|
|
605
|
+
caller: value.caller,
|
|
606
|
+
signer: value.signer,
|
|
607
|
+
build: value.build,
|
|
608
|
+
permissions: REQUIRED_PERMISSIONS,
|
|
609
|
+
});
|
|
610
|
+
const preparation = prepareGitHubArtifactAttestation({
|
|
611
|
+
subjectPath: artifactPath,
|
|
612
|
+
platformManifestPath,
|
|
613
|
+
releasePassportPath,
|
|
614
|
+
policy,
|
|
615
|
+
});
|
|
616
|
+
assertEqual(preparation.predicateRoot, value.attestation.predicateRoot, "predicate root");
|
|
617
|
+
const bundleStatement = decodeBundleStatement(readJson(bundlePath, "attestation bundle"));
|
|
618
|
+
validateStatement(bundleStatement, preparation);
|
|
619
|
+
const results = Array.isArray(verificationResults) ? verificationResults : [];
|
|
620
|
+
const verified = results.some((result) => {
|
|
621
|
+
try {
|
|
622
|
+
validateStatement(verificationStatement(result), preparation);
|
|
623
|
+
return true;
|
|
624
|
+
} catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
if (!verified) throw new Error("gh verification output has no matching verified statement");
|
|
629
|
+
} catch (error) {
|
|
630
|
+
issues.push({ code: "github-artifact-attestation.invalid", message: error.message });
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
contract: GITHUB_ARTIFACT_ATTESTATION_VERIFICATION_CONTRACT,
|
|
634
|
+
ok: issues.length === 0,
|
|
635
|
+
outcome: issues.length === 0 ? "verified" : "rejected",
|
|
636
|
+
issues,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export function githubArtifactAttestationRequiredPermissions() {
|
|
641
|
+
return [...REQUIRED_PERMISSIONS];
|
|
642
|
+
}
|
package/packages/core/index.js
CHANGED
|
@@ -373,6 +373,25 @@ export {
|
|
|
373
373
|
verifyReleasePassport,
|
|
374
374
|
} from "./release-passport.js";
|
|
375
375
|
|
|
376
|
+
export {
|
|
377
|
+
GITHUB_ARTIFACT_ATTESTATION_EVIDENCE_CONTRACT,
|
|
378
|
+
GITHUB_ARTIFACT_ATTESTATION_POLICY_CONTRACT,
|
|
379
|
+
GITHUB_ARTIFACT_ATTESTATION_PREDICATE_CONTRACT,
|
|
380
|
+
GITHUB_ARTIFACT_ATTESTATION_PREDICATE_TYPE,
|
|
381
|
+
GITHUB_ARTIFACT_ATTESTATION_VERIFICATION_CONTRACT,
|
|
382
|
+
GITHUB_ARTIFACT_ATTESTATION_WORKFLOW,
|
|
383
|
+
createGitHubArtifactAttestationEvidence,
|
|
384
|
+
createGitHubArtifactAttestationPolicy,
|
|
385
|
+
createGitHubArtifactAttestationVerificationPlan,
|
|
386
|
+
githubArtifactAttestationRequiredPermissions,
|
|
387
|
+
githubArtifactAttestationSemanticRoot,
|
|
388
|
+
githubArtifactAttestationSha256Buffer,
|
|
389
|
+
githubArtifactAttestationSha256File,
|
|
390
|
+
normalizeGitHubArtifactAttestationPolicy,
|
|
391
|
+
prepareGitHubArtifactAttestation,
|
|
392
|
+
verifyGitHubArtifactAttestationEvidence,
|
|
393
|
+
} from "./github-artifact-attestation.js";
|
|
394
|
+
|
|
376
395
|
export {
|
|
377
396
|
KFD_AGENT_HUB_ADOPTION_CONTRACT,
|
|
378
397
|
KFD_AGENT_HUB_ADOPTION_SCHEMA,
|
|
@@ -106,7 +106,7 @@ export function detectPublicationAuthoritySignals(workflowText = "") {
|
|
|
106
106
|
const signals = [];
|
|
107
107
|
const rules = [
|
|
108
108
|
["write-all", /permissions\s*:\s*write-all|permissions\s*:\s*\{[^}]*write-all/i],
|
|
109
|
-
["write-permission", /^\s*(?:contents|packages|deployments|checks|issues|pull-requests|actions)\s*:\s*write\s*$/im],
|
|
109
|
+
["write-permission", /^\s*(?:contents|packages|deployments|checks|issues|pull-requests|actions|attestations|artifact-metadata)\s*:\s*write\s*$/im],
|
|
110
110
|
["oidc", /^\s*id-token\s*:\s*write\s*$/im],
|
|
111
111
|
["environment", /^\s*environment\s*:/im],
|
|
112
112
|
["npm-publish", /\bnpm\s+(?:stage\s+)?publish\b/i],
|
|
@@ -96,6 +96,7 @@ export const RELEASE_PASSPORT_SCHEMA = {
|
|
|
96
96
|
platformArtifactManifests: { type: "array", items: OBJECT },
|
|
97
97
|
distTagPromotion: OBJECT,
|
|
98
98
|
controllerReceipts: { type: "array", items: OBJECT },
|
|
99
|
+
githubArtifactAttestations: { type: "array", items: OBJECT },
|
|
99
100
|
"kfd-1": OBJECT,
|
|
100
101
|
"kfd-2": OBJECT,
|
|
101
102
|
"kfd-3": OBJECT,
|
|
@@ -123,6 +124,7 @@ const BUILDCHAIN_AGGREGATION_FIELDS = [
|
|
|
123
124
|
"distTagPromotion",
|
|
124
125
|
"controllerReceipts",
|
|
125
126
|
"kfdSupport",
|
|
127
|
+
"githubArtifactAttestations",
|
|
126
128
|
"artifacts",
|
|
127
129
|
"evidence",
|
|
128
130
|
"recovery",
|