@xemahq/xema 0.55.0 → 0.57.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.
@@ -62,6 +62,7 @@
62
62
 
63
63
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
64
64
  import { join } from 'node:path';
65
+ import { isDeepStrictEqual } from 'node:util';
65
66
 
66
67
  import { adapterHostRow, assertAdapterBundlesMatchHost, fetchRegistryManifest } from './adapter-bundled-versions.mjs';
67
68
  import {
@@ -75,6 +76,7 @@ import {
75
76
  writeAuthorizationLock,
76
77
  } from './authorization-base.mjs';
77
78
  import { RefusalError, captureRefusal, isRefusal, throwCollectedRefusals } from './collected-refusals.mjs';
79
+ import { developGitBundleKey, developGitBundleSubjects } from './develop-git-bundles.mjs';
78
80
  import { resolveDispatchedRuntimeComponents } from './dispatched-runtime-components.mjs';
79
81
  import { fleetChartSetLockPath, writeEditionChartSet } from './edition-chart-set.mjs';
80
82
  import { workspaceSection } from './edition-config.mjs';
@@ -96,7 +98,14 @@ import {
96
98
  validateImmutableDistributionLock,
97
99
  validateServiceDependencyOwnerParity,
98
100
  } from './lib.mjs';
99
- import { BundleSignerPolicyAbsentError, BundleSignerRefusedError, GitSourcedHostModuleBundleError, loadBundleSignerSelection } from './locked-bundle-signer.mjs';
101
+ import {
102
+ BundleSignerPolicyAbsentError,
103
+ BundleSignerRefusedError,
104
+ DevelopGitBundleError,
105
+ GitSourcedBundle,
106
+ GitSourcedHostModuleBundleError,
107
+ loadBundleSignerSelection,
108
+ } from './locked-bundle-signer.mjs';
100
109
  import { admitDistributionLock } from './operation-grant-admission.mjs';
101
110
  import { PUBLISHED_PIN_SOURCE, pinnedSource } from './pin-source.mjs';
102
111
  import { verifiedPlatformServiceIndex } from './platform-service-ownership.mjs';
@@ -1792,7 +1801,14 @@ export function resolveUnadmittedDistributionFromInputs(
1792
1801
  // trust policy selects for it. See `locked-bundle-signer.mjs`.
1793
1802
  const bundleSignerRefusals = [];
1794
1803
  const bundleSignerOf = (e, bundleSource, componentKey) => {
1795
- const subject = { biomeId: e.id, ...(componentKey === undefined ? {} : { componentKey }), origin: e.origin, repo: e.repo, bundleSource };
1804
+ const subject = {
1805
+ biomeId: e.id,
1806
+ ...(componentKey === undefined ? {} : { componentKey }),
1807
+ origin: e.origin,
1808
+ repo: e.repo,
1809
+ ...(isGitSourced(e) ? { source: requireSourceRecord(e, sourceRecords) } : {}),
1810
+ bundleSource,
1811
+ };
1796
1812
  if (composed?.bundleSigners === undefined) {
1797
1813
  throw new BundleSignerPolicyAbsentError(
1798
1814
  `${componentKey === undefined ? `biome "${e.id}"` : `host module "${componentKey}" of biome "${e.id}"`} is an OCI bundle, but this resolve was given no bundle signer selection`,
@@ -1801,12 +1817,45 @@ export function resolveUnadmittedDistributionFromInputs(
1801
1817
  // Every row is selected before any is refused, so one run names them all; the lock is not built from a refused row.
1802
1818
  return captureRefusal(bundleSignerRefusals, () => composed.bundleSigners.signerFor(subject), isRefusal);
1803
1819
  };
1804
- // A row drawn from a GIT source carries no OCI bundle (biome-level; an OCI host-module bundle is refused below), hence no bundleSigner: it is built
1805
- // from the pinned source, and the manifest's `bundleSource` names a release artifact not built from this commit.
1806
- // Omitted here, at the copy point. (An entry of a declared `oci` source has a stamp but no record: not git.)
1820
+ // A row drawn from a GIT source is built from the pinned source, and the manifest's biome-level `bundleSource` names an
1821
+ // artifact its release lane publishes, not one proven to be built from this commit. So, at the copy point:
1822
+ // - with `composed.developGitBundles` (a VENDOR DEVELOP resolve, XEMA-2456) the row carries the bundle the develop
1823
+ // resolve verified for it (`develop-git-bundles.mjs`: the develop signer at the attested commit, content-bound to
1824
+ // the frozen one), by digest, and its develop bundle signer. A git-sourced OCI row the resolve did not verify is
1825
+ // refused, never stripped;
1826
+ // - otherwise (release, customer, dist-check) it carries neither, as before; the release refuses the declaration.
1827
+ // An OCI host-module bundle on a git-sourced biome is refused below in every channel. (An entry of a declared `oci`
1828
+ // source has a stamp but no record: not git.)
1807
1829
  const isGitSourced = (e) => e.source !== undefined && sourceRecords.has(e.source);
1808
- const bundleSourceOf = (e) => (e.bundleSource === undefined || isGitSourced(e) ? {} : { bundleSource: e.bundleSource });
1809
- const bundleSignerFor = (e) => (e.bundleSource?.kind === 'oci' && !isGitSourced(e) ? { bundleSigner: bundleSignerOf(e, e.bundleSource) } : {});
1830
+ const developGitBundleOf = (e) => {
1831
+ if (composed?.developGitBundles === undefined || !isGitSourced(e) || e.bundleSource?.kind !== 'oci') {return undefined;}
1832
+ const verified = composed.developGitBundles.get(developGitBundleKey(e.source, e.id));
1833
+ if (verified === undefined) {
1834
+ throw new DevelopGitBundleError(`biome "${e.id}" (git source "${e.source}") declares an OCI bundle (${e.bundleSource.ociRef}) that this develop resolve did not verify`);
1835
+ }
1836
+ return verified;
1837
+ };
1838
+ const bundleSourceOf = (e) => {
1839
+ if (e.bundleSource === undefined) {return {};}
1840
+ if (!isGitSourced(e)) {return { bundleSource: e.bundleSource };}
1841
+ const verified = developGitBundleOf(e);
1842
+ return verified === undefined ? {} : { bundleSource: verified.bundleSource };
1843
+ };
1844
+ const bundleSignerFor = (e) => {
1845
+ if (e.bundleSource?.kind !== 'oci') {return {};}
1846
+ if (!isGitSourced(e)) {return { bundleSigner: bundleSignerOf(e, e.bundleSource) };}
1847
+ const verified = developGitBundleOf(e);
1848
+ if (verified === undefined) {return {};}
1849
+ const bundleSigner = bundleSignerOf(e, verified.bundleSource);
1850
+ // A bundle carried from a pinned lock (an offline re-seal) was verified under the pinned signer; a develop row rotated
1851
+ // since then would put into the lock a signer the bundle was never verified under, so any difference is refused.
1852
+ if (verified.pinnedBundleSigner !== undefined && !isDeepStrictEqual(verified.pinnedBundleSigner, bundleSigner)) {
1853
+ throw new DevelopGitBundleError(
1854
+ `biome "${e.id}" (git source "${e.source}"): the pinned edition verified ${verified.bundleSource.ociRef} under bundle signer ${JSON.stringify(verified.pinnedBundleSigner)}, but the current trust policy selects ${JSON.stringify(bundleSigner)}; resolve the develop edition to verify the bundle under the current row`,
1855
+ );
1856
+ }
1857
+ return { bundleSigner };
1858
+ };
1810
1859
  const hostModuleOf = (e, hostModule) => {
1811
1860
  if (isGitSourced(e)) {
1812
1861
  // The lock schema requires a host module's bundleSource, so an OCI one cannot be omitted: it is refused, in every
@@ -2405,7 +2454,7 @@ export async function prepareDistributionWithIndex(id, { materialize, inputs, re
2405
2454
  validateImmutableBiomeIndex(inputs?.biomeIndex ?? pinned.biomeIndex),
2406
2455
  platformServiceIndex,
2407
2456
  );
2408
- const { entries, sourceRecords } = await walkDeclaredSources({
2457
+ const { entries, sourceRecords, checkouts } = await walkDeclaredSources({
2409
2458
  sources: composed.declaredSources,
2410
2459
  ...(materialize === undefined ? {} : { materialize }),
2411
2460
  });
@@ -2416,19 +2465,33 @@ export async function prepareDistributionWithIndex(id, { materialize, inputs, re
2416
2465
  inputs !== undefined && Object.hasOwn(inputs, 'storePublisherTrust')
2417
2466
  ? inputs.storePublisherTrust
2418
2467
  : readStorePublisherTrust();
2419
- const lock = resolveUnadmittedDistributionFromInputs(
2420
- id,
2421
- index,
2422
- schemas,
2423
- {
2424
- ...composed,
2425
- sourceRecords,
2426
- storePublisherTrust,
2427
- bundleSigners: inputs?.bundleSigners ?? loadBundleSignerSelection({ dataRoot: dataRoot(), schemas }),
2428
- },
2429
- new Date().toISOString(),
2430
- platformServiceIndex,
2431
- );
2468
+ // A VENDOR DEVELOP resolve hands `inputs.developGitBundles` (develop-git-bundles.mjs); nothing else does, so a release,
2469
+ // customer or dist-check resolve keeps a git-sourced row bundle-less, exactly as before.
2470
+ const developGitBundles = inputs?.developGitBundles;
2471
+ const bundleSigners =
2472
+ inputs?.bundleSigners ??
2473
+ loadBundleSignerSelection({
2474
+ dataRoot: dataRoot(),
2475
+ schemas,
2476
+ gitSourcedBundle: developGitBundles === undefined ? GitSourcedBundle.Refused : GitSourcedBundle.DevelopVerified,
2477
+ });
2478
+ const resolvedAt = new Date().toISOString();
2479
+ const resolveWith = (verified) =>
2480
+ resolveUnadmittedDistributionFromInputs(
2481
+ id,
2482
+ index,
2483
+ schemas,
2484
+ { ...composed, sourceRecords, storePublisherTrust, bundleSigners, ...(verified === undefined ? {} : { developGitBundles: verified }) },
2485
+ resolvedAt,
2486
+ platformServiceIndex,
2487
+ );
2488
+ // Selection never reads a bundle, so the first pass selects exactly the biomes the second ships: only THEIR bundles are
2489
+ // verified (a source's unselected biome is never fetched), and the second pass refuses any git-sourced OCI row the
2490
+ // verifier did not answer for.
2491
+ const selectedOnly = resolveWith(undefined);
2492
+ if (developGitBundles === undefined) {return { lock: selectedOnly, index, schemas };}
2493
+ const subjects = developGitBundleSubjects({ lock: selectedOnly, index });
2494
+ const lock = subjects.length === 0 ? selectedOnly : resolveWith(await developGitBundles.verify({ subjects, checkouts, schemas }));
2432
2495
  return { lock, index, schemas };
2433
2496
  }
2434
2497
 
@@ -73,7 +73,7 @@ import {
73
73
  } from './lib.mjs';
74
74
  import { assertReleaseContent } from './release-channel-guard.mjs';
75
75
  import { composeEdition } from './resolve-distribution.mjs';
76
- import { readStrictJsonFile } from './strict-json.mjs';
76
+ import { parseStrictJson, readStrictJsonFile } from './strict-json.mjs';
77
77
  import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
78
78
  import {
79
79
  assertOciBundleSourceOrigin,
@@ -2089,22 +2089,35 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed, reco
2089
2089
  }
2090
2090
 
2091
2091
  export const PREVIOUS_SEAL_USAGE =
2092
- 'exactly one of `--previous-lock <images.lock.json> --previous-lock-sha256 <hex>` (the caller takes the digest from the images.lock ' +
2093
- 'layer of the latest signed, published edition of this distribution, under the per-edition concurrency group) or `--first-seal`';
2092
+ 'exactly one of `--previous-seal-signer <certificate identity>` (the CLI reads the images.lock layer of the latest edition ' +
2093
+ 'signed by that identity in this edition\'s registry repository; run under the per-edition concurrency group), ' +
2094
+ '`--previous-lock <images.lock.json> --previous-lock-sha256 <hex>` (the same layer, taken by the caller) or `--first-seal` ' +
2095
+ '(only for a distribution that has never had an edition)';
2094
2096
 
2095
2097
  /**
2096
- * The previous seal an images-lock write carries composed artifacts forward from, as an EXPLICIT input: the previous lock
2097
- * with the sha256 of its bytes (taken by the caller from the signed, published edition's images.lock layer), or
2098
- * `--first-seal` (null: generation 1). Neither, both kinds, or half of the pair is refused; the bytes must hash to the given
2099
- * digest, and the lock is parsed with the kernel's `parseImageLock` and must be this edition's own (`distributionId`).
2098
+ * The previous seal an images-lock write carries composed artifacts forward from, as an EXPLICIT input of exactly one kind:
2099
+ * - `registry` (`--previous-seal-signer`): `{ find }`, where `find()` returns the latest signed edition's images.lock bytes
2100
+ * and their sha256 (previous-seal-registry.mjs);
2101
+ * - the previous lock with the sha256 of its bytes (taken by the caller from the signed, published edition's images.lock
2102
+ * layer);
2103
+ * - `--first-seal` (null: generation 1).
2104
+ * None, more than one kind, or half of the pair is refused; the bytes must hash to the given digest, and the lock is parsed
2105
+ * with the kernel's `parseImageLock` and must be this edition's own (`distributionId`).
2100
2106
  */
2101
- export async function resolvePreviousSeal({ distributionId, previousLockPath, previousLockSha256, firstSeal = false }) {
2107
+ export async function resolvePreviousSeal({ distributionId, previousLockPath, previousLockSha256, firstSeal = false, registry = undefined }) {
2102
2108
  const given = [previousLockPath, previousLockSha256].filter((value) => value !== undefined);
2103
- if (firstSeal) {
2104
- if (given.length > 0) {
2105
- throw new Error(`--first-seal and a previous lock (--previous-lock/--previous-lock-sha256) are both given; give ${PREVIOUS_SEAL_USAGE}`);
2109
+ const kinds = [registry !== undefined, given.length > 0, firstSeal].filter(Boolean).length;
2110
+ if (kinds > 1) {
2111
+ throw new Error(`more than one previous seal is given (--previous-seal-signer, --previous-lock/--previous-lock-sha256, --first-seal); give ${PREVIOUS_SEAL_USAGE}`);
2112
+ }
2113
+ if (firstSeal) {return null;}
2114
+ if (registry !== undefined) {
2115
+ const { bytes, imagesLockSha256, edition } = await registry.find();
2116
+ const actual = createHash('sha256').update(bytes).digest('hex');
2117
+ if (actual !== imagesLockSha256) {
2118
+ throw new Error(`the images lock of ${edition} has sha256 ${actual}, not its layer digest ${imagesLockSha256}`);
2106
2119
  }
2107
- return null;
2120
+ return parsePreviousLock(distributionId, parseStrictJson(bytes.toString('utf8'), `${edition} images.lock.json`), `the images lock of ${edition}`);
2108
2121
  }
2109
2122
  if (given.length !== 2) {
2110
2123
  throw new Error(
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { execFileSync } from 'node:child_process';
6
6
 
7
- import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
7
+ import { certificateConstraintArgs, certificateIdentityArgs } from './cosign-certificate-constraints.mjs';
8
8
  import { COSIGN_ENV, decodeExactUtf8, sha256Digest, validatePinnedTrustedRoot } from './image-evidence.mjs';
9
9
  import { splitImageRef } from './registry-credentials.mjs';
10
10
  import { BUNDLE_MEDIA_TYPE } from './sigstore-bundle-media-type.mjs';
@@ -119,29 +119,85 @@ export async function fetchBundleProvenance({ clientFor, ociRef }) {
119
119
  return found[0];
120
120
  }
121
121
 
122
- const tail = (error) => String(error?.stderr ?? error?.message ?? error).trim().split('\n').slice(-4).join(' | ').slice(0, 1024);
123
-
124
122
  /**
125
- * (a) against the registry: keyless `cosign verify` and `cosign verify-attestation --type slsaprovenance1` of the bundle
126
- * artifact under EXACTLY the signer row's identity and issuer, the row's workflow repository, ref and the stated
127
- * commit, the pinned trusted root and the trusted cosign binary. `--offline` verifies the tlog inclusion proof carried
128
- * with the signature; the tlog is never ignored. The registry credential is the operator's (HOME / DOCKER_CONFIG).
123
+ * The `<repository>@sha256:<hex>` a bundle reference names: a digest reference as it is, a tag reference resolved at
124
+ * the registry (the manifest the tag serves, its bytes verified against the digest the registry described).
129
125
  */
130
- export function verifyBundleOnRegistry({ trustedCosign, policy, policyRoot, row, workflowSha, ociRef, env = process.env }) {
126
+ export async function bundleDigestRef({ clientFor, ociRef }) {
127
+ if (ociRef.includes('@')) {return ociRef;}
128
+ const colon = ociRef.lastIndexOf(':');
129
+ if (colon <= ociRef.lastIndexOf('/')) {
130
+ throw new Error(`bundle reference ${JSON.stringify(ociRef)} names neither a tag nor a digest`);
131
+ }
132
+ const name = ociRef.slice(0, colon);
133
+ const { authority, repository } = splitImageRef(name);
134
+ const { digest } = await clientFor(authority).getManifestByTag(repository, ociRef.slice(colon + 1));
135
+ return `${name}@${digest}`;
136
+ }
137
+
138
+ const tail = (error) => String(error?.stderr ?? error?.message ?? error).trim().split('\n').slice(-4).join(' | ').slice(0, 1024);
139
+
140
+ /** The cosign run of a registry verification: the trusted binary, the operator's registry credential (HOME / DOCKER_CONFIG). */
141
+ function registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env }) {
131
142
  const root = validatePinnedTrustedRoot(policyRoot, policy);
132
- const common = ['--offline', '--trusted-root', root.path, ...certificateConstraintArgs(row, workflowSha)];
133
143
  const environment = {
134
144
  ...COSIGN_ENV,
135
145
  ...(env.HOME === undefined ? {} : { HOME: env.HOME }),
136
146
  ...(env.DOCKER_CONFIG === undefined ? {} : { DOCKER_CONFIG: env.DOCKER_CONFIG }),
137
147
  };
138
- const run = (command, args) => {
139
- try {
140
- execFileSync(assertPreparedExecutable(trustedCosign, 'cosign'), [command, ...args], { env: environment, stdio: ['ignore', 'ignore', 'pipe'], timeout: REGISTRY_COSIGN_TIMEOUT_MS });
141
- } catch (error) {
142
- throw new Error(`cosign ${command} refused ${ociRef} under row "${row.policyId}": ${tail(error)}`);
143
- }
148
+ return {
149
+ trustArgs: ['--offline', '--trusted-root', root.path],
150
+ run(command, args) {
151
+ try {
152
+ return execFileSync(assertPreparedExecutable(trustedCosign, 'cosign'), [command, ...args], {
153
+ env: environment,
154
+ encoding: 'utf8',
155
+ stdio: ['ignore', 'pipe', 'pipe'],
156
+ timeout: REGISTRY_COSIGN_TIMEOUT_MS,
157
+ });
158
+ } catch (error) {
159
+ throw new Error(`cosign ${command} refused ${ociRef} under row "${row.policyId}": ${tail(error)}`);
160
+ }
161
+ },
144
162
  };
145
- run('verify', [...common, ociRef]);
146
- run('verify-attestation', ['--type', COSIGN_SLSA_PROVENANCE_ALIAS, ...common, ociRef]);
163
+ }
164
+
165
+ /**
166
+ * (a) against the registry: keyless `cosign verify` and `cosign verify-attestation --type slsaprovenance1` of the bundle
167
+ * artifact under EXACTLY the signer row's identity and issuer, the row's workflow repository, ref and the stated
168
+ * commit, the pinned trusted root and the trusted cosign binary. `--offline` verifies the tlog inclusion proof carried
169
+ * with the signature; the tlog is never ignored. The registry credential is the operator's (HOME / DOCKER_CONFIG).
170
+ */
171
+ export function verifyBundleOnRegistry({ trustedCosign, policy, policyRoot, row, workflowSha, ociRef, env = process.env }) {
172
+ const cosign = registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env });
173
+ const common = [...cosign.trustArgs, ...certificateConstraintArgs(row, workflowSha)];
174
+ cosign.run('verify', [...common, ociRef]);
175
+ cosign.run('verify-attestation', ['--type', COSIGN_SLSA_PROVENANCE_ALIAS, ...common, ociRef]);
176
+ }
177
+
178
+ const WORKFLOW_SHA = /^[0-9a-f]{40}$/u;
179
+
180
+ /**
181
+ * The commit the row's workflow signed the bundle artifact at, as cosign states it: `cosign verify` under the row's
182
+ * identity, issuer, workflow repository and ref (no commit: it is what is asked), and the `githubWorkflowSha` claim of
183
+ * every signature cosign verified. Exactly one commit is required; none, or two, is refused. The caller then holds the
184
+ * signature AND the provenance to that commit with `verifyBundleOnRegistry`.
185
+ */
186
+ export function verifiedBundleSignerCommit({ trustedCosign, policy, policyRoot, row, ociRef, env = process.env }) {
187
+ const cosign = registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env });
188
+ const output = cosign.run('verify', [...cosign.trustArgs, ...certificateIdentityArgs(row), '--output', 'json', ociRef]);
189
+ let verified;
190
+ try {
191
+ verified = JSON.parse(output);
192
+ } catch {
193
+ throw new Error(`cosign verify of ${ociRef} under row "${row.policyId}" printed no JSON list of verified signatures`);
194
+ }
195
+ const commits = [...new Set((Array.isArray(verified) ? verified : []).map((signature) => signature?.optional?.githubWorkflowSha))];
196
+ if (commits.length !== 1 || !WORKFLOW_SHA.test(String(commits[0]))) {
197
+ throw new Error(
198
+ `${ociRef} carries signatures by row "${row.policyId}" (${row.certificateIdentity}) stating ${commits.length} workflow commits ` +
199
+ `(${commits.map(String).join(', ')}); exactly one 40-hex commit is required`,
200
+ );
201
+ }
202
+ return commits[0];
147
203
  }
@@ -66,31 +66,56 @@ export function parseProvenanceStatement(value) {
66
66
  const readEvidenceFile = (root, path, label, maxBytes) => readBoundedNoFollowFile(root, path, label, maxBytes);
67
67
 
68
68
  /**
69
- * The trust-policy row `policyId` names, held to everything a third-party signer row must be: one row, the release
70
- * channel signed from refs/heads/main, the GitHub Actions issuer, the exact identity of its own workflow, and the
71
- * pinned repository and workflow. Throws naming the row and the field.
69
+ * A keyless signer row of `channel`, held to everything such a row must be: the channel signed from that channel's ref, the
70
+ * GitHub Actions issuer and the exact identity of its own workflow. Throws naming the row and the field.
72
71
  */
73
- export function thirdPartySignerRow({ policy, policyId, source, schemas, label }) {
74
- const rows = policy.policies.filter((row) => row.policyId === policyId);
75
- if (rows.length !== 1) {
76
- throw new Error(`${label}: the trust policy has ${rows.length} rows with policyId "${policyId}"; exactly one is required`);
77
- }
78
- const [row] = rows;
79
- const ref = schemas.EVIDENCE_CHANNEL_REFS[THIRD_PARTY_EVIDENCE_CHANNEL];
80
- if (row.channel !== THIRD_PARTY_EVIDENCE_CHANNEL || row.githubWorkflow?.ref !== ref) {
72
+ export function assertChannelSignerRow({ row, channel, schemas, label }) {
73
+ const ref = schemas.EVIDENCE_CHANNEL_REFS[channel];
74
+ if (row.channel !== channel || row.githubWorkflow?.ref !== ref) {
81
75
  throw new Error(
82
- `${label}: row "${policyId}" is not a ${THIRD_PARTY_EVIDENCE_CHANNEL} row signed from ${ref} ` +
76
+ `${label}: row "${row.policyId}" is not a ${channel} row signed from ${ref} ` +
83
77
  `(channel ${JSON.stringify(row.channel)}, ref ${JSON.stringify(row.githubWorkflow?.ref)})`,
84
78
  );
85
79
  }
86
80
  if (row.oidcIssuer !== GITHUB_ACTIONS_OIDC_ISSUER) {
87
- throw new Error(`${label}: row "${policyId}" oidcIssuer ${JSON.stringify(row.oidcIssuer)} is not ${GITHUB_ACTIONS_OIDC_ISSUER}`);
81
+ throw new Error(`${label}: row "${row.policyId}" oidcIssuer ${JSON.stringify(row.oidcIssuer)} is not ${GITHUB_ACTIONS_OIDC_ISSUER}`);
88
82
  }
89
83
  const { repository, path } = row.githubWorkflow;
90
84
  const identity = `https://github.com/${repository}/${path}@${ref}`;
91
85
  if (row.certificateIdentity !== identity) {
92
- throw new Error(`${label}: row "${policyId}" certificateIdentity ${JSON.stringify(row.certificateIdentity)} is not the exact workflow identity ${identity}`);
86
+ throw new Error(`${label}: row "${row.policyId}" certificateIdentity ${JSON.stringify(row.certificateIdentity)} is not the exact workflow identity ${identity}`);
93
87
  }
88
+ return row;
89
+ }
90
+
91
+ /**
92
+ * The ONE `channel` row that signs the SLSA provenance of `repository`'s biome bundles, held to `assertChannelSignerRow`.
93
+ * None, or several, is refused naming the repository and the channel.
94
+ */
95
+ export function bundleSignerRowFor({ policy, repository, channel, schemas, label }) {
96
+ const rows = policy.policies.filter(
97
+ (row) => row.channel === channel && row.predicateTypes?.includes(SLSA_PROVENANCE_V1) && row.githubWorkflow?.repository === repository,
98
+ );
99
+ if (rows.length !== 1) {
100
+ throw new Error(
101
+ `${label}: the trust policy has ${rows.length} ${channel} rows authorizing ${SLSA_PROVENANCE_V1} for ${repository}; exactly one bundle-signer row is required`,
102
+ );
103
+ }
104
+ return assertChannelSignerRow({ row: rows[0], channel, schemas, label });
105
+ }
106
+
107
+ /**
108
+ * The trust-policy row `policyId` names, held to everything a third-party signer row must be: one row, the release
109
+ * channel signed from refs/heads/main, the GitHub Actions issuer, the exact identity of its own workflow, and the
110
+ * pinned repository and workflow. Throws naming the row and the field.
111
+ */
112
+ export function thirdPartySignerRow({ policy, policyId, source, schemas, label }) {
113
+ const rows = policy.policies.filter((row) => row.policyId === policyId);
114
+ if (rows.length !== 1) {
115
+ throw new Error(`${label}: the trust policy has ${rows.length} rows with policyId "${policyId}"; exactly one is required`);
116
+ }
117
+ const row = assertChannelSignerRow({ row: rows[0], channel: THIRD_PARTY_EVIDENCE_CHANNEL, schemas, label });
118
+ const { repository, path } = row.githubWorkflow;
94
119
  if (repository !== source.repository) {
95
120
  throw new Error(`${label}: row "${policyId}" signs for ${repository}, but the source is pinned to ${source.repository}`);
96
121
  }
@@ -16,8 +16,8 @@ import { bundleImageNames, fetchBundleManifest, fetchBundleProvenance, verifyBun
16
16
  import {
17
17
  PROVENANCE_BUNDLE_FILE,
18
18
  PROVENANCE_STATEMENT_FILE,
19
- SLSA_PROVENANCE_V1,
20
19
  THIRD_PARTY_EVIDENCE_CHANNEL,
20
+ bundleSignerRowFor,
21
21
  digestOfOciRef,
22
22
  provenanceDirectoryForDigest,
23
23
  thirdPartySignerRow,
@@ -28,22 +28,6 @@ import { assertThirdPartyUniqueness } from './third-party-source-uniqueness.mjs'
28
28
 
29
29
  const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
30
30
 
31
- /** The one release-channel row that signs the biome bundle's provenance for `repository`. */
32
- function bundleSignerRow(policy, repository) {
33
- const rows = policy.policies.filter(
34
- (row) =>
35
- row.channel === THIRD_PARTY_EVIDENCE_CHANNEL &&
36
- row.predicateTypes.includes(SLSA_PROVENANCE_V1) &&
37
- row.githubWorkflow?.repository === repository,
38
- );
39
- if (rows.length !== 1) {
40
- throw new Error(
41
- `the trust policy has ${rows.length} ${THIRD_PARTY_EVIDENCE_CHANNEL} rows authorizing ${SLSA_PROVENANCE_V1} for ${repository}; exactly one bundle-signer row is required`,
42
- );
43
- }
44
- return rows[0];
45
- }
46
-
47
31
  /** The one release-channel row whose imageRepositories lists `imageRef`. */
48
32
  function imageSignerRow(policy, schemas, imageRef) {
49
33
  const rows = policyCandidates(policy, schemas.IMAGE_PRODUCER_PREDICATE_TYPE, THIRD_PARTY_EVIDENCE_CHANNEL, imageRef);
@@ -116,7 +100,7 @@ async function collectImage({ image, request, context, clientFor, services }) {
116
100
  export async function planThirdPartySourceWrite({ request, context, clientFor, withProducerWorkflowPin, dataRoot, verifyOnRegistry = verifyBundleOnRegistry }) {
117
101
  const { policy, schemas } = context;
118
102
  const repositoryUrl = `https://github.com/${request.repository}`;
119
- const row = bundleSignerRow(policy, request.repository);
103
+ const row = bundleSignerRowFor({ policy, repository: request.repository, channel: THIRD_PARTY_EVIDENCE_CHANNEL, schemas, label: `third-party source "${request.name}" bundle signer` });
120
104
  const workflowPath = row.githubWorkflow.path;
121
105
  // The row must be exact (release channel, refs/heads/main, the GitHub issuer, its own workflow identity) before cosign runs.
122
106
  thirdPartySignerRow({ policy, policyId: row.policyId, source: { repository: request.repository, producerWorkflow: { path: workflowPath } }, schemas, label: `third-party source "${request.name}" bundle signer` });
@@ -38,6 +38,13 @@
38
38
  // channel neither arm nor disarm the other: a policy that authorizes the three
39
39
  // stages on the release channel and pins both binaries is ARMED for a release
40
40
  // edition whatever it says about develop.
41
+ //
42
+ // ONLY A STAGE ROW ARMS A CHANNEL: a row counts toward a channel's arming when it
43
+ // authorizes one of the stage predicates judged here. A bundle-signer row (SLSA
44
+ // provenance of a third-party bundle, e.g. the develop row of a git-sourced
45
+ // biome) authorizes bundle verification and arms no stage, so a channel whose
46
+ // only rows are bundle signers is UNARMED, exactly like a channel with no row.
47
+ // One stage row on the channel still makes it PARTIAL until every stage is met.
41
48
 
42
49
  export const TrustPolicyArmingState = Object.freeze({
43
50
  ARMED: 'armed',
@@ -81,8 +88,11 @@ export function assessTrustPolicyArming(policy, schemas, channel, { consumesCata
81
88
  stages.push(['edition-catalogue', schemas.EDITION_CATALOGUE_PREDICATE_TYPE]);
82
89
  }
83
90
  }
84
- const onChannel = policy.policies.filter((candidate) => candidate.channel === channel);
85
- const authorized = stages.filter(([, predicateType]) => onChannel.some((candidate) => candidate.predicateTypes.includes(predicateType)));
91
+ const stagePredicateTypes = stages.map(([, predicateType]) => predicateType);
92
+ const stageRows = policy.policies.filter(
93
+ (candidate) => candidate.channel === channel && candidate.predicateTypes.some((predicateType) => stagePredicateTypes.includes(predicateType)),
94
+ );
95
+ const authorized = stages.filter(([, predicateType]) => stageRows.some((candidate) => candidate.predicateTypes.includes(predicateType)));
86
96
 
87
97
  if (policy.policies.length === 0) {
88
98
  unmet.push(
@@ -120,12 +130,13 @@ export function assessTrustPolicyArming(policy, schemas, channel, { consumesCata
120
130
  policy.policies.length === 0 &&
121
131
  policy.trustedCosignBinaryDigests.length === 0 &&
122
132
  policy.trustedGitBinaryDigests.length === 0;
123
- // The channel states nothing while another channel does: its signers and the
124
- // binary pins are that channel's arming, not half of this one's.
125
- const anotherChannelOnly = onChannel.length === 0 && policy.policies.length > 0;
133
+ // The channel has no stage row while the policy has rows (another channel's,
134
+ // or bundle signers): those rows and the binary pins are not half of this
135
+ // channel's arming.
136
+ const noStageRow = stageRows.length === 0 && policy.policies.length > 0;
126
137
  const state = armed
127
138
  ? TrustPolicyArmingState.ARMED
128
- : bootstrap || anotherChannelOnly
139
+ : bootstrap || noStageRow
129
140
  ? TrustPolicyArmingState.UNARMED
130
141
  : TrustPolicyArmingState.PARTIAL;
131
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/xema",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/xema-dev/xema-cli.git",
@@ -100,6 +100,7 @@
100
100
  "typecheck": "tsc --noEmit"
101
101
  },
102
102
  "xemaRelease": {
103
- "sourceDigest": "sha256:c63c5439e820edd884875355490af818610cba7dce1d21eb8a7649438716b795"
103
+ "sourceDigest": "sha256:23c811648b74b4daf999382a1d0f6aa4cad8bfaad8b3f150e51326d709a4982b",
104
+ "sha": "b7d08af5d37faac60b04770fd2b0701811b96459"
104
105
  }
105
106
  }