@xemahq/xema 0.48.0 → 0.49.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/assets/appliance-base/distribution.lock.json +3936 -1515
- package/dist/assets/appliance-base/images.lock.json +878 -1161
- package/dist/assets/appliance-lean/distribution.lock.json +3663 -916
- package/dist/assets/appliance-lean/images.lock.json +825 -924
- package/dist/assets/boot-graph.json +398 -93
- package/dist/assets/distribution-assets.manifest.json +10 -10
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +5 -3
- package/dist/commands/edition.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +0 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +4386 -2503
- package/dist/edition/verify-image-evidence.bundle.provenance.json +590 -488
- package/dist/runtime/auth/cli-application.d.ts +18 -0
- package/dist/runtime/auth/cli-application.d.ts.map +1 -0
- package/dist/runtime/auth/cli-application.js +39 -0
- package/dist/runtime/auth/cli-application.js.map +1 -0
- 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 +5 -1
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +21 -0
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +0 -18
- package/dist/runtime/launch.js.map +1 -1
- package/edition/README.md +20 -3
- package/edition/biome-content.mjs +5 -2
- package/edition/build-runtime-image-source-index.mjs +79 -5
- package/edition/canonical-evidence-json.mjs +82 -0
- package/edition/catalogue-evidence.mjs +8 -5
- package/edition/customer-edition-verbs.mjs +9 -3
- package/edition/develop-coverage-run.mjs +2 -1
- package/edition/develop-coverage.mjs +12 -1
- package/edition/develop-edition-resolve.mjs +3 -2
- package/edition/develop-edition.mjs +15 -3
- package/edition/develop-record-verify.mjs +216 -0
- package/edition/develop-select-records.mjs +24 -225
- package/edition/develop-verify-image-record.mjs +90 -0
- package/edition/dist-check.mjs +22 -2
- package/edition/distribution-evidence-set.mjs +30 -2
- package/edition/edition-artifact.mjs +110 -22
- package/edition/edition-ceiling.mjs +180 -0
- package/edition/edition-config.mjs +91 -0
- package/edition/edition-constants.mjs +21 -3
- package/edition/edition-contract-parse.mjs +88 -3
- package/edition/edition-contract.json +47 -4
- package/edition/edition-image-seal.mjs +9 -2
- package/edition/edition-kind.mjs +10 -0
- package/edition/edition-protocol.mjs +7 -1
- package/edition/edition-publisher.mjs +21 -0
- package/edition/edition-seal.mjs +257 -0
- package/edition/evidence-grammars.mjs +2 -0
- package/edition/evidence-trust-pin.mjs +66 -10
- package/edition/evidence-trust-policy.mjs +39 -29
- package/edition/external-source-walk.mjs +16 -3
- package/edition/fleet-coordinate-finality.mjs +18 -5
- package/edition/fulcio-certificate-extensions.mjs +231 -0
- package/edition/image-evidence.mjs +34 -79
- package/edition/locked-bundle-signer.mjs +224 -0
- package/edition/oci-evidence-verbs.mjs +7 -1
- package/edition/pinned-cosign.mjs +70 -0
- package/edition/plan-image-evidence-refresh.mjs +3 -1
- package/edition/producer-evidence-bundle.mjs +3 -1
- package/edition/producer-evidence-index-file.mjs +4 -0
- package/edition/release-edition-artifact.mjs +3 -3
- package/edition/repin-evidence-trust.mjs +6 -1
- package/edition/resolve-distribution.mjs +47 -6
- package/edition/resolve-edition.mjs +1 -1
- package/edition/resolve-images.mjs +53 -7
- package/edition/seal-image-digests.mjs +19 -8
- package/edition/sealed-distribution-lock.mjs +2 -2
- package/edition/third-party-admission.mjs +91 -0
- package/edition/third-party-bundle-registry.mjs +147 -0
- package/edition/third-party-certificate-extensions.mjs +21 -0
- package/edition/third-party-image-pins.mjs +41 -0
- package/edition/third-party-pins-trusted.mjs +24 -0
- package/edition/third-party-producer-evidence.mjs +76 -0
- package/edition/third-party-source-check.mjs +28 -0
- package/edition/third-party-source-context.mjs +41 -0
- package/edition/third-party-source-evidence.mjs +255 -0
- package/edition/third-party-source-flags.mjs +134 -0
- package/edition/third-party-source-pins.mjs +259 -0
- package/edition/third-party-source-uniqueness.mjs +54 -0
- package/edition/third-party-source-walk.mjs +115 -0
- package/edition/third-party-source-writer.mjs +229 -0
- package/edition/third-party-subjects.mjs +127 -0
- package/edition/trust-policy-image-ownership.mjs +85 -0
- package/edition/verify-catalogue-evidence.mjs +2 -0
- package/edition/verify-image-evidence.mjs +2 -0
- package/edition/verify-image-platforms.mjs +33 -9
- package/package.json +5 -5
- package/dist/runtime/auth/client-bootstrap.d.ts +0 -11
- package/dist/runtime/auth/client-bootstrap.d.ts.map +0 -1
- package/dist/runtime/auth/client-bootstrap.js +0 -60
- package/dist/runtime/auth/client-bootstrap.js.map +0 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// The third-party source pin file (`third-party-source-pins.json`, XEMA-1611 design section 3).
|
|
2
|
+
//
|
|
3
|
+
// ONE reviewed record per third-party image source: the explicit grant that lets a biome from an
|
|
4
|
+
// organisation outside the vendor fleet enter an edition with sealed, fully evidenced images. It
|
|
5
|
+
// is validated here (the CLI), not by a kernel contract, and it is written only by the trusted
|
|
6
|
+
// writer. The file lives at the root of the data root in use, beside the evidence trust policy;
|
|
7
|
+
// there is no vendor path and no vendor key.
|
|
8
|
+
//
|
|
9
|
+
// * A MISSING, EMPTY or UNPARSABLE (not JSON) file means "no third-party source": the loader
|
|
10
|
+
// returns an empty set and a reason every consumer puts in its refusal.
|
|
11
|
+
// * A file that parses but is not canonical JSON or does not match the schema THROWS: it is a
|
|
12
|
+
// reviewed record, and a record that is present but wrong is never read as "no sources".
|
|
13
|
+
// * The file's sha256 joins the evidence trust pin (`thirdPartySourcePinsDigest`).
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { parseCanonicalJsonBytes, sha256Digest } from './canonical-evidence-json.mjs';
|
|
19
|
+
import {
|
|
20
|
+
CANONICAL_IDENTIFIER,
|
|
21
|
+
COMMIT_PATTERN,
|
|
22
|
+
GITHUB_REPOSITORY_PATTERN,
|
|
23
|
+
SHA256_DIGEST_PATTERN,
|
|
24
|
+
SHA256_HEX_PATTERN,
|
|
25
|
+
} from './evidence-grammars.mjs';
|
|
26
|
+
import { readBoundedNoFollowFile } from './no-follow-file.mjs';
|
|
27
|
+
|
|
28
|
+
export const THIRD_PARTY_SOURCE_PINS_FILE = 'third-party-source-pins.json';
|
|
29
|
+
export const THIRD_PARTY_SOURCE_PINS_SCHEMA_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
const MAX_PINS_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
const LABEL = THIRD_PARTY_SOURCE_PINS_FILE;
|
|
33
|
+
|
|
34
|
+
const SOURCE_NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
|
35
|
+
const RUNTIME_IDENTITY = /^[a-z0-9](?:[a-z0-9._-]{0,254}[a-z0-9])?$/;
|
|
36
|
+
const GITHUB_NUMERIC_ID = /^[1-9][0-9]*$/;
|
|
37
|
+
const OCI_REPOSITORY = /^[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]+)?(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)+$/;
|
|
38
|
+
const OCI_REF_BY_DIGEST = new RegExp(`${OCI_REPOSITORY.source.slice(0, -1)}@sha256:[a-f0-9]{64}$`);
|
|
39
|
+
const WORKFLOW_PATH = /^\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml$/;
|
|
40
|
+
const SUBJECT_KINDS = Object.freeze(['service', 'platform', 'host', 'runtime-component', 'composed-process']);
|
|
41
|
+
const SOURCE_HASH_ALGORITHMS = Object.freeze([
|
|
42
|
+
'xema-image-input-sha256-v1',
|
|
43
|
+
'xema-image-input-sha256-v2',
|
|
44
|
+
'xema-image-input-sha256-v3',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const SOURCE_KEYS = Object.freeze([
|
|
48
|
+
'bundle',
|
|
49
|
+
'images',
|
|
50
|
+
'name',
|
|
51
|
+
'producerWorkflow',
|
|
52
|
+
'repository',
|
|
53
|
+
'repositoryId',
|
|
54
|
+
'repositoryOwnerId',
|
|
55
|
+
'repositoryUrl',
|
|
56
|
+
'sourceCommit',
|
|
57
|
+
]);
|
|
58
|
+
const BUNDLE_KEYS = Object.freeze([
|
|
59
|
+
'biomeId',
|
|
60
|
+
'bundleDigest',
|
|
61
|
+
'ociRef',
|
|
62
|
+
'signerPolicyId',
|
|
63
|
+
'sourceManifestHash',
|
|
64
|
+
'statementDigest',
|
|
65
|
+
]);
|
|
66
|
+
const WORKFLOW_KEYS = Object.freeze(['path', 'sha']);
|
|
67
|
+
const IMAGE_KEYS = Object.freeze([
|
|
68
|
+
'bundleDigest',
|
|
69
|
+
'imageRef',
|
|
70
|
+
'manifestDigest',
|
|
71
|
+
'policyId',
|
|
72
|
+
'sourceHashAlgorithm',
|
|
73
|
+
'sourceTreeHash',
|
|
74
|
+
'statementDigest',
|
|
75
|
+
'subjectId',
|
|
76
|
+
'subjectKind',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/** Why a data root has no third-party source. */
|
|
80
|
+
export const ThirdPartyPinsAbsence = Object.freeze({
|
|
81
|
+
Missing: 'missing',
|
|
82
|
+
Empty: 'empty',
|
|
83
|
+
Unparsable: 'unparsable',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const exactKeys = (value, keys) =>
|
|
87
|
+
value !== null &&
|
|
88
|
+
typeof value === 'object' &&
|
|
89
|
+
!Array.isArray(value) &&
|
|
90
|
+
Object.keys(value).sort().join() === keys.join();
|
|
91
|
+
|
|
92
|
+
function field(path, value, test, expectation) {
|
|
93
|
+
if (typeof value !== 'string' || !test(value)) {
|
|
94
|
+
throw new Error(`${LABEL}: ${path} must be ${expectation}`);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
const matching = (pattern) => (value) => pattern.test(value);
|
|
99
|
+
|
|
100
|
+
function requireKeys(value, keys, path) {
|
|
101
|
+
if (!exactKeys(value, keys)) {
|
|
102
|
+
throw new Error(`${LABEL}: ${path} must have exactly the keys {${keys.join(', ')}}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseImage(image, path) {
|
|
107
|
+
requireKeys(image, IMAGE_KEYS, path);
|
|
108
|
+
const oneOf = (list) => (value) => list.includes(value);
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
subjectKind: field(`${path}.subjectKind`, image.subjectKind, oneOf(SUBJECT_KINDS), `one of ${SUBJECT_KINDS.join(', ')}`),
|
|
111
|
+
subjectId: field(`${path}.subjectId`, image.subjectId, matching(CANONICAL_IDENTIFIER), 'a canonical identifier'),
|
|
112
|
+
imageRef: field(`${path}.imageRef`, image.imageRef, matching(OCI_REPOSITORY), 'an OCI repository reference without tag or digest'),
|
|
113
|
+
manifestDigest: field(`${path}.manifestDigest`, image.manifestDigest, matching(SHA256_DIGEST_PATTERN), 'sha256:<64 hex>'),
|
|
114
|
+
sourceTreeHash: field(`${path}.sourceTreeHash`, image.sourceTreeHash, matching(SHA256_HEX_PATTERN), '64 lowercase hex digits'),
|
|
115
|
+
sourceHashAlgorithm: field(`${path}.sourceHashAlgorithm`, image.sourceHashAlgorithm, oneOf(SOURCE_HASH_ALGORITHMS), `one of ${SOURCE_HASH_ALGORITHMS.join(', ')}`),
|
|
116
|
+
statementDigest: field(`${path}.statementDigest`, image.statementDigest, matching(SHA256_DIGEST_PATTERN), 'sha256:<64 hex>'),
|
|
117
|
+
bundleDigest: field(`${path}.bundleDigest`, image.bundleDigest, matching(SHA256_DIGEST_PATTERN), 'sha256:<64 hex>'),
|
|
118
|
+
policyId: field(`${path}.policyId`, image.policyId, matching(CANONICAL_IDENTIFIER), 'a canonical policy id'),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseSource(source, path) {
|
|
123
|
+
requireKeys(source, SOURCE_KEYS, path);
|
|
124
|
+
const name = field(`${path}.name`, source.name, matching(SOURCE_NAME), 'a lowercase source name');
|
|
125
|
+
const repository = field(`${path}.repository`, source.repository, matching(GITHUB_REPOSITORY_PATTERN), 'owner/name');
|
|
126
|
+
const repositoryUrl = field(
|
|
127
|
+
`${path}.repositoryUrl`,
|
|
128
|
+
source.repositoryUrl,
|
|
129
|
+
(value) => value === `https://github.com/${repository}` || value === `https://github.com/${repository}.git`,
|
|
130
|
+
`https://github.com/${repository}[.git] (the URL of repository)`,
|
|
131
|
+
);
|
|
132
|
+
requireKeys(source.producerWorkflow, WORKFLOW_KEYS, `${path}.producerWorkflow`);
|
|
133
|
+
requireKeys(source.bundle, BUNDLE_KEYS, `${path}.bundle`);
|
|
134
|
+
const sourceCommit = field(`${path}.sourceCommit`, source.sourceCommit, matching(COMMIT_PATTERN), 'an exact 40-hex commit');
|
|
135
|
+
const producerWorkflow = Object.freeze({
|
|
136
|
+
path: field(`${path}.producerWorkflow.path`, source.producerWorkflow.path, matching(WORKFLOW_PATH), 'a .github/workflows/*.yml|yaml path'),
|
|
137
|
+
sha: field(`${path}.producerWorkflow.sha`, source.producerWorkflow.sha, matching(COMMIT_PATTERN), 'an exact 40-hex commit'),
|
|
138
|
+
});
|
|
139
|
+
if (producerWorkflow.sha !== sourceCommit) {
|
|
140
|
+
throw new Error(`${LABEL}: ${path}.producerWorkflow.sha ${producerWorkflow.sha} must equal sourceCommit ${sourceCommit}`);
|
|
141
|
+
}
|
|
142
|
+
const bundle = Object.freeze({
|
|
143
|
+
ociRef: field(`${path}.bundle.ociRef`, source.bundle.ociRef, matching(OCI_REF_BY_DIGEST), '<repository>@sha256:<64 hex> (pinned by digest)'),
|
|
144
|
+
biomeId: field(`${path}.bundle.biomeId`, source.bundle.biomeId, matching(RUNTIME_IDENTITY), 'a lowercase biome id'),
|
|
145
|
+
sourceManifestHash: field(`${path}.bundle.sourceManifestHash`, source.bundle.sourceManifestHash, matching(SHA256_HEX_PATTERN), '64 lowercase hex digits'),
|
|
146
|
+
signerPolicyId: field(`${path}.bundle.signerPolicyId`, source.bundle.signerPolicyId, matching(CANONICAL_IDENTIFIER), 'a canonical policy id'),
|
|
147
|
+
statementDigest: field(`${path}.bundle.statementDigest`, source.bundle.statementDigest, matching(SHA256_DIGEST_PATTERN), 'sha256:<64 hex>'),
|
|
148
|
+
bundleDigest: field(`${path}.bundle.bundleDigest`, source.bundle.bundleDigest, matching(SHA256_DIGEST_PATTERN), 'sha256:<64 hex>'),
|
|
149
|
+
});
|
|
150
|
+
if (!Array.isArray(source.images) || source.images.length === 0) {
|
|
151
|
+
throw new Error(`${LABEL}: ${path}.images must list at least one image`);
|
|
152
|
+
}
|
|
153
|
+
const images = source.images.map((image, index) => parseImage(image, `${path}.images[${index}]`));
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
name,
|
|
156
|
+
repository,
|
|
157
|
+
repositoryUrl,
|
|
158
|
+
repositoryId: field(`${path}.repositoryId`, source.repositoryId, matching(GITHUB_NUMERIC_ID), 'a decimal GitHub repository id'),
|
|
159
|
+
repositoryOwnerId: field(`${path}.repositoryOwnerId`, source.repositoryOwnerId, matching(GITHUB_NUMERIC_ID), 'a decimal GitHub owner id'),
|
|
160
|
+
sourceCommit,
|
|
161
|
+
producerWorkflow,
|
|
162
|
+
bundle,
|
|
163
|
+
images: Object.freeze(images),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function assertUnique(sources, pick, what) {
|
|
168
|
+
const seen = new Map();
|
|
169
|
+
sources.forEach((source, index) => {
|
|
170
|
+
for (const value of pick(source)) {
|
|
171
|
+
if (seen.has(value)) {
|
|
172
|
+
throw new Error(`${LABEL}: ${what} "${value}" is pinned by sources[${seen.get(value)}] and sources[${index}]`);
|
|
173
|
+
}
|
|
174
|
+
seen.set(value, index);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Validate a parsed pin document. Throws on the first violation, naming the key. */
|
|
180
|
+
export function parseThirdPartySourcePins(document) {
|
|
181
|
+
requireKeys(document, ['schemaVersion', 'sources'], 'the document');
|
|
182
|
+
if (document.schemaVersion !== THIRD_PARTY_SOURCE_PINS_SCHEMA_VERSION) {
|
|
183
|
+
throw new Error(`${LABEL}: schemaVersion must be ${THIRD_PARTY_SOURCE_PINS_SCHEMA_VERSION}`);
|
|
184
|
+
}
|
|
185
|
+
if (!Array.isArray(document.sources)) {
|
|
186
|
+
throw new Error(`${LABEL}: sources must be an array`);
|
|
187
|
+
}
|
|
188
|
+
const sources = document.sources.map((source, index) => parseSource(source, `sources[${index}]`));
|
|
189
|
+
assertUnique(sources, (source) => [source.name], 'source name');
|
|
190
|
+
assertUnique(sources, (source) => [source.repository], 'repository');
|
|
191
|
+
assertUnique(sources, (source) => [source.repositoryId], 'repositoryId');
|
|
192
|
+
assertUnique(sources, (source) => [source.bundle.biomeId], 'biomeId');
|
|
193
|
+
assertUnique(sources, (source) => source.images.map((image) => image.imageRef), 'imageRef');
|
|
194
|
+
sources.forEach((source, index) => {
|
|
195
|
+
const ids = source.images.map((image) => image.subjectId);
|
|
196
|
+
if (new Set(ids).size !== ids.length) {
|
|
197
|
+
throw new Error(`${LABEL}: sources[${index}].images repeats a subjectId`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
return Object.freeze(sources);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The pinned sources, with lookups. An empty set carries the `reason` consumers put in their refusal. */
|
|
204
|
+
function sourceSet(sources, absence) {
|
|
205
|
+
const byName = new Map(sources.map((source) => [source.name, source]));
|
|
206
|
+
const byRepository = new Map(sources.map((source) => [source.repository, source]));
|
|
207
|
+
const reason =
|
|
208
|
+
absence === null
|
|
209
|
+
? null
|
|
210
|
+
: `${THIRD_PARTY_SOURCE_PINS_FILE} is ${absence} in the data root, so no third-party source is pinned`;
|
|
211
|
+
return Object.freeze({
|
|
212
|
+
sources,
|
|
213
|
+
absence,
|
|
214
|
+
reason,
|
|
215
|
+
byName: (name) => byName.get(name),
|
|
216
|
+
byRepository: (repository) => byRepository.get(repository),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Read the pin file's exact bytes from the data root: `null` when the file does not exist.
|
|
222
|
+
* This is what the evidence trust pin digests.
|
|
223
|
+
*/
|
|
224
|
+
export function readThirdPartySourcePinsBytes(dataRoot) {
|
|
225
|
+
if (!existsSync(join(dataRoot, THIRD_PARTY_SOURCE_PINS_FILE))) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return readBoundedNoFollowFile(dataRoot, THIRD_PARTY_SOURCE_PINS_FILE, LABEL, MAX_PINS_BYTES).bytes;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** sha256 of the pin file's bytes, or `null` when the data root has no such file. */
|
|
232
|
+
export function thirdPartySourcePinsDigest(dataRoot) {
|
|
233
|
+
const bytes = readThirdPartySourcePinsBytes(dataRoot);
|
|
234
|
+
return bytes === null ? null : sha256Digest(bytes);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Load the third-party source pins of the data root in use.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} dataRoot
|
|
241
|
+
* @returns {{sources: readonly object[], absence: string|null, reason: string|null,
|
|
242
|
+
* byName: (name: string) => object|undefined, byRepository: (repository: string) => object|undefined}}
|
|
243
|
+
*/
|
|
244
|
+
export function loadThirdPartySourcePins(dataRoot) {
|
|
245
|
+
const bytes = readThirdPartySourcePinsBytes(dataRoot);
|
|
246
|
+
if (bytes === null) {
|
|
247
|
+
return sourceSet([], ThirdPartyPinsAbsence.Missing);
|
|
248
|
+
}
|
|
249
|
+
if (bytes.length === 0) {
|
|
250
|
+
return sourceSet([], ThirdPartyPinsAbsence.Empty);
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
JSON.parse(bytes.toString('utf8'));
|
|
254
|
+
} catch {
|
|
255
|
+
return sourceSet([], ThirdPartyPinsAbsence.Unparsable);
|
|
256
|
+
}
|
|
257
|
+
const document = parseCanonicalJsonBytes(bytes, LABEL);
|
|
258
|
+
return sourceSet(parseThirdPartySourcePins(document), null);
|
|
259
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Check (e) of a third-party source pin (XEMA-1611 design section 3): who may be pinned next to whom.
|
|
2
|
+
//
|
|
3
|
+
// It reuses the pin loader's uniqueness (names, repositories, repository ids, biome ids, image references) and the
|
|
4
|
+
// trust policy's image ownership (one publisher per image repository, no first-party namespace for a foreign owner),
|
|
5
|
+
// and adds what only the data root's own indexes can answer: the third-party repository owner is not a first-party
|
|
6
|
+
// owner, the biome id does not collide with a biome, platform service or supplemental subject, and the name and
|
|
7
|
+
// repository are not repositories of the runtime image source index.
|
|
8
|
+
|
|
9
|
+
import { parseThirdPartySourcePins, THIRD_PARTY_SOURCE_PINS_SCHEMA_VERSION } from './third-party-source-pins.mjs';
|
|
10
|
+
import { assertTrustPolicyImageOwnership, firstPartyOwnersFromIndex, isFirstPartyImageNamespace } from './trust-policy-image-ownership.mjs';
|
|
11
|
+
|
|
12
|
+
const repositoryPathOf = (repositoryUrl) => new URL(repositoryUrl).pathname.replace(/^\//u, '').replace(/\.git$/u, '');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Refuse a set of pinned sources that breaks (e). Throws naming the source and the collision.
|
|
16
|
+
*
|
|
17
|
+
* @param {{sources: readonly object[], policy: object, runtimeSourceIndex: object, biomeIndex: object[], platformServiceIndex: {services: object[]}}} data
|
|
18
|
+
*/
|
|
19
|
+
export function assertThirdPartyUniqueness({ sources, policy, runtimeSourceIndex, biomeIndex, platformServiceIndex }) {
|
|
20
|
+
parseThirdPartySourcePins({ schemaVersion: THIRD_PARTY_SOURCE_PINS_SCHEMA_VERSION, sources });
|
|
21
|
+
const firstPartyOwners = firstPartyOwnersFromIndex(runtimeSourceIndex);
|
|
22
|
+
assertTrustPolicyImageOwnership({ policy, firstPartyOwners });
|
|
23
|
+
const biomeIds = new Set(biomeIndex.map((biome) => biome.id));
|
|
24
|
+
const subjectIds = new Set([
|
|
25
|
+
...platformServiceIndex.services.map((service) => service.name),
|
|
26
|
+
...runtimeSourceIndex.supplementalSubjects.map((subject) => subject.id),
|
|
27
|
+
]);
|
|
28
|
+
const indexRepositories = new Set(runtimeSourceIndex.repositoryPins.flatMap((pin) => [pin.repo, repositoryPathOf(pin.repositoryUrl).toLowerCase()]));
|
|
29
|
+
const indexRepositoryNames = new Set(runtimeSourceIndex.repositoryPins.map((pin) => pin.repo.split('/').at(-1)));
|
|
30
|
+
for (const source of sources) {
|
|
31
|
+
const where = `third-party source "${source.name}"`;
|
|
32
|
+
const owner = source.repository.split('/')[0].toLowerCase();
|
|
33
|
+
if (firstPartyOwners.has(owner)) {
|
|
34
|
+
throw new Error(`${where}: repository ${source.repository} is owned by "${owner}", a first-party owner (the owner of an index repository pin, or xemahq)`);
|
|
35
|
+
}
|
|
36
|
+
if (indexRepositories.has(source.repository.toLowerCase())) {
|
|
37
|
+
throw new Error(`${where}: repository ${source.repository} is a repository of the runtime image source index`);
|
|
38
|
+
}
|
|
39
|
+
if (indexRepositories.has(source.name) || indexRepositoryNames.has(source.name)) {
|
|
40
|
+
throw new Error(`${where}: the name collides with a repository of the runtime image source index`);
|
|
41
|
+
}
|
|
42
|
+
if (biomeIds.has(source.bundle.biomeId)) {
|
|
43
|
+
throw new Error(`${where}: biomeId "${source.bundle.biomeId}" collides with a biome of the biome index`);
|
|
44
|
+
}
|
|
45
|
+
if (subjectIds.has(source.bundle.biomeId)) {
|
|
46
|
+
throw new Error(`${where}: biomeId "${source.bundle.biomeId}" collides with a platform service or supplemental subject of the data root`);
|
|
47
|
+
}
|
|
48
|
+
for (const image of source.images) {
|
|
49
|
+
if (isFirstPartyImageNamespace(image.imageRef, firstPartyOwners)) {
|
|
50
|
+
throw new Error(`${where}: image ${image.imageRef} is in a first-party namespace (<registry>/<first-party owner>/<any>)`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The `kind: oci` branch of the edition source walk (XEMA-1611 design section 4).
|
|
2
|
+
//
|
|
3
|
+
// A declared OCI source names a pin of the data root's `third-party-source-pins.json` (written only by the trusted
|
|
4
|
+
// writer). Walking it pulls the pinned bundle BY DIGEST, holds its manifest to the pinned `sourceManifestHash`, re-runs
|
|
5
|
+
// checks (a) to (d) of the writer from the evidence COMMITTED under the data root (`verifyThirdPartySourceEvidence`, the
|
|
6
|
+
// one verifier the writer and `--check-third-party` use), and projects the manifest into an index entry through
|
|
7
|
+
// `manifestToIndexEntry`. It reads no git repository and writes no lock `source`: the lock's `LockedBiomeSource` is
|
|
8
|
+
// git-only, so the row is held to its pin by the image plane instead (`third-party-subjects.mjs`).
|
|
9
|
+
|
|
10
|
+
import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
|
|
11
|
+
import { loadKernelBiomeContracts, loadKernelDistributionSchemas, manifestToIndexEntry } from './lib.mjs';
|
|
12
|
+
import { createRegistryClientResolver } from './oci-image-source-evidence.mjs';
|
|
13
|
+
import { disposeRegistrySessions } from './oras.mjs';
|
|
14
|
+
import { fetchBundleGeneratedIndex, fetchBundleManifest } from './third-party-bundle-registry.mjs';
|
|
15
|
+
import { loadThirdPartySourceContext } from './third-party-source-context.mjs';
|
|
16
|
+
import { verifyThirdPartySourceEvidence } from './third-party-source-evidence.mjs';
|
|
17
|
+
import { COSIGN_BIN_ENV } from './third-party-source-flags.mjs';
|
|
18
|
+
import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
|
|
19
|
+
import { OCI_BUNDLE_SOURCE_KIND } from './third-party-subjects.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} options
|
|
23
|
+
* @param {{name: string, kind: string, registry: string}} options.source the declared `oci` source
|
|
24
|
+
* @param {{reason: string|null, byName: (name: string) => object|undefined}} options.pins
|
|
25
|
+
* @param {string} options.dataRoot the evidence under it is the committed evidence of the pin
|
|
26
|
+
* @param {object} options.context `loadThirdPartySourceContext`
|
|
27
|
+
* @param {(authority: string) => object} options.clientFor
|
|
28
|
+
* @param {(value: unknown) => object} options.parseBundleSource the kernel's bundle source schema
|
|
29
|
+
* @returns {Promise<object>} the (unstamped) index entry of the bundle's biome
|
|
30
|
+
*/
|
|
31
|
+
async function walkThirdPartySource({ source, pin, dataRoot, context, clientFor, parseBundleSource }) {
|
|
32
|
+
const where = `source "${source.name}"`;
|
|
33
|
+
if (!pin.bundle.ociRef.startsWith(`${source.registry}/`)) {
|
|
34
|
+
throw new Error(`${where} declares registry ${source.registry}, but its pinned bundle ${pin.bundle.ociRef} is not served from it`);
|
|
35
|
+
}
|
|
36
|
+
// (a) to (d) from the committed evidence: the signature, the provenance subject (= the pinned bundle digest), the
|
|
37
|
+
// certificate extensions and every image's evidence.
|
|
38
|
+
const evidence = verifyThirdPartySourceEvidence({ source: pin, evidenceRoot: dataRoot, context });
|
|
39
|
+
const path = biomeDirectory(pin, evidence.images, where);
|
|
40
|
+
|
|
41
|
+
const { manifest } = await fetchBundleManifest({ clientFor, ociRef: pin.bundle.ociRef });
|
|
42
|
+
const actualHash = await context.schemas.computeBiomeSourceManifestHash(manifest);
|
|
43
|
+
if (actualHash !== pin.bundle.sourceManifestHash) {
|
|
44
|
+
throw new Error(`${where}: bundle ${pin.bundle.ociRef} hashes its manifest to ${actualHash}, but the pin records ${pin.bundle.sourceManifestHash}`);
|
|
45
|
+
}
|
|
46
|
+
if (manifest?.xema?.id !== pin.bundle.biomeId) {
|
|
47
|
+
throw new Error(`${where}: bundle ${pin.bundle.ociRef} declares biome ${JSON.stringify(manifest?.xema?.id)}, but the pin is for "${pin.bundle.biomeId}"`);
|
|
48
|
+
}
|
|
49
|
+
const generatedIndex = await fetchBundleGeneratedIndex({ clientFor, ociRef: pin.bundle.ociRef });
|
|
50
|
+
const entry = await manifestToIndexEntry({
|
|
51
|
+
raw: manifest,
|
|
52
|
+
generatedIndex,
|
|
53
|
+
rel: `${source.name}/${pin.bundle.ociRef}`,
|
|
54
|
+
repo: pin.repository,
|
|
55
|
+
path,
|
|
56
|
+
});
|
|
57
|
+
if ((entry.hostModules ?? []).length > 0) {
|
|
58
|
+
throw new Error(`${where}: biome "${entry.id}" ships host modules, which a third-party OCI bundle is not admitted to carry`);
|
|
59
|
+
}
|
|
60
|
+
// Where the bundle lives is the pin's statement, never the manifest's: a manifest that names another bundle source is refused.
|
|
61
|
+
const bundleSource = parseBundleSource({ kind: OCI_BUNDLE_SOURCE_KIND, ociRef: pin.bundle.ociRef });
|
|
62
|
+
if (entry.bundleSource !== undefined && JSON.stringify(entry.bundleSource) !== JSON.stringify(bundleSource)) {
|
|
63
|
+
throw new Error(`${where}: biome "${entry.id}" declares bundleSource ${JSON.stringify(entry.bundleSource)}, which is not the pinned bundle ${pin.bundle.ociRef}`);
|
|
64
|
+
}
|
|
65
|
+
return { ...entry, bundleSource };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The biome's directory in the publisher's repository: the `source.path` its verified image statements all state (a
|
|
70
|
+
* subject's `path` must equal it at admission). A pin with no image has no subject to bind, so the repository root.
|
|
71
|
+
*/
|
|
72
|
+
function biomeDirectory(pin, statements, where) {
|
|
73
|
+
const paths = [...new Set(statements.map((statement) => statement.predicate.source.path))];
|
|
74
|
+
if (paths.length > 1) {
|
|
75
|
+
throw new Error(`${where}: the pinned images' evidence states different source paths (${paths.join(', ')}), so the biome has no one directory`);
|
|
76
|
+
}
|
|
77
|
+
return paths[0] ?? pin.repository;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The pin of a declared OCI source, or the refusal that names why there is none (a missing or empty pin file included). */
|
|
81
|
+
function requirePin(pins, source) {
|
|
82
|
+
const pin = pins.byName(source.name);
|
|
83
|
+
if (pin === undefined) {
|
|
84
|
+
throw new Error(`source "${source.name}" is kind "oci", but ${pins.reason ?? `the data root's third-party source pins hold no pin named "${source.name}"`}`);
|
|
85
|
+
}
|
|
86
|
+
return pin;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Walk every declared OCI source: `{source, entry}` per source, the index entry of its bundle unstamped (the caller stamps it). The data
|
|
91
|
+
* root, schemas, cosign binary, registry client and verification context are the process's unless `thirdParty` injects
|
|
92
|
+
* them (the contract suite drives a fixture registry and a digest-pinned cosign double).
|
|
93
|
+
*
|
|
94
|
+
* @param {object[]} sources declared sources of kind `oci`
|
|
95
|
+
* @param {{env?: NodeJS.ProcessEnv, thirdParty?: {dataRoot?: string, schemas?: object, cosignBinary?: string, clientFor?: Function, context?: object}}} options
|
|
96
|
+
*/
|
|
97
|
+
export async function walkOciSources(sources, { env = process.env, thirdParty = {} } = {}) {
|
|
98
|
+
const root = thirdParty.dataRoot ?? configuredDataRoot();
|
|
99
|
+
const pins = loadThirdPartySourcePins(root);
|
|
100
|
+
const pinned = sources.map((source) => [source, requirePin(pins, source)]);
|
|
101
|
+
const schemas = thirdParty.schemas ?? (await loadKernelDistributionSchemas());
|
|
102
|
+
const context = thirdParty.context ?? loadThirdPartySourceContext({ dataRoot: root, schemas, cosignBinary: thirdParty.cosignBinary ?? env[COSIGN_BIN_ENV] });
|
|
103
|
+
const clientFor = thirdParty.clientFor ?? createRegistryClientResolver({ dataRoot: root });
|
|
104
|
+
const parseBundleSource = (value) => loadKernelBiomeContracts().BiomeBundleSourceSpecSchema.parse(value);
|
|
105
|
+
try {
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (const [source, pin] of pinned) {
|
|
108
|
+
entries.push({ source, entry: await walkThirdPartySource({ source, pin, dataRoot: root, context, clientFor, parseBundleSource }) });
|
|
109
|
+
}
|
|
110
|
+
return entries;
|
|
111
|
+
} finally {
|
|
112
|
+
if (thirdParty.context === undefined) {context.dispose();}
|
|
113
|
+
if (thirdParty.clientFor === undefined) {disposeRegistrySessions();}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// The trusted writer of a third-party source pin (XEMA-1611 design section 3): `xema edition index runtime-image-source
|
|
2
|
+
// --trusted-write --third-party <name> ...`. It PLANS the write: reads the bundle and the evidence from the registry,
|
|
3
|
+
// runs checks (a) to (e) on the exact bytes it would commit, and returns the files, the pin record and the candidate
|
|
4
|
+
// index. `commitThirdPartySourceWrite` then writes them. The third-party repository is never cloned.
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { REPIN_COMMAND } from './evidence-trust-pin.mjs';
|
|
11
|
+
import { canonicalJsonBytes, evidenceDirectoryForDigest, policyCandidates, sha256Digest } from './image-evidence.mjs';
|
|
12
|
+
import { resolveImageSourceEvidenceReferrer } from './oci-image-source-evidence.mjs';
|
|
13
|
+
import { PRODUCER_EVIDENCE_INDEX_FILE } from './producer-evidence-index-file.mjs';
|
|
14
|
+
import { splitImageRef } from './registry-credentials.mjs';
|
|
15
|
+
import { bundleImageNames, fetchBundleManifest, fetchBundleProvenance, verifyBundleOnRegistry } from './third-party-bundle-registry.mjs';
|
|
16
|
+
import {
|
|
17
|
+
PROVENANCE_BUNDLE_FILE,
|
|
18
|
+
PROVENANCE_STATEMENT_FILE,
|
|
19
|
+
SLSA_PROVENANCE_V1,
|
|
20
|
+
THIRD_PARTY_EVIDENCE_CHANNEL,
|
|
21
|
+
digestOfOciRef,
|
|
22
|
+
provenanceDirectoryForDigest,
|
|
23
|
+
thirdPartySignerRow,
|
|
24
|
+
verifyThirdPartySourceEvidence,
|
|
25
|
+
} from './third-party-source-evidence.mjs';
|
|
26
|
+
import { THIRD_PARTY_SOURCE_PINS_FILE, THIRD_PARTY_SOURCE_PINS_SCHEMA_VERSION, loadThirdPartySourcePins } from './third-party-source-pins.mjs';
|
|
27
|
+
import { assertThirdPartyUniqueness } from './third-party-source-uniqueness.mjs';
|
|
28
|
+
|
|
29
|
+
const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
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
|
+
/** The one release-channel row whose imageRepositories lists `imageRef`. */
|
|
48
|
+
function imageSignerRow(policy, schemas, imageRef) {
|
|
49
|
+
const rows = policyCandidates(policy, schemas.IMAGE_PRODUCER_PREDICATE_TYPE, THIRD_PARTY_EVIDENCE_CHANNEL, imageRef);
|
|
50
|
+
if (rows.length !== 1) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${rows.length} ${THIRD_PARTY_EVIDENCE_CHANNEL} trust policy rows list ${imageRef} in their imageRepositories for image-source evidence; exactly one is required`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return rows[0];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function collectImage({ image, request, context, clientFor, services }) {
|
|
59
|
+
const { schemas, expectedContractAuthority, policy } = context;
|
|
60
|
+
const label = `image ${image.imageRef}@${image.manifestDigest}`;
|
|
61
|
+
const row = imageSignerRow(policy, schemas, image.imageRef);
|
|
62
|
+
const { authority } = splitImageRef(image.imageRef);
|
|
63
|
+
const skipped = [];
|
|
64
|
+
const evidence = await resolveImageSourceEvidenceReferrer({
|
|
65
|
+
client: clientFor(authority),
|
|
66
|
+
imageRepository: image.imageRef,
|
|
67
|
+
subjectDigest: image.manifestDigest,
|
|
68
|
+
schemas,
|
|
69
|
+
expectedContractAuthority,
|
|
70
|
+
onInvalidReferrer: (report) => skipped.push(`${report.artifactDigest}: ${report.reason}`),
|
|
71
|
+
});
|
|
72
|
+
if (evidence === null) {
|
|
73
|
+
throw new Error(`${label} has no valid image-source evidence referrer${skipped.length === 0 ? '' : ` (skipped: ${skipped.join('; ')})`}`);
|
|
74
|
+
}
|
|
75
|
+
if (evidence.records.length !== 1) {
|
|
76
|
+
throw new Error(`${label}: the evidence referrer carries ${evidence.records.length} statements; a third-party image has exactly one`);
|
|
77
|
+
}
|
|
78
|
+
const [record] = evidence.records;
|
|
79
|
+
const { source } = record.statement.predicate;
|
|
80
|
+
if (!services.has(source.id)) {
|
|
81
|
+
throw new Error(`${label}: its statement attests ${source.kind}:${source.id}, which is not an oci-image service of bundle ${request.bundleOciRef} (${[...services].join(', ')})`);
|
|
82
|
+
}
|
|
83
|
+
const directory = evidenceDirectoryForDigest(image.manifestDigest);
|
|
84
|
+
return {
|
|
85
|
+
pin: {
|
|
86
|
+
subjectKind: source.kind,
|
|
87
|
+
subjectId: source.id,
|
|
88
|
+
imageRef: image.imageRef,
|
|
89
|
+
manifestDigest: image.manifestDigest,
|
|
90
|
+
sourceTreeHash: source.sourceTreeHash,
|
|
91
|
+
sourceHashAlgorithm: source.sourceHashAlgorithm,
|
|
92
|
+
statementDigest: record.descriptor.statementDigest,
|
|
93
|
+
bundleDigest: record.descriptor.bundleDigest,
|
|
94
|
+
policyId: row.policyId,
|
|
95
|
+
},
|
|
96
|
+
files: [
|
|
97
|
+
[`${directory}/${PRODUCER_EVIDENCE_INDEX_FILE}`, evidence.indexBytes],
|
|
98
|
+
[`${directory}/${record.descriptor.statementPath}`, record.statementBytes],
|
|
99
|
+
[`${directory}/${record.descriptor.bundlePath}`, record.bundleBytes],
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Plan the write of one third-party source. Throws on the first violated check; writes nothing to the data root.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} options
|
|
108
|
+
* @param {{name, repository, repositoryId, repositoryOwnerId, sourceCommit, bundleOciRef, biomeId, images: {imageRef, manifestDigest}[]}} options.request
|
|
109
|
+
* @param {object} options.context `loadThirdPartySourceContext`
|
|
110
|
+
* @param {(authority: string) => object} options.clientFor the registry client of an authority
|
|
111
|
+
* @param {(request: {committedIndex: object, pin: {repository: string, workflowCommit: string}}) => object} options.withProducerWorkflowPin
|
|
112
|
+
* the committed runtime image source index with the pin merged in, built by the index generator
|
|
113
|
+
* @param {string} options.dataRoot
|
|
114
|
+
* @param {(options: object) => void} [options.verifyOnRegistry]
|
|
115
|
+
*/
|
|
116
|
+
export async function planThirdPartySourceWrite({ request, context, clientFor, withProducerWorkflowPin, dataRoot, verifyOnRegistry = verifyBundleOnRegistry }) {
|
|
117
|
+
const { policy, schemas } = context;
|
|
118
|
+
const repositoryUrl = `https://github.com/${request.repository}`;
|
|
119
|
+
const row = bundleSignerRow(policy, request.repository);
|
|
120
|
+
const workflowPath = row.githubWorkflow.path;
|
|
121
|
+
// The row must be exact (release channel, refs/heads/main, the GitHub issuer, its own workflow identity) before cosign runs.
|
|
122
|
+
thirdPartySignerRow({ policy, policyId: row.policyId, source: { repository: request.repository, producerWorkflow: { path: workflowPath } }, schemas, label: `third-party source "${request.name}" bundle signer` });
|
|
123
|
+
|
|
124
|
+
// (a) the bundle: the registry's signature and provenance under exactly this row, then its bytes.
|
|
125
|
+
verifyOnRegistry({
|
|
126
|
+
trustedCosign: context.trustedCosign,
|
|
127
|
+
policy,
|
|
128
|
+
policyRoot: context.policyRoot,
|
|
129
|
+
row,
|
|
130
|
+
workflowSha: request.sourceCommit,
|
|
131
|
+
ociRef: request.bundleOciRef,
|
|
132
|
+
});
|
|
133
|
+
const { manifest } = await fetchBundleManifest({ clientFor, ociRef: request.bundleOciRef });
|
|
134
|
+
if (manifest?.xema?.id !== request.biomeId) {
|
|
135
|
+
throw new Error(`bundle ${request.bundleOciRef} declares xema.id ${JSON.stringify(manifest?.xema?.id)}, but --biome-id is ${JSON.stringify(request.biomeId)}`);
|
|
136
|
+
}
|
|
137
|
+
const sourceManifestHash = await schemas.computeBiomeSourceManifestHash(manifest);
|
|
138
|
+
const services = bundleImageNames(manifest);
|
|
139
|
+
const provenance = await fetchBundleProvenance({ clientFor, ociRef: request.bundleOciRef });
|
|
140
|
+
const provenanceDirectory = provenanceDirectoryForDigest(digestOfOciRef(request.bundleOciRef));
|
|
141
|
+
|
|
142
|
+
// (b) each image's image-source/v2 evidence, as an OCI referrer of imageRef@digest.
|
|
143
|
+
const images = [];
|
|
144
|
+
const files = [
|
|
145
|
+
[`${provenanceDirectory}/${PROVENANCE_STATEMENT_FILE}`, provenance.statementBytes],
|
|
146
|
+
[`${provenanceDirectory}/${PROVENANCE_BUNDLE_FILE}`, provenance.bundleBytes],
|
|
147
|
+
];
|
|
148
|
+
for (const image of request.images) {
|
|
149
|
+
const collected = await collectImage({ image, request, context, clientFor, services });
|
|
150
|
+
images.push(collected.pin);
|
|
151
|
+
files.push(...collected.files);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const source = {
|
|
155
|
+
name: request.name,
|
|
156
|
+
repository: request.repository,
|
|
157
|
+
repositoryUrl,
|
|
158
|
+
repositoryId: request.repositoryId,
|
|
159
|
+
repositoryOwnerId: request.repositoryOwnerId,
|
|
160
|
+
sourceCommit: request.sourceCommit,
|
|
161
|
+
producerWorkflow: { path: workflowPath, sha: request.sourceCommit },
|
|
162
|
+
bundle: {
|
|
163
|
+
ociRef: request.bundleOciRef,
|
|
164
|
+
biomeId: request.biomeId,
|
|
165
|
+
sourceManifestHash,
|
|
166
|
+
signerPolicyId: row.policyId,
|
|
167
|
+
statementDigest: sha256Digest(provenance.statementBytes),
|
|
168
|
+
bundleDigest: sha256Digest(provenance.bundleBytes),
|
|
169
|
+
},
|
|
170
|
+
images: images.sort((left, right) => compare(left.imageRef, right.imageRef)),
|
|
171
|
+
};
|
|
172
|
+
const committed = loadThirdPartySourcePins(dataRoot).sources.filter((candidate) => candidate.name !== request.name);
|
|
173
|
+
const sources = [...committed, source].sort((left, right) => compare(left.name, right.name));
|
|
174
|
+
|
|
175
|
+
// (e) uniqueness, over the pins as they would be committed.
|
|
176
|
+
assertThirdPartyUniqueness({ sources, policy, runtimeSourceIndex: context.runtimeSourceIndex, biomeIndex: context.biomeIndex, platformServiceIndex: context.platformServiceIndex });
|
|
177
|
+
|
|
178
|
+
// (a) offline over the exact bytes to commit, (b) to (d): the very verification `--check-third-party` repeats.
|
|
179
|
+
const candidateIndex = schemas.parseRuntimeImageSourceIndex(
|
|
180
|
+
withProducerWorkflowPin({ committedIndex: context.runtimeSourceIndex, pin: { repository: request.repository, workflowCommit: request.sourceCommit } }),
|
|
181
|
+
);
|
|
182
|
+
const staging = mkdtempSync(join(tmpdir(), 'xema-third-party-write-'));
|
|
183
|
+
try {
|
|
184
|
+
for (const [path, bytes] of files) {
|
|
185
|
+
const target = join(staging, path);
|
|
186
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
187
|
+
writeFileSync(target, bytes, { flag: 'wx' });
|
|
188
|
+
}
|
|
189
|
+
verifyThirdPartySourceEvidence({ source, evidenceRoot: staging, context: { ...context, runtimeSourceIndex: candidateIndex } });
|
|
190
|
+
} finally {
|
|
191
|
+
rmSync(staging, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
return { source, sources, files, candidateIndex };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function writeNewOrIdentical(dataRoot, path, bytes) {
|
|
197
|
+
const target = join(dataRoot, path);
|
|
198
|
+
try {
|
|
199
|
+
writeFileSync(target, bytes, { flag: 'wx', mode: 0o644 });
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error.code !== 'EEXIST') {throw error;}
|
|
202
|
+
if (!readFileSync(target).equals(bytes)) {
|
|
203
|
+
throw new Error(`${path} already exists with different bytes; evidence is content-addressed and is never overwritten`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Write a planned source: the evidence files (content-addressed, never overwritten), then the pin file, then the
|
|
210
|
+
* index. The evidence trust pin is NOT repinned: reviewing the pin file and running `xema edition repin-evidence-trust`
|
|
211
|
+
* is the intended next step, and until then the pin load refuses.
|
|
212
|
+
*
|
|
213
|
+
* @returns {string} the next step to print
|
|
214
|
+
*/
|
|
215
|
+
export function commitThirdPartySourceWrite({ dataRoot, plan, indexPath, indexText }) {
|
|
216
|
+
for (const [path, bytes] of plan.files) {
|
|
217
|
+
mkdirSync(dirname(join(dataRoot, path)), { recursive: true });
|
|
218
|
+
writeNewOrIdentical(dataRoot, path, bytes);
|
|
219
|
+
}
|
|
220
|
+
writeFileSync(
|
|
221
|
+
join(dataRoot, THIRD_PARTY_SOURCE_PINS_FILE),
|
|
222
|
+
canonicalJsonBytes({ schemaVersion: THIRD_PARTY_SOURCE_PINS_SCHEMA_VERSION, sources: plan.sources }),
|
|
223
|
+
);
|
|
224
|
+
writeFileSync(indexPath, indexText);
|
|
225
|
+
return (
|
|
226
|
+
`pinned third-party source "${plan.source.name}" (${plan.source.repository}@${plan.source.sourceCommit}, ${plan.source.images.length} image(s)); ` +
|
|
227
|
+
`run \`${REPIN_COMMAND}\` after reviewing ${THIRD_PARTY_SOURCE_PINS_FILE}: the evidence trust pin does not yet record it, so the pin load refuses until then`
|
|
228
|
+
);
|
|
229
|
+
}
|