@xemahq/xema 0.40.3 → 0.41.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/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +1 -0
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +281 -9
- package/dist/edition/verify-image-evidence.bundle.provenance.json +65 -47
- package/dist/runtime/edition/verb-map.d.ts +3 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +4 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/edition/README.md +5 -0
- package/edition/catalogue-evidence.mjs +225 -0
- package/edition/catalogue-fetch.mjs +40 -7
- package/edition/customer-edition-verbs.mjs +477 -0
- package/edition/customer-flags.mjs +2 -0
- package/edition/develop-coverage-readers.mjs +212 -0
- package/edition/develop-coverage-run.mjs +48 -0
- package/edition/develop-coverage-verdict.mjs +153 -0
- package/edition/develop-coverage.mjs +120 -0
- package/edition/develop-edition-resolve.mjs +18 -7
- package/edition/develop-edition.mjs +47 -220
- package/edition/develop-freeze.mjs +50 -13
- package/edition/develop-image-probe.mjs +7 -7
- package/edition/edition-artifact.mjs +31 -14
- package/edition/edition-constants.mjs +9 -0
- package/edition/edition-contract.json +2 -1
- package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
- package/edition/edition-workspace.mjs +4 -3
- package/edition/evidence-trust-pin.mjs +28 -8
- package/edition/evidence-trust-policy.mjs +51 -7
- package/edition/image-evidence.mjs +2 -2
- package/edition/lib.mjs +7 -0
- package/edition/oci-image-source-evidence.mjs +3 -0
- package/edition/oras-process.mjs +3 -3
- package/edition/oras.mjs +47 -5
- package/edition/produce-edition-catalogue-evidence.mjs +252 -0
- package/edition/produce-image-source-evidence.mjs +11 -2
- package/edition/release-edition.mjs +54 -0
- package/edition/repin-evidence-trust.mjs +5 -3
- package/edition/sigstore-bundle-media-type.mjs +4 -0
- package/edition/trust-policy-arming.mjs +14 -1
- package/edition/verify-catalogue-evidence.mjs +245 -0
- package/edition/verify-image-evidence.mjs +15 -1
- package/package.json +2 -2
- package/edition/develop-customer-edition.mjs +0 -149
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// edition-image-seal — seal image subjects against the repositories of one channel.
|
|
2
2
|
//
|
|
3
|
-
// The release sealer verifies `<imageRef>:<contentHash>` and writes the digest
|
|
4
|
-
// the registry served. This does the same for
|
|
3
|
+
// The vendor release sealer verifies `<imageRef>:<contentHash>` and writes the digest
|
|
4
|
+
// the registry served. This does the same for an edition of either channel, with the
|
|
5
5
|
// SAME verifier (`verifyDistributionPlatforms`, required platforms enforced)
|
|
6
|
-
// and the SAME digest decision (`planDigestSeal`)
|
|
7
|
-
// producer's develop lane pushes to (`developImageRef`, contract C1)
|
|
6
|
+
// and the SAME digest decision (`planDigestSeal`). Only the repository differs:
|
|
7
|
+
// develop the repository the producer's develop lane pushes to (`developImageRef`, contract C1)
|
|
8
|
+
// release the subject's own imageRef (the identity)
|
|
9
|
+
// It never builds or rebuilds an image: a coordinate that is not there is refused, and the digest
|
|
10
|
+
// sealed is the digest the registry serves for `<repository>:<contentHash>`.
|
|
8
11
|
//
|
|
9
12
|
// A coordinate that is not there is "not built yet": the producer's CI is not
|
|
10
13
|
// green or still running. EVERY such subject is collected and named at once;
|
|
@@ -13,23 +16,39 @@
|
|
|
13
16
|
|
|
14
17
|
import { developImageRef } from './develop-image-coordinate.mjs';
|
|
15
18
|
import { notBuiltYetError } from './develop-image-probe.mjs';
|
|
19
|
+
import { EditionChannel } from './edition-channel.mjs';
|
|
16
20
|
import { editionsRoot } from './edition-workspace.mjs';
|
|
17
21
|
import { validateImmutableImageLock, loadKernelDistributionSchemas, } from './lib.mjs';
|
|
18
22
|
import { isNotPublishedYet, planDigestSeal } from './seal-image-digests.mjs';
|
|
19
23
|
import { verifyDistributionPlatforms } from './verify-image-platforms.mjs';
|
|
20
24
|
|
|
25
|
+
/** The repository a channel's own image subjects are verified and sealed in, from the subject's release imageRef. */
|
|
26
|
+
const CHANNEL_IMAGE_REF = Object.freeze({
|
|
27
|
+
[EditionChannel.Develop]: developImageRef,
|
|
28
|
+
[EditionChannel.Release]: (releaseImageRef) => releaseImageRef,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** The repository `channel` seals a subject whose release imageRef is `releaseImageRef` in. */
|
|
32
|
+
export function channelImageRef(channel, releaseImageRef) {
|
|
33
|
+
const toChannelRef = CHANNEL_IMAGE_REF[channel];
|
|
34
|
+
if (toChannelRef === undefined) {throw new Error(`cannot seal image subjects for channel ${JSON.stringify(channel)}`);}
|
|
35
|
+
return toChannelRef(releaseImageRef);
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
/**
|
|
22
|
-
* The row-level seal: every subject re-homed to its
|
|
39
|
+
* The row-level seal: every subject re-homed to its channel's repository, verified
|
|
23
40
|
* there for its required platforms, and given the digest the registry served.
|
|
24
41
|
* It takes SUBJECTS, not a lock, so a caller that owns only some rows of a lock
|
|
25
42
|
* (a customer's own image rows) seals exactly those.
|
|
26
43
|
*
|
|
27
44
|
* @param {object} request
|
|
45
|
+
* @param {string} request.channel `develop` or `release`: which repositories the subjects are sealed in
|
|
28
46
|
* @param {string} request.distributionId the edition the subjects belong to (named in refusals)
|
|
29
47
|
* @param {object[]} request.subjects image subjects (release refs, unsealed)
|
|
30
|
-
* @returns the subjects with
|
|
48
|
+
* @returns the subjects with the channel's refs and verified digests
|
|
31
49
|
*/
|
|
32
|
-
export async function
|
|
50
|
+
export async function sealImageSubjects({
|
|
51
|
+
channel,
|
|
33
52
|
distributionId,
|
|
34
53
|
subjects,
|
|
35
54
|
distributionsRoot = editionsRoot(),
|
|
@@ -38,12 +57,12 @@ export async function sealDevelopImageSubjects({
|
|
|
38
57
|
run = null,
|
|
39
58
|
progress = null,
|
|
40
59
|
}) {
|
|
41
|
-
const
|
|
60
|
+
const channelSubjects = subjects.map((subject) => ({
|
|
42
61
|
...subject,
|
|
43
|
-
imageRef:
|
|
62
|
+
imageRef: channelImageRef(channel, subject.imageRef),
|
|
44
63
|
}));
|
|
45
64
|
const report = await verifyDistributionPlatforms({
|
|
46
|
-
imageLock: { distributionId, subjects:
|
|
65
|
+
imageLock: { distributionId, subjects: channelSubjects },
|
|
47
66
|
distributionsRoot,
|
|
48
67
|
env,
|
|
49
68
|
fetchImpl,
|
|
@@ -54,11 +73,11 @@ export async function sealDevelopImageSubjects({
|
|
|
54
73
|
const aborting = report.failures.find(({ error }) => !isNotPublishedYet(error));
|
|
55
74
|
if (aborting) {throw aborting.error;}
|
|
56
75
|
if (report.failures.length > 0) {
|
|
57
|
-
throw notBuiltYetError(distributionId, report.failures.map(({ subject }) => subject));
|
|
76
|
+
throw notBuiltYetError(distributionId, report.failures.map(({ subject }) => subject), channel);
|
|
58
77
|
}
|
|
59
|
-
const { writes, unchanged } = planDigestSeal({ subjects:
|
|
78
|
+
const { writes, unchanged } = planDigestSeal({ subjects: channelSubjects, verified: report.subjects });
|
|
60
79
|
const digests = new Map([...writes, ...unchanged].map(({ key, digest }) => [key, digest]));
|
|
61
|
-
return
|
|
80
|
+
return channelSubjects.map((subject) => ({
|
|
62
81
|
...subject,
|
|
63
82
|
imageDigest: digests.get(`${subject.kind}:${subject.id}`),
|
|
64
83
|
}));
|
|
@@ -70,10 +89,10 @@ export async function sealDevelopImageSubjects({
|
|
|
70
89
|
*
|
|
71
90
|
* @param {object} request
|
|
72
91
|
* @param {object} request.imageLock the snapshot images lock (release refs, unsealed)
|
|
73
|
-
* @returns the images lock with
|
|
92
|
+
* @returns the images lock with the channel's refs and verified digests, kernel-validated
|
|
74
93
|
*/
|
|
75
|
-
export async function
|
|
76
|
-
const subjects = await
|
|
94
|
+
export async function sealImages({ imageLock, ...sealOptions }) {
|
|
95
|
+
const subjects = await sealImageSubjects({
|
|
77
96
|
distributionId: imageLock.distributionId,
|
|
78
97
|
subjects: imageLock.subjects,
|
|
79
98
|
...sealOptions,
|
|
@@ -182,9 +182,10 @@ export function isEntryModule(moduleUrl) {
|
|
|
182
182
|
/**
|
|
183
183
|
* The entry's one call: configure the roots from `process.argv` and remove the
|
|
184
184
|
* root flags from it, so the entry's own parser sees only its own arguments.
|
|
185
|
-
* `options` are those of `configureEditionWorkspaceFromArgv
|
|
185
|
+
* `options` are those of `configureEditionWorkspaceFromArgv`, plus `failureExitCode` (default 1): the exit code of
|
|
186
|
+
* a refused root, for a verb whose exit 1 already means something else.
|
|
186
187
|
*/
|
|
187
|
-
export function configureEditionWorkspaceFromProcess(needs, options) {
|
|
188
|
+
export function configureEditionWorkspaceFromProcess(needs, { failureExitCode = 1, ...options } = {}) {
|
|
188
189
|
let rest;
|
|
189
190
|
try {
|
|
190
191
|
rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), needs, options);
|
|
@@ -195,7 +196,7 @@ export function configureEditionWorkspaceFromProcess(needs, options) {
|
|
|
195
196
|
process.stderr.write(
|
|
196
197
|
`${error.message}\nusage: this command takes ${needs.map((name) => `${WORKSPACE_FLAGS[name]} <dir>`).join(' ')}\n`,
|
|
197
198
|
);
|
|
198
|
-
process.exit(
|
|
199
|
+
process.exit(failureExitCode);
|
|
199
200
|
}
|
|
200
201
|
process.argv.splice(2, process.argv.length - 2, ...rest);
|
|
201
202
|
}
|
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
// change and the registry reader cannot change without a reviewed data-root
|
|
11
11
|
// commit.
|
|
12
12
|
//
|
|
13
|
+
// A customer data root has no such index; its pin records `kernelContractAuthority: null` and pins only what exists.
|
|
14
|
+
//
|
|
13
15
|
// The kernel contract authority is pinned HERE because nothing else can hold
|
|
14
16
|
// it: `runtime-image-source-index.json` states the authority, and the image
|
|
15
17
|
// locks and statements are compared with what that file states. Comparing the
|
|
16
18
|
// file with itself decides nothing, so its authority is first held to this
|
|
17
19
|
// pin, and only the pinned authority is what evidence is compared with.
|
|
18
20
|
|
|
19
|
-
import { closeSync, constants as fsConstants, openSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { closeSync, constants as fsConstants, existsSync, openSync, writeFileSync } from 'node:fs';
|
|
20
22
|
import { join } from 'node:path';
|
|
21
23
|
|
|
22
24
|
import { COMMIT_PATTERN } from './evidence-grammars.mjs';
|
|
@@ -61,7 +63,7 @@ const VERIFIER_KEYS = Object.freeze(['bundleDigest', 'provenanceDigest']);
|
|
|
61
63
|
const AUTHORITY_PIN_KEYS = Object.freeze(['digest', 'sourceCommit']);
|
|
62
64
|
const PIN_SHAPE =
|
|
63
65
|
'{schemaVersion, policyDigest, verifier: {bundleDigest, provenanceDigest}, ' +
|
|
64
|
-
'kernelContractAuthority: {sourceCommit, digest}, trustedOrasBinaryDigests: [sha256:…, …]}';
|
|
66
|
+
'kernelContractAuthority: {sourceCommit, digest} | null, trustedOrasBinaryDigests: [sha256:…, …]}';
|
|
65
67
|
|
|
66
68
|
const exactKeys = (value, keys) =>
|
|
67
69
|
value !== null &&
|
|
@@ -117,7 +119,7 @@ export function readEvidenceTrustPin({ dataRoot, declaration }) {
|
|
|
117
119
|
if (
|
|
118
120
|
!exactKeys(pin, PIN_KEYS) ||
|
|
119
121
|
!exactKeys(pin.verifier, VERIFIER_KEYS) ||
|
|
120
|
-
!exactKeys(pin.kernelContractAuthority, AUTHORITY_PIN_KEYS) ||
|
|
122
|
+
!(pin.kernelContractAuthority === null || exactKeys(pin.kernelContractAuthority, AUTHORITY_PIN_KEYS)) ||
|
|
121
123
|
!Array.isArray(pin.trustedOrasBinaryDigests)
|
|
122
124
|
) {
|
|
123
125
|
throw new Error(`${declaration.pin}: the pin is exactly ${PIN_SHAPE}`);
|
|
@@ -137,10 +139,13 @@ export function readEvidenceTrustPin({ dataRoot, declaration }) {
|
|
|
137
139
|
'verifier.provenanceDigest',
|
|
138
140
|
),
|
|
139
141
|
}),
|
|
140
|
-
kernelContractAuthority:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
kernelContractAuthority:
|
|
143
|
+
pin.kernelContractAuthority === null
|
|
144
|
+
? null
|
|
145
|
+
: Object.freeze({
|
|
146
|
+
sourceCommit: requireCommit(pin.kernelContractAuthority.sourceCommit),
|
|
147
|
+
digest: requireDigest(pin.kernelContractAuthority.digest, 'kernelContractAuthority.digest'),
|
|
148
|
+
}),
|
|
144
149
|
trustedOrasBinaryDigests: requireOrasDigests(pin.trustedOrasBinaryDigests),
|
|
145
150
|
});
|
|
146
151
|
}
|
|
@@ -175,6 +180,14 @@ export function kernelContractAuthorityPin(authority) {
|
|
|
175
180
|
});
|
|
176
181
|
}
|
|
177
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Whether the data root holds a runtime image source index. A vendor data root does; a customer data root has none
|
|
185
|
+
* (it consumes a catalogue and states no kernel contract authority of its own), and its pin records `null`.
|
|
186
|
+
*/
|
|
187
|
+
export function hasRuntimeImageSourceIndex(dataRoot) {
|
|
188
|
+
return existsSync(join(dataRoot, RUNTIME_IMAGE_SOURCE_INDEX_FILE));
|
|
189
|
+
}
|
|
190
|
+
|
|
178
191
|
/** The kernel contract authority the data root's runtime image source index STATES; nothing is trusted yet. */
|
|
179
192
|
export function readStatedKernelContractAuthority({ dataRoot, schemas }) {
|
|
180
193
|
if (!schemas?.parseRuntimeImageSourceIndex) {
|
|
@@ -192,6 +205,13 @@ export function readStatedKernelContractAuthority({ dataRoot, schemas }) {
|
|
|
192
205
|
/** Refuse an authority that is not the one the pin records. */
|
|
193
206
|
export function assertPinnedKernelContractAuthority({ pin, authority, label }) {
|
|
194
207
|
const actual = kernelContractAuthorityPin(authority);
|
|
208
|
+
if (pin.kernelContractAuthority === null) {
|
|
209
|
+
throw new KernelContractAuthorityPinError(
|
|
210
|
+
KERNEL_CONTRACT_AUTHORITY_NOT_PINNED_ONE,
|
|
211
|
+
`${label} states kernel contract authority ${actual.sourceCommit} (${actual.digest}) but the pin records none ` +
|
|
212
|
+
`(it was written for a data root without a runtime image source index); review the index, then re-pin with \`${REPIN_COMMAND}\``,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
195
215
|
if (
|
|
196
216
|
actual.sourceCommit !== pin.kernelContractAuthority.sourceCommit ||
|
|
197
217
|
actual.digest !== pin.kernelContractAuthority.digest
|
|
@@ -283,7 +303,7 @@ export function writeEvidenceTrustPin({
|
|
|
283
303
|
bundleDigest: verifier.bundleDigest,
|
|
284
304
|
provenanceDigest: verifier.provenanceDigest,
|
|
285
305
|
},
|
|
286
|
-
kernelContractAuthority: { ...kernelContractAuthorityPin(kernelContractAuthority) },
|
|
306
|
+
kernelContractAuthority: kernelContractAuthority === null ? null : { ...kernelContractAuthorityPin(kernelContractAuthority) },
|
|
287
307
|
trustedOrasBinaryDigests: [...requireOrasDigests(trustedOrasBinaryDigests)].sort(),
|
|
288
308
|
};
|
|
289
309
|
const text = `${JSON.stringify(pin, null, 2)}\n`;
|
|
@@ -133,6 +133,28 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
|
|
|
133
133
|
return { policy, policyDigest };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The data root's declared policy, loaded and held to its pin exactly as the
|
|
138
|
+
* arming checks load it, for a READ-ONLY consumer (the develop coverage verb).
|
|
139
|
+
* A data root that declares no policy has nothing to report coverage against
|
|
140
|
+
* and is refused, naming the declaration it lacks. The POLICY is held to its pin; this CLI's built verifier
|
|
141
|
+
* is not. Coverage therefore runs this CLI's own, unpinned verifier code (case A), so a green coverage is a
|
|
142
|
+
* forecast of what resolve accepts, not a guarantee: resolve can still refuse under the pinned verifier.
|
|
143
|
+
*
|
|
144
|
+
* @returns {{ policy: object, dataRoot: string }}
|
|
145
|
+
*/
|
|
146
|
+
export async function loadDeclaredPinnedPolicy() {
|
|
147
|
+
const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
|
|
148
|
+
const root = configuredDataRoot();
|
|
149
|
+
if (declaration.policy === null) {
|
|
150
|
+
throw new Error(`${join(root, EDITION_WORKSPACE_CONFIG_FILE)} declares no evidenceTrustPolicy; there is no pinned policy to report coverage against`);
|
|
151
|
+
}
|
|
152
|
+
const schemas = await loadKernelDistributionSchemas();
|
|
153
|
+
const { policy, policyDigest } = loadDeclaredTrustPolicy({ dataRoot: root, declaration, schemas });
|
|
154
|
+
assertPinnedPolicy({ pin: readEvidenceTrustPin({ dataRoot: root, declaration }), policyDigest });
|
|
155
|
+
return { policy, dataRoot: root };
|
|
156
|
+
}
|
|
157
|
+
|
|
136
158
|
/**
|
|
137
159
|
* The arming of the data root's declared policy on `channel`, loaded and pinned
|
|
138
160
|
* exactly as `descriptorEvidenceTrust` and the check load it: `{ declared, state,
|
|
@@ -140,9 +162,9 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
|
|
|
140
162
|
* false` and one unmet entry; a declared policy that does not hold to its pin
|
|
141
163
|
* (digest, verifier) throws. Nothing is written.
|
|
142
164
|
*
|
|
143
|
-
* @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string} }} options
|
|
165
|
+
* @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string}, consumesCatalogue?: boolean }} options
|
|
144
166
|
*/
|
|
145
|
-
export async function assessDeclaredTrustPolicyArming({ channel, locateVerifier = locateBuiltVerifier }) {
|
|
167
|
+
export async function assessDeclaredTrustPolicyArming({ channel, locateVerifier = locateBuiltVerifier, consumesCatalogue = false }) {
|
|
146
168
|
const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
|
|
147
169
|
const root = configuredDataRoot();
|
|
148
170
|
const schemas = await loadKernelDistributionSchemas();
|
|
@@ -155,13 +177,26 @@ export async function assessDeclaredTrustPolicyArming({ channel, locateVerifier
|
|
|
155
177
|
};
|
|
156
178
|
}
|
|
157
179
|
const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
|
|
158
|
-
const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
|
|
180
|
+
const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel, { consumesCatalogue });
|
|
159
181
|
return { declared: true, state, unmet };
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
/** What a develop edition says, once, when it is written or checked without an armed develop channel. */
|
|
163
185
|
export const DEVELOP_NOT_ARMED_NOTICE = 'develop channel: trust policy not armed, evidence is not signature-verified';
|
|
164
186
|
|
|
187
|
+
/** A customer develop edition under a policy ARMED on develop: its catalogue carries no E4 evidence to verify. */
|
|
188
|
+
export class CatalogueEvidenceUnverifiableError extends Error {
|
|
189
|
+
constructor(root) {
|
|
190
|
+
super(
|
|
191
|
+
`the data root ${root} declares an evidence trust policy ARMED on the develop channel, but a develop catalogue carries no ` +
|
|
192
|
+
'signed edition-catalogue (E4) evidence, so the catalogue this customer develop edition consumes cannot be verified and ' +
|
|
193
|
+
'the edition is never recorded as armed. Resolve it under a policy that is not armed on develop (no develop signer rows), ' +
|
|
194
|
+
'or resolve a release edition, whose catalogue evidence is verified.',
|
|
195
|
+
);
|
|
196
|
+
this.name = 'CatalogueEvidenceUnverifiableError';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
165
200
|
/**
|
|
166
201
|
* The evidence state an edition of `channel` records in its descriptor. Arming
|
|
167
202
|
* is judged for that ONE channel (`assessTrustPolicyArming`): the signers of
|
|
@@ -179,9 +214,15 @@ export const DEVELOP_NOT_ARMED_NOTICE = 'develop channel: trust policy not armed
|
|
|
179
214
|
* A policy edited after pinning, a drifted verifier, and a policy PARTIALLY
|
|
180
215
|
* ARMED on `channel` are refused on either channel.
|
|
181
216
|
*
|
|
182
|
-
*
|
|
217
|
+
* `consumesCatalogue` (a customer edition consumes a catalogue): on RELEASE it adds the edition-catalogue (E4) row to the
|
|
218
|
+
* required ones, and the catalogue's E4 evidence is verified before it is parsed. A DEVELOP catalogue carries no E4
|
|
219
|
+
* evidence (the vendor's develop-edition workflow signs the artifact but attaches no E4 statement), so on develop no E4
|
|
220
|
+
* row is required and nothing can verify the catalogue: a customer develop edition is never written `armed`. A policy
|
|
221
|
+
* ARMED on develop throws CatalogueEvidenceUnverifiableError instead of recording a claim nothing checked.
|
|
222
|
+
*
|
|
223
|
+
* @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string}, consumesCatalogue?: boolean }} options
|
|
183
224
|
*/
|
|
184
|
-
export async function descriptorEvidenceTrust({ channel, locateVerifier = locateBuiltVerifier }) {
|
|
225
|
+
export async function descriptorEvidenceTrust({ channel, locateVerifier = locateBuiltVerifier, consumesCatalogue = false }) {
|
|
185
226
|
const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
|
|
186
227
|
const root = configuredDataRoot();
|
|
187
228
|
const schemas = await loadKernelDistributionSchemas();
|
|
@@ -196,8 +237,11 @@ export async function descriptorEvidenceTrust({ channel, locateVerifier = locate
|
|
|
196
237
|
return developNotArmed();
|
|
197
238
|
}
|
|
198
239
|
const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
|
|
199
|
-
const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
|
|
200
|
-
if (state === TrustPolicyArmingState.ARMED) {
|
|
240
|
+
const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel, { consumesCatalogue: consumesCatalogue && release });
|
|
241
|
+
if (state === TrustPolicyArmingState.ARMED) {
|
|
242
|
+
if (consumesCatalogue && !release) {throw new CatalogueEvidenceUnverifiableError(root);}
|
|
243
|
+
return EvidenceTrustState.Armed;
|
|
244
|
+
}
|
|
201
245
|
if (state === TrustPolicyArmingState.PARTIAL) {
|
|
202
246
|
if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`);}
|
|
203
247
|
throw new Error(`the declared evidence trust policy is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')}); an edition cannot be written against it`);
|
|
@@ -42,7 +42,7 @@ const MAX_SIGSTORE_BUNDLE_BYTES = 16 * 1024 * 1024;
|
|
|
42
42
|
const MAX_TRUSTED_ROOT_BYTES = 8 * 1024 * 1024;
|
|
43
43
|
/** The kernel's `SignerKind.OidcCertificate`: the one signer kind this verifier runs (cosign keyless). */
|
|
44
44
|
const OIDC_CERTIFICATE_SIGNER = 'oidc-certificate';
|
|
45
|
-
const COSIGN_ENV = Object.freeze({
|
|
45
|
+
export const COSIGN_ENV = Object.freeze({
|
|
46
46
|
LANG: 'C',
|
|
47
47
|
LC_ALL: 'C',
|
|
48
48
|
PATH: '/usr/bin:/bin',
|
|
@@ -632,7 +632,7 @@ function assertEvidenceChannel(channel) {
|
|
|
632
632
|
* selects which policy rows are tried; what authenticates a channel is the
|
|
633
633
|
* certificate matching a row of that channel.
|
|
634
634
|
*/
|
|
635
|
-
function policyCandidates(policy, predicateType, channel, imageRepository) {
|
|
635
|
+
export function policyCandidates(policy, predicateType, channel, imageRepository) {
|
|
636
636
|
const candidates = policy.policies.filter(
|
|
637
637
|
(candidate) =>
|
|
638
638
|
candidate.channel === channel &&
|
package/edition/lib.mjs
CHANGED
|
@@ -734,6 +734,8 @@ export async function loadKernelDistributionSchemas() {
|
|
|
734
734
|
parseImageProducerStatement,
|
|
735
735
|
parseDistributionImagesStatement,
|
|
736
736
|
parseImageDeliveryEntitlementStatement,
|
|
737
|
+
parseEditionCatalogueStatement,
|
|
738
|
+
editionCatalogueRefusal,
|
|
737
739
|
parseDistributionImageEvidenceSet,
|
|
738
740
|
parseDistributionImageReplayState,
|
|
739
741
|
parseSigstoreOfflineTrustPolicy,
|
|
@@ -751,6 +753,7 @@ export async function loadKernelDistributionSchemas() {
|
|
|
751
753
|
IMAGE_PRODUCER_PREDICATE_TYPE,
|
|
752
754
|
DISTRIBUTION_IMAGES_PREDICATE_TYPE,
|
|
753
755
|
IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
|
|
756
|
+
EDITION_CATALOGUE_PREDICATE_TYPE,
|
|
754
757
|
EVIDENCE_CHANNELS,
|
|
755
758
|
EVIDENCE_CHANNEL_REFS,
|
|
756
759
|
resolveFirstPartyCapabilityDomains,
|
|
@@ -927,6 +930,8 @@ export async function loadKernelDistributionSchemas() {
|
|
|
927
930
|
parseImageProducerStatement,
|
|
928
931
|
parseDistributionImagesStatement,
|
|
929
932
|
parseImageDeliveryEntitlementStatement,
|
|
933
|
+
parseEditionCatalogueStatement,
|
|
934
|
+
editionCatalogueRefusal,
|
|
930
935
|
parseDistributionImageEvidenceSet,
|
|
931
936
|
parseDistributionImageReplayState,
|
|
932
937
|
parseSigstoreOfflineTrustPolicy,
|
|
@@ -943,6 +948,8 @@ export async function loadKernelDistributionSchemas() {
|
|
|
943
948
|
IMAGE_PRODUCER_PREDICATE_TYPE,
|
|
944
949
|
DISTRIBUTION_IMAGES_PREDICATE_TYPE,
|
|
945
950
|
IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
|
|
951
|
+
// Optional: a kernel without the E4 predicate is a catalogue consumer that cannot be armed (trust-policy-arming.mjs).
|
|
952
|
+
EDITION_CATALOGUE_PREDICATE_TYPE,
|
|
946
953
|
EVIDENCE_CHANNELS,
|
|
947
954
|
EVIDENCE_CHANNEL_REFS,
|
|
948
955
|
resolveFirstPartyCapabilityDomains,
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
|
|
23
23
|
export const IMAGE_SOURCE_EVIDENCE_ARTIFACT_TYPE =
|
|
24
24
|
'application/vnd.xema.image-source-evidence.v2';
|
|
25
|
+
/** The E4 statement an edition artifact carries as an `oras attach` referrer (the catalogue's signed evidence). */
|
|
26
|
+
export const EDITION_CATALOGUE_EVIDENCE_ARTIFACT_TYPE =
|
|
27
|
+
'application/vnd.xema.edition-catalogue-evidence.v1';
|
|
25
28
|
export const OCI_IMAGE_MANIFEST_MEDIA_TYPE =
|
|
26
29
|
'application/vnd.oci.image.manifest.v1+json';
|
|
27
30
|
const OCI_TITLE_ANNOTATION = 'org.opencontainers.image.title';
|
package/edition/oras-process.mjs
CHANGED
|
@@ -8,16 +8,16 @@
|
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Run `file` with `args
|
|
11
|
+
* Run `file` with `args` (in `cwd` when given); resolve with stdout bytes. A failure rejects with the
|
|
12
12
|
* child's stderr text on `error.stderr` (a string), so the caller reads the
|
|
13
13
|
* registry status from what oras itself wrote.
|
|
14
14
|
*/
|
|
15
|
-
export function runExecutable(file, args, { input, env, timeoutMs, maxBufferBytes }) {
|
|
15
|
+
export function runExecutable(file, args, { input, env, cwd, timeoutMs, maxBufferBytes }) {
|
|
16
16
|
return new Promise((resolve, reject) => {
|
|
17
17
|
const child = execFile(
|
|
18
18
|
file,
|
|
19
19
|
args,
|
|
20
|
-
{ env, timeout: timeoutMs, maxBuffer: maxBufferBytes, encoding: 'buffer' },
|
|
20
|
+
{ env, ...(cwd === undefined ? {} : { cwd }), timeout: timeoutMs, maxBuffer: maxBufferBytes, encoding: 'buffer' },
|
|
21
21
|
(error, stdout, stderr) => {
|
|
22
22
|
if (error) {
|
|
23
23
|
error.stderr = stderr.toString('utf8');
|
package/edition/oras.mjs
CHANGED
|
@@ -255,7 +255,7 @@ export function prepareTrustedOras({ env = process.env, trustedDigests, notice =
|
|
|
255
255
|
/** The default runner: the trusted oras of the configured data root, under the scrubbed environment. */
|
|
256
256
|
function defaultRunner(trust = configuredOrasTrust) {
|
|
257
257
|
let prepared;
|
|
258
|
-
return async (args, { stdin } = {}) => {
|
|
258
|
+
return async (args, { stdin, cwd } = {}) => {
|
|
259
259
|
if (prepared === undefined) {
|
|
260
260
|
prepared = prepareTrustedOras({ trustedDigests: trust() });
|
|
261
261
|
process.once('exit', prepared.dispose);
|
|
@@ -263,6 +263,7 @@ function defaultRunner(trust = configuredOrasTrust) {
|
|
|
263
263
|
try {
|
|
264
264
|
return await runExecutable(prepared.path(), args, {
|
|
265
265
|
input: stdin,
|
|
266
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
266
267
|
env: orasEnvironment(prepared.home),
|
|
267
268
|
timeoutMs: ORAS_RUN_TIMEOUT_MS,
|
|
268
269
|
maxBufferBytes: ORAS_MAX_BUFFER_BYTES,
|
|
@@ -364,7 +365,7 @@ export function disposeRegistrySessions() {
|
|
|
364
365
|
}
|
|
365
366
|
|
|
366
367
|
/**
|
|
367
|
-
*
|
|
368
|
+
* An OCI registry client (reads, and `attach`) bound to ONE registry authority and ONE
|
|
368
369
|
* resolved credential, doing every operation with the pinned oras.
|
|
369
370
|
*/
|
|
370
371
|
export class OrasRegistry {
|
|
@@ -456,7 +457,7 @@ export class OrasRegistry {
|
|
|
456
457
|
}
|
|
457
458
|
|
|
458
459
|
/** One oras invocation under this registry's shared empty config; returns stdout bytes. */
|
|
459
|
-
async #oras(subcommand, flags, ref) {
|
|
460
|
+
async #oras(subcommand, flags, ref, { cwd, operands = [] } = {}) {
|
|
460
461
|
await this.#ensureRealmIsSameOrigin();
|
|
461
462
|
const { configPath } = registrySession(this.authority);
|
|
462
463
|
const args = [...subcommand, '--registry-config', configPath];
|
|
@@ -465,9 +466,9 @@ export class OrasRegistry {
|
|
|
465
466
|
args.push('--username', this.#username, '--password-stdin');
|
|
466
467
|
stdin = this.#token;
|
|
467
468
|
}
|
|
468
|
-
args.push(...flags, ref);
|
|
469
|
+
args.push(...flags, ref, ...operands);
|
|
469
470
|
try {
|
|
470
|
-
const out = await this.#run(args, { stdin });
|
|
471
|
+
const out = await this.#run(args, { stdin, ...(cwd === undefined ? {} : { cwd }) });
|
|
471
472
|
return Buffer.isBuffer(out) ? out : Buffer.from(out ?? '');
|
|
472
473
|
} catch (error) {
|
|
473
474
|
throw this.#failure(ref, error);
|
|
@@ -559,6 +560,47 @@ export class OrasRegistry {
|
|
|
559
560
|
return descriptor;
|
|
560
561
|
}
|
|
561
562
|
|
|
563
|
+
/**
|
|
564
|
+
* ATTACH `files` (paths relative to `cwd`, each with its media type) as an artifact of `artifactType` that is a referrer
|
|
565
|
+
* of `subjectDigest`, under the same referrers form discover reads ({@link REFERRERS_DISTRIBUTION_SPEC}). Returns the
|
|
566
|
+
* digest of the referrer manifest oras reports. The only write this client does; the caller states every byte.
|
|
567
|
+
*
|
|
568
|
+
* @param {string} repository
|
|
569
|
+
* @param {string} subjectDigest
|
|
570
|
+
* @param {{ artifactType: string, files: {path: string, mediaType: string}[], annotations?: Record<string,string>, cwd: string }} attachment
|
|
571
|
+
*/
|
|
572
|
+
async attach(repository, subjectDigest, { artifactType, files, annotations = {}, cwd }) {
|
|
573
|
+
if (!SHA256_DIGEST_PATTERN.test(subjectDigest)) {
|
|
574
|
+
throw new Error(`"${subjectDigest}" is not a canonical lowercase sha256 digest`);
|
|
575
|
+
}
|
|
576
|
+
if (typeof artifactType !== 'string' || artifactType.length === 0) {
|
|
577
|
+
throw new Error('an attach requires a non-empty artifact type');
|
|
578
|
+
}
|
|
579
|
+
if (!Array.isArray(files) || files.length === 0 || typeof cwd !== 'string' || !isAbsolute(cwd)) {
|
|
580
|
+
throw new Error('an attach requires at least one file and an absolute working directory');
|
|
581
|
+
}
|
|
582
|
+
const ref = `${this.#ref(repository)}@${subjectDigest}`;
|
|
583
|
+
const flags = ['--distribution-spec', REFERRERS_DISTRIBUTION_SPEC, '--artifact-type', artifactType];
|
|
584
|
+
for (const [key, value] of Object.entries(annotations)) {
|
|
585
|
+
flags.push('--annotation', `${key}=${value}`);
|
|
586
|
+
}
|
|
587
|
+
flags.push('--format', 'json');
|
|
588
|
+
const out = await this.#oras(['attach'], flags, ref, {
|
|
589
|
+
cwd,
|
|
590
|
+
operands: files.map((file) => `${file.path}:${file.mediaType}`),
|
|
591
|
+
});
|
|
592
|
+
let document;
|
|
593
|
+
try {
|
|
594
|
+
document = JSON.parse(out.toString('utf8'));
|
|
595
|
+
} catch (error) {
|
|
596
|
+
throw new Error(`${ref} attach did not report JSON: ${error.message}`);
|
|
597
|
+
}
|
|
598
|
+
if (!SHA256_DIGEST_PATTERN.test(document?.digest)) {
|
|
599
|
+
throw new Error(`${ref} attach reported no canonical referrer digest`);
|
|
600
|
+
}
|
|
601
|
+
return document.digest;
|
|
602
|
+
}
|
|
603
|
+
|
|
562
604
|
/** Every tag of a repository; oras follows the registry's pagination. A failure throws, never an empty list. */
|
|
563
605
|
async listTags(repository) {
|
|
564
606
|
const ref = this.#ref(repository);
|