@xemahq/xema 0.47.1 → 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 +4649 -2742
- package/dist/edition/verify-image-evidence.bundle.provenance.json +597 -495
- 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 +8 -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/oci-image-source-evidence.mjs +8 -7
- 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
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
serializeJson,
|
|
27
27
|
sha256Hex,
|
|
28
28
|
} from './lib.mjs';
|
|
29
|
+
import { createRegistryClientResolver } from './oci-image-source-evidence.mjs';
|
|
30
|
+
import { disposeRegistrySessions } from './oras.mjs';
|
|
29
31
|
import {
|
|
30
32
|
platformServiceRuntimeBindings,
|
|
31
33
|
} from './platform-service-source-authority.mjs';
|
|
@@ -36,6 +38,15 @@ import {
|
|
|
36
38
|
parseSourceModeArgs,
|
|
37
39
|
} from './source-authority.mjs';
|
|
38
40
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
41
|
+
import { checkThirdPartySourcePins } from './third-party-source-check.mjs';
|
|
42
|
+
import { loadThirdPartySourceContext } from './third-party-source-context.mjs';
|
|
43
|
+
import {
|
|
44
|
+
CHECK_THIRD_PARTY_FLAG,
|
|
45
|
+
THIRD_PARTY_FLAG,
|
|
46
|
+
THIRD_PARTY_WRITER_USAGE,
|
|
47
|
+
takeThirdPartyFlags,
|
|
48
|
+
} from './third-party-source-flags.mjs';
|
|
49
|
+
import { commitThirdPartySourceWrite, planThirdPartySourceWrite } from './third-party-source-writer.mjs';
|
|
39
50
|
|
|
40
51
|
if (isEntryModule(import.meta.url)) {
|
|
41
52
|
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Sources, WorkspaceRoot.Kernel]);
|
|
@@ -398,6 +409,18 @@ export function mergeProducerWorkflowPins(committedPins, statedPins) {
|
|
|
398
409
|
return [...merged.values()].sort((left, right) => compareStrings(left.repository, right.repository));
|
|
399
410
|
}
|
|
400
411
|
|
|
412
|
+
/**
|
|
413
|
+
* The committed index with one producer workflow pin merged in by `mergeProducerWorkflowPins`: what the third-party
|
|
414
|
+
* writer adds (`{repository, workflowCommit: sourceCommit}`). Every repository pin and subject is kept exactly: a
|
|
415
|
+
* third-party repository is not an index repository, so nothing about it can be re-resolved.
|
|
416
|
+
*/
|
|
417
|
+
export function indexWithProducerWorkflowPin({ committedIndex, pin }) {
|
|
418
|
+
return {
|
|
419
|
+
...committedIndex,
|
|
420
|
+
producerWorkflowPins: mergeProducerWorkflowPins(committedIndex.producerWorkflowPins, [pin]),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
401
424
|
const PRODUCER_WORKFLOW_PIN_FLAG = '--producer-workflow-pin';
|
|
402
425
|
|
|
403
426
|
/**
|
|
@@ -972,12 +995,38 @@ const USAGE =
|
|
|
972
995
|
'--repin-contract-authority --kernel-commit <40-hex-commit>) ' +
|
|
973
996
|
'[--source published|workspace] [--kernel-root <absolute-checkout>] ' +
|
|
974
997
|
'[--repo <repository> [--at <40-hex-commit>]] [--producer-workflow-pin <owner/repo>=<40-hex-commit> ...] ' +
|
|
975
|
-
'(--trusted-write on a data root with no committed contract authority also requires --kernel-commit)'
|
|
998
|
+
'(--trusted-write on a data root with no committed contract authority also requires --kernel-commit)\n' +
|
|
999
|
+
` build-runtime-image-source-index.mjs --trusted-write ${THIRD_PARTY_WRITER_USAGE}\n` +
|
|
1000
|
+
` build-runtime-image-source-index.mjs ${CHECK_THIRD_PARTY_FLAG} [--cosign <absolute cosign path>]`;
|
|
1001
|
+
|
|
1002
|
+
/** `--trusted-write --third-party <name>`: verify one third-party source and commit its pin, evidence and producer workflow pin. */
|
|
1003
|
+
async function writeThirdPartySource({ schemas, writer, cosignBinary, kernelRoot }) {
|
|
1004
|
+
const context = loadThirdPartySourceContext({ dataRoot: dataRoot(), schemas, cosignBinary });
|
|
1005
|
+
try {
|
|
1006
|
+
const plan = await planThirdPartySourceWrite({
|
|
1007
|
+
request: writer,
|
|
1008
|
+
context,
|
|
1009
|
+
clientFor: createRegistryClientResolver({ dataRoot: dataRoot() }),
|
|
1010
|
+
dataRoot: dataRoot(),
|
|
1011
|
+
withProducerWorkflowPin: indexWithProducerWorkflowPin,
|
|
1012
|
+
});
|
|
1013
|
+
await validateRuntimeImageSourceIndex(plan.candidateIndex, schemas, { kernelRoot });
|
|
1014
|
+
const next = commitThirdPartySourceWrite({
|
|
1015
|
+
dataRoot: dataRoot(),
|
|
1016
|
+
plan,
|
|
1017
|
+
indexPath: runtimeImageSourceIndexPath(),
|
|
1018
|
+
indexText: serializeJson(plan.candidateIndex),
|
|
1019
|
+
});
|
|
1020
|
+
console.log(`[runtime-image-source-index] ${next}`);
|
|
1021
|
+
} finally {
|
|
1022
|
+
context.dispose();
|
|
1023
|
+
disposeRegistrySessions();
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
976
1026
|
|
|
977
1027
|
async function main() {
|
|
978
|
-
const {
|
|
979
|
-
|
|
980
|
-
);
|
|
1028
|
+
const { writer, cosignBinary, remaining: afterThirdPartyFlags } = takeThirdPartyFlags(process.argv.slice(2));
|
|
1029
|
+
const { kernelRoot, remaining: afterKernelRootFlag } = parseKernelRoot(afterThirdPartyFlags);
|
|
981
1030
|
const { kernelCommit, remaining: afterKernelRoot } = parseKernelCommit(afterKernelRootFlag);
|
|
982
1031
|
const { producerWorkflowPins, remaining: afterWorkflowPins } = parseProducerWorkflowPinFlags(afterKernelRoot);
|
|
983
1032
|
const { repoScope, remaining: afterRepoScope } = parseRepoScope(afterWorkflowPins);
|
|
@@ -989,15 +1038,40 @@ async function main() {
|
|
|
989
1038
|
const [flag] = remaining;
|
|
990
1039
|
if (
|
|
991
1040
|
remaining.length !== 1 ||
|
|
992
|
-
!['--check', '--check-sources', '--trusted-write', REPIN_FLAG].includes(flag)
|
|
1041
|
+
![CHECK_THIRD_PARTY_FLAG, '--check', '--check-sources', '--trusted-write', REPIN_FLAG].includes(flag)
|
|
993
1042
|
) {
|
|
994
1043
|
throw new Error(USAGE);
|
|
995
1044
|
}
|
|
1045
|
+
if (cosignBinary !== undefined && writer === undefined && flag !== CHECK_THIRD_PARTY_FLAG) {
|
|
1046
|
+
throw new Error(`--cosign belongs to ${THIRD_PARTY_FLAG} or ${CHECK_THIRD_PARTY_FLAG}: ${flag} runs no cosign\n${USAGE}`);
|
|
1047
|
+
}
|
|
996
1048
|
if (kernelCommit !== undefined && ![REPIN_FLAG, '--trusted-write'].includes(flag)) {
|
|
997
1049
|
throw new Error(`${KERNEL_COMMIT_FLAG} states the commit of a new contract authority: ${flag} chooses none\n${USAGE}`);
|
|
998
1050
|
}
|
|
999
1051
|
assertProducerWorkflowPinsWritten(producerWorkflowPins, flag, USAGE);
|
|
1000
1052
|
const schemas = await loadKernelDistributionSchemas();
|
|
1053
|
+
if (flag === CHECK_THIRD_PARTY_FLAG) {
|
|
1054
|
+
if (repoScope !== undefined || exactCommit !== undefined || kernelCommit !== undefined) {
|
|
1055
|
+
throw new Error(`${CHECK_THIRD_PARTY_FLAG} re-verifies the committed third-party pins only: it takes no --repo, --at or ${KERNEL_COMMIT_FLAG}`);
|
|
1056
|
+
}
|
|
1057
|
+
const { checked } = await checkThirdPartySourcePins({ dataRoot: dataRoot(), schemas, cosignBinary });
|
|
1058
|
+
console.log(
|
|
1059
|
+
checked === 0
|
|
1060
|
+
? '[runtime-image-source-index] no third-party source is pinned in this data root'
|
|
1061
|
+
: `[runtime-image-source-index] third-party source check passed for ${checked} pinned source(s)`,
|
|
1062
|
+
);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (writer !== undefined) {
|
|
1066
|
+
if (flag !== '--trusted-write') {
|
|
1067
|
+
throw new Error(`${THIRD_PARTY_FLAG} writes a pin: it takes --trusted-write\n${USAGE}`);
|
|
1068
|
+
}
|
|
1069
|
+
if (repoScope !== undefined || exactCommit !== undefined || kernelCommit !== undefined || producerWorkflowPins.length > 0) {
|
|
1070
|
+
throw new Error(`${THIRD_PARTY_FLAG} writes one third-party pin: it takes no --repo, --at, ${KERNEL_COMMIT_FLAG} or ${PRODUCER_WORKFLOW_PIN_FLAG}`);
|
|
1071
|
+
}
|
|
1072
|
+
await writeThirdPartySource({ schemas, writer, cosignBinary, kernelRoot });
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1001
1075
|
if (flag === REPIN_FLAG) {
|
|
1002
1076
|
if (repoScope !== undefined || exactCommit !== undefined) {
|
|
1003
1077
|
throw new Error(`${REPIN_FLAG} replaces the contract authority only: it takes no --repo or --at`);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Canonical evidence JSON, exact UTF-8 and sha256 digests: the primitives every evidence module shares. Its own module so a
|
|
2
|
+
// module `image-evidence.mjs` imports (the third-party pin loader) can use them without an import cycle.
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import { compareStrings } from './lib.mjs';
|
|
7
|
+
|
|
8
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', {
|
|
9
|
+
fatal: true,
|
|
10
|
+
ignoreBOM: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export function canonicalJson(value) {
|
|
14
|
+
if (
|
|
15
|
+
value === null ||
|
|
16
|
+
typeof value === 'boolean' ||
|
|
17
|
+
typeof value === 'string'
|
|
18
|
+
) {
|
|
19
|
+
return JSON.stringify(value);
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === 'number') {
|
|
22
|
+
if (!Number.isSafeInteger(value)) {
|
|
23
|
+
throw new Error('canonical evidence JSON permits only safe integers');
|
|
24
|
+
}
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === 'object') {
|
|
31
|
+
const entries = Object.entries(value).sort(([left], [right]) =>
|
|
32
|
+
compareStrings(left, right),
|
|
33
|
+
);
|
|
34
|
+
return `{${entries
|
|
35
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
|
|
36
|
+
.join(',')}}`;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`unsupported canonical evidence JSON value ${typeof value}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function canonicalJsonBytes(value) {
|
|
42
|
+
return Buffer.from(`${canonicalJson(value)}\n`, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function sha256Digest(bytes) {
|
|
46
|
+
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exact UTF-8 decode. Exported because the producers need byte-for-byte the
|
|
51
|
+
* same strictness the verifier applies — a producer that accepts bytes the
|
|
52
|
+
* verifier will reject signs evidence that can never verify.
|
|
53
|
+
*/
|
|
54
|
+
export function decodeExactUtf8(bytes, label) {
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = STRICT_UTF8.decode(bytes);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(`${label} contains invalid UTF-8`);
|
|
60
|
+
}
|
|
61
|
+
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
|
62
|
+
throw new Error(`${label} is not exact UTF-8`);
|
|
63
|
+
}
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseCanonicalJsonBytes(bytes, label) {
|
|
68
|
+
const text = decodeExactUtf8(bytes, label);
|
|
69
|
+
let value;
|
|
70
|
+
try {
|
|
71
|
+
value = JSON.parse(text);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(`${label} is not valid JSON: ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
const canonical = canonicalJsonBytes(value);
|
|
76
|
+
if (!bytes.equals(canonical)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`${label} is not canonical JSON (duplicate keys, non-canonical ordering, or whitespace are refused)`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
@@ -69,11 +69,14 @@ export class CatalogueVerifierNotPinnedError extends Error {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** The edition a catalogue-consumer refusal names: the customer edition of `channel` that reads the catalogue. */
|
|
73
|
+
const catalogueConsumer = (channel) => ({ edition: `${channel} edition`, channel });
|
|
74
|
+
|
|
72
75
|
/** The pinned built verifier's bundle path, after the data root's pin of it held; refuses before anything is spawned. */
|
|
73
|
-
function pinnedVerifierBundle(locateVerifier) {
|
|
76
|
+
function pinnedVerifierBundle(locateVerifier, channel) {
|
|
74
77
|
const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
|
|
75
78
|
const root = configuredDataRoot();
|
|
76
|
-
if (declaration.policy === null) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
|
|
79
|
+
if (declaration.policy === null) {throw new EvidenceTrustRequiredError(root, 'declares no policy', catalogueConsumer(channel));}
|
|
77
80
|
try {
|
|
78
81
|
const pin = readEvidenceTrustPin({ dataRoot: root, declaration });
|
|
79
82
|
assertPinnedVerifier({ pin, actual: readBuiltVerifierDigests(locateVerifier) });
|
|
@@ -111,12 +114,12 @@ const stderrOf = (result) => String(result.stderr ?? '').trim() || (result.error
|
|
|
111
114
|
* @param {{ channel: string, locateVerifier?: Function, spawn?: Function }} options
|
|
112
115
|
*/
|
|
113
116
|
export function assertCatalogueConsumerArmed({ channel, locateVerifier = locateBuiltVerifier, spawn = spawnSync }) {
|
|
114
|
-
const { bundlePath, root } = pinnedVerifierBundle(locateVerifier);
|
|
117
|
+
const { bundlePath, root } = pinnedVerifierBundle(locateVerifier, channel);
|
|
115
118
|
const result = runVerifier({ spawn, bundlePath, args: ['arming', '--channel', channel, '--data-root', root] });
|
|
116
119
|
if (result.status === 0) {return;}
|
|
117
120
|
const stderr = stderrOf(result);
|
|
118
121
|
if (stderr.includes('EVIDENCE_TRUST_REQUIRED_UNARMED')) {
|
|
119
|
-
throw new EvidenceTrustRequiredError(root, `declares a policy that is not armed on the ${channel} channel for a catalogue consumer (${stderr})
|
|
122
|
+
throw new EvidenceTrustRequiredError(root, `declares a policy that is not armed on the ${channel} channel for a catalogue consumer (${stderr})`, catalogueConsumer(channel));
|
|
120
123
|
}
|
|
121
124
|
throw new CatalogueEvidenceNotVerifiedError(`the ${channel} catalogue`, `the arming check failed: ${stderr}`);
|
|
122
125
|
}
|
|
@@ -181,7 +184,7 @@ export async function verifyCatalogueEvidenceReferrer({
|
|
|
181
184
|
if (sha256Of(statementBytes) !== layers.statement.digest || sha256Of(bundleBytes) !== layers.bundle.digest) {
|
|
182
185
|
throw new CatalogueEvidenceNotVerifiedError(ref, 'a referrer layer does not hash to its digest');
|
|
183
186
|
}
|
|
184
|
-
const { bundlePath, root } = pinnedVerifierBundle(locateVerifier);
|
|
187
|
+
const { bundlePath, root } = pinnedVerifierBundle(locateVerifier, channel);
|
|
185
188
|
const evidenceRoot = mkdtempSync(join(tmpdir(), 'xema-catalogue-evidence-'));
|
|
186
189
|
try {
|
|
187
190
|
writeFileSync(join(evidenceRoot, CATALOGUE_EVIDENCE_STATEMENT_TITLE), statementBytes, { flag: 'wx', mode: 0o600 });
|
|
@@ -25,7 +25,7 @@ import { isDevelopImageRef } from './develop-image-coordinate.mjs';
|
|
|
25
25
|
import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
26
26
|
import { LOCAL_WORKFLOW, assertEngineImage, assertExistingArtifactMatches, snapshotEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
|
|
27
27
|
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
28
|
-
import { CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY, EditionChannel, EditionLockFile } from './edition-constants.mjs';
|
|
28
|
+
import { CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY, EditionChannel, EditionKind, EditionLockFile } from './edition-constants.mjs';
|
|
29
29
|
import { channelImageRef, sealImageSubjects } from './edition-image-seal.mjs';
|
|
30
30
|
import { WorkspaceRoot } from './edition-workspace.mjs';
|
|
31
31
|
import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
|
|
@@ -219,6 +219,12 @@ const COMMANDS = Object.freeze({
|
|
|
219
219
|
switches: [],
|
|
220
220
|
required: ['--branch', '--registry', '--cosign', '--out'],
|
|
221
221
|
},
|
|
222
|
+
'verify-image-record': {
|
|
223
|
+
positional: false,
|
|
224
|
+
values: ['--image', '--producer', '--registry', '--cosign'],
|
|
225
|
+
switches: [],
|
|
226
|
+
required: ['--image', '--producer', '--registry', '--cosign'],
|
|
227
|
+
},
|
|
222
228
|
'check-overrides': { positional: false, values: ['--overrides'], switches: [], required: ['--overrides'] },
|
|
223
229
|
'choose-kernel': { positional: false, values: ['--kernel-pin', '--out'], switches: [], required: ['--out'] },
|
|
224
230
|
'check-existing': {
|
|
@@ -360,7 +366,7 @@ async function resolveCustomer(channel, options, env) {
|
|
|
360
366
|
sourceImages,
|
|
361
367
|
env,
|
|
362
368
|
});
|
|
363
|
-
const evidenceTrustPolicy = await descriptorEvidenceTrust({ channel, consumesCatalogue: true });
|
|
369
|
+
const evidenceTrustPolicy = await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel, consumesCatalogue: true });
|
|
364
370
|
const target = snapshotEditionTarget(channel, id, pins, { registry: options['--registry'], engineImage: options['--engine-image'], sourceImages });
|
|
365
371
|
// A release edition is admitted only sealed: every image names the digest the registry serves. (A develop edition is not held to it today.)
|
|
366
372
|
if (channel === EditionChannel.Release) {assertSealedImageLock(resolved.imageLock, `the images lock of "${id}"`);}
|
|
@@ -393,7 +399,7 @@ export async function checkExistingCustomer(channel, options, env, { adopt = ado
|
|
|
393
399
|
artifactDir: resolve(options['--artifact']),
|
|
394
400
|
distributionId: frame.id,
|
|
395
401
|
channel,
|
|
396
|
-
evidenceTrustPolicy: await descriptorEvidenceTrust({ channel, locateVerifier, consumesCatalogue: true }),
|
|
402
|
+
evidenceTrustPolicy: await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel, locateVerifier, consumesCatalogue: true }),
|
|
397
403
|
pins: frame.pins,
|
|
398
404
|
engineImage: options['--engine-image'],
|
|
399
405
|
catalogueDigest: frame.catalogueDigest,
|
|
@@ -9,6 +9,7 @@ import { DEVELOP_EDITION_STEPS, resolveDevelopSnapshot } from './develop-edition
|
|
|
9
9
|
import { loadDeclaredPinnedPolicy } from './evidence-trust-policy.mjs';
|
|
10
10
|
import { prepareTrustedCosignBinary } from './image-evidence.mjs';
|
|
11
11
|
import { readPinnedContractAuthority } from './produce-image-source-evidence.mjs';
|
|
12
|
+
import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
|
|
12
13
|
|
|
13
14
|
const stderrProgress = (line) => process.stderr.write(`${line}\n`);
|
|
14
15
|
|
|
@@ -37,7 +38,7 @@ export async function runDevelopCoverage({
|
|
|
37
38
|
progress(`[coverage] image content hashes computed (${imageLock.subjects.length} subjects)`);
|
|
38
39
|
const readers = createCoverageReaders({ policy, policyRoot: dataRoot, trustedCosign, schemas, expectedContractAuthority: readPinnedContractAuthority({ dataRoot, schemas }), dataRoot, env, ...readerOptions });
|
|
39
40
|
try {
|
|
40
|
-
const report = await collectCoverage({ distributionId: id, subjects: imageLock.subjects, policy, readers, progress });
|
|
41
|
+
const report = await collectCoverage({ distributionId: id, subjects: imageLock.subjects, policy, readers, thirdPartyPins: loadThirdPartySourcePins(dataRoot), progress });
|
|
41
42
|
write(json ? `${JSON.stringify(report, null, 2)}\n` : renderCoverageTable(report));
|
|
42
43
|
return coverageExitCode(report);
|
|
43
44
|
} finally {
|
|
@@ -11,6 +11,8 @@ import { AttestationStatus, COVERED_VERDICTS, DEVELOP_EVIDENCE_CHANNEL, DEVELOP_
|
|
|
11
11
|
import { developImageRef } from './develop-image-coordinate.mjs';
|
|
12
12
|
import { policyCandidates } from './image-evidence.mjs';
|
|
13
13
|
import { mapWithConcurrency } from './map-with-concurrency.mjs';
|
|
14
|
+
import { imageSubjectKey, thirdPartyImagePins } from './third-party-image-pins.mjs';
|
|
15
|
+
import { attestedContentHash } from './third-party-subjects.mjs';
|
|
14
16
|
|
|
15
17
|
/** In flight at once, as `develop resolve` probes. */
|
|
16
18
|
export const COVERAGE_CONCURRENCY = 6;
|
|
@@ -32,9 +34,18 @@ export const expectedService = (subject) => subject.id;
|
|
|
32
34
|
* @param {object[]} request.subjects the images lock subjects (release refs, unsealed), contentHash = F
|
|
33
35
|
* @param {object} request.policy the pinned trust policy (rows are filtered per develop repository)
|
|
34
36
|
* @param {{ developDigest: Function, readAttestation: Function, readSignatures: Function }} request.readers
|
|
37
|
+
* @param {{byRepository: (repository: string) => object|undefined}} [request.thirdPartyPins] the data root's third-party
|
|
38
|
+
* source pins: a subject of a pinned repository is not built by the develop lane, so it is no coverage subject
|
|
35
39
|
* @param {(line: string) => void} [request.progress]
|
|
36
40
|
*/
|
|
37
|
-
export async function collectCoverage({ distributionId, subjects, policy, readers, concurrency = COVERAGE_CONCURRENCY, progress = () => {} }) {
|
|
41
|
+
export async function collectCoverage({ distributionId, subjects: lockSubjects, policy, readers, thirdPartyPins = undefined, concurrency = COVERAGE_CONCURRENCY, progress = () => {} }) {
|
|
42
|
+
const pinnedImages = thirdPartyPins === undefined ? new Map() : thirdPartyImagePins({ pins: thirdPartyPins, subjects: lockSubjects });
|
|
43
|
+
const subjects = lockSubjects.filter((subject) => !pinnedImages.has(imageSubjectKey(subject)));
|
|
44
|
+
for (const subject of lockSubjects.filter((candidate) => pinnedImages.has(imageSubjectKey(candidate)))) {
|
|
45
|
+
progress(
|
|
46
|
+
`[coverage] third-party ${imageSubjectKey(subject)} ${subject.imageRef}@${pinnedImages.get(imageSubjectKey(subject)).manifestDigest} ${attestedContentHash(subject)}, not a develop coverage subject`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
38
49
|
const rows = await mapWithConcurrency(subjects, concurrency, async (subject) => {
|
|
39
50
|
const developSubject = { ...subject, imageRef: developImageRef(subject.imageRef) };
|
|
40
51
|
const digest = await readers.developDigest(developSubject);
|
|
@@ -16,13 +16,14 @@ import { createDevelopImageProbe } from './develop-image-probe.mjs';
|
|
|
16
16
|
import { assertFrozenCheckouts, buildSnapshotInputs } from './develop-snapshot-inputs.mjs';
|
|
17
17
|
import { EditionChannel } from './edition-constants.mjs';
|
|
18
18
|
import { sealImages } from './edition-image-seal.mjs';
|
|
19
|
-
import { kernelPackageDir } from './edition-workspace.mjs';
|
|
19
|
+
import { dataRoot, kernelPackageDir } from './edition-workspace.mjs';
|
|
20
20
|
import { kernelPackageOfAuthority } from './kernel-package.mjs';
|
|
21
21
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
22
22
|
import { deployPinKey } from './pin-source.mjs';
|
|
23
23
|
import { composeAuthorizationBase, resolveDistributionWithIndex } from './resolve-distribution.mjs';
|
|
24
24
|
import { externalSourceTrees, resolveImages, sourceTreeLocator } from './resolve-images.mjs';
|
|
25
25
|
import { createPinnedSourceAuthority } from './source-authority.mjs';
|
|
26
|
+
import { loadTrustedThirdPartySourcePins } from './third-party-pins-trusted.mjs';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The steps the resolve runs, named so a test can stand in for the ones that
|
|
@@ -83,7 +84,7 @@ export async function resolveDevelopEdition({ id, pins, env = process.env, sealO
|
|
|
83
84
|
phase(`image content hashes computed (${imageLock.subjects.length} subjects)`);
|
|
84
85
|
await probe.settle();
|
|
85
86
|
phase('every develop image is present');
|
|
86
|
-
const sealedImageLock = await steps.sealImages({ channel: EditionChannel.Develop, imageLock, env, progress, ...sealOptions });
|
|
87
|
+
const sealedImageLock = await steps.sealImages({ channel: EditionChannel.Develop, imageLock, env, progress, thirdPartyPins: loadTrustedThirdPartySourcePins(dataRoot()), ...sealOptions });
|
|
87
88
|
phase('images sealed');
|
|
88
89
|
// The unsealed lock becomes the one v5 grammar only here, once the images it
|
|
89
90
|
// owns are sealed: each biomeContentHash is recomputed from the frozen sources.
|
|
@@ -45,6 +45,14 @@
|
|
|
45
45
|
// annotations-missing | annotation-mismatch | not-built). Prints a table grouped by producer repo
|
|
46
46
|
// and `covered N/M` (--json: the same as data). Exit 0 only when every subject is covered, 1 when
|
|
47
47
|
// not, 2 on an operational error. Takes the roots `resolve` takes; writes nothing.
|
|
48
|
+
// verify-image-record --image <name>@sha256:<digest> --producer <owner/repo> --registry <root> --cosign <absolute-path>
|
|
49
|
+
// whether an authenticated develop build record of the producer lists exactly this image name and digest: the
|
|
50
|
+
// producer's records are read newest run first and verified as select-records verifies them (shared core); a record of a
|
|
51
|
+
// workflow other than the producer's `.github/workflows/ci.yaml` is skipped, and the newest ci.yaml record that lists the image answers.
|
|
52
|
+
// Prints { image, digest, producer, commit }: commit is that of the newest verified ci.yaml record (by run number, then attempt)
|
|
53
|
+
// that lists the digest; it can be an older commit when a later run targeted it, which is safe because the digest binds the bytes.
|
|
54
|
+
// Refuses (exit 1) when no ci.yaml record lists the image, the record
|
|
55
|
+
// names another producer, or cosign refuses the identity. Takes the roots `resolve` takes.
|
|
48
56
|
// select-records --branch develop --registry <root> --cosign <absolute-path> --out <file> [--at <ISO-8601 UTC>] [--published-pins <file>]
|
|
49
57
|
// for every develop-graph member, take the newest build record the data root's record workflow signed for
|
|
50
58
|
// the branch (<root>/build-records/<name>, verified offline by digest against the pinned trusted root, as
|
|
@@ -91,8 +99,9 @@ import { CoverageExit } from './develop-coverage.mjs';
|
|
|
91
99
|
import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
|
|
92
100
|
import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
|
|
93
101
|
import { runSelectRecords } from './develop-select-records.mjs';
|
|
102
|
+
import { runVerifyImageRecord } from './develop-verify-image-record.mjs';
|
|
94
103
|
import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
|
|
95
|
-
import { EditionChannel } from './edition-constants.mjs';
|
|
104
|
+
import { EditionChannel, EditionKind } from './edition-constants.mjs';
|
|
96
105
|
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir } from './edition-workspace.mjs';
|
|
97
106
|
import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
|
|
98
107
|
import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protocol-choice.mjs';
|
|
@@ -154,7 +163,7 @@ async function run(argv, env) {
|
|
|
154
163
|
const pins = readStrictJsonFile(options['--pins']);
|
|
155
164
|
const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
|
|
156
165
|
const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage: options['--engine-image'] });
|
|
157
|
-
const evidenceTrustPolicy = await descriptorEvidenceTrust({ channel: EditionChannel.Develop });
|
|
166
|
+
const evidenceTrustPolicy = await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel: EditionChannel.Develop });
|
|
158
167
|
const { catalogue, ...resolved } = await resolveDevelopEdition({ id, pins, env });
|
|
159
168
|
const outDir = resolve(options['--out']);
|
|
160
169
|
writeEditionArtifact({
|
|
@@ -182,13 +191,16 @@ async function run(argv, env) {
|
|
|
182
191
|
case 'select-records':
|
|
183
192
|
await runSelectRecords({ options, env });
|
|
184
193
|
return;
|
|
194
|
+
case 'verify-image-record':
|
|
195
|
+
await runVerifyImageRecord({ options, env });
|
|
196
|
+
return;
|
|
185
197
|
case 'check-existing': {
|
|
186
198
|
if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
|
|
187
199
|
assertExistingArtifactMatches({
|
|
188
200
|
artifactDir: resolve(options['--artifact']),
|
|
189
201
|
distributionId: id,
|
|
190
202
|
channel: EditionChannel.Develop,
|
|
191
|
-
evidenceTrustPolicy: await descriptorEvidenceTrust({ channel: EditionChannel.Develop }),
|
|
203
|
+
evidenceTrustPolicy: await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel: EditionChannel.Develop }),
|
|
192
204
|
pins: readStrictJsonFile(options['--pins']),
|
|
193
205
|
engineImage: options['--engine-image'],
|
|
194
206
|
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// develop-record-verify — the verify core of a signed build record, shared by `develop select-records` and
|
|
2
|
+
// `develop verify-image-record`.
|
|
3
|
+
//
|
|
4
|
+
// A record is trusted only after cosign verified it, offline against the pinned trusted root, as signed by the
|
|
5
|
+
// data root's `record-build.yaml` at `refs/heads/<branch>`, called by the member's own repository at the
|
|
6
|
+
// record's own commit; the record must also name that member as its producer, its own branch and its own tag.
|
|
7
|
+
// Both verbs read records through `loadVerifiedRecord`: there is one copy of this logic.
|
|
8
|
+
//
|
|
9
|
+
// Two failure classes: `RecordRefused` (the record is missing, or cosign or the record itself says no) and a
|
|
10
|
+
// plain Error (the registry or cosign could not answer), which the operator retries rather than reads.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
|
|
14
|
+
import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
|
|
15
|
+
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
16
|
+
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
17
|
+
import { cosignEnvironment, withPinnedCosign } from './pinned-cosign.mjs';
|
|
18
|
+
import { RecordRefused } from './record-refused.mjs';
|
|
19
|
+
import { splitImageRef } from './registry-credentials.mjs';
|
|
20
|
+
import { isNotPublishedYet } from './seal-image-digests.mjs';
|
|
21
|
+
|
|
22
|
+
/** The repository segment, under the registry root, that holds a member's build records. */
|
|
23
|
+
export const BUILD_RECORDS_SEGMENT = 'build-records';
|
|
24
|
+
export const RECORD_SIGNER_WORKFLOW = '.github/workflows/record-build.yaml';
|
|
25
|
+
export const RECORD_SIGNER_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
|
|
26
|
+
/** The branches a record signer is declared for, each with the ref the record workflow runs at. */
|
|
27
|
+
const RECORD_SIGNER_REFS = Object.freeze({ develop: 'refs/heads/develop' });
|
|
28
|
+
/**
|
|
29
|
+
* A cosign exit whose stderr shows the registry was not reached before any verdict. Only this, a cosign that
|
|
30
|
+
* could not run, and a timeout are operational; every other non-zero exit of `cosign verify` is a verdict.
|
|
31
|
+
*/
|
|
32
|
+
const COSIGN_TRANSPORT_FAILURE = /dial tcp|i\/o timeout|connection (refused|reset)|no such host|TLS handshake timeout|context deadline exceeded/i;
|
|
33
|
+
|
|
34
|
+
/** The record's producer is a kind this CLI has no claims for: a closed set, never guessed at. */
|
|
35
|
+
export class UnknownBuildRecordProducerError extends RecordRefused {
|
|
36
|
+
constructor(repo, kind) {
|
|
37
|
+
super(repo, `the record's producer kind ${JSON.stringify(kind)} is not one select-records can verify`);
|
|
38
|
+
this.name = 'UnknownBuildRecordProducerError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class DataRootRepositoryUndeclaredError extends Error {
|
|
43
|
+
constructor() {
|
|
44
|
+
super('edition-workspace.json declares no dataRootRepositoryUrl; select-records cannot name the record signer');
|
|
45
|
+
this.name = 'DataRootRepositoryUndeclaredError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class KernelLacksBuildRecordSchemaError extends Error {
|
|
50
|
+
constructor(missing) {
|
|
51
|
+
super(`the kernel's distribution module does not export ${missing.join(', ')}; select-records needs a kernel with the build-record contract`);
|
|
52
|
+
this.name = 'KernelLacksBuildRecordSchemaError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The data root's `owner/name`, from the workspace declaration; refused by name when it declares none. */
|
|
57
|
+
export function declaredDataRootRepositoryUrl() {
|
|
58
|
+
const url = editionWorkspaceConfig().dataRootRepositoryUrl;
|
|
59
|
+
if (url === undefined) {throw new DataRootRepositoryUndeclaredError();}
|
|
60
|
+
return url;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The kernel's build-record exports, refused as a set when any is absent. */
|
|
64
|
+
export function requireBuildRecordKernel(schemas) {
|
|
65
|
+
const names = ['BUILD_RECORD_MEDIA_TYPE', 'BuildRecordSchema', 'buildRecordTag', 'parseBuildRecordTag', 'compareBuildRecordRuns'];
|
|
66
|
+
const missing = names.filter((name) => schemas?.[name] === undefined);
|
|
67
|
+
if (missing.length > 0 || typeof schemas.BuildRecordSchema?.safeParse !== 'function') {
|
|
68
|
+
throw new KernelLacksBuildRecordSchemaError(missing.length > 0 ? missing : ['a BuildRecordSchema with safeParse']);
|
|
69
|
+
}
|
|
70
|
+
return schemas;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The ref and data-root repository a branch's records are signed by; refused before any network call. */
|
|
74
|
+
export function recordSignerOrigin({ branch, dataRootRepositoryUrl }) {
|
|
75
|
+
const ref = Object.hasOwn(RECORD_SIGNER_REFS, branch) ? RECORD_SIGNER_REFS[branch] : undefined;
|
|
76
|
+
if (ref === undefined) {throw new Error(`no record signer declared for branch ${branch}`);}
|
|
77
|
+
if (dataRootRepositoryUrl === undefined) {throw new DataRootRepositoryUndeclaredError();}
|
|
78
|
+
const dataRepository = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(dataRootRepositoryUrl, 'dataRootRepositoryUrl'), 'dataRootRepositoryUrl');
|
|
79
|
+
return { ref, dataRepository };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The signer of the branch's records: the data root's record workflow at the branch, as the certificate states
|
|
84
|
+
* it, with the repository that CALLED the workflow (the member) as the workflow-repository claim.
|
|
85
|
+
*/
|
|
86
|
+
export function recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl }) {
|
|
87
|
+
const { ref, dataRepository } = recordSignerOrigin({ branch, dataRootRepositoryUrl });
|
|
88
|
+
const member = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(memberRepositoryUrl, 'repositoryUrl'), 'repositoryUrl');
|
|
89
|
+
return {
|
|
90
|
+
certificateIdentity: `https://github.com/${dataRepository}/${RECORD_SIGNER_WORKFLOW}@${ref}`,
|
|
91
|
+
oidcIssuer: RECORD_SIGNER_OIDC_ISSUER,
|
|
92
|
+
githubWorkflow: { repository: member, ref },
|
|
93
|
+
memberRepository: member,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a record's producer says, read through the ONE switch over its kind: the repository that called the
|
|
99
|
+
* record workflow (the cosign caller claim, held equal to the member's) and the run the snapshot names.
|
|
100
|
+
*/
|
|
101
|
+
export function producerClaims({ record, repo, memberRepository }) {
|
|
102
|
+
switch (record.producer?.kind) {
|
|
103
|
+
case 'github-actions': {
|
|
104
|
+
const { repository, runNumber, runAttempt } = record.producer;
|
|
105
|
+
if (repository !== memberRepository) {throw new RecordRefused(repo, `record is produced by repository ${repository}, not ${memberRepository}`);}
|
|
106
|
+
return { repository, snapshot: `${runNumber}.${runAttempt}` };
|
|
107
|
+
}
|
|
108
|
+
default:
|
|
109
|
+
throw new UnknownBuildRecordProducerError(repo, record.producer?.kind);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Run one registry client call; ONLY a failure of the client itself is "the registry did not answer". */
|
|
114
|
+
async function fromRegistry(repo, what, call) {
|
|
115
|
+
try {
|
|
116
|
+
return await call();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new Error(`select-records: ${repo}: the registry did not answer for ${what}: ${error.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read one record by its tag: manifest by digest, its single layer, parsed against the kernel's schema. */
|
|
123
|
+
async function readRecord({ kernel, client, repository, repo, tag }) {
|
|
124
|
+
const served = await fromRegistry(repo, `${repository}:${tag}`, () => client.getManifestByTag(repository, tag));
|
|
125
|
+
const manifest = await fromRegistry(repo, `${repository}@${served.digest}`, () => client.getManifestByDigest(repository, served.digest));
|
|
126
|
+
const layers = manifest.document.layers;
|
|
127
|
+
if (!Array.isArray(layers) || layers.length !== 1) {
|
|
128
|
+
throw new RecordRefused(repo, `record ${tag} has ${Array.isArray(layers) ? layers.length : 'no'} layers, not exactly one`);
|
|
129
|
+
}
|
|
130
|
+
const [layer] = layers;
|
|
131
|
+
if (layer.mediaType !== kernel.BUILD_RECORD_MEDIA_TYPE) {
|
|
132
|
+
throw new RecordRefused(repo, `record ${tag} layer media type is ${JSON.stringify(layer.mediaType)}, not ${kernel.BUILD_RECORD_MEDIA_TYPE}`);
|
|
133
|
+
}
|
|
134
|
+
const bytes = await fromRegistry(repo, `${repository}@${layer.digest}`, () => client.getBlob(repository, layer.digest));
|
|
135
|
+
let document;
|
|
136
|
+
try {
|
|
137
|
+
document = JSON.parse(bytes.toString('utf8'));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
throw new RecordRefused(repo, `record ${tag} is not JSON: ${error.message}`);
|
|
140
|
+
}
|
|
141
|
+
const parsed = kernel.BuildRecordSchema.safeParse(document);
|
|
142
|
+
if (!parsed.success) {throw new RecordRefused(repo, `record ${tag} does not parse as a build record: ${parsed.error.message}`);}
|
|
143
|
+
return { record: parsed.data, digest: served.digest };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The tag's record, verified by digest against the constrained signer, then held to its own repo, branch and tag. */
|
|
147
|
+
export async function loadVerifiedRecord({ kernel, client, runCosign, cosignEnv, trustedRootPath, repository, member, branch, dataRootRepositoryUrl, tag }) {
|
|
148
|
+
const repo = member.repo;
|
|
149
|
+
const { record, digest } = await readRecord({ kernel, client, repository, repo, tag });
|
|
150
|
+
const signer = recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl: member.repositoryUrl });
|
|
151
|
+
const { repository: callerRepository, snapshot } = producerClaims({ record, repo, memberRepository: signer.memberRepository });
|
|
152
|
+
signer.githubWorkflow.repository = callerRepository;
|
|
153
|
+
const ref = `${repository}@${digest}`;
|
|
154
|
+
const args = ['verify', '--offline', '--trusted-root', trustedRootPath, ...certificateConstraintArgs(signer, record.commit), ref];
|
|
155
|
+
let result;
|
|
156
|
+
try {
|
|
157
|
+
result = await runCosign(args, cosignEnv);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// no numeric exit code: cosign did not run (spawn failure) or did not answer (timeout, kill signal)
|
|
160
|
+
throw new Error(`select-records: ${repo}: cosign could not run for ${ref}: ${error.message}`);
|
|
161
|
+
}
|
|
162
|
+
if (result.code !== 0) {
|
|
163
|
+
if (COSIGN_TRANSPORT_FAILURE.test(result.stderr)) {
|
|
164
|
+
throw new Error(`select-records: ${repo}: cosign could not reach the registry for ${ref} (exit ${result.code}): ${result.stderr.trim()}`);
|
|
165
|
+
}
|
|
166
|
+
throw new RecordRefused(repo, `cosign refused record ${tag} (${digest}) as signed by ${signer.certificateIdentity} (exit ${result.code}): ${result.stderr.trim()}`);
|
|
167
|
+
}
|
|
168
|
+
if (record.branch !== branch) {throw new RecordRefused(repo, `record ${tag} is for branch ${record.branch}, not ${branch}`);}
|
|
169
|
+
if (kernel.buildRecordTag(record) !== tag) {throw new RecordRefused(repo, `record tagged ${tag} says it is ${kernel.buildRecordTag(record)}`);}
|
|
170
|
+
return { record, snapshot };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
/** The registry repository that holds a member's build records. */
|
|
175
|
+
export function recordsRepositoryOf(registry, member) {
|
|
176
|
+
return `${registry}/${BUILD_RECORDS_SEGMENT}/${member.repo.split('/').at(-1)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Every record tag a member published for `branch`, newest run first, with the registry client of its authority.
|
|
181
|
+
* Refused when none was ever published; a registry that did not answer is a plain Error.
|
|
182
|
+
*/
|
|
183
|
+
export async function listBuildRecordRuns({ kernel, clientFor, registry, member, branch }) {
|
|
184
|
+
const repository = recordsRepositoryOf(registry, member);
|
|
185
|
+
const { authority } = splitImageRef(repository);
|
|
186
|
+
const client = clientFor(authority);
|
|
187
|
+
let tags;
|
|
188
|
+
try {
|
|
189
|
+
tags = await client.listTags(repository);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (isNotPublishedYet(error)) {throw new RecordRefused(member.repo, `${repository} does not exist: no build record was ever published`);}
|
|
192
|
+
throw new Error(`select-records: ${member.repo}: the registry did not answer for ${repository} tags: ${error.message}`);
|
|
193
|
+
}
|
|
194
|
+
const runs = tags
|
|
195
|
+
.map((tag) => ({ tag, run: kernel.parseBuildRecordTag(tag, branch) }))
|
|
196
|
+
.filter(({ run }) => run !== undefined)
|
|
197
|
+
.sort((a, b) => kernel.compareBuildRecordRuns(b.run, a.run));
|
|
198
|
+
if (runs.length === 0) {throw new RecordRefused(member.repo, `${repository} has no build record for branch ${branch}`);}
|
|
199
|
+
return { repository, authority, client, runs };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Prepare everything record verification needs (the declared pinned policy, the trusted cosign, the pinned trusted
|
|
204
|
+
* root, the registry clients and their credentials), hand it to `use`, and dispose of it. The injections are
|
|
205
|
+
* replaced by tests.
|
|
206
|
+
*/
|
|
207
|
+
export async function withRecordVerifier({ cosignBinary, env, loadKernel = loadKernelDistributionSchemas, ...pinned }, use) {
|
|
208
|
+
const dataRootRepositoryUrl = declaredDataRootRepositoryUrl();
|
|
209
|
+
return withPinnedCosign(
|
|
210
|
+
{ cosignBinary, env, ...pinned, afterPolicy: async () => ({ kernel: requireBuildRecordKernel(await loadKernel()) }) },
|
|
211
|
+
({ kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }) => use({ dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The environment cosign runs with for one authority: the pinned base plus its declared credential (pinned-cosign.mjs). */
|
|
216
|
+
export { cosignEnvironment };
|