@xemahq/xema 0.51.0 → 0.52.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.
@@ -132,6 +132,13 @@ const SOURCE_REPO_PATTERN = /^(?:repos|submodules)\/[a-zA-Z0-9_.-]+$/;
132
132
  // The kernel's `CanonicalIdentifierSchema` shape (runId / subject id).
133
133
  const CANONICAL_IDENTIFIER_PATTERN =
134
134
  /^[a-zA-Z0-9](?:[a-zA-Z0-9._:/@+-]{0,254}[a-zA-Z0-9])?$/;
135
+ /** `--subject-id` has the kernel's `CanonicalIdentifierSchema` shape. */
136
+ export function assertCanonicalSubjectId(subjectId) {
137
+ if (!CANONICAL_IDENTIFIER_PATTERN.test(subjectId ?? '')) {
138
+ throw new Error(`--subject-id ${JSON.stringify(subjectId ?? null)} is not a canonical identifier`);
139
+ }
140
+ }
141
+
135
142
  // cosign v2 attaches every attestation of a subject as one layer of the
136
143
  // single `sha256-<subject-digest>.att` manifest; each layer is a DSSE
137
144
  // envelope whose `predicateType` annotation names the attestation.
@@ -848,6 +855,54 @@ export async function resolveImageArtifactEvidence({
848
855
  if (!SHA256_HEX_PATTERN.test(sourceTreeHash ?? '')) {
849
856
  throw new Error('sourceTreeHash must be 64 lowercase sha256 hex characters');
850
857
  }
858
+ assertReferenceProvenance(referenceProvenance);
859
+ const { authority, repository } = splitImageRef(imageRepository);
860
+ const declarations = loadRegistryCredentialDeclarations(distributionsRoot);
861
+ const credential = resolveRegistryCredential({ authority, declarations, env });
862
+ const client = registryFor
863
+ ? registryFor({ authority, credential })
864
+ : new OrasRegistry({ credential, fetchImpl });
865
+ return publishedArtifactEvidence({
866
+ client,
867
+ repository,
868
+ imageRepository,
869
+ read: { tag: sourceTreeHash },
870
+ expectedDigest: manifestDigest,
871
+ coordinate: `${imageRepository}:${sourceTreeHash}`,
872
+ referenceProvenance,
873
+ });
874
+ }
875
+
876
+ /**
877
+ * The artifact section of a producer subject read BY DIGEST: the manifest at
878
+ * `<imageRepository>@<manifestDigest>` (its bytes must hash to that digest),
879
+ * its hash-proven platforms and its cosign SBOM + SLSA attestation digests. A
880
+ * third-party publisher's image carries no `:<sourceTreeHash>` tag
881
+ * (`evidence assemble-third-party-input`), so nothing is read by tag.
882
+ */
883
+ export async function resolveImageArtifactEvidenceByDigest({
884
+ client,
885
+ imageRepository,
886
+ manifestDigest,
887
+ referenceProvenance,
888
+ }) {
889
+ if (!SHA256_DIGEST_PATTERN.test(manifestDigest ?? '')) {
890
+ throw new Error('the image manifest digest must be an exact sha256:<64-hex> digest');
891
+ }
892
+ assertReferenceProvenance(referenceProvenance);
893
+ const { repository } = splitImageRef(imageRepository);
894
+ return publishedArtifactEvidence({
895
+ client,
896
+ repository,
897
+ imageRepository,
898
+ read: { digest: manifestDigest },
899
+ expectedDigest: manifestDigest,
900
+ coordinate: `${imageRepository}@${manifestDigest}`,
901
+ referenceProvenance,
902
+ });
903
+ }
904
+
905
+ function assertReferenceProvenance(referenceProvenance) {
851
906
  if (
852
907
  typeof referenceProvenance !== 'object' ||
853
908
  referenceProvenance === null ||
@@ -858,19 +913,29 @@ export async function resolveImageArtifactEvidence({
858
913
  'it is what binds the selected provenance attestation to the primary build',
859
914
  );
860
915
  }
861
- const { authority, repository } = splitImageRef(imageRepository);
862
- const declarations = loadRegistryCredentialDeclarations(distributionsRoot);
863
- const credential = resolveRegistryCredential({ authority, declarations, env });
864
- const client = registryFor
865
- ? registryFor({ authority, credential })
866
- : new OrasRegistry({ credential, fetchImpl });
867
- const coordinate = `${imageRepository}:${sourceTreeHash}`;
868
- const top = await client.getManifestByTag(repository, sourceTreeHash);
869
- if (top.digest !== manifestDigest) {
916
+ }
917
+
918
+ /** The artifact evidence of the manifest `read` names (exactly one of `{tag}` or `{digest}`), which must be `expectedDigest`. */
919
+ async function publishedArtifactEvidence({
920
+ client,
921
+ repository,
922
+ imageRepository,
923
+ read,
924
+ expectedDigest,
925
+ coordinate,
926
+ referenceProvenance,
927
+ }) {
928
+ const top =
929
+ read.tag === undefined
930
+ ? await client.getManifestByDigest(repository, read.digest)
931
+ : await client.getManifestByTag(repository, read.tag);
932
+ if (top.digest !== expectedDigest) {
870
933
  throw new Error(
871
- `${coordinate} serves ${top.digest}, not the caller-declared ${manifestDigest} — ` +
872
- 'the image was not published from this source identity (wrong build commit, ' +
873
- 'or the cross-repo record at the build commit differs from the one this source carries)',
934
+ read.tag === undefined
935
+ ? `${coordinate} was served as ${top.digest}: the registry returned a manifest that is not the digest it was read by`
936
+ : `${coordinate} serves ${top.digest}, not the caller-declared ${expectedDigest} — ` +
937
+ 'the image was not published from this source identity (wrong build commit, ' +
938
+ 'or the cross-repo record at the build commit differs from the one this source carries)',
874
939
  );
875
940
  }
876
941
  // Proves EVERY platform by its own hash-verified image config and refuses
@@ -879,7 +944,7 @@ export async function resolveImageArtifactEvidence({
879
944
  const published = await resolvePublishedPlatforms({
880
945
  client,
881
946
  repository,
882
- tag: sourceTreeHash,
947
+ ...read,
883
948
  coordinate,
884
949
  });
885
950
  if (published.indexDigest !== top.digest) {
@@ -944,6 +1009,69 @@ export async function resolveImageArtifactEvidence({
944
1009
  */
945
1010
  export const DEFAULT_PRODUCER_TTL_SECONDS = 3 * 24 * 60 * 60;
946
1011
 
1012
+ /** Where the ceiling on a producer lifetime is held: the closed set of producer kinds. */
1013
+ export const ProducerTtlCeilingSource = Object.freeze({
1014
+ /** First party: the data root's trust policy `maxProducerTtlSeconds`, read here. */
1015
+ TrustPolicy: 'trust-policy',
1016
+ /**
1017
+ * A third-party publisher on public inputs: the vendor policy's `maxProducerTtlSeconds`, enforced by
1018
+ * the third-party pin writer (`index runtime-image-source --trusted-write --third-party`) and `verify-evidence`.
1019
+ */
1020
+ VendorAdmission: 'vendor-admission',
1021
+ });
1022
+
1023
+ /** The latest instant an RFC 3339 `expiresAt` (four-digit year) can name. */
1024
+ const LATEST_EXPIRES_AT = Date.UTC(9999, 11, 31, 23, 59, 59);
1025
+
1026
+ /** `--ttl-seconds` as given, or undefined when it is not. */
1027
+ export const producerTtlSecondsFlag = (value) => (value === undefined ? undefined : Number(value));
1028
+
1029
+ /**
1030
+ * The producer lifetime of an input issued at `now`. The default is DEFAULT_PRODUCER_TTL_SECONDS, bowed to the
1031
+ * ceiling when it is read here (`ceiling.source` TrustPolicy, `ceiling.maxTtlSeconds`); an explicit value above that
1032
+ * ceiling is refused. With VendorAdmission the ceiling is the vendor policy's, enforced by the third-party pin writer
1033
+ * (`index runtime-image-source --trusted-write --third-party`) and `verify-evidence`; this CLI holds no second one. Either way a value that is not a positive integer, or whose `expiresAt` is past the last
1034
+ * instant RFC 3339 can name, is refused by name.
1035
+ */
1036
+ export function resolveProducerTtlSeconds({ ttlSeconds, now, ceiling }) {
1037
+ let maxTtlSeconds;
1038
+ if (ceiling?.source === ProducerTtlCeilingSource.TrustPolicy) {
1039
+ ({ maxTtlSeconds } = ceiling);
1040
+ if (!Number.isSafeInteger(maxTtlSeconds) || maxTtlSeconds < 1) {
1041
+ throw new Error(`the trust policy's maxProducerTtlSeconds ${String(maxTtlSeconds)} is not a positive integer`);
1042
+ }
1043
+ } else if (ceiling?.source === ProducerTtlCeilingSource.VendorAdmission) {
1044
+ maxTtlSeconds = null;
1045
+ } else {
1046
+ throw new Error(`a producer TTL ceiling source is one of ${Object.values(ProducerTtlCeilingSource).join(', ')}`);
1047
+ }
1048
+ // The DEFAULT bows to the policy ceiling; an EXPLICIT `--ttl-seconds` above
1049
+ // it still fails below. Tightening `maxProducerTtlSeconds` is a legitimate
1050
+ // operator decision, and it must not break every producer until someone
1051
+ // edits a constant in this file — but a caller who NAMES a lifetime the
1052
+ // verifier will reject has made a mistake, and gets told so.
1053
+ const effective =
1054
+ ttlSeconds ?? (maxTtlSeconds === null ? DEFAULT_PRODUCER_TTL_SECONDS : Math.min(DEFAULT_PRODUCER_TTL_SECONDS, maxTtlSeconds));
1055
+ if (maxTtlSeconds !== null && (!Number.isSafeInteger(effective) || effective < 1 || effective > maxTtlSeconds)) {
1056
+ throw new Error(
1057
+ `--ttl-seconds must be an integer between 1 and the trust policy's maxProducerTtlSeconds (${maxTtlSeconds})`,
1058
+ );
1059
+ }
1060
+ if (!Number.isSafeInteger(effective) || effective < 1) {
1061
+ throw new Error(
1062
+ `--ttl-seconds must be a positive integer number of seconds (got ${String(ttlSeconds)}); the vendor policy's ` +
1063
+ 'maxProducerTtlSeconds is its ceiling, enforced by the pin writer and verify-evidence',
1064
+ );
1065
+ }
1066
+ if (now + effective * 1000 > LATEST_EXPIRES_AT) {
1067
+ throw new Error(
1068
+ `--ttl-seconds ${effective} puts expiresAt past ${new Date(LATEST_EXPIRES_AT).toISOString()}, the last instant an ` +
1069
+ 'RFC 3339 time can name',
1070
+ );
1071
+ }
1072
+ return effective;
1073
+ }
1074
+
947
1075
  function readProducerTtlSeconds(distributionsRoot) {
948
1076
  const declared = editionWorkspaceConfig().evidenceTrustPolicy.policy;
949
1077
  if (declared === null) {
@@ -1011,9 +1139,7 @@ export async function assembleProducerInput({
1011
1139
  `--subject-kind "${String(subjectKind)}" must be one of ${subjectKinds.join(', ')}`,
1012
1140
  );
1013
1141
  }
1014
- if (!CANONICAL_IDENTIFIER_PATTERN.test(subjectId ?? '')) {
1015
- throw new Error('--subject-id is not a canonical identifier');
1016
- }
1142
+ assertCanonicalSubjectId(subjectId);
1017
1143
  if (!SOURCE_REPO_PATTERN.test(sourceRepo ?? '')) {
1018
1144
  throw new Error(
1019
1145
  `--source-repo "${String(sourceRepo)}" must be an aggregator-plane repository (repos/<name> or submodules/<name>)`,
@@ -1090,24 +1216,42 @@ export async function assembleProducerInput({
1090
1216
  fetchImpl,
1091
1217
  registryFor,
1092
1218
  });
1093
- const maxTtlSeconds = readProducerTtlSeconds(distributionsRoot);
1094
- // The DEFAULT bows to the policy ceiling; an EXPLICIT `--ttl-seconds` above
1095
- // it still fails below. Tightening `maxProducerTtlSeconds` is a legitimate
1096
- // operator decision, and it must not break every producer until someone
1097
- // edits a constant in this file — but a caller who NAMES a lifetime the
1098
- // verifier will reject has made a mistake, and gets told so.
1099
- const effectiveTtlSeconds =
1100
- ttlSeconds ?? Math.min(DEFAULT_PRODUCER_TTL_SECONDS, maxTtlSeconds);
1101
- if (
1102
- !Number.isSafeInteger(effectiveTtlSeconds) ||
1103
- effectiveTtlSeconds < 1 ||
1104
- effectiveTtlSeconds > maxTtlSeconds
1105
- ) {
1106
- throw new Error(
1107
- `--ttl-seconds must be an integer between 1 and the trust policy's maxProducerTtlSeconds (${maxTtlSeconds})`,
1108
- );
1109
- }
1110
- const input = {
1219
+ const effectiveTtlSeconds = resolveProducerTtlSeconds({
1220
+ ttlSeconds,
1221
+ now,
1222
+ ceiling: { source: ProducerTtlCeilingSource.TrustPolicy, maxTtlSeconds: readProducerTtlSeconds(distributionsRoot) },
1223
+ });
1224
+ const input = producerInputDocument({
1225
+ contractAuthority,
1226
+ context,
1227
+ builder,
1228
+ now,
1229
+ ttlSeconds: effectiveTtlSeconds,
1230
+ subject: {
1231
+ kind: subjectKind,
1232
+ id: subjectId,
1233
+ repositoryUrl: source.repositoryUrl,
1234
+ repo: sourceRepo,
1235
+ path: sourcePath,
1236
+ sourceCommit,
1237
+ sourceManifestHash: source.sourceManifestHash,
1238
+ sourceTreeHash: source.sourceTreeHash,
1239
+ imageRepository,
1240
+ ...artifact,
1241
+ },
1242
+ });
1243
+ assertAssembledProducerInput({ input, schemas, contractAuthority, context });
1244
+ return input;
1245
+ }
1246
+
1247
+ /**
1248
+ * The producer input of ONE subject, in the exact shape
1249
+ * `produce-image-source-evidence.mjs` accepts. `context` is
1250
+ * {@link assertWorkflowContext}'s and `builder` {@link deriveBuilder}'s; the
1251
+ * subject carries every per-subject field (source identity and artifact).
1252
+ */
1253
+ export function producerInputDocument({ contractAuthority, context, builder, now, ttlSeconds, subject }) {
1254
+ return {
1111
1255
  schemaVersion: 1,
1112
1256
  contractAuthority: { ...contractAuthority },
1113
1257
  sourceHashAlgorithm: imageInputSourceHashAlgorithm(),
@@ -1131,31 +1275,36 @@ export async function assembleProducerInput({
1131
1275
  runId: context.runId,
1132
1276
  runAttempt: context.runAttempt,
1133
1277
  issuedAt: rfc3339(now),
1134
- expiresAt: rfc3339(now + effectiveTtlSeconds * 1000),
1135
- expectedSubjects: [`${subjectKind}:${subjectId}`],
1278
+ expiresAt: rfc3339(now + ttlSeconds * 1000),
1279
+ expectedSubjects: [`${subject.kind}:${subject.id}`],
1136
1280
  subjects: [
1137
1281
  {
1138
- kind: subjectKind,
1139
- id: subjectId,
1140
- repositoryUrl: source.repositoryUrl,
1141
- repo: sourceRepo,
1142
- path: sourcePath,
1143
- sourceCommit,
1144
- sourceManifestHash: source.sourceManifestHash,
1145
- sourceTreeHash: source.sourceTreeHash,
1146
- imageRepository,
1147
- manifestDigest: artifact.manifestDigest,
1148
- manifestMediaType: artifact.manifestMediaType,
1149
- platforms: artifact.platforms,
1150
- sbomDigest: artifact.sbomDigest,
1151
- provenanceDigest: artifact.provenanceDigest,
1282
+ kind: subject.kind,
1283
+ id: subject.id,
1284
+ repositoryUrl: subject.repositoryUrl,
1285
+ repo: subject.repo,
1286
+ path: subject.path,
1287
+ sourceCommit: subject.sourceCommit,
1288
+ sourceManifestHash: subject.sourceManifestHash,
1289
+ sourceTreeHash: subject.sourceTreeHash,
1290
+ imageRepository: subject.imageRepository,
1291
+ manifestDigest: subject.manifestDigest,
1292
+ manifestMediaType: subject.manifestMediaType,
1293
+ platforms: subject.platforms,
1294
+ sbomDigest: subject.sbomDigest,
1295
+ provenanceDigest: subject.provenanceDigest,
1152
1296
  },
1153
1297
  ],
1154
1298
  };
1155
- // Prove the output satisfies the producer BEFORE anything reads it: the
1156
- // exact field contract, then the kernel statement schema the producer builds
1157
- // (the same parse produce-image-source-evidence.mjs runs before signing),
1158
- // then the GitHub context the producer action compares against.
1299
+ }
1300
+
1301
+ /**
1302
+ * Prove an assembled input satisfies the producer BEFORE anything reads it:
1303
+ * the exact field contract, then the kernel statement schema the producer
1304
+ * builds (the same parse produce-image-source-evidence.mjs runs before
1305
+ * signing), then the GitHub context the producer compares against.
1306
+ */
1307
+ export function assertAssembledProducerInput({ input, schemas, contractAuthority, context }) {
1159
1308
  assertProducerInput(input, contractAuthority);
1160
1309
  buildImageSourceStatements(input, schemas, contractAuthority);
1161
1310
  assertGitHubProducerContext(input, {
@@ -1164,23 +1313,13 @@ export async function assembleProducerInput({
1164
1313
  sha: context.sha,
1165
1314
  workflowRef: context.workflowRef,
1166
1315
  });
1167
- return input;
1168
1316
  }
1169
1317
 
1170
- function requireValue(flags, name) {
1171
- const value = flags.get(name);
1172
- if (value === undefined) {
1173
- throw new Error(`--${name} is required`);
1174
- }
1175
- return value;
1176
- }
1177
-
1178
- async function main() {
1179
- const args = configureEditionWorkspaceFromArgv(process.argv.slice(2), [
1180
- WorkspaceRoot.Data,
1181
- WorkspaceRoot.Sources,
1182
- WorkspaceRoot.Kernel,
1183
- ]);
1318
+ /**
1319
+ * The `--name value` pairs of an assembler's argv: each flag once, each with a
1320
+ * value, none outside `known`.
1321
+ */
1322
+ export function readAssemblerFlags(args, known) {
1184
1323
  const flags = new Map();
1185
1324
  for (let index = 0; index < args.length; index += 2) {
1186
1325
  const flag = args[index];
@@ -1197,6 +1336,39 @@ async function main() {
1197
1336
  }
1198
1337
  flags.set(name, value);
1199
1338
  }
1339
+ for (const name of flags.keys()) {
1340
+ if (!known.has(name)) {
1341
+ throw new Error(`unknown option "--${name}"`);
1342
+ }
1343
+ }
1344
+ return flags;
1345
+ }
1346
+
1347
+ /**
1348
+ * Write an assembled input to `output`. The output directory is caller scratch
1349
+ * space — created deterministically (the producer action's input-manifest
1350
+ * must be repository-relative, so the caller cannot sidestep this by pointing
1351
+ * at RUNNER_TEMP). `wx` refuses to overwrite an existing manifest.
1352
+ */
1353
+ export function writeProducerInputFile(output, input) {
1354
+ mkdirSync(dirname(output), { recursive: true });
1355
+ writeFileSync(output, serializeJson(input), { flag: 'wx', mode: 0o600 });
1356
+ }
1357
+
1358
+ function requireValue(flags, name) {
1359
+ const value = flags.get(name);
1360
+ if (value === undefined) {
1361
+ throw new Error(`--${name} is required`);
1362
+ }
1363
+ return value;
1364
+ }
1365
+
1366
+ async function main() {
1367
+ const args = configureEditionWorkspaceFromArgv(process.argv.slice(2), [
1368
+ WorkspaceRoot.Data,
1369
+ WorkspaceRoot.Sources,
1370
+ WorkspaceRoot.Kernel,
1371
+ ]);
1200
1372
  const known = new Set([
1201
1373
  'output',
1202
1374
  'image-repository',
@@ -1211,11 +1383,7 @@ async function main() {
1211
1383
  'provenance',
1212
1384
  'ttl-seconds',
1213
1385
  ]);
1214
- for (const name of flags.keys()) {
1215
- if (!known.has(name)) {
1216
- throw new Error(`unknown option "--${name}"`);
1217
- }
1218
- }
1386
+ const flags = readAssemblerFlags(args, known);
1219
1387
  const output = resolve(requireValue(flags, 'output'));
1220
1388
  const input = await assembleProducerInput({
1221
1389
  imageRepository: requireValue(flags, 'image-repository'),
@@ -1228,16 +1396,9 @@ async function main() {
1228
1396
  provenancePath: resolve(requireValue(flags, 'provenance')),
1229
1397
  sourceManifestHash: flags.get('source-manifest-hash'),
1230
1398
  sourceTreeHash: flags.get('source-tree-hash'),
1231
- ttlSeconds: flags.has('ttl-seconds')
1232
- ? Number(flags.get('ttl-seconds'))
1233
- : undefined,
1399
+ ttlSeconds: producerTtlSecondsFlag(flags.get('ttl-seconds')),
1234
1400
  });
1235
- // The output directory is caller scratch space — create it deterministically
1236
- // (the producer action's input-manifest must be repository-relative, so the
1237
- // caller cannot sidestep this by pointing at RUNNER_TEMP). `wx` below still
1238
- // refuses to overwrite an existing manifest.
1239
- mkdirSync(dirname(output), { recursive: true });
1240
- writeFileSync(output, serializeJson(input), { flag: 'wx', mode: 0o600 });
1401
+ writeProducerInputFile(output, input);
1241
1402
  console.log(
1242
1403
  `[assemble-producer-input] wrote ${output} for subject ` +
1243
1404
  `${input.subjects[0].kind}:${input.subjects[0].id} ` +