@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.
- package/dist/edition/verify-image-evidence.bundle.mjs +7 -4
- package/dist/edition/verify-image-evidence.bundle.provenance.json +61 -61
- package/edition/biome-content.mjs +4 -4
- package/edition/candidate-edition.mjs +8 -3
- package/edition/develop-edition-resolve.mjs +14 -7
- package/edition/develop-git-bundles.mjs +201 -0
- package/edition/external-source-walk.mjs +37 -15
- package/edition/locked-bundle-signer.mjs +52 -8
- package/edition/resolve-distribution.mjs +84 -21
- package/edition/third-party-bundle-registry.mjs +73 -17
- package/edition/third-party-source-evidence.mjs +39 -14
- package/edition/third-party-source-writer.mjs +2 -18
- package/edition/trust-policy-arming.mjs +17 -6
- package/package.json +2 -2
|
@@ -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
|
-
*
|
|
126
|
-
*
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
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 ${
|
|
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 =
|
|
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
|
|
85
|
-
const
|
|
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
|
|
124
|
-
// binary pins are
|
|
125
|
-
|
|
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 ||
|
|
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.
|
|
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:
|
|
103
|
+
"sourceDigest": "sha256:80acaaf576705b4a71ecd5e67794fea62c6fc834652a2830bbd03a2d855d2409"
|
|
104
104
|
}
|
|
105
105
|
}
|