@xemahq/xema 0.48.0 → 0.49.1

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.
Files changed (98) hide show
  1. package/dist/assets/appliance-base/distribution.lock.json +3936 -1515
  2. package/dist/assets/appliance-base/images.lock.json +878 -1161
  3. package/dist/assets/appliance-lean/distribution.lock.json +3663 -916
  4. package/dist/assets/appliance-lean/images.lock.json +825 -924
  5. package/dist/assets/boot-graph.json +398 -93
  6. package/dist/assets/distribution-assets.manifest.json +10 -10
  7. package/dist/commands/edition.d.ts.map +1 -1
  8. package/dist/commands/edition.js +5 -3
  9. package/dist/commands/edition.js.map +1 -1
  10. package/dist/dev/env-defaults.d.ts.map +1 -1
  11. package/dist/dev/env-defaults.js +0 -1
  12. package/dist/dev/env-defaults.js.map +1 -1
  13. package/dist/edition/verify-image-evidence.bundle.mjs +4386 -2503
  14. package/dist/edition/verify-image-evidence.bundle.provenance.json +590 -488
  15. package/dist/runtime/auth/cli-application.d.ts +18 -0
  16. package/dist/runtime/auth/cli-application.d.ts.map +1 -0
  17. package/dist/runtime/auth/cli-application.js +39 -0
  18. package/dist/runtime/auth/cli-application.js.map +1 -0
  19. package/dist/runtime/edition/verb-map.d.ts +3 -1
  20. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  21. package/dist/runtime/edition/verb-map.js +5 -1
  22. package/dist/runtime/edition/verb-map.js.map +1 -1
  23. package/dist/runtime/env-seeder.d.ts.map +1 -1
  24. package/dist/runtime/env-seeder.js +21 -0
  25. package/dist/runtime/env-seeder.js.map +1 -1
  26. package/dist/runtime/launch.d.ts.map +1 -1
  27. package/dist/runtime/launch.js +0 -18
  28. package/dist/runtime/launch.js.map +1 -1
  29. package/edition/README.md +20 -3
  30. package/edition/biome-content.mjs +5 -2
  31. package/edition/build-runtime-image-source-index.mjs +79 -5
  32. package/edition/canonical-evidence-json.mjs +82 -0
  33. package/edition/catalogue-evidence.mjs +8 -5
  34. package/edition/customer-edition-verbs.mjs +9 -3
  35. package/edition/develop-coverage-run.mjs +2 -1
  36. package/edition/develop-coverage.mjs +12 -1
  37. package/edition/develop-edition-resolve.mjs +3 -2
  38. package/edition/develop-edition.mjs +15 -3
  39. package/edition/develop-record-verify.mjs +216 -0
  40. package/edition/develop-select-records.mjs +24 -225
  41. package/edition/develop-verify-image-record.mjs +90 -0
  42. package/edition/dist-check.mjs +24 -3
  43. package/edition/distribution-evidence-set.mjs +30 -2
  44. package/edition/edition-artifact.mjs +110 -22
  45. package/edition/edition-ceiling.mjs +180 -0
  46. package/edition/edition-config.mjs +91 -0
  47. package/edition/edition-constants.mjs +21 -3
  48. package/edition/edition-contract-parse.mjs +88 -3
  49. package/edition/edition-contract.json +47 -4
  50. package/edition/edition-image-seal.mjs +9 -2
  51. package/edition/edition-kind.mjs +10 -0
  52. package/edition/edition-protocol.mjs +7 -1
  53. package/edition/edition-publisher.mjs +21 -0
  54. package/edition/edition-seal.mjs +257 -0
  55. package/edition/evidence-grammars.mjs +2 -0
  56. package/edition/evidence-trust-pin.mjs +66 -10
  57. package/edition/evidence-trust-policy.mjs +39 -29
  58. package/edition/external-source-walk.mjs +16 -3
  59. package/edition/fleet-coordinate-finality.mjs +18 -5
  60. package/edition/fulcio-certificate-extensions.mjs +231 -0
  61. package/edition/image-evidence.mjs +34 -79
  62. package/edition/locked-bundle-signer.mjs +241 -0
  63. package/edition/oci-evidence-verbs.mjs +7 -1
  64. package/edition/pinned-cosign.mjs +70 -0
  65. package/edition/plan-image-evidence-refresh.mjs +3 -1
  66. package/edition/producer-evidence-bundle.mjs +3 -1
  67. package/edition/producer-evidence-index-file.mjs +4 -0
  68. package/edition/release-edition-artifact.mjs +3 -3
  69. package/edition/repin-evidence-trust.mjs +6 -1
  70. package/edition/resolve-distribution.mjs +90 -7
  71. package/edition/resolve-edition.mjs +1 -1
  72. package/edition/resolve-images.mjs +53 -7
  73. package/edition/seal-image-digests.mjs +19 -8
  74. package/edition/sealed-distribution-lock.mjs +2 -2
  75. package/edition/third-party-admission.mjs +91 -0
  76. package/edition/third-party-bundle-registry.mjs +147 -0
  77. package/edition/third-party-certificate-extensions.mjs +21 -0
  78. package/edition/third-party-image-pins.mjs +41 -0
  79. package/edition/third-party-pins-trusted.mjs +24 -0
  80. package/edition/third-party-producer-evidence.mjs +76 -0
  81. package/edition/third-party-source-check.mjs +28 -0
  82. package/edition/third-party-source-context.mjs +41 -0
  83. package/edition/third-party-source-evidence.mjs +255 -0
  84. package/edition/third-party-source-flags.mjs +134 -0
  85. package/edition/third-party-source-pins.mjs +259 -0
  86. package/edition/third-party-source-uniqueness.mjs +54 -0
  87. package/edition/third-party-source-walk.mjs +115 -0
  88. package/edition/third-party-source-writer.mjs +229 -0
  89. package/edition/third-party-subjects.mjs +128 -0
  90. package/edition/trust-policy-image-ownership.mjs +85 -0
  91. package/edition/verify-catalogue-evidence.mjs +2 -0
  92. package/edition/verify-image-evidence.mjs +2 -0
  93. package/edition/verify-image-platforms.mjs +33 -9
  94. package/package.json +5 -5
  95. package/dist/runtime/auth/client-bootstrap.d.ts +0 -11
  96. package/dist/runtime/auth/client-bootstrap.d.ts.map +0 -1
  97. package/dist/runtime/auth/client-bootstrap.js +0 -60
  98. package/dist/runtime/auth/client-bootstrap.js.map +0 -1
@@ -21,179 +21,32 @@
21
21
  // the newest remaining valid one. The guard is deploy-latest's (part 07): its owner refuses a snapshot that goes
22
22
  // backwards. This verb checks nothing against current pins.
23
23
 
24
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
25
- import { tmpdir } from 'node:os';
26
- import { join } from 'node:path';
27
-
28
- import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
29
- import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
30
24
  import { writeOnce } from './customer-edition-verbs.mjs';
31
- import { basicAuthParts, defaultRunCosign } from './develop-coverage-readers.mjs';
32
25
  import { developGraphMembers } from './develop-graph.mjs';
33
26
  import { PublishedRecordMissingError, newestCommitOnDevelop } from './develop-record-ancestry.mjs';
27
+ import {
28
+ cosignEnvironment,
29
+ listBuildRecordRuns,
30
+ loadVerifiedRecord,
31
+ recordSignerOrigin,
32
+ requireBuildRecordKernel,
33
+ withRecordVerifier,
34
+ } from './develop-record-verify.mjs';
34
35
  import { editionWorkspaceConfig } from './edition-config.mjs';
35
- import { dataRoot } from './edition-workspace.mjs';
36
- import { loadDeclaredPinnedPolicy } from './evidence-trust-policy.mjs';
37
- import { COSIGN_ENV, prepareTrustedCosignBinary, validatePinnedTrustedRoot } from './image-evidence.mjs';
38
- import { compareStrings, loadKernelDistributionSchemas } from './lib.mjs';
36
+ import { compareStrings } from './lib.mjs';
39
37
  import { RecordRefused } from './record-refused.mjs';
40
- import { loadRegistryCredentialDeclarations, resolveRegistryCredential, splitImageRef } from './registry-credentials.mjs';
41
- import { isNotPublishedYet } from './seal-image-digests.mjs';
42
38
  import { readStrictJsonFile } from './strict-json.mjs';
43
- import { assertPreparedExecutable } from './trusted-executable.mjs';
44
- import { registryClientsFor } from './verify-image-platforms.mjs';
45
-
46
- /** The repository segment, under the registry root, that holds a member's build records. */
47
- export const BUILD_RECORDS_SEGMENT = 'build-records';
48
- export const RECORD_SIGNER_WORKFLOW = '.github/workflows/record-build.yaml';
49
- export const RECORD_SIGNER_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
50
- /** The branches a record signer is declared for, each with the ref the record workflow runs at. */
51
- const RECORD_SIGNER_REFS = Object.freeze({ develop: 'refs/heads/develop' });
52
- /**
53
- * A cosign exit whose stderr shows the registry was not reached before any verdict. Only this, a cosign that
54
- * could not run, and a timeout are operational; every other non-zero exit of `cosign verify` is a verdict.
55
- */
56
- const COSIGN_TRANSPORT_FAILURE = /dial tcp|i\/o timeout|connection (refused|reset)|no such host|TLS handshake timeout|context deadline exceeded/i;
57
- const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
58
-
59
- /** The record's producer is a kind this CLI has no claims for: a closed set, never guessed at. */
60
- export class UnknownBuildRecordProducerError extends RecordRefused {
61
- constructor(repo, kind) {
62
- super(repo, `the record's producer kind ${JSON.stringify(kind)} is not one select-records can verify`);
63
- this.name = 'UnknownBuildRecordProducerError';
64
- }
65
- }
66
-
67
- export class DataRootRepositoryUndeclaredError extends Error {
68
- constructor() {
69
- super('edition-workspace.json declares no dataRootRepositoryUrl; select-records cannot name the record signer');
70
- this.name = 'DataRootRepositoryUndeclaredError';
71
- }
72
- }
73
-
74
- export class KernelLacksBuildRecordSchemaError extends Error {
75
- constructor(missing) {
76
- super(`the kernel's distribution module does not export ${missing.join(', ')}; select-records needs a kernel with the build-record contract`);
77
- this.name = 'KernelLacksBuildRecordSchemaError';
78
- }
79
- }
80
-
81
- /** The data root's `owner/name`, from the workspace declaration; refused by name when it declares none. */
82
- export function declaredDataRootRepositoryUrl() {
83
- const url = editionWorkspaceConfig().dataRootRepositoryUrl;
84
- if (url === undefined) {throw new DataRootRepositoryUndeclaredError();}
85
- return url;
86
- }
87
-
88
- /** The kernel's build-record exports, refused as a set when any is absent. */
89
- export function requireBuildRecordKernel(schemas) {
90
- const names = ['BUILD_RECORD_MEDIA_TYPE', 'BuildRecordSchema', 'buildRecordTag', 'parseBuildRecordTag', 'compareBuildRecordRuns'];
91
- const missing = names.filter((name) => schemas?.[name] === undefined);
92
- if (missing.length > 0 || typeof schemas.BuildRecordSchema?.safeParse !== 'function') {
93
- throw new KernelLacksBuildRecordSchemaError(missing.length > 0 ? missing : ['a BuildRecordSchema with safeParse']);
94
- }
95
- return schemas;
96
- }
97
-
98
- /** The ref and data-root repository a branch's records are signed by; refused before any network call. */
99
- export function recordSignerOrigin({ branch, dataRootRepositoryUrl }) {
100
- const ref = Object.hasOwn(RECORD_SIGNER_REFS, branch) ? RECORD_SIGNER_REFS[branch] : undefined;
101
- if (ref === undefined) {throw new Error(`no record signer declared for branch ${branch}`);}
102
- if (dataRootRepositoryUrl === undefined) {throw new DataRootRepositoryUndeclaredError();}
103
- const dataRepository = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(dataRootRepositoryUrl, 'dataRootRepositoryUrl'), 'dataRootRepositoryUrl');
104
- return { ref, dataRepository };
105
- }
106
-
107
- /**
108
- * The signer of the branch's records: the data root's record workflow at the branch, as the certificate states
109
- * it, with the repository that CALLED the workflow (the member) as the workflow-repository claim.
110
- */
111
- export function recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl }) {
112
- const { ref, dataRepository } = recordSignerOrigin({ branch, dataRootRepositoryUrl });
113
- const member = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(memberRepositoryUrl, 'repositoryUrl'), 'repositoryUrl');
114
- return {
115
- certificateIdentity: `https://github.com/${dataRepository}/${RECORD_SIGNER_WORKFLOW}@${ref}`,
116
- oidcIssuer: RECORD_SIGNER_OIDC_ISSUER,
117
- githubWorkflow: { repository: member, ref },
118
- memberRepository: member,
119
- };
120
- }
121
-
122
- /**
123
- * What a record's producer says, read through the ONE switch over its kind: the repository that called the
124
- * record workflow (the cosign caller claim, held equal to the member's) and the run the snapshot names.
125
- */
126
- export function producerClaims({ record, repo, memberRepository }) {
127
- switch (record.producer?.kind) {
128
- case 'github-actions': {
129
- const { repository, runNumber, runAttempt } = record.producer;
130
- if (repository !== memberRepository) {throw new RecordRefused(repo, `record is produced by repository ${repository}, not ${memberRepository}`);}
131
- return { repository, snapshot: `${runNumber}.${runAttempt}` };
132
- }
133
- default:
134
- throw new UnknownBuildRecordProducerError(repo, record.producer?.kind);
135
- }
136
- }
137
39
 
138
- /** Run one registry client call; ONLY a failure of the client itself is "the registry did not answer". */
139
- async function fromRegistry(repo, what, call) {
140
- try {
141
- return await call();
142
- } catch (error) {
143
- throw new Error(`select-records: ${repo}: the registry did not answer for ${what}: ${error.message}`);
144
- }
145
- }
40
+ // The record-verification names this verb's tests and callers read from here, owned by the shared core.
41
+ export {
42
+ DataRootRepositoryUndeclaredError,
43
+ KernelLacksBuildRecordSchemaError,
44
+ UnknownBuildRecordProducerError,
45
+ declaredDataRootRepositoryUrl,
46
+ recordSigner,
47
+ } from './develop-record-verify.mjs';
146
48
 
147
- /** Read one record by its tag: manifest by digest, its single layer, parsed against the kernel's schema. */
148
- async function readRecord({ kernel, client, repository, repo, tag }) {
149
- const served = await fromRegistry(repo, `${repository}:${tag}`, () => client.getManifestByTag(repository, tag));
150
- const manifest = await fromRegistry(repo, `${repository}@${served.digest}`, () => client.getManifestByDigest(repository, served.digest));
151
- const layers = manifest.document.layers;
152
- if (!Array.isArray(layers) || layers.length !== 1) {
153
- throw new RecordRefused(repo, `record ${tag} has ${Array.isArray(layers) ? layers.length : 'no'} layers, not exactly one`);
154
- }
155
- const [layer] = layers;
156
- if (layer.mediaType !== kernel.BUILD_RECORD_MEDIA_TYPE) {
157
- throw new RecordRefused(repo, `record ${tag} layer media type is ${JSON.stringify(layer.mediaType)}, not ${kernel.BUILD_RECORD_MEDIA_TYPE}`);
158
- }
159
- const bytes = await fromRegistry(repo, `${repository}@${layer.digest}`, () => client.getBlob(repository, layer.digest));
160
- let document;
161
- try {
162
- document = JSON.parse(bytes.toString('utf8'));
163
- } catch (error) {
164
- throw new RecordRefused(repo, `record ${tag} is not JSON: ${error.message}`);
165
- }
166
- const parsed = kernel.BuildRecordSchema.safeParse(document);
167
- if (!parsed.success) {throw new RecordRefused(repo, `record ${tag} does not parse as a build record: ${parsed.error.message}`);}
168
- return { record: parsed.data, digest: served.digest };
169
- }
170
-
171
- /** The tag's record, verified by digest against the constrained signer, then held to its own repo, branch and tag. */
172
- async function loadVerifiedRecord({ kernel, client, runCosign, cosignEnv, trustedRootPath, repository, member, branch, dataRootRepositoryUrl, tag }) {
173
- const repo = member.repo;
174
- const { record, digest } = await readRecord({ kernel, client, repository, repo, tag });
175
- const signer = recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl: member.repositoryUrl });
176
- const { repository: callerRepository, snapshot } = producerClaims({ record, repo, memberRepository: signer.memberRepository });
177
- signer.githubWorkflow.repository = callerRepository;
178
- const ref = `${repository}@${digest}`;
179
- const args = ['verify', '--offline', '--trusted-root', trustedRootPath, ...certificateConstraintArgs(signer, record.commit), ref];
180
- let result;
181
- try {
182
- result = await runCosign(args, cosignEnv);
183
- } catch (error) {
184
- // no numeric exit code: cosign did not run (spawn failure) or did not answer (timeout, kill signal)
185
- throw new Error(`select-records: ${repo}: cosign could not run for ${ref}: ${error.message}`);
186
- }
187
- if (result.code !== 0) {
188
- if (COSIGN_TRANSPORT_FAILURE.test(result.stderr)) {
189
- throw new Error(`select-records: ${repo}: cosign could not reach the registry for ${ref} (exit ${result.code}): ${result.stderr.trim()}`);
190
- }
191
- throw new RecordRefused(repo, `cosign refused record ${tag} (${digest}) as signed by ${signer.certificateIdentity} (exit ${result.code}): ${result.stderr.trim()}`);
192
- }
193
- if (record.branch !== branch) {throw new RecordRefused(repo, `record ${tag} is for branch ${record.branch}, not ${branch}`);}
194
- if (kernel.buildRecordTag(record) !== tag) {throw new RecordRefused(repo, `record tagged ${tag} says it is ${kernel.buildRecordTag(record)}`);}
195
- return { record, snapshot };
196
- }
49
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
197
50
 
198
51
  /**
199
52
  * @param {object} request
@@ -221,26 +74,12 @@ export async function selectRecords({ branch, registry, at, members, dataRootRep
221
74
  }
222
75
  const selected = [];
223
76
  for (const member of members) {
224
- const repository = `${registry}/${BUILD_RECORDS_SEGMENT}/${member.repo.split('/').at(-1)}`;
225
- const { authority } = splitImageRef(repository);
226
- const client = clientFor(authority);
227
- let tags;
228
- try {
229
- tags = await client.listTags(repository);
230
- } catch (error) {
231
- if (isNotPublishedYet(error)) {throw new RecordRefused(member.repo, `${repository} does not exist: no build record was ever published`);}
232
- throw new Error(`select-records: ${member.repo}: the registry did not answer for ${repository} tags: ${error.message}`);
233
- }
234
- const runs = tags
235
- .map((tag) => ({ tag, run: kernel.parseBuildRecordTag(tag, branch) }))
236
- .filter(({ run }) => run !== undefined)
237
- .sort((a, b) => kernel.compareBuildRecordRuns(b.run, a.run));
238
- if (runs.length === 0) {throw new RecordRefused(member.repo, `${repository} has no build record for branch ${branch}`);}
77
+ const { repository, authority, client, runs } = await listBuildRecordRuns({ kernel, clientFor, registry, member, branch });
239
78
  // EVERY candidate is read and verified; any that fails refuses the run. None is skipped.
240
79
  const verified = [];
241
80
  for (const { tag, run } of runs) {
242
81
  const loaded = await loadVerifiedRecord({
243
- kernel, client, runCosign, cosignEnv: { ...COSIGN_ENV, ...cosignEnvFor(authority) }, trustedRootPath,
82
+ kernel, client, runCosign, cosignEnv: cosignEnvironment(cosignEnvFor, authority), trustedRootPath,
244
83
  repository, member, branch, dataRootRepositoryUrl, tag,
245
84
  });
246
85
  verified.push({ tag, run, ...loaded });
@@ -302,55 +141,18 @@ export function readPublishedPins(path) {
302
141
  export async function runSelectRecords({
303
142
  options,
304
143
  env = process.env,
305
- loadPolicy = loadDeclaredPinnedPolicy,
306
- prepareCosign = prepareTrustedCosignBinary,
307
- loadKernel = loadKernelDistributionSchemas,
308
- trustedRootBytes = (root, pinnedPolicy) => validatePinnedTrustedRoot(root, pinnedPolicy).bytes,
309
- cosignPathOf = (handle) => assertPreparedExecutable(handle, 'cosign'),
310
144
  members = developGraphMembers(),
311
- runCosign: injectedRunCosign,
312
- clientsOverride,
313
145
  runGit,
314
146
  write = writeOnce,
147
+ ...verifierInjections
315
148
  }) {
316
- const dataRootRepositoryUrl = declaredDataRootRepositoryUrl();
317
- const { policy, dataRoot: policyRoot } = await loadPolicy();
318
- const kernel = requireBuildRecordKernel(await loadKernel());
319
- const trustedCosign = prepareCosign({ cosignBinary: options['--cosign'], policy });
320
- const cosignPath = cosignPathOf(trustedCosign);
321
- const runCosign = injectedRunCosign ?? ((args, cosignEnv) => defaultRunCosign(cosignPath, args, cosignEnv));
322
- const scratch = mkdtempSync(join(tmpdir(), 'xema-select-records-'));
323
- try {
324
- const trustedRootPath = join(scratch, 'trusted-root.json');
325
- writeFileSync(trustedRootPath, trustedRootBytes(policyRoot, policy), { flag: 'wx', mode: 0o600 });
326
- const declarations = clientsOverride === undefined ? loadRegistryCredentialDeclarations(dataRoot()) : null;
327
- const clients = new Map();
328
- const clientFor = (authority) => {
329
- if (clientsOverride !== undefined) {return clientsOverride(authority);}
330
- if (!clients.has(authority)) {
331
- clients.set(authority, registryClientsFor([{ imageRef: `${authority}/x` }], { declarations, env }).get(authority));
332
- }
333
- return clients.get(authority);
334
- };
335
- // cosign gets the SAME declared credential oras uses, in its environment, never argv.
336
- const cosignEnvFor = (authority) => {
337
- if (clientsOverride !== undefined) {return {};}
338
- const credential = resolveRegistryCredential({ authority, declarations, env });
339
- if (credential.access === 'anonymous') {return {};}
340
- const { username, password } = basicAuthParts(credential);
341
- return { COSIGN_REGISTRY_USERNAME: username, COSIGN_REGISTRY_PASSWORD: password };
342
- };
149
+ return withRecordVerifier({ cosignBinary: options['--cosign'], env, ...verifierInjections }, async (verifier) => {
343
150
  const selection = await selectRecords({
344
151
  branch: options['--branch'],
345
152
  registry: options['--registry'],
346
153
  at: options['--at'],
347
154
  members,
348
- dataRootRepositoryUrl,
349
- kernel,
350
- clientFor,
351
- runCosign,
352
- cosignEnvFor,
353
- trustedRootPath,
155
+ ...verifier,
354
156
  publishedPins: options['--published-pins'] === undefined ? undefined : readPublishedPins(options['--published-pins']),
355
157
  workspace: editionWorkspaceConfig(),
356
158
  env,
@@ -358,8 +160,5 @@ export async function runSelectRecords({
358
160
  });
359
161
  write(options['--out'], selection);
360
162
  return selection;
361
- } finally {
362
- rmSync(scratch, { recursive: true, force: true });
363
- trustedCosign.dispose();
364
- }
163
+ });
365
164
  }
@@ -0,0 +1,90 @@
1
+ // develop-verify-image-record — the body of `xema edition develop verify-image-record`.
2
+ //
3
+ // Does an authenticated build record bind this image digest to this producer? The producer's records of the
4
+ // develop branch are read newest run first and each is verified by the shared core (`develop-record-verify.mjs`:
5
+ // cosign keyless identity of the data root's record workflow, caller repository = the producer, ref, workflow
6
+ // sha = the record's commit, and the record's producer repository = the producer). Only a record produced by the
7
+ // producer's own CI workflow (`.github/workflows/ci.yaml`) can name the images it serves: a verified record of
8
+ // another workflow is skipped, and the answer is the newest ci.yaml record that lists the exact `name@digest`.
9
+ //
10
+ // Deterministic: `listBuildRecordRuns` (develop-record-verify.mjs:205) sorts the tags by the kernel's
11
+ // `compareBuildRecordRuns` (build-record.ts:218-228: run number, then attempt), highest first, and the first
12
+ // match wins; a record that fails verification refuses the call before any older one is read.
13
+ //
14
+ // The returned `commit` is the commit of the newest verified ci.yaml record (by run number, then attempt) that lists
15
+ // the digest. That can be an older commit when a later run targeted it (a manual ci.yaml run on an older commit gets a
16
+ // higher run number), and for an image the producer REUSED by content hash it is NOT the commit that built the image.
17
+ // This is safe because the digest is what binds the bytes: every ci.yaml record lists every image served at its commit,
18
+ // built or reused, so the record names the running digest without a rebuild.
19
+ //
20
+ // Every refusal is a `RecordRefused` naming the producer: no ci.yaml record lists the image, the record names
21
+ // another producer, or cosign refuses the identity. A registry or cosign that could not answer is a plain Error.
22
+ // On success it prints `{image, digest, producer, commit}`.
23
+
24
+ import {
25
+ cosignEnvironment,
26
+ listBuildRecordRuns,
27
+ loadVerifiedRecord,
28
+ withRecordVerifier,
29
+ } from './develop-record-verify.mjs';
30
+ import { serializeJson } from './lib.mjs';
31
+ import { RecordRefused } from './record-refused.mjs';
32
+
33
+ /** The only branch with a declared record signer. */
34
+ export const VERIFY_IMAGE_RECORD_BRANCH = 'develop';
35
+ /** The only workflow whose records name a producer's served images. */
36
+ export const PRODUCER_IMAGE_WORKFLOW = '.github/workflows/ci.yaml';
37
+
38
+ const IMAGE_REFERENCE = /^([^@\s]+)@(sha256:[0-9a-f]{64})$/;
39
+ const PRODUCER_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
40
+
41
+ /** `name@sha256:<64 hex>` split into its name and digest; anything else is refused. */
42
+ export function parseImageReference(image) {
43
+ const match = IMAGE_REFERENCE.exec(image);
44
+ if (match === null) {throw new Error(`--image must be <name>@sha256:<64 hex digest>; got ${JSON.stringify(image)}`);}
45
+ return { name: match[1], digest: match[2] };
46
+ }
47
+
48
+ /** The producer member the records are read for: its repository `owner/name` and the URL the signer constrains. */
49
+ function producerMember(producer) {
50
+ if (!PRODUCER_REPOSITORY.test(producer)) {throw new Error(`--producer must be <owner>/<repo>; got ${JSON.stringify(producer)}`);}
51
+ return { repo: producer, repositoryUrl: `https://github.com/${producer}.git` };
52
+ }
53
+
54
+ /**
55
+ * @param {object} request
56
+ * @param {string} request.image `<name>@sha256:<digest>`
57
+ * @param {string} request.producer `<owner>/<repo>`
58
+ * @param {string} request.registry the registry root that holds `build-records/<repo>`
59
+ * @returns {Promise<{image: string, digest: string, producer: string, commit: string}>}
60
+ */
61
+ export async function verifyImageRecord({ image, producer, registry, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }) {
62
+ const { name, digest } = parseImageReference(image);
63
+ const member = producerMember(producer);
64
+ const branch = VERIFY_IMAGE_RECORD_BRANCH;
65
+ const { repository, authority, client, runs } = await listBuildRecordRuns({ kernel, clientFor, registry, member, branch });
66
+ for (const { tag } of runs) {
67
+ const { record } = await loadVerifiedRecord({
68
+ kernel, client, runCosign, cosignEnv: cosignEnvironment(cosignEnvFor, authority), trustedRootPath,
69
+ repository, member, branch, dataRootRepositoryUrl, tag,
70
+ });
71
+ if (record.producer.workflow !== PRODUCER_IMAGE_WORKFLOW) {continue;}
72
+ if (!record.images.some((listed) => listed.name === name && listed.digest === digest)) {continue;}
73
+ return { image: name, digest, producer, commit: record.commit };
74
+ }
75
+ throw new RecordRefused(producer, `no verified ${PRODUCER_IMAGE_WORKFLOW} build record of ${repository} for branch ${branch} lists ${name}@${digest}`);
76
+ }
77
+
78
+ /** The `verify-image-record` verb. */
79
+ export async function runVerifyImageRecord({ options, env = process.env, write = (text) => process.stdout.write(text), ...verifierInjections }) {
80
+ return withRecordVerifier({ cosignBinary: options['--cosign'], env, ...verifierInjections }, async (verifier) => {
81
+ const verified = await verifyImageRecord({
82
+ image: options['--image'],
83
+ producer: options['--producer'],
84
+ registry: options['--registry'],
85
+ ...verifier,
86
+ });
87
+ write(serializeJson(verified));
88
+ return verified;
89
+ });
90
+ }
@@ -48,7 +48,7 @@ import { editionWorkspaceConfig } from './edition-config.mjs';
48
48
  import { editionPinnedInputs } from './edition-pinned-inputs.mjs';
49
49
  import { assertKernelEditionProtocol } from './edition-protocol.mjs';
50
50
  import { describeEditionHolds } from './edition-source-pins.mjs';
51
- import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, editionsRoot, isEntryModule, kernelPackageDir, platformServiceIndexPath, runtimeImageSourceIndexPath, workspaceArgv } from './edition-workspace.mjs';
51
+ import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule, kernelPackageDir, platformServiceIndexPath, runtimeImageSourceIndexPath, workspaceArgv } from './edition-workspace.mjs';
52
52
  import { checkEvidenceTrustPolicy } from './evidence-trust-policy.mjs';
53
53
  import {
54
54
  compareIndexEntries,
@@ -64,7 +64,7 @@ import {
64
64
  validateServiceDependencyOwnerParity,
65
65
  } from './lib.mjs';
66
66
  import { commandFor } from './remedy-command.mjs';
67
- import { resolveDistributionWithIndex } from './resolve-distribution.mjs';
67
+ import { assertReleaseGitSourcesDeclareNoBundle, resolveDistributionWithIndex } from './resolve-distribution.mjs';
68
68
  import {
69
69
  resolveDistributionDeclaredSources,
70
70
  resolveDistributionRequiredPlatforms,
@@ -72,12 +72,15 @@ import {
72
72
  resolveImages,
73
73
  resolveComposedProcessStructure,
74
74
  resolveImageStructure,
75
+ structureSubjects,
75
76
  sourceTreeLocator,
76
77
  supplementalSourcesFor,
77
78
  } from './resolve-images.mjs';
78
79
  import { withoutSealedDigests } from './seal-image-digests.mjs';
79
80
  import { createPinnedSourceAuthority } from './source-authority.mjs';
80
81
  import { readStorePublisherTrust } from './store-publisher-trust.mjs';
82
+ import { CHECK_THIRD_PARTY_FLAG, COSIGN_BIN_ENV, COSIGN_FLAG } from './third-party-source-flags.mjs';
83
+ import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
81
84
 
82
85
  if (isEntryModule(import.meta.url)) {
83
86
  configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Deploy, WorkspaceRoot.Cache]);
@@ -157,6 +160,7 @@ export async function checkDistributionLock(id) {
157
160
  const lockPath = join(editionsRoot(), id, 'distribution.lock.json');
158
161
  try {
159
162
  const { lock: fresh, index } = await resolveDistributionWithIndex(id);
163
+ assertReleaseGitSourcesDeclareNoBundle(fresh, index);
160
164
  const verdict = await compareCommittedLock(lockPath, fresh);
161
165
  if (verdict === LockCheck.MISSING) {
162
166
  console.error(
@@ -302,7 +306,11 @@ export async function checkImagesLock(id, checkSourceEvidence) {
302
306
  schemas,
303
307
  requiredPlatforms,
304
308
  resolveDistributionDeclaredSources(schemas, id),
305
- { biomeIndex: pinned.biomeIndex, supplementalSources: supplementalSourcesFor(runtimeSourceIndex) },
309
+ {
310
+ biomeIndex: pinned.biomeIndex,
311
+ supplementalSources: supplementalSourcesFor(runtimeSourceIndex),
312
+ thirdPartyPins: loadThirdPartySourcePins(dataRoot()),
313
+ },
306
314
  ),
307
315
  schemas,
308
316
  requiredPlatforms,
@@ -311,6 +319,7 @@ export async function checkImagesLock(id, checkSourceEvidence) {
311
319
  distributionId: id,
312
320
  contractAuthority: runtimeSourceIndex.contractAuthority,
313
321
  ...structure,
322
+ subjects: structureSubjects(structure),
314
323
  };
315
324
  if (!existsSync(imageLockPath)) {
316
325
  console.error(
@@ -385,6 +394,18 @@ export async function checkCommittedRuntimeImageSourceIndex(checkSources) {
385
394
  ],
386
395
  { stdio: 'inherit' },
387
396
  );
397
+ // Every pinned third-party source, from its committed evidence (a data root with none passes trivially).
398
+ const cosign = process.env[COSIGN_BIN_ENV];
399
+ execFileSync(
400
+ process.execPath,
401
+ [
402
+ join(HERE, 'build-runtime-image-source-index.mjs'),
403
+ CHECK_THIRD_PARTY_FLAG,
404
+ ...(cosign === undefined || cosign === '' ? [] : [COSIGN_FLAG, cosign]),
405
+ ...workspaceArgv(),
406
+ ],
407
+ { stdio: 'inherit' },
408
+ );
388
409
  return true;
389
410
  } catch (error) {
390
411
  console.error(
@@ -67,6 +67,8 @@ import {
67
67
  } from './oci-image-source-evidence.mjs';
68
68
  import { BUNDLE_MEDIA_TYPE, canonicalSignedBundleBytes, cosignVerifyAttestationArgs, validateProducedBundle } from './produce-image-source-evidence.mjs';
69
69
  import { splitImageRef } from './registry-credentials.mjs';
70
+ import { imageSubjectKey, thirdPartyImagePins } from './third-party-image-pins.mjs';
71
+ import { thirdPartyProducerRecord } from './third-party-producer-evidence.mjs';
70
72
 
71
73
  /**
72
74
  * The ONE Sigstore bundle media type this plane stores — re-exported, never
@@ -467,6 +469,8 @@ export async function resolveProducerEvidence({
467
469
  expectedContractAuthority,
468
470
  registryClientFactory,
469
471
  channel,
472
+ // The reader of the edition's validated locks; a seam so the assembly is testable without a whole edition tree.
473
+ loadLocks = loadValidatedLocks,
470
474
  }) {
471
475
  assertCanonicalDistributionId(distributionId);
472
476
  assertAbsolutePath(dataRoot, 'data root');
@@ -474,8 +478,8 @@ export async function resolveProducerEvidence({
474
478
  if (!expectedContractAuthority) {
475
479
  throw new Error('the expected kernel contract authority is required');
476
480
  }
477
- const { distributionLock, imageLock, repositoryPins, runtimeSourceIndex } =
478
- await loadValidatedLocks(distributionId, dataRoot, schemas, { editionsRoot, channel });
481
+ const { distributionLock, imageLock, repositoryPins, runtimeSourceIndex, thirdPartySources } =
482
+ await loadLocks(distributionId, dataRoot, schemas, { editionsRoot, channel });
479
483
  assertKernelContractAuthorityEqual(
480
484
  runtimeSourceIndex.contractAuthority,
481
485
  expectedContractAuthority,
@@ -486,8 +490,32 @@ export async function resolveProducerEvidence({
486
490
  factory: registryClientFactory,
487
491
  });
488
492
 
493
+ const thirdPartyImages = thirdPartyImagePins({
494
+ pins: { byRepository: (repository) => thirdPartySources.get(repository) },
495
+ subjects: imageLock.subjects,
496
+ });
489
497
  const producers = [];
490
498
  for (const subject of imageLock.subjects) {
499
+ const pinImage = thirdPartyImages.get(imageSubjectKey(subject));
500
+ if (pinImage !== undefined) {
501
+ // A third-party subject's contentHash is publisher-attested, not a tag: its evidence is the committed evidence its pin
502
+ // binds, read from the data root with no registry call.
503
+ producers.push(
504
+ thirdPartyProducerRecord({
505
+ subject,
506
+ pinImage,
507
+ dataRoot,
508
+ schemas,
509
+ expectedSource: expectedProducerSource({
510
+ subject,
511
+ repositoryPin: repositoryPins.get(subject.repo),
512
+ sourceHashAlgorithm: imageLock.sourceHashAlgorithm,
513
+ }),
514
+ stem: subjectStem(subject),
515
+ }),
516
+ );
517
+ continue;
518
+ }
491
519
  const expectedSource = expectedProducerSource({
492
520
  subject,
493
521
  repositoryPin: repositoryPins.get(subject.repo),