@xemahq/xema 0.55.0 → 0.56.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.
@@ -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.56.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/xema-dev/xema-cli.git",
@@ -100,6 +100,6 @@
100
100
  "typecheck": "tsc --noEmit"
101
101
  },
102
102
  "xemaRelease": {
103
- "sourceDigest": "sha256:c63c5439e820edd884875355490af818610cba7dce1d21eb8a7649438716b795"
103
+ "sourceDigest": "sha256:80acaaf576705b4a71ecd5e67794fea62c6fc834652a2830bbd03a2d855d2409"
104
104
  }
105
105
  }