@xemahq/xema 0.48.0 → 0.49.1
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 +24 -3
- 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 +241 -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 +90 -7
- 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 +128 -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,134 @@
|
|
|
1
|
+
// The flags of `xema edition index runtime-image-source --trusted-write --third-party <name>` and of
|
|
2
|
+
// `--check-third-party` (XEMA-1611 design section 3). The writer takes DECLARED facts and proves them; nothing here
|
|
3
|
+
// is derived from a clone of the third-party repository.
|
|
4
|
+
|
|
5
|
+
import { isAbsolute } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { COMMIT_PATTERN, GITHUB_REPOSITORY_PATTERN, SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
|
|
8
|
+
|
|
9
|
+
export const THIRD_PARTY_FLAG = '--third-party';
|
|
10
|
+
export const CHECK_THIRD_PARTY_FLAG = '--check-third-party';
|
|
11
|
+
export const COSIGN_FLAG = '--cosign';
|
|
12
|
+
/** The environment variable `dist-check` reads the cosign binary of the third-party check from (an absolute path). */
|
|
13
|
+
export const COSIGN_BIN_ENV = 'XEMA_COSIGN_BIN';
|
|
14
|
+
|
|
15
|
+
const WRITER_FLAGS = Object.freeze({
|
|
16
|
+
repository: '--repository',
|
|
17
|
+
repositoryId: '--repository-id',
|
|
18
|
+
repositoryOwnerId: '--repository-owner-id',
|
|
19
|
+
sourceCommit: '--source-commit',
|
|
20
|
+
bundle: '--bundle',
|
|
21
|
+
biomeId: '--biome-id',
|
|
22
|
+
});
|
|
23
|
+
const IMAGE_FLAG = '--image';
|
|
24
|
+
|
|
25
|
+
export const THIRD_PARTY_WRITER_USAGE =
|
|
26
|
+
`${THIRD_PARTY_FLAG} <name> --repository <owner/name> --repository-id <github id> --repository-owner-id <github id> ` +
|
|
27
|
+
'--source-commit <40-hex> --bundle <oci repository>@sha256:<64-hex> --biome-id <id> ' +
|
|
28
|
+
`${IMAGE_FLAG} <image repository>@sha256:<64-hex> [${IMAGE_FLAG} ...] ${COSIGN_FLAG} <absolute cosign path>`;
|
|
29
|
+
|
|
30
|
+
/** Remove every `flag value` pair named `flag` from argv; the values in order, and the argv without them. */
|
|
31
|
+
function take(argv, flag) {
|
|
32
|
+
const values = [];
|
|
33
|
+
const remaining = [];
|
|
34
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
35
|
+
if (argv[index] !== flag) {
|
|
36
|
+
remaining.push(argv[index]);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const value = argv[index + 1];
|
|
40
|
+
if (value === undefined || value.startsWith('--')) {
|
|
41
|
+
throw new Error(`${flag} requires a value`);
|
|
42
|
+
}
|
|
43
|
+
values.push(value);
|
|
44
|
+
index += 1;
|
|
45
|
+
}
|
|
46
|
+
return { values, remaining };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function single(argv, flag) {
|
|
50
|
+
const { values, remaining } = take(argv, flag);
|
|
51
|
+
if (values.length > 1) {
|
|
52
|
+
throw new Error(`${flag} was given more than once`);
|
|
53
|
+
}
|
|
54
|
+
return { value: values[0], remaining };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function matching(flag, value, pattern, expectation) {
|
|
58
|
+
if (!pattern.test(value)) {
|
|
59
|
+
throw new Error(`${flag} must be ${expectation}; got ${JSON.stringify(value)}`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const OCI_REPOSITORY_AT_DIGEST = /^[^@\s]+@sha256:[a-f0-9]{64}$/u;
|
|
65
|
+
const NUMERIC_ID = /^[1-9][0-9]*$/u;
|
|
66
|
+
|
|
67
|
+
function parseImage(value) {
|
|
68
|
+
matching(IMAGE_FLAG, value, OCI_REPOSITORY_AT_DIGEST, '<image repository>@sha256:<64-hex>');
|
|
69
|
+
const at = value.indexOf('@');
|
|
70
|
+
const manifestDigest = value.slice(at + 1);
|
|
71
|
+
matching(IMAGE_FLAG, manifestDigest, SHA256_DIGEST_PATTERN, 'a sha256 digest');
|
|
72
|
+
return { imageRef: value.slice(0, at), manifestDigest };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Take the third-party flags out of `argv`. `writer` is the parsed write request (or `undefined` when `--third-party`
|
|
77
|
+
* is absent, in which case any writer-only flag is refused); `cosignBinary` is `--cosign` (absolute) or `undefined`.
|
|
78
|
+
*/
|
|
79
|
+
export function takeThirdPartyFlags(argv) {
|
|
80
|
+
let rest = argv;
|
|
81
|
+
const name = single(rest, THIRD_PARTY_FLAG);
|
|
82
|
+
rest = name.remaining;
|
|
83
|
+
const cosign = single(rest, COSIGN_FLAG);
|
|
84
|
+
rest = cosign.remaining;
|
|
85
|
+
const taken = {};
|
|
86
|
+
for (const [key, flag] of Object.entries(WRITER_FLAGS)) {
|
|
87
|
+
const result = single(rest, flag);
|
|
88
|
+
rest = result.remaining;
|
|
89
|
+
taken[key] = result.value;
|
|
90
|
+
}
|
|
91
|
+
const images = take(rest, IMAGE_FLAG);
|
|
92
|
+
rest = images.remaining;
|
|
93
|
+
if (cosign.value !== undefined && !isAbsolute(cosign.value)) {
|
|
94
|
+
throw new Error(`${COSIGN_FLAG} must be an absolute path to the cosign binary`);
|
|
95
|
+
}
|
|
96
|
+
const writerFlagsGiven = Object.values(taken).some((value) => value !== undefined) || images.values.length > 0;
|
|
97
|
+
if (name.value === undefined) {
|
|
98
|
+
if (writerFlagsGiven) {
|
|
99
|
+
throw new Error(`the writer flags (${[...Object.values(WRITER_FLAGS), IMAGE_FLAG].join(', ')}) belong to ${THIRD_PARTY_FLAG} <name>`);
|
|
100
|
+
}
|
|
101
|
+
return { writer: undefined, cosignBinary: cosign.value, remaining: rest };
|
|
102
|
+
}
|
|
103
|
+
const require = (key) => {
|
|
104
|
+
if (taken[key] === undefined) {
|
|
105
|
+
throw new Error(`${THIRD_PARTY_FLAG} requires ${WRITER_FLAGS[key]}\nusage: ${THIRD_PARTY_WRITER_USAGE}`);
|
|
106
|
+
}
|
|
107
|
+
return taken[key];
|
|
108
|
+
};
|
|
109
|
+
if (images.values.length === 0) {
|
|
110
|
+
throw new Error(`${THIRD_PARTY_FLAG} requires ${IMAGE_FLAG} at least once\nusage: ${THIRD_PARTY_WRITER_USAGE}`);
|
|
111
|
+
}
|
|
112
|
+
if (cosign.value === undefined) {
|
|
113
|
+
throw new Error(`${THIRD_PARTY_FLAG} requires ${COSIGN_FLAG}\nusage: ${THIRD_PARTY_WRITER_USAGE}`);
|
|
114
|
+
}
|
|
115
|
+
const parsedImages = images.values.map(parseImage);
|
|
116
|
+
if (new Set(parsedImages.map((image) => image.imageRef)).size !== parsedImages.length) {
|
|
117
|
+
throw new Error(`${IMAGE_FLAG} names an image repository more than once`);
|
|
118
|
+
}
|
|
119
|
+
const bundle = matching(WRITER_FLAGS.bundle, require('bundle'), OCI_REPOSITORY_AT_DIGEST, '<oci repository>@sha256:<64-hex>');
|
|
120
|
+
return {
|
|
121
|
+
writer: {
|
|
122
|
+
name: name.value,
|
|
123
|
+
repository: matching(WRITER_FLAGS.repository, require('repository'), GITHUB_REPOSITORY_PATTERN, 'owner/name'),
|
|
124
|
+
repositoryId: matching(WRITER_FLAGS.repositoryId, require('repositoryId'), NUMERIC_ID, 'a decimal GitHub repository id'),
|
|
125
|
+
repositoryOwnerId: matching(WRITER_FLAGS.repositoryOwnerId, require('repositoryOwnerId'), NUMERIC_ID, 'a decimal GitHub owner id'),
|
|
126
|
+
sourceCommit: matching(WRITER_FLAGS.sourceCommit, require('sourceCommit'), COMMIT_PATTERN, 'an exact 40-hex commit'),
|
|
127
|
+
bundleOciRef: bundle,
|
|
128
|
+
biomeId: require('biomeId'),
|
|
129
|
+
images: parsedImages,
|
|
130
|
+
},
|
|
131
|
+
cosignBinary: cosign.value,
|
|
132
|
+
remaining: rest,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -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
|
+
}
|