@xemahq/xema 0.48.0 → 0.49.0

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 +22 -2
  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 +224 -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 +47 -6
  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 +127 -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
@@ -26,6 +26,8 @@ import {
26
26
  serializeJson,
27
27
  sha256Hex,
28
28
  } from './lib.mjs';
29
+ import { createRegistryClientResolver } from './oci-image-source-evidence.mjs';
30
+ import { disposeRegistrySessions } from './oras.mjs';
29
31
  import {
30
32
  platformServiceRuntimeBindings,
31
33
  } from './platform-service-source-authority.mjs';
@@ -36,6 +38,15 @@ import {
36
38
  parseSourceModeArgs,
37
39
  } from './source-authority.mjs';
38
40
  import { readStrictJsonFile } from './strict-json.mjs';
41
+ import { checkThirdPartySourcePins } from './third-party-source-check.mjs';
42
+ import { loadThirdPartySourceContext } from './third-party-source-context.mjs';
43
+ import {
44
+ CHECK_THIRD_PARTY_FLAG,
45
+ THIRD_PARTY_FLAG,
46
+ THIRD_PARTY_WRITER_USAGE,
47
+ takeThirdPartyFlags,
48
+ } from './third-party-source-flags.mjs';
49
+ import { commitThirdPartySourceWrite, planThirdPartySourceWrite } from './third-party-source-writer.mjs';
39
50
 
40
51
  if (isEntryModule(import.meta.url)) {
41
52
  configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Sources, WorkspaceRoot.Kernel]);
@@ -398,6 +409,18 @@ export function mergeProducerWorkflowPins(committedPins, statedPins) {
398
409
  return [...merged.values()].sort((left, right) => compareStrings(left.repository, right.repository));
399
410
  }
400
411
 
412
+ /**
413
+ * The committed index with one producer workflow pin merged in by `mergeProducerWorkflowPins`: what the third-party
414
+ * writer adds (`{repository, workflowCommit: sourceCommit}`). Every repository pin and subject is kept exactly: a
415
+ * third-party repository is not an index repository, so nothing about it can be re-resolved.
416
+ */
417
+ export function indexWithProducerWorkflowPin({ committedIndex, pin }) {
418
+ return {
419
+ ...committedIndex,
420
+ producerWorkflowPins: mergeProducerWorkflowPins(committedIndex.producerWorkflowPins, [pin]),
421
+ };
422
+ }
423
+
401
424
  const PRODUCER_WORKFLOW_PIN_FLAG = '--producer-workflow-pin';
402
425
 
403
426
  /**
@@ -972,12 +995,38 @@ const USAGE =
972
995
  '--repin-contract-authority --kernel-commit <40-hex-commit>) ' +
973
996
  '[--source published|workspace] [--kernel-root <absolute-checkout>] ' +
974
997
  '[--repo <repository> [--at <40-hex-commit>]] [--producer-workflow-pin <owner/repo>=<40-hex-commit> ...] ' +
975
- '(--trusted-write on a data root with no committed contract authority also requires --kernel-commit)';
998
+ '(--trusted-write on a data root with no committed contract authority also requires --kernel-commit)\n' +
999
+ ` build-runtime-image-source-index.mjs --trusted-write ${THIRD_PARTY_WRITER_USAGE}\n` +
1000
+ ` build-runtime-image-source-index.mjs ${CHECK_THIRD_PARTY_FLAG} [--cosign <absolute cosign path>]`;
1001
+
1002
+ /** `--trusted-write --third-party <name>`: verify one third-party source and commit its pin, evidence and producer workflow pin. */
1003
+ async function writeThirdPartySource({ schemas, writer, cosignBinary, kernelRoot }) {
1004
+ const context = loadThirdPartySourceContext({ dataRoot: dataRoot(), schemas, cosignBinary });
1005
+ try {
1006
+ const plan = await planThirdPartySourceWrite({
1007
+ request: writer,
1008
+ context,
1009
+ clientFor: createRegistryClientResolver({ dataRoot: dataRoot() }),
1010
+ dataRoot: dataRoot(),
1011
+ withProducerWorkflowPin: indexWithProducerWorkflowPin,
1012
+ });
1013
+ await validateRuntimeImageSourceIndex(plan.candidateIndex, schemas, { kernelRoot });
1014
+ const next = commitThirdPartySourceWrite({
1015
+ dataRoot: dataRoot(),
1016
+ plan,
1017
+ indexPath: runtimeImageSourceIndexPath(),
1018
+ indexText: serializeJson(plan.candidateIndex),
1019
+ });
1020
+ console.log(`[runtime-image-source-index] ${next}`);
1021
+ } finally {
1022
+ context.dispose();
1023
+ disposeRegistrySessions();
1024
+ }
1025
+ }
976
1026
 
977
1027
  async function main() {
978
- const { kernelRoot, remaining: afterKernelRootFlag } = parseKernelRoot(
979
- process.argv.slice(2),
980
- );
1028
+ const { writer, cosignBinary, remaining: afterThirdPartyFlags } = takeThirdPartyFlags(process.argv.slice(2));
1029
+ const { kernelRoot, remaining: afterKernelRootFlag } = parseKernelRoot(afterThirdPartyFlags);
981
1030
  const { kernelCommit, remaining: afterKernelRoot } = parseKernelCommit(afterKernelRootFlag);
982
1031
  const { producerWorkflowPins, remaining: afterWorkflowPins } = parseProducerWorkflowPinFlags(afterKernelRoot);
983
1032
  const { repoScope, remaining: afterRepoScope } = parseRepoScope(afterWorkflowPins);
@@ -989,15 +1038,40 @@ async function main() {
989
1038
  const [flag] = remaining;
990
1039
  if (
991
1040
  remaining.length !== 1 ||
992
- !['--check', '--check-sources', '--trusted-write', REPIN_FLAG].includes(flag)
1041
+ ![CHECK_THIRD_PARTY_FLAG, '--check', '--check-sources', '--trusted-write', REPIN_FLAG].includes(flag)
993
1042
  ) {
994
1043
  throw new Error(USAGE);
995
1044
  }
1045
+ if (cosignBinary !== undefined && writer === undefined && flag !== CHECK_THIRD_PARTY_FLAG) {
1046
+ throw new Error(`--cosign belongs to ${THIRD_PARTY_FLAG} or ${CHECK_THIRD_PARTY_FLAG}: ${flag} runs no cosign\n${USAGE}`);
1047
+ }
996
1048
  if (kernelCommit !== undefined && ![REPIN_FLAG, '--trusted-write'].includes(flag)) {
997
1049
  throw new Error(`${KERNEL_COMMIT_FLAG} states the commit of a new contract authority: ${flag} chooses none\n${USAGE}`);
998
1050
  }
999
1051
  assertProducerWorkflowPinsWritten(producerWorkflowPins, flag, USAGE);
1000
1052
  const schemas = await loadKernelDistributionSchemas();
1053
+ if (flag === CHECK_THIRD_PARTY_FLAG) {
1054
+ if (repoScope !== undefined || exactCommit !== undefined || kernelCommit !== undefined) {
1055
+ throw new Error(`${CHECK_THIRD_PARTY_FLAG} re-verifies the committed third-party pins only: it takes no --repo, --at or ${KERNEL_COMMIT_FLAG}`);
1056
+ }
1057
+ const { checked } = await checkThirdPartySourcePins({ dataRoot: dataRoot(), schemas, cosignBinary });
1058
+ console.log(
1059
+ checked === 0
1060
+ ? '[runtime-image-source-index] no third-party source is pinned in this data root'
1061
+ : `[runtime-image-source-index] third-party source check passed for ${checked} pinned source(s)`,
1062
+ );
1063
+ return;
1064
+ }
1065
+ if (writer !== undefined) {
1066
+ if (flag !== '--trusted-write') {
1067
+ throw new Error(`${THIRD_PARTY_FLAG} writes a pin: it takes --trusted-write\n${USAGE}`);
1068
+ }
1069
+ if (repoScope !== undefined || exactCommit !== undefined || kernelCommit !== undefined || producerWorkflowPins.length > 0) {
1070
+ throw new Error(`${THIRD_PARTY_FLAG} writes one third-party pin: it takes no --repo, --at, ${KERNEL_COMMIT_FLAG} or ${PRODUCER_WORKFLOW_PIN_FLAG}`);
1071
+ }
1072
+ await writeThirdPartySource({ schemas, writer, cosignBinary, kernelRoot });
1073
+ return;
1074
+ }
1001
1075
  if (flag === REPIN_FLAG) {
1002
1076
  if (repoScope !== undefined || exactCommit !== undefined) {
1003
1077
  throw new Error(`${REPIN_FLAG} replaces the contract authority only: it takes no --repo or --at`);
@@ -0,0 +1,82 @@
1
+ // Canonical evidence JSON, exact UTF-8 and sha256 digests: the primitives every evidence module shares. Its own module so a
2
+ // module `image-evidence.mjs` imports (the third-party pin loader) can use them without an import cycle.
3
+
4
+ import { createHash } from 'node:crypto';
5
+
6
+ import { compareStrings } from './lib.mjs';
7
+
8
+ const STRICT_UTF8 = new TextDecoder('utf-8', {
9
+ fatal: true,
10
+ ignoreBOM: true,
11
+ });
12
+
13
+ export function canonicalJson(value) {
14
+ if (
15
+ value === null ||
16
+ typeof value === 'boolean' ||
17
+ typeof value === 'string'
18
+ ) {
19
+ return JSON.stringify(value);
20
+ }
21
+ if (typeof value === 'number') {
22
+ if (!Number.isSafeInteger(value)) {
23
+ throw new Error('canonical evidence JSON permits only safe integers');
24
+ }
25
+ return JSON.stringify(value);
26
+ }
27
+ if (Array.isArray(value)) {
28
+ return `[${value.map(canonicalJson).join(',')}]`;
29
+ }
30
+ if (typeof value === 'object') {
31
+ const entries = Object.entries(value).sort(([left], [right]) =>
32
+ compareStrings(left, right),
33
+ );
34
+ return `{${entries
35
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
36
+ .join(',')}}`;
37
+ }
38
+ throw new Error(`unsupported canonical evidence JSON value ${typeof value}`);
39
+ }
40
+
41
+ export function canonicalJsonBytes(value) {
42
+ return Buffer.from(`${canonicalJson(value)}\n`, 'utf8');
43
+ }
44
+
45
+ export function sha256Digest(bytes) {
46
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
47
+ }
48
+
49
+ /**
50
+ * Exact UTF-8 decode. Exported because the producers need byte-for-byte the
51
+ * same strictness the verifier applies — a producer that accepts bytes the
52
+ * verifier will reject signs evidence that can never verify.
53
+ */
54
+ export function decodeExactUtf8(bytes, label) {
55
+ let text;
56
+ try {
57
+ text = STRICT_UTF8.decode(bytes);
58
+ } catch {
59
+ throw new Error(`${label} contains invalid UTF-8`);
60
+ }
61
+ if (!Buffer.from(text, 'utf8').equals(bytes)) {
62
+ throw new Error(`${label} is not exact UTF-8`);
63
+ }
64
+ return text;
65
+ }
66
+
67
+ export function parseCanonicalJsonBytes(bytes, label) {
68
+ const text = decodeExactUtf8(bytes, label);
69
+ let value;
70
+ try {
71
+ value = JSON.parse(text);
72
+ } catch (error) {
73
+ throw new Error(`${label} is not valid JSON: ${error.message}`);
74
+ }
75
+ const canonical = canonicalJsonBytes(value);
76
+ if (!bytes.equals(canonical)) {
77
+ throw new Error(
78
+ `${label} is not canonical JSON (duplicate keys, non-canonical ordering, or whitespace are refused)`,
79
+ );
80
+ }
81
+ return value;
82
+ }
@@ -69,11 +69,14 @@ export class CatalogueVerifierNotPinnedError extends Error {
69
69
  }
70
70
  }
71
71
 
72
+ /** The edition a catalogue-consumer refusal names: the customer edition of `channel` that reads the catalogue. */
73
+ const catalogueConsumer = (channel) => ({ edition: `${channel} edition`, channel });
74
+
72
75
  /** The pinned built verifier's bundle path, after the data root's pin of it held; refuses before anything is spawned. */
73
- function pinnedVerifierBundle(locateVerifier) {
76
+ function pinnedVerifierBundle(locateVerifier, channel) {
74
77
  const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
75
78
  const root = configuredDataRoot();
76
- if (declaration.policy === null) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
79
+ if (declaration.policy === null) {throw new EvidenceTrustRequiredError(root, 'declares no policy', catalogueConsumer(channel));}
77
80
  try {
78
81
  const pin = readEvidenceTrustPin({ dataRoot: root, declaration });
79
82
  assertPinnedVerifier({ pin, actual: readBuiltVerifierDigests(locateVerifier) });
@@ -111,12 +114,12 @@ const stderrOf = (result) => String(result.stderr ?? '').trim() || (result.error
111
114
  * @param {{ channel: string, locateVerifier?: Function, spawn?: Function }} options
112
115
  */
113
116
  export function assertCatalogueConsumerArmed({ channel, locateVerifier = locateBuiltVerifier, spawn = spawnSync }) {
114
- const { bundlePath, root } = pinnedVerifierBundle(locateVerifier);
117
+ const { bundlePath, root } = pinnedVerifierBundle(locateVerifier, channel);
115
118
  const result = runVerifier({ spawn, bundlePath, args: ['arming', '--channel', channel, '--data-root', root] });
116
119
  if (result.status === 0) {return;}
117
120
  const stderr = stderrOf(result);
118
121
  if (stderr.includes('EVIDENCE_TRUST_REQUIRED_UNARMED')) {
119
- throw new EvidenceTrustRequiredError(root, `declares a policy that is not armed on the ${channel} channel for a catalogue consumer (${stderr})`);
122
+ throw new EvidenceTrustRequiredError(root, `declares a policy that is not armed on the ${channel} channel for a catalogue consumer (${stderr})`, catalogueConsumer(channel));
120
123
  }
121
124
  throw new CatalogueEvidenceNotVerifiedError(`the ${channel} catalogue`, `the arming check failed: ${stderr}`);
122
125
  }
@@ -181,7 +184,7 @@ export async function verifyCatalogueEvidenceReferrer({
181
184
  if (sha256Of(statementBytes) !== layers.statement.digest || sha256Of(bundleBytes) !== layers.bundle.digest) {
182
185
  throw new CatalogueEvidenceNotVerifiedError(ref, 'a referrer layer does not hash to its digest');
183
186
  }
184
- const { bundlePath, root } = pinnedVerifierBundle(locateVerifier);
187
+ const { bundlePath, root } = pinnedVerifierBundle(locateVerifier, channel);
185
188
  const evidenceRoot = mkdtempSync(join(tmpdir(), 'xema-catalogue-evidence-'));
186
189
  try {
187
190
  writeFileSync(join(evidenceRoot, CATALOGUE_EVIDENCE_STATEMENT_TITLE), statementBytes, { flag: 'wx', mode: 0o600 });
@@ -25,7 +25,7 @@ import { isDevelopImageRef } from './develop-image-coordinate.mjs';
25
25
  import { assertCanonicalDistributionId } from './distribution-paths.mjs';
26
26
  import { LOCAL_WORKFLOW, assertEngineImage, assertExistingArtifactMatches, snapshotEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
27
27
  import { editionWorkspaceConfig } from './edition-config.mjs';
28
- import { CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY, EditionChannel, EditionLockFile } from './edition-constants.mjs';
28
+ import { CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY, EditionChannel, EditionKind, EditionLockFile } from './edition-constants.mjs';
29
29
  import { channelImageRef, sealImageSubjects } from './edition-image-seal.mjs';
30
30
  import { WorkspaceRoot } from './edition-workspace.mjs';
31
31
  import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
@@ -219,6 +219,12 @@ const COMMANDS = Object.freeze({
219
219
  switches: [],
220
220
  required: ['--branch', '--registry', '--cosign', '--out'],
221
221
  },
222
+ 'verify-image-record': {
223
+ positional: false,
224
+ values: ['--image', '--producer', '--registry', '--cosign'],
225
+ switches: [],
226
+ required: ['--image', '--producer', '--registry', '--cosign'],
227
+ },
222
228
  'check-overrides': { positional: false, values: ['--overrides'], switches: [], required: ['--overrides'] },
223
229
  'choose-kernel': { positional: false, values: ['--kernel-pin', '--out'], switches: [], required: ['--out'] },
224
230
  'check-existing': {
@@ -360,7 +366,7 @@ async function resolveCustomer(channel, options, env) {
360
366
  sourceImages,
361
367
  env,
362
368
  });
363
- const evidenceTrustPolicy = await descriptorEvidenceTrust({ channel, consumesCatalogue: true });
369
+ const evidenceTrustPolicy = await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel, consumesCatalogue: true });
364
370
  const target = snapshotEditionTarget(channel, id, pins, { registry: options['--registry'], engineImage: options['--engine-image'], sourceImages });
365
371
  // A release edition is admitted only sealed: every image names the digest the registry serves. (A develop edition is not held to it today.)
366
372
  if (channel === EditionChannel.Release) {assertSealedImageLock(resolved.imageLock, `the images lock of "${id}"`);}
@@ -393,7 +399,7 @@ export async function checkExistingCustomer(channel, options, env, { adopt = ado
393
399
  artifactDir: resolve(options['--artifact']),
394
400
  distributionId: frame.id,
395
401
  channel,
396
- evidenceTrustPolicy: await descriptorEvidenceTrust({ channel, locateVerifier, consumesCatalogue: true }),
402
+ evidenceTrustPolicy: await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel, locateVerifier, consumesCatalogue: true }),
397
403
  pins: frame.pins,
398
404
  engineImage: options['--engine-image'],
399
405
  catalogueDigest: frame.catalogueDigest,
@@ -9,6 +9,7 @@ import { DEVELOP_EDITION_STEPS, resolveDevelopSnapshot } from './develop-edition
9
9
  import { loadDeclaredPinnedPolicy } from './evidence-trust-policy.mjs';
10
10
  import { prepareTrustedCosignBinary } from './image-evidence.mjs';
11
11
  import { readPinnedContractAuthority } from './produce-image-source-evidence.mjs';
12
+ import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
12
13
 
13
14
  const stderrProgress = (line) => process.stderr.write(`${line}\n`);
14
15
 
@@ -37,7 +38,7 @@ export async function runDevelopCoverage({
37
38
  progress(`[coverage] image content hashes computed (${imageLock.subjects.length} subjects)`);
38
39
  const readers = createCoverageReaders({ policy, policyRoot: dataRoot, trustedCosign, schemas, expectedContractAuthority: readPinnedContractAuthority({ dataRoot, schemas }), dataRoot, env, ...readerOptions });
39
40
  try {
40
- const report = await collectCoverage({ distributionId: id, subjects: imageLock.subjects, policy, readers, progress });
41
+ const report = await collectCoverage({ distributionId: id, subjects: imageLock.subjects, policy, readers, thirdPartyPins: loadThirdPartySourcePins(dataRoot), progress });
41
42
  write(json ? `${JSON.stringify(report, null, 2)}\n` : renderCoverageTable(report));
42
43
  return coverageExitCode(report);
43
44
  } finally {
@@ -11,6 +11,8 @@ import { AttestationStatus, COVERED_VERDICTS, DEVELOP_EVIDENCE_CHANNEL, DEVELOP_
11
11
  import { developImageRef } from './develop-image-coordinate.mjs';
12
12
  import { policyCandidates } from './image-evidence.mjs';
13
13
  import { mapWithConcurrency } from './map-with-concurrency.mjs';
14
+ import { imageSubjectKey, thirdPartyImagePins } from './third-party-image-pins.mjs';
15
+ import { attestedContentHash } from './third-party-subjects.mjs';
14
16
 
15
17
  /** In flight at once, as `develop resolve` probes. */
16
18
  export const COVERAGE_CONCURRENCY = 6;
@@ -32,9 +34,18 @@ export const expectedService = (subject) => subject.id;
32
34
  * @param {object[]} request.subjects the images lock subjects (release refs, unsealed), contentHash = F
33
35
  * @param {object} request.policy the pinned trust policy (rows are filtered per develop repository)
34
36
  * @param {{ developDigest: Function, readAttestation: Function, readSignatures: Function }} request.readers
37
+ * @param {{byRepository: (repository: string) => object|undefined}} [request.thirdPartyPins] the data root's third-party
38
+ * source pins: a subject of a pinned repository is not built by the develop lane, so it is no coverage subject
35
39
  * @param {(line: string) => void} [request.progress]
36
40
  */
37
- export async function collectCoverage({ distributionId, subjects, policy, readers, concurrency = COVERAGE_CONCURRENCY, progress = () => {} }) {
41
+ export async function collectCoverage({ distributionId, subjects: lockSubjects, policy, readers, thirdPartyPins = undefined, concurrency = COVERAGE_CONCURRENCY, progress = () => {} }) {
42
+ const pinnedImages = thirdPartyPins === undefined ? new Map() : thirdPartyImagePins({ pins: thirdPartyPins, subjects: lockSubjects });
43
+ const subjects = lockSubjects.filter((subject) => !pinnedImages.has(imageSubjectKey(subject)));
44
+ for (const subject of lockSubjects.filter((candidate) => pinnedImages.has(imageSubjectKey(candidate)))) {
45
+ progress(
46
+ `[coverage] third-party ${imageSubjectKey(subject)} ${subject.imageRef}@${pinnedImages.get(imageSubjectKey(subject)).manifestDigest} ${attestedContentHash(subject)}, not a develop coverage subject`,
47
+ );
48
+ }
38
49
  const rows = await mapWithConcurrency(subjects, concurrency, async (subject) => {
39
50
  const developSubject = { ...subject, imageRef: developImageRef(subject.imageRef) };
40
51
  const digest = await readers.developDigest(developSubject);
@@ -16,13 +16,14 @@ import { createDevelopImageProbe } from './develop-image-probe.mjs';
16
16
  import { assertFrozenCheckouts, buildSnapshotInputs } from './develop-snapshot-inputs.mjs';
17
17
  import { EditionChannel } from './edition-constants.mjs';
18
18
  import { sealImages } from './edition-image-seal.mjs';
19
- import { kernelPackageDir } from './edition-workspace.mjs';
19
+ import { dataRoot, kernelPackageDir } from './edition-workspace.mjs';
20
20
  import { kernelPackageOfAuthority } from './kernel-package.mjs';
21
21
  import { loadKernelDistributionSchemas } from './lib.mjs';
22
22
  import { deployPinKey } from './pin-source.mjs';
23
23
  import { composeAuthorizationBase, resolveDistributionWithIndex } from './resolve-distribution.mjs';
24
24
  import { externalSourceTrees, resolveImages, sourceTreeLocator } from './resolve-images.mjs';
25
25
  import { createPinnedSourceAuthority } from './source-authority.mjs';
26
+ import { loadTrustedThirdPartySourcePins } from './third-party-pins-trusted.mjs';
26
27
 
27
28
  /**
28
29
  * The steps the resolve runs, named so a test can stand in for the ones that
@@ -83,7 +84,7 @@ export async function resolveDevelopEdition({ id, pins, env = process.env, sealO
83
84
  phase(`image content hashes computed (${imageLock.subjects.length} subjects)`);
84
85
  await probe.settle();
85
86
  phase('every develop image is present');
86
- const sealedImageLock = await steps.sealImages({ channel: EditionChannel.Develop, imageLock, env, progress, ...sealOptions });
87
+ const sealedImageLock = await steps.sealImages({ channel: EditionChannel.Develop, imageLock, env, progress, thirdPartyPins: loadTrustedThirdPartySourcePins(dataRoot()), ...sealOptions });
87
88
  phase('images sealed');
88
89
  // The unsealed lock becomes the one v5 grammar only here, once the images it
89
90
  // owns are sealed: each biomeContentHash is recomputed from the frozen sources.
@@ -45,6 +45,14 @@
45
45
  // annotations-missing | annotation-mismatch | not-built). Prints a table grouped by producer repo
46
46
  // and `covered N/M` (--json: the same as data). Exit 0 only when every subject is covered, 1 when
47
47
  // not, 2 on an operational error. Takes the roots `resolve` takes; writes nothing.
48
+ // verify-image-record --image <name>@sha256:<digest> --producer <owner/repo> --registry <root> --cosign <absolute-path>
49
+ // whether an authenticated develop build record of the producer lists exactly this image name and digest: the
50
+ // producer's records are read newest run first and verified as select-records verifies them (shared core); a record of a
51
+ // workflow other than the producer's `.github/workflows/ci.yaml` is skipped, and the newest ci.yaml record that lists the image answers.
52
+ // Prints { image, digest, producer, commit }: commit is that of the newest verified ci.yaml record (by run number, then attempt)
53
+ // that lists the digest; it can be an older commit when a later run targeted it, which is safe because the digest binds the bytes.
54
+ // Refuses (exit 1) when no ci.yaml record lists the image, the record
55
+ // names another producer, or cosign refuses the identity. Takes the roots `resolve` takes.
48
56
  // select-records --branch develop --registry <root> --cosign <absolute-path> --out <file> [--at <ISO-8601 UTC>] [--published-pins <file>]
49
57
  // for every develop-graph member, take the newest build record the data root's record workflow signed for
50
58
  // the branch (<root>/build-records/<name>, verified offline by digest against the pinned trusted root, as
@@ -91,8 +99,9 @@ import { CoverageExit } from './develop-coverage.mjs';
91
99
  import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
92
100
  import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
93
101
  import { runSelectRecords } from './develop-select-records.mjs';
102
+ import { runVerifyImageRecord } from './develop-verify-image-record.mjs';
94
103
  import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
95
- import { EditionChannel } from './edition-constants.mjs';
104
+ import { EditionChannel, EditionKind } from './edition-constants.mjs';
96
105
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir } from './edition-workspace.mjs';
97
106
  import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
98
107
  import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protocol-choice.mjs';
@@ -154,7 +163,7 @@ async function run(argv, env) {
154
163
  const pins = readStrictJsonFile(options['--pins']);
155
164
  const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
156
165
  const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage: options['--engine-image'] });
157
- const evidenceTrustPolicy = await descriptorEvidenceTrust({ channel: EditionChannel.Develop });
166
+ const evidenceTrustPolicy = await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel: EditionChannel.Develop });
158
167
  const { catalogue, ...resolved } = await resolveDevelopEdition({ id, pins, env });
159
168
  const outDir = resolve(options['--out']);
160
169
  writeEditionArtifact({
@@ -182,13 +191,16 @@ async function run(argv, env) {
182
191
  case 'select-records':
183
192
  await runSelectRecords({ options, env });
184
193
  return;
194
+ case 'verify-image-record':
195
+ await runVerifyImageRecord({ options, env });
196
+ return;
185
197
  case 'check-existing': {
186
198
  if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
187
199
  assertExistingArtifactMatches({
188
200
  artifactDir: resolve(options['--artifact']),
189
201
  distributionId: id,
190
202
  channel: EditionChannel.Develop,
191
- evidenceTrustPolicy: await descriptorEvidenceTrust({ channel: EditionChannel.Develop }),
203
+ evidenceTrustPolicy: await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel: EditionChannel.Develop }),
192
204
  pins: readStrictJsonFile(options['--pins']),
193
205
  engineImage: options['--engine-image'],
194
206
  });
@@ -0,0 +1,216 @@
1
+ // develop-record-verify — the verify core of a signed build record, shared by `develop select-records` and
2
+ // `develop verify-image-record`.
3
+ //
4
+ // A record is trusted only after cosign verified it, offline against the pinned trusted root, as signed by the
5
+ // data root's `record-build.yaml` at `refs/heads/<branch>`, called by the member's own repository at the
6
+ // record's own commit; the record must also name that member as its producer, its own branch and its own tag.
7
+ // Both verbs read records through `loadVerifiedRecord`: there is one copy of this logic.
8
+ //
9
+ // Two failure classes: `RecordRefused` (the record is missing, or cosign or the record itself says no) and a
10
+ // plain Error (the registry or cosign could not answer), which the operator retries rather than reads.
11
+
12
+
13
+ import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
14
+ import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
15
+ import { editionWorkspaceConfig } from './edition-config.mjs';
16
+ import { loadKernelDistributionSchemas } from './lib.mjs';
17
+ import { cosignEnvironment, withPinnedCosign } from './pinned-cosign.mjs';
18
+ import { RecordRefused } from './record-refused.mjs';
19
+ import { splitImageRef } from './registry-credentials.mjs';
20
+ import { isNotPublishedYet } from './seal-image-digests.mjs';
21
+
22
+ /** The repository segment, under the registry root, that holds a member's build records. */
23
+ export const BUILD_RECORDS_SEGMENT = 'build-records';
24
+ export const RECORD_SIGNER_WORKFLOW = '.github/workflows/record-build.yaml';
25
+ export const RECORD_SIGNER_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
26
+ /** The branches a record signer is declared for, each with the ref the record workflow runs at. */
27
+ const RECORD_SIGNER_REFS = Object.freeze({ develop: 'refs/heads/develop' });
28
+ /**
29
+ * A cosign exit whose stderr shows the registry was not reached before any verdict. Only this, a cosign that
30
+ * could not run, and a timeout are operational; every other non-zero exit of `cosign verify` is a verdict.
31
+ */
32
+ const COSIGN_TRANSPORT_FAILURE = /dial tcp|i\/o timeout|connection (refused|reset)|no such host|TLS handshake timeout|context deadline exceeded/i;
33
+
34
+ /** The record's producer is a kind this CLI has no claims for: a closed set, never guessed at. */
35
+ export class UnknownBuildRecordProducerError extends RecordRefused {
36
+ constructor(repo, kind) {
37
+ super(repo, `the record's producer kind ${JSON.stringify(kind)} is not one select-records can verify`);
38
+ this.name = 'UnknownBuildRecordProducerError';
39
+ }
40
+ }
41
+
42
+ export class DataRootRepositoryUndeclaredError extends Error {
43
+ constructor() {
44
+ super('edition-workspace.json declares no dataRootRepositoryUrl; select-records cannot name the record signer');
45
+ this.name = 'DataRootRepositoryUndeclaredError';
46
+ }
47
+ }
48
+
49
+ export class KernelLacksBuildRecordSchemaError extends Error {
50
+ constructor(missing) {
51
+ super(`the kernel's distribution module does not export ${missing.join(', ')}; select-records needs a kernel with the build-record contract`);
52
+ this.name = 'KernelLacksBuildRecordSchemaError';
53
+ }
54
+ }
55
+
56
+ /** The data root's `owner/name`, from the workspace declaration; refused by name when it declares none. */
57
+ export function declaredDataRootRepositoryUrl() {
58
+ const url = editionWorkspaceConfig().dataRootRepositoryUrl;
59
+ if (url === undefined) {throw new DataRootRepositoryUndeclaredError();}
60
+ return url;
61
+ }
62
+
63
+ /** The kernel's build-record exports, refused as a set when any is absent. */
64
+ export function requireBuildRecordKernel(schemas) {
65
+ const names = ['BUILD_RECORD_MEDIA_TYPE', 'BuildRecordSchema', 'buildRecordTag', 'parseBuildRecordTag', 'compareBuildRecordRuns'];
66
+ const missing = names.filter((name) => schemas?.[name] === undefined);
67
+ if (missing.length > 0 || typeof schemas.BuildRecordSchema?.safeParse !== 'function') {
68
+ throw new KernelLacksBuildRecordSchemaError(missing.length > 0 ? missing : ['a BuildRecordSchema with safeParse']);
69
+ }
70
+ return schemas;
71
+ }
72
+
73
+ /** The ref and data-root repository a branch's records are signed by; refused before any network call. */
74
+ export function recordSignerOrigin({ branch, dataRootRepositoryUrl }) {
75
+ const ref = Object.hasOwn(RECORD_SIGNER_REFS, branch) ? RECORD_SIGNER_REFS[branch] : undefined;
76
+ if (ref === undefined) {throw new Error(`no record signer declared for branch ${branch}`);}
77
+ if (dataRootRepositoryUrl === undefined) {throw new DataRootRepositoryUndeclaredError();}
78
+ const dataRepository = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(dataRootRepositoryUrl, 'dataRootRepositoryUrl'), 'dataRootRepositoryUrl');
79
+ return { ref, dataRepository };
80
+ }
81
+
82
+ /**
83
+ * The signer of the branch's records: the data root's record workflow at the branch, as the certificate states
84
+ * it, with the repository that CALLED the workflow (the member) as the workflow-repository claim.
85
+ */
86
+ export function recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl }) {
87
+ const { ref, dataRepository } = recordSignerOrigin({ branch, dataRootRepositoryUrl });
88
+ const member = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(memberRepositoryUrl, 'repositoryUrl'), 'repositoryUrl');
89
+ return {
90
+ certificateIdentity: `https://github.com/${dataRepository}/${RECORD_SIGNER_WORKFLOW}@${ref}`,
91
+ oidcIssuer: RECORD_SIGNER_OIDC_ISSUER,
92
+ githubWorkflow: { repository: member, ref },
93
+ memberRepository: member,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * What a record's producer says, read through the ONE switch over its kind: the repository that called the
99
+ * record workflow (the cosign caller claim, held equal to the member's) and the run the snapshot names.
100
+ */
101
+ export function producerClaims({ record, repo, memberRepository }) {
102
+ switch (record.producer?.kind) {
103
+ case 'github-actions': {
104
+ const { repository, runNumber, runAttempt } = record.producer;
105
+ if (repository !== memberRepository) {throw new RecordRefused(repo, `record is produced by repository ${repository}, not ${memberRepository}`);}
106
+ return { repository, snapshot: `${runNumber}.${runAttempt}` };
107
+ }
108
+ default:
109
+ throw new UnknownBuildRecordProducerError(repo, record.producer?.kind);
110
+ }
111
+ }
112
+
113
+ /** Run one registry client call; ONLY a failure of the client itself is "the registry did not answer". */
114
+ async function fromRegistry(repo, what, call) {
115
+ try {
116
+ return await call();
117
+ } catch (error) {
118
+ throw new Error(`select-records: ${repo}: the registry did not answer for ${what}: ${error.message}`);
119
+ }
120
+ }
121
+
122
+ /** Read one record by its tag: manifest by digest, its single layer, parsed against the kernel's schema. */
123
+ async function readRecord({ kernel, client, repository, repo, tag }) {
124
+ const served = await fromRegistry(repo, `${repository}:${tag}`, () => client.getManifestByTag(repository, tag));
125
+ const manifest = await fromRegistry(repo, `${repository}@${served.digest}`, () => client.getManifestByDigest(repository, served.digest));
126
+ const layers = manifest.document.layers;
127
+ if (!Array.isArray(layers) || layers.length !== 1) {
128
+ throw new RecordRefused(repo, `record ${tag} has ${Array.isArray(layers) ? layers.length : 'no'} layers, not exactly one`);
129
+ }
130
+ const [layer] = layers;
131
+ if (layer.mediaType !== kernel.BUILD_RECORD_MEDIA_TYPE) {
132
+ throw new RecordRefused(repo, `record ${tag} layer media type is ${JSON.stringify(layer.mediaType)}, not ${kernel.BUILD_RECORD_MEDIA_TYPE}`);
133
+ }
134
+ const bytes = await fromRegistry(repo, `${repository}@${layer.digest}`, () => client.getBlob(repository, layer.digest));
135
+ let document;
136
+ try {
137
+ document = JSON.parse(bytes.toString('utf8'));
138
+ } catch (error) {
139
+ throw new RecordRefused(repo, `record ${tag} is not JSON: ${error.message}`);
140
+ }
141
+ const parsed = kernel.BuildRecordSchema.safeParse(document);
142
+ if (!parsed.success) {throw new RecordRefused(repo, `record ${tag} does not parse as a build record: ${parsed.error.message}`);}
143
+ return { record: parsed.data, digest: served.digest };
144
+ }
145
+
146
+ /** The tag's record, verified by digest against the constrained signer, then held to its own repo, branch and tag. */
147
+ export async function loadVerifiedRecord({ kernel, client, runCosign, cosignEnv, trustedRootPath, repository, member, branch, dataRootRepositoryUrl, tag }) {
148
+ const repo = member.repo;
149
+ const { record, digest } = await readRecord({ kernel, client, repository, repo, tag });
150
+ const signer = recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl: member.repositoryUrl });
151
+ const { repository: callerRepository, snapshot } = producerClaims({ record, repo, memberRepository: signer.memberRepository });
152
+ signer.githubWorkflow.repository = callerRepository;
153
+ const ref = `${repository}@${digest}`;
154
+ const args = ['verify', '--offline', '--trusted-root', trustedRootPath, ...certificateConstraintArgs(signer, record.commit), ref];
155
+ let result;
156
+ try {
157
+ result = await runCosign(args, cosignEnv);
158
+ } catch (error) {
159
+ // no numeric exit code: cosign did not run (spawn failure) or did not answer (timeout, kill signal)
160
+ throw new Error(`select-records: ${repo}: cosign could not run for ${ref}: ${error.message}`);
161
+ }
162
+ if (result.code !== 0) {
163
+ if (COSIGN_TRANSPORT_FAILURE.test(result.stderr)) {
164
+ throw new Error(`select-records: ${repo}: cosign could not reach the registry for ${ref} (exit ${result.code}): ${result.stderr.trim()}`);
165
+ }
166
+ throw new RecordRefused(repo, `cosign refused record ${tag} (${digest}) as signed by ${signer.certificateIdentity} (exit ${result.code}): ${result.stderr.trim()}`);
167
+ }
168
+ if (record.branch !== branch) {throw new RecordRefused(repo, `record ${tag} is for branch ${record.branch}, not ${branch}`);}
169
+ if (kernel.buildRecordTag(record) !== tag) {throw new RecordRefused(repo, `record tagged ${tag} says it is ${kernel.buildRecordTag(record)}`);}
170
+ return { record, snapshot };
171
+ }
172
+
173
+
174
+ /** The registry repository that holds a member's build records. */
175
+ export function recordsRepositoryOf(registry, member) {
176
+ return `${registry}/${BUILD_RECORDS_SEGMENT}/${member.repo.split('/').at(-1)}`;
177
+ }
178
+
179
+ /**
180
+ * Every record tag a member published for `branch`, newest run first, with the registry client of its authority.
181
+ * Refused when none was ever published; a registry that did not answer is a plain Error.
182
+ */
183
+ export async function listBuildRecordRuns({ kernel, clientFor, registry, member, branch }) {
184
+ const repository = recordsRepositoryOf(registry, member);
185
+ const { authority } = splitImageRef(repository);
186
+ const client = clientFor(authority);
187
+ let tags;
188
+ try {
189
+ tags = await client.listTags(repository);
190
+ } catch (error) {
191
+ if (isNotPublishedYet(error)) {throw new RecordRefused(member.repo, `${repository} does not exist: no build record was ever published`);}
192
+ throw new Error(`select-records: ${member.repo}: the registry did not answer for ${repository} tags: ${error.message}`);
193
+ }
194
+ const runs = tags
195
+ .map((tag) => ({ tag, run: kernel.parseBuildRecordTag(tag, branch) }))
196
+ .filter(({ run }) => run !== undefined)
197
+ .sort((a, b) => kernel.compareBuildRecordRuns(b.run, a.run));
198
+ if (runs.length === 0) {throw new RecordRefused(member.repo, `${repository} has no build record for branch ${branch}`);}
199
+ return { repository, authority, client, runs };
200
+ }
201
+
202
+ /**
203
+ * Prepare everything record verification needs (the declared pinned policy, the trusted cosign, the pinned trusted
204
+ * root, the registry clients and their credentials), hand it to `use`, and dispose of it. The injections are
205
+ * replaced by tests.
206
+ */
207
+ export async function withRecordVerifier({ cosignBinary, env, loadKernel = loadKernelDistributionSchemas, ...pinned }, use) {
208
+ const dataRootRepositoryUrl = declaredDataRootRepositoryUrl();
209
+ return withPinnedCosign(
210
+ { cosignBinary, env, ...pinned, afterPolicy: async () => ({ kernel: requireBuildRecordKernel(await loadKernel()) }) },
211
+ ({ kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }) => use({ dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }),
212
+ );
213
+ }
214
+
215
+ /** The environment cosign runs with for one authority: the pinned base plus its declared credential (pinned-cosign.mjs). */
216
+ export { cosignEnvironment };