@xemahq/xema 0.40.3 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +1 -0
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +281 -9
- package/dist/edition/verify-image-evidence.bundle.provenance.json +65 -47
- package/dist/runtime/edition/verb-map.d.ts +3 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +4 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/edition/README.md +5 -0
- package/edition/catalogue-evidence.mjs +225 -0
- package/edition/catalogue-fetch.mjs +40 -7
- package/edition/customer-edition-verbs.mjs +477 -0
- package/edition/customer-flags.mjs +2 -0
- package/edition/develop-coverage-readers.mjs +212 -0
- package/edition/develop-coverage-run.mjs +48 -0
- package/edition/develop-coverage-verdict.mjs +153 -0
- package/edition/develop-coverage.mjs +120 -0
- package/edition/develop-edition-resolve.mjs +18 -7
- package/edition/develop-edition.mjs +47 -220
- package/edition/develop-freeze.mjs +50 -13
- package/edition/develop-image-probe.mjs +7 -7
- package/edition/edition-artifact.mjs +31 -14
- package/edition/edition-constants.mjs +9 -0
- package/edition/edition-contract.json +2 -1
- package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
- package/edition/edition-workspace.mjs +4 -3
- package/edition/evidence-trust-pin.mjs +28 -8
- package/edition/evidence-trust-policy.mjs +51 -7
- package/edition/image-evidence.mjs +2 -2
- package/edition/lib.mjs +7 -0
- package/edition/oci-image-source-evidence.mjs +3 -0
- package/edition/oras-process.mjs +3 -3
- package/edition/oras.mjs +47 -5
- package/edition/produce-edition-catalogue-evidence.mjs +252 -0
- package/edition/produce-image-source-evidence.mjs +11 -2
- package/edition/release-edition.mjs +54 -0
- package/edition/repin-evidence-trust.mjs +5 -3
- package/edition/sigstore-bundle-media-type.mjs +4 -0
- package/edition/trust-policy-arming.mjs +14 -1
- package/edition/verify-catalogue-evidence.mjs +245 -0
- package/edition/verify-image-evidence.mjs +15 -1
- package/package.json +2 -2
- package/edition/develop-customer-edition.mjs +0 -149
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// develop-coverage-readers — the READ-ONLY registry, attestation and signature readers behind
|
|
2
|
+
// `xema edition develop coverage`. Nothing here signs, attests, pushes or writes a lock.
|
|
3
|
+
//
|
|
4
|
+
// Every reader takes the develop coordinate of ONE subject and returns plain data; the verdict is
|
|
5
|
+
// decided elsewhere (develop-coverage-verdict.mjs), so the decision is tested without a registry.
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
|
|
13
|
+
import { AttestationStatus, DEVELOP_EVIDENCE_CHANNEL } from './develop-coverage-verdict.mjs';
|
|
14
|
+
import { COSIGN_ENV, policyCandidates, sha256Digest, validatePinnedTrustedRoot, verifySigstoreEvidenceDescriptor } from './image-evidence.mjs';
|
|
15
|
+
import { resolveImageSourceEvidenceReferrer } from './oci-image-source-evidence.mjs';
|
|
16
|
+
import { BUNDLE_MEDIA_TYPE } from './produce-image-source-evidence.mjs';
|
|
17
|
+
import { loadRegistryCredentialDeclarations, resolveRegistryCredential, splitImageRef } from './registry-credentials.mjs';
|
|
18
|
+
import { isNotPublishedYet } from './seal-image-digests.mjs';
|
|
19
|
+
import { assertPreparedExecutable } from './trusted-executable.mjs';
|
|
20
|
+
import { registryClientsFor } from './verify-image-platforms.mjs';
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
// One external call to cosign; it reads a registry. A run that exceeds it fails naming the subject, never a verdict.
|
|
24
|
+
const COSIGN_CALL_TIMEOUT_MS = 60_000;
|
|
25
|
+
const COSIGN_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
26
|
+
/** cosign's exact refusal when a digest carries no signature at all. */
|
|
27
|
+
const NO_SIGNATURES_MESSAGE = 'no signatures found';
|
|
28
|
+
const ANY = '.*';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The signatures cosign verified, as data. `cosign verify --output json` prints one object per signature whose
|
|
32
|
+
* `optional` holds the signed annotations next to the certificate's claims.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} stdout
|
|
35
|
+
* @param {string} digest the digest that was verified; a signature over another digest is refused
|
|
36
|
+
* @returns {{ signer: object, annotations: Record<string, string> }[]}
|
|
37
|
+
*/
|
|
38
|
+
export function parseCosignVerifyOutput(stdout, digest) {
|
|
39
|
+
const payloads = JSON.parse(stdout);
|
|
40
|
+
if (!Array.isArray(payloads)) {throw new Error('cosign verify output is not a JSON array');}
|
|
41
|
+
return payloads.map((payload) => {
|
|
42
|
+
if (payload?.critical?.image?.['docker-manifest-digest'] !== digest) {
|
|
43
|
+
throw new Error(`cosign verified a signature over ${String(payload?.critical?.image?.['docker-manifest-digest'])}, not ${digest}`);
|
|
44
|
+
}
|
|
45
|
+
const optional = payload.optional ?? {};
|
|
46
|
+
const { Subject, Issuer, githubWorkflowTrigger, githubWorkflowSha, githubWorkflowRepository, githubWorkflowRef, ...rest } = optional;
|
|
47
|
+
const annotations = Object.fromEntries(Object.entries(rest).filter(([, value]) => typeof value === 'string'));
|
|
48
|
+
return {
|
|
49
|
+
signer: {
|
|
50
|
+
certificateIdentity: Subject ?? '',
|
|
51
|
+
issuer: Issuer ?? '',
|
|
52
|
+
trigger: githubWorkflowTrigger ?? '',
|
|
53
|
+
workflowSha: githubWorkflowSha ?? '',
|
|
54
|
+
workflowRepository: githubWorkflowRepository ?? '',
|
|
55
|
+
workflowRef: githubWorkflowRef ?? '',
|
|
56
|
+
},
|
|
57
|
+
annotations,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function defaultRunCosign(cosignPath, args, env) {
|
|
63
|
+
return execFileAsync(cosignPath, args, { env, timeout: COSIGN_CALL_TIMEOUT_MS, maxBuffer: COSIGN_MAX_OUTPUT_BYTES }).then(
|
|
64
|
+
({ stdout }) => ({ code: 0, stdout, stderr: '' }),
|
|
65
|
+
(error) => {
|
|
66
|
+
if (typeof error.code === 'number') {return { code: error.code, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };}
|
|
67
|
+
throw error;
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function basicAuthParts(credential) {
|
|
73
|
+
const decoded = Buffer.from(credential.basicAuth, 'base64').toString('utf8');
|
|
74
|
+
const colon = decoded.indexOf(':');
|
|
75
|
+
return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} request
|
|
80
|
+
* @param {object} request.policy the pinned trust policy (kernel-parsed)
|
|
81
|
+
* @param {string} request.policyRoot the directory the policy's trusted root is read from (the data root)
|
|
82
|
+
* @param {object} request.trustedCosign the digest-pinned cosign handle (prepareTrustedCosignBinary)
|
|
83
|
+
* @param {object} request.schemas kernel distribution schemas
|
|
84
|
+
* @param {string} request.dataRoot declares how each registry is read
|
|
85
|
+
* @param {object} request.env environment the registry credentials are named in
|
|
86
|
+
* @param {Function} [request.runCosign] (path, args, env) => { code, stdout, stderr }; replaced by tests
|
|
87
|
+
* @param {Function} [request.fetchImpl] @param {Function} [request.run] oras runner, replaced by tests
|
|
88
|
+
* @param {Function} [request.clientFor] (authority) => registry client, replaced by tests
|
|
89
|
+
*/
|
|
90
|
+
export function createCoverageReaders({ policy, policyRoot, trustedCosign, schemas, dataRoot, env, runCosign = defaultRunCosign, fetchImpl, run, clientFor: injectedClientFor }) {
|
|
91
|
+
const cosignPath = assertPreparedExecutable(trustedCosign, 'cosign');
|
|
92
|
+
const declarations = injectedClientFor === undefined ? loadRegistryCredentialDeclarations(dataRoot) : null;
|
|
93
|
+
const scratch = mkdtempSync(join(tmpdir(), 'xema-coverage-'));
|
|
94
|
+
const trustedRootPath = join(scratch, 'trusted-root.json');
|
|
95
|
+
writeFileSync(trustedRootPath, validatePinnedTrustedRoot(policyRoot, policy).bytes, { flag: 'wx', mode: 0o600 });
|
|
96
|
+
const clients = new Map();
|
|
97
|
+
const credentials = new Map();
|
|
98
|
+
|
|
99
|
+
function clientFor(subject) {
|
|
100
|
+
const { authority } = splitImageRef(subject.imageRef);
|
|
101
|
+
if (injectedClientFor !== undefined) {return injectedClientFor(authority);}
|
|
102
|
+
if (!clients.has(authority)) {
|
|
103
|
+
const made = registryClientsFor([subject], { declarations, env, fetchImpl, run });
|
|
104
|
+
clients.set(authority, made.get(authority));
|
|
105
|
+
}
|
|
106
|
+
return clients.get(authority);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// cosign is handed the SAME declared credential oras uses, through its environment (never argv), and only after
|
|
110
|
+
// the oras client of that authority has made its first call: that call is the token-realm check.
|
|
111
|
+
function cosignRegistryEnv(subject) {
|
|
112
|
+
if (injectedClientFor !== undefined) {return {};}
|
|
113
|
+
const { authority } = splitImageRef(subject.imageRef);
|
|
114
|
+
if (!credentials.has(authority)) {
|
|
115
|
+
const credential = resolveRegistryCredential({ authority, declarations, env });
|
|
116
|
+
if (credential.access === 'anonymous') {
|
|
117
|
+
credentials.set(authority, {});
|
|
118
|
+
} else {
|
|
119
|
+
const { username, password } = basicAuthParts(credential);
|
|
120
|
+
credentials.set(authority, { COSIGN_REGISTRY_USERNAME: username, COSIGN_REGISTRY_PASSWORD: password });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return credentials.get(authority);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function developDigest(subject) {
|
|
127
|
+
const { repository } = splitImageRef(subject.imageRef);
|
|
128
|
+
try {
|
|
129
|
+
const served = await clientFor(subject).getManifestByTag(repository, subject.contentHash);
|
|
130
|
+
return served.digest;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (isNotPublishedYet(error)) {return null;}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function readAttestation(subject, digest) {
|
|
138
|
+
const rows = policyCandidates(policy, schemas.IMAGE_PRODUCER_PREDICATE_TYPE, DEVELOP_EVIDENCE_CHANNEL, subject.imageRef);
|
|
139
|
+
const evidence = await resolveImageSourceEvidenceReferrer({
|
|
140
|
+
client: clientFor(subject),
|
|
141
|
+
imageRepository: subject.imageRef,
|
|
142
|
+
subjectDigest: digest,
|
|
143
|
+
schemas,
|
|
144
|
+
});
|
|
145
|
+
if (evidence === null) {return { status: AttestationStatus.None };}
|
|
146
|
+
if (rows.length === 0) {return { status: AttestationStatus.NoRow };}
|
|
147
|
+
const bound = evidence.records.filter(({ statement }) => {
|
|
148
|
+
const { artifact, source } = statement.predicate;
|
|
149
|
+
return (
|
|
150
|
+
artifact.manifestDigest === digest &&
|
|
151
|
+
artifact.imageRepository === subject.imageRef &&
|
|
152
|
+
source.kind === subject.kind &&
|
|
153
|
+
source.id === subject.id &&
|
|
154
|
+
source.sourceTreeHash === subject.contentHash
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
if (bound.length === 0) {
|
|
158
|
+
return { status: AttestationStatus.Refused, detail: `no record binds ${subject.kind}:${subject.id} at ${digest} for source tree ${subject.contentHash}` };
|
|
159
|
+
}
|
|
160
|
+
const failures = [];
|
|
161
|
+
for (const record of bound) {
|
|
162
|
+
const evidenceRoot = mkdtempSync(join(scratch, 'record-'));
|
|
163
|
+
try {
|
|
164
|
+
writeFileSync(join(evidenceRoot, 'statement.json'), record.statementBytes, { flag: 'wx', mode: 0o600 });
|
|
165
|
+
writeFileSync(join(evidenceRoot, 'bundle.sigstore.json'), record.bundleBytes, { flag: 'wx', mode: 0o600 });
|
|
166
|
+
const { identityPolicy } = verifySigstoreEvidenceDescriptor({
|
|
167
|
+
evidenceRoot,
|
|
168
|
+
descriptor: {
|
|
169
|
+
statementPath: 'statement.json',
|
|
170
|
+
statementDigest: sha256Digest(record.statementBytes),
|
|
171
|
+
bundlePath: 'bundle.sigstore.json',
|
|
172
|
+
bundleDigest: sha256Digest(record.bundleBytes),
|
|
173
|
+
bundleMediaType: BUNDLE_MEDIA_TYPE,
|
|
174
|
+
},
|
|
175
|
+
policy,
|
|
176
|
+
predicateType: schemas.IMAGE_PRODUCER_PREDICATE_TYPE,
|
|
177
|
+
channel: DEVELOP_EVIDENCE_CHANNEL,
|
|
178
|
+
imageRepository: subject.imageRef,
|
|
179
|
+
parseStatement: schemas.parseImageProducerStatement,
|
|
180
|
+
policyRoot,
|
|
181
|
+
trustedCosign,
|
|
182
|
+
});
|
|
183
|
+
return { status: AttestationStatus.Verified, detail: identityPolicy.policyId };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(evidenceRoot, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { status: AttestationStatus.Refused, detail: failures.join('; ') };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function readSignatures(subject, digest) {
|
|
194
|
+
const ref = `${subject.imageRef}@${digest}`;
|
|
195
|
+
const args = ['verify', '--offline', '--trusted-root', trustedRootPath, '--certificate-identity-regexp', ANY, '--certificate-oidc-issuer-regexp', ANY, '--output', 'json', ref];
|
|
196
|
+
const result = await runCosign(cosignPath, args, { ...COSIGN_ENV, ...cosignRegistryEnv(subject) });
|
|
197
|
+
if (result.code !== 0) {
|
|
198
|
+
if (result.stderr.includes(NO_SIGNATURES_MESSAGE)) {return [];}
|
|
199
|
+
throw new Error(`cosign verify ${ref} failed (exit ${result.code}): ${result.stderr.trim()}`);
|
|
200
|
+
}
|
|
201
|
+
return parseCosignVerifyOutput(result.stdout, digest);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
developDigest,
|
|
206
|
+
readAttestation,
|
|
207
|
+
readSignatures,
|
|
208
|
+
dispose() {
|
|
209
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// develop-coverage-run — the body of `xema edition develop coverage <id> --pins <file> --cosign <path> [--json]`.
|
|
2
|
+
//
|
|
3
|
+
// The snapshot half is `develop resolve`'s own (resolveDevelopSnapshot, then resolveImages), so F is computed
|
|
4
|
+
// exactly as resolve computes it. What follows is read-only: registry digests, referrers and signatures.
|
|
5
|
+
|
|
6
|
+
import { createCoverageReaders } from './develop-coverage-readers.mjs';
|
|
7
|
+
import { collectCoverage, coverageExitCode, renderCoverageTable } from './develop-coverage.mjs';
|
|
8
|
+
import { DEVELOP_EDITION_STEPS, resolveDevelopSnapshot } from './develop-edition-resolve.mjs';
|
|
9
|
+
import { loadDeclaredPinnedPolicy } from './evidence-trust-policy.mjs';
|
|
10
|
+
import { prepareTrustedCosignBinary } from './image-evidence.mjs';
|
|
11
|
+
|
|
12
|
+
const stderrProgress = (line) => process.stderr.write(`${line}\n`);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @returns {Promise<number>} the exit code: 0 covered, 1 not covered
|
|
16
|
+
* @throws on an operational error (unreadable pins, registry, cosign): the entry exits 2
|
|
17
|
+
*/
|
|
18
|
+
export async function runDevelopCoverage({
|
|
19
|
+
id,
|
|
20
|
+
pins,
|
|
21
|
+
cosignBinary,
|
|
22
|
+
json = false,
|
|
23
|
+
env = process.env,
|
|
24
|
+
steps = DEVELOP_EDITION_STEPS,
|
|
25
|
+
loadPolicy = loadDeclaredPinnedPolicy,
|
|
26
|
+
prepareCosign = prepareTrustedCosignBinary,
|
|
27
|
+
readerOptions = {},
|
|
28
|
+
progress = stderrProgress,
|
|
29
|
+
write = (text) => process.stdout.write(text),
|
|
30
|
+
}) {
|
|
31
|
+
const { policy, dataRoot } = await loadPolicy();
|
|
32
|
+
const trustedCosign = prepareCosign({ cosignBinary, policy });
|
|
33
|
+
try {
|
|
34
|
+
const { schemas, inputs, lock } = await resolveDevelopSnapshot({ id, pins, steps, phase: (name) => progress(`[coverage] ${name}`) });
|
|
35
|
+
const { imageLock } = await steps.resolveImages(id, { inputs, lock });
|
|
36
|
+
progress(`[coverage] image content hashes computed (${imageLock.subjects.length} subjects)`);
|
|
37
|
+
const readers = createCoverageReaders({ policy, policyRoot: dataRoot, trustedCosign, schemas, dataRoot, env, ...readerOptions });
|
|
38
|
+
try {
|
|
39
|
+
const report = await collectCoverage({ distributionId: id, subjects: imageLock.subjects, policy, readers, progress });
|
|
40
|
+
write(json ? `${JSON.stringify(report, null, 2)}\n` : renderCoverageTable(report));
|
|
41
|
+
return coverageExitCode(report);
|
|
42
|
+
} finally {
|
|
43
|
+
readers.dispose();
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
trustedCosign.dispose();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// develop-coverage-verdict — the closed verdict vocabulary of `xema edition develop coverage`
|
|
2
|
+
// and the pure decision that turns what was READ about one develop image into a verdict.
|
|
3
|
+
//
|
|
4
|
+
// A verdict is `attested` (case A: a V4 attestation verifies under a pinned develop row) or
|
|
5
|
+
// `signature-verified` (case B: the producer's signature over the digest, with the signed claim
|
|
6
|
+
// annotations, verifies under a pinned develop row and every check passes). Everything else names
|
|
7
|
+
// what is missing; nothing is skipped and nothing defaults.
|
|
8
|
+
|
|
9
|
+
/** The closed set of verdicts. */
|
|
10
|
+
export const CoverageVerdict = Object.freeze({
|
|
11
|
+
Attested: 'attested',
|
|
12
|
+
SignatureVerified: 'signature-verified',
|
|
13
|
+
NoRow: 'no-row',
|
|
14
|
+
Unsigned: 'unsigned',
|
|
15
|
+
AnnotationsMissing: 'annotations-missing',
|
|
16
|
+
AnnotationMismatch: 'annotation-mismatch',
|
|
17
|
+
NotBuilt: 'not-built',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** The verdicts that count toward `covered N/M` (the P2b gate). */
|
|
21
|
+
export const COVERED_VERDICTS = Object.freeze([CoverageVerdict.Attested, CoverageVerdict.SignatureVerified]);
|
|
22
|
+
|
|
23
|
+
/** The CLI-local predicate a case-B develop policy row carries (T72 design, section 2). */
|
|
24
|
+
export const DEVELOP_IMAGE_SIGNATURE_PREDICATE = 'https://xema.dev/develop-image-signature/v1';
|
|
25
|
+
|
|
26
|
+
/** The evidence channel the coverage verb reads rows of. */
|
|
27
|
+
export const DEVELOP_EVIDENCE_CHANNEL = 'develop';
|
|
28
|
+
|
|
29
|
+
/** The workflow triggers a develop signer's certificate may carry (T72 design, section 3). */
|
|
30
|
+
export const DEVELOP_SIGNER_TRIGGERS = Object.freeze(['push', 'workflow_dispatch']);
|
|
31
|
+
|
|
32
|
+
/** The one ref a develop signer's certificate may carry. */
|
|
33
|
+
export const DEVELOP_SIGNER_REF = 'refs/heads/develop';
|
|
34
|
+
|
|
35
|
+
/** The signed claim a develop lane puts on its signature (T72 design, section 3, case-B binding). */
|
|
36
|
+
export const DevelopSignatureAnnotation = Object.freeze({
|
|
37
|
+
ContentHash: 'dev.xema.content-hash',
|
|
38
|
+
Service: 'dev.xema.service',
|
|
39
|
+
SourceCommit: 'dev.xema.source.commit',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** What reading a V4 attestation of one digest concluded. */
|
|
43
|
+
export const AttestationStatus = Object.freeze({
|
|
44
|
+
/** No evidence referrer is attached to the digest. */
|
|
45
|
+
None: 'none',
|
|
46
|
+
/** A referrer exists, but the pinned policy has no develop producer row for this repository. */
|
|
47
|
+
NoRow: 'no-row',
|
|
48
|
+
/** A row exists and the record does not verify or does not bind this subject. */
|
|
49
|
+
Refused: 'refused',
|
|
50
|
+
/** A record verifies under a pinned develop row and binds this subject. */
|
|
51
|
+
Verified: 'verified',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Worst to best: a subject with several signatures takes the best one, because resolve accepts the digest on any.
|
|
55
|
+
const SIGNATURE_RANK = Object.freeze([
|
|
56
|
+
CoverageVerdict.AnnotationsMissing,
|
|
57
|
+
CoverageVerdict.AnnotationMismatch,
|
|
58
|
+
CoverageVerdict.NoRow,
|
|
59
|
+
CoverageVerdict.SignatureVerified,
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** Whether a pinned develop row names exactly the identity a certificate carries. */
|
|
63
|
+
export function rowMatchesSigner(row, signer) {
|
|
64
|
+
return (
|
|
65
|
+
row.certificateIdentity === signer.certificateIdentity &&
|
|
66
|
+
row.oidcIssuer === signer.issuer &&
|
|
67
|
+
row.githubWorkflow.repository === signer.workflowRepository &&
|
|
68
|
+
row.githubWorkflow.ref === signer.workflowRef &&
|
|
69
|
+
DEVELOP_SIGNER_TRIGGERS.includes(signer.trigger)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The verdict of ONE signature.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} request
|
|
77
|
+
* @param {{ contentHash: string, service: string }} request.expected the in-run hash F and service S
|
|
78
|
+
* @param {{ certificateIdentity: string, issuer: string, workflowRepository: string, workflowRef: string, trigger: string, workflowSha: string }} request.signer
|
|
79
|
+
* @param {Record<string, string>} request.annotations the signed annotations
|
|
80
|
+
* @param {object[]} request.rows pinned develop signature rows of this producer (may be empty)
|
|
81
|
+
*/
|
|
82
|
+
export function classifySignature({ expected, signer, annotations, rows }) {
|
|
83
|
+
const claimed = {
|
|
84
|
+
contentHash: annotations[DevelopSignatureAnnotation.ContentHash],
|
|
85
|
+
service: annotations[DevelopSignatureAnnotation.Service],
|
|
86
|
+
sourceCommit: annotations[DevelopSignatureAnnotation.SourceCommit],
|
|
87
|
+
};
|
|
88
|
+
const missing = Object.entries(DevelopSignatureAnnotation)
|
|
89
|
+
.filter(([, key]) => typeof annotations[key] !== 'string' || annotations[key] === '')
|
|
90
|
+
.map(([, key]) => key);
|
|
91
|
+
if (missing.length > 0) {
|
|
92
|
+
return { verdict: CoverageVerdict.AnnotationsMissing, detail: `missing ${missing.join(', ')}` };
|
|
93
|
+
}
|
|
94
|
+
const mismatches = [];
|
|
95
|
+
if (claimed.contentHash !== expected.contentHash) {
|
|
96
|
+
mismatches.push(`content-hash ${claimed.contentHash} != ${expected.contentHash}`);
|
|
97
|
+
}
|
|
98
|
+
if (claimed.service !== expected.service) {
|
|
99
|
+
mismatches.push(`service ${claimed.service} != ${expected.service}`);
|
|
100
|
+
}
|
|
101
|
+
if (!DEVELOP_SIGNER_TRIGGERS.includes(signer.trigger)) {
|
|
102
|
+
mismatches.push(`certificate trigger ${signer.trigger} is not ${DEVELOP_SIGNER_TRIGGERS.join(' or ')}`);
|
|
103
|
+
}
|
|
104
|
+
if (signer.workflowRef !== DEVELOP_SIGNER_REF) {
|
|
105
|
+
mismatches.push(`certificate ref ${signer.workflowRef} is not ${DEVELOP_SIGNER_REF}`);
|
|
106
|
+
}
|
|
107
|
+
if (claimed.sourceCommit !== signer.workflowSha) {
|
|
108
|
+
mismatches.push(`annotated source commit ${claimed.sourceCommit} != certificate commit ${signer.workflowSha}`);
|
|
109
|
+
}
|
|
110
|
+
if (mismatches.length > 0) {
|
|
111
|
+
return { verdict: CoverageVerdict.AnnotationMismatch, detail: mismatches.join('; ') };
|
|
112
|
+
}
|
|
113
|
+
if (!rows.some((row) => rowMatchesSigner(row, signer))) {
|
|
114
|
+
return {
|
|
115
|
+
verdict: CoverageVerdict.NoRow,
|
|
116
|
+
detail: rows.length === 0 ? 'no pinned develop row for this producer' : 'no pinned develop row names this signer',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { verdict: CoverageVerdict.SignatureVerified, detail: '' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The verdict of one subject from everything read about it. Case A wins; otherwise the best signature.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} request
|
|
126
|
+
* @param {string|null} request.digest the registry digest served for `<developRef>:<F>`, null when absent
|
|
127
|
+
* @param {{ status: string, detail?: string }} request.attestation
|
|
128
|
+
* @param {{ signer: object, annotations: object }[]} request.signatures every signature read from the digest
|
|
129
|
+
* @param {object[]} request.rows pinned develop signature rows of this producer
|
|
130
|
+
* @param {{ contentHash: string, service: string }} request.expected
|
|
131
|
+
* @returns {{ verdict: string, detail: string, signer: object|null }}
|
|
132
|
+
*/
|
|
133
|
+
export function classifySubject({ digest, attestation, signatures, rows, expected }) {
|
|
134
|
+
if (digest === null) {
|
|
135
|
+
return { verdict: CoverageVerdict.NotBuilt, detail: 'no manifest at the content-hash tag', signer: null };
|
|
136
|
+
}
|
|
137
|
+
if (attestation.status === AttestationStatus.Verified) {
|
|
138
|
+
return { verdict: CoverageVerdict.Attested, detail: attestation.detail ?? '', signer: null };
|
|
139
|
+
}
|
|
140
|
+
if (signatures.length === 0) {
|
|
141
|
+
if (attestation.status === AttestationStatus.NoRow) {
|
|
142
|
+
return { verdict: CoverageVerdict.NoRow, detail: 'an attestation exists and the policy has no develop producer row', signer: null };
|
|
143
|
+
}
|
|
144
|
+
const refused = attestation.status === AttestationStatus.Refused ? `attestation refused: ${attestation.detail}` : 'no signature and no attestation';
|
|
145
|
+
return { verdict: CoverageVerdict.Unsigned, detail: refused, signer: null };
|
|
146
|
+
}
|
|
147
|
+
const judged = signatures.map((signature) => ({
|
|
148
|
+
...classifySignature({ expected, signer: signature.signer, annotations: signature.annotations, rows }),
|
|
149
|
+
signer: signature.signer,
|
|
150
|
+
}));
|
|
151
|
+
judged.sort((left, right) => SIGNATURE_RANK.indexOf(right.verdict) - SIGNATURE_RANK.indexOf(left.verdict));
|
|
152
|
+
return judged[0];
|
|
153
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// develop-coverage — `xema edition develop coverage <id>`: for every image subject an edition selects,
|
|
2
|
+
// is its DEVELOP image producer-signed in the form T72 resolve is expected to accept? A forecast, not a guarantee: the
|
|
3
|
+
// policy is held to its pin, but this CLI's own unpinned verifier code runs, so resolve can still refuse. Read-only: it writes no lock,
|
|
4
|
+
// signature or tag. It is the P2b gate (exit 0 only at 100%).
|
|
5
|
+
//
|
|
6
|
+
// The subjects, their develop refs and content hashes F are the ones `develop resolve` computes (the
|
|
7
|
+
// caller passes the images lock that resolve's own first half produced); the registry digest D is the
|
|
8
|
+
// one served for `<developRef>:<F>`; the verdict is decided by develop-coverage-verdict.mjs.
|
|
9
|
+
|
|
10
|
+
import { AttestationStatus, COVERED_VERDICTS, DEVELOP_EVIDENCE_CHANNEL, DEVELOP_IMAGE_SIGNATURE_PREDICATE, classifySubject } from './develop-coverage-verdict.mjs';
|
|
11
|
+
import { developImageRef } from './develop-image-coordinate.mjs';
|
|
12
|
+
import { policyCandidates } from './image-evidence.mjs';
|
|
13
|
+
import { mapWithConcurrency } from './map-with-concurrency.mjs';
|
|
14
|
+
|
|
15
|
+
/** In flight at once, as `develop resolve` probes. */
|
|
16
|
+
export const COVERAGE_CONCURRENCY = 6;
|
|
17
|
+
|
|
18
|
+
export const COVERAGE_JSON_SCHEMA = 'xema.develop-coverage/v1';
|
|
19
|
+
|
|
20
|
+
/** Exit codes of the verb; 2 is an operational error and is decided by the entry. */
|
|
21
|
+
export const CoverageExit = Object.freeze({ Covered: 0, NotCovered: 1, Operational: 2 });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The service name a develop lane signs for a subject (`dev.xema.service`): the subject's id in the lock.
|
|
25
|
+
* One definition; the signing lanes (T72 P1) must sign this.
|
|
26
|
+
*/
|
|
27
|
+
export const expectedService = (subject) => subject.id;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} request
|
|
31
|
+
* @param {string} request.distributionId
|
|
32
|
+
* @param {object[]} request.subjects the images lock subjects (release refs, unsealed), contentHash = F
|
|
33
|
+
* @param {object} request.policy the pinned trust policy (rows are filtered per develop repository)
|
|
34
|
+
* @param {{ developDigest: Function, readAttestation: Function, readSignatures: Function }} request.readers
|
|
35
|
+
* @param {(line: string) => void} [request.progress]
|
|
36
|
+
*/
|
|
37
|
+
export async function collectCoverage({ distributionId, subjects, policy, readers, concurrency = COVERAGE_CONCURRENCY, progress = () => {} }) {
|
|
38
|
+
const rows = await mapWithConcurrency(subjects, concurrency, async (subject) => {
|
|
39
|
+
const developSubject = { ...subject, imageRef: developImageRef(subject.imageRef) };
|
|
40
|
+
const digest = await readers.developDigest(developSubject);
|
|
41
|
+
let attestation = { status: AttestationStatus.None };
|
|
42
|
+
let signatures = [];
|
|
43
|
+
if (digest !== null) {
|
|
44
|
+
attestation = await readers.readAttestation(developSubject, digest);
|
|
45
|
+
if (attestation.status !== AttestationStatus.Verified) {signatures = await readers.readSignatures(developSubject, digest);}
|
|
46
|
+
}
|
|
47
|
+
const signatureRows = policyCandidates(policy, DEVELOP_IMAGE_SIGNATURE_PREDICATE, DEVELOP_EVIDENCE_CHANNEL, developSubject.imageRef);
|
|
48
|
+
const decided = classifySubject({
|
|
49
|
+
digest,
|
|
50
|
+
attestation,
|
|
51
|
+
signatures,
|
|
52
|
+
rows: signatureRows,
|
|
53
|
+
expected: { contentHash: subject.contentHash, service: expectedService(subject) },
|
|
54
|
+
});
|
|
55
|
+
progress(`[coverage] ${subject.kind}:${subject.id} ${decided.verdict}`);
|
|
56
|
+
return {
|
|
57
|
+
kind: subject.kind,
|
|
58
|
+
id: subject.id,
|
|
59
|
+
producer: subject.repo,
|
|
60
|
+
developRef: developSubject.imageRef,
|
|
61
|
+
contentHash: subject.contentHash,
|
|
62
|
+
digest,
|
|
63
|
+
verdict: decided.verdict,
|
|
64
|
+
detail: decided.detail,
|
|
65
|
+
signer: decided.signer,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
return summarize(distributionId, rows);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function summarize(distributionId, subjects) {
|
|
72
|
+
const byProducer = new Map();
|
|
73
|
+
for (const subject of subjects) {
|
|
74
|
+
const entry = byProducer.get(subject.producer) ?? { producer: subject.producer, total: 0, covered: 0, subjects: [] };
|
|
75
|
+
entry.total += 1;
|
|
76
|
+
if (COVERED_VERDICTS.includes(subject.verdict)) {entry.covered += 1;}
|
|
77
|
+
entry.subjects.push(subject);
|
|
78
|
+
byProducer.set(subject.producer, entry);
|
|
79
|
+
}
|
|
80
|
+
const producers = [...byProducer.values()].sort((left, right) => (left.producer < right.producer ? -1 : left.producer > right.producer ? 1 : 0));
|
|
81
|
+
for (const producer of producers) {
|
|
82
|
+
producer.subjects.sort((left, right) => (`${left.kind}:${left.id}` < `${right.kind}:${right.id}` ? -1 : 1));
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
schema: COVERAGE_JSON_SCHEMA,
|
|
86
|
+
distributionId,
|
|
87
|
+
covered: producers.reduce((sum, producer) => sum + producer.covered, 0),
|
|
88
|
+
total: subjects.length,
|
|
89
|
+
producers,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Exit 0 only when every subject is covered; an edition with no subjects covers nothing. */
|
|
94
|
+
export function coverageExitCode(report) {
|
|
95
|
+
return report.total > 0 && report.covered === report.total ? CoverageExit.Covered : CoverageExit.NotCovered;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function summaryLine(report) {
|
|
99
|
+
return `covered ${report.covered}/${report.total}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function signerText(signer) {
|
|
103
|
+
if (signer === null) {return '';}
|
|
104
|
+
return `${signer.certificateIdentity} (${signer.issuer}, ${signer.trigger}, ${signer.workflowSha})`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The table, grouped by producer repo, ending in the summary line. */
|
|
108
|
+
export function renderCoverageTable(report) {
|
|
109
|
+
const lines = [];
|
|
110
|
+
for (const producer of report.producers) {
|
|
111
|
+
lines.push(`${producer.producer} covered ${producer.covered}/${producer.total}`);
|
|
112
|
+
for (const subject of producer.subjects) {
|
|
113
|
+
const parts = [` ${subject.kind}:${subject.id}`, subject.verdict, subject.digest ?? '-'];
|
|
114
|
+
const tail = [subject.detail, signerText(subject.signer)].filter((text) => text !== '').join(' | ');
|
|
115
|
+
lines.push(tail === '' ? parts.join(' ') : `${parts.join(' ')} ${tail}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
lines.push(summaryLine(report));
|
|
119
|
+
return `${lines.join('\n')}\n`;
|
|
120
|
+
}
|
|
@@ -11,9 +11,9 @@ import { biomeSourceTreeIds, readBiomeSourceFacts, sealLock } from './biome-cont
|
|
|
11
11
|
import { buildCatalogue } from './catalogue.mjs';
|
|
12
12
|
import { resolveDevelopChartSet } from './develop-chart-set.mjs';
|
|
13
13
|
import { createDevelopImageProbe } from './develop-image-probe.mjs';
|
|
14
|
-
import { sealDevelopImages } from './develop-seal.mjs';
|
|
15
14
|
import { assertFrozenCheckouts, buildSnapshotInputs } from './develop-snapshot-inputs.mjs';
|
|
16
15
|
import { EditionChannel } from './edition-constants.mjs';
|
|
16
|
+
import { sealImages } from './edition-image-seal.mjs';
|
|
17
17
|
import { kernelPackageDir } from './edition-workspace.mjs';
|
|
18
18
|
import { kernelPackageOfAuthority } from './kernel-package.mjs';
|
|
19
19
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
@@ -33,21 +33,32 @@ export const DEVELOP_EDITION_STEPS = Object.freeze({
|
|
|
33
33
|
assertAdapterBundlesMatchHost,
|
|
34
34
|
composeAuthorizationBase,
|
|
35
35
|
resolveImages,
|
|
36
|
-
|
|
36
|
+
sealImages,
|
|
37
37
|
resolveDevelopChartSet,
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
const stderrProgress = (line) => process.stderr.write(`${line}\n`);
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The first half of every develop resolve: the frozen snapshot's inputs and the
|
|
44
|
+
* distribution lock composed over them. `resolveDevelopEdition` and the
|
|
45
|
+
* read-only `coverage` verb both start here, so the content hash each computes
|
|
46
|
+
* for a subject is the same by construction.
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveDevelopSnapshot({ id, pins, steps = DEVELOP_EDITION_STEPS, phase = () => {} }) {
|
|
46
49
|
const schemas = await loadKernelDistributionSchemas();
|
|
47
50
|
steps.assertFrozenCheckouts(pins);
|
|
48
51
|
const inputs = await steps.buildSnapshotInputs({ id, pins, schemas });
|
|
49
52
|
phase('snapshot inputs built');
|
|
50
53
|
const { lock, index } = await steps.resolveDistributionWithIndex(id, { inputs });
|
|
54
|
+
return { schemas, inputs, lock, index };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function resolveDevelopEdition({ id, pins, env = process.env, sealOptions = {}, fetchKernelTarball, fetchManifest = fetchRegistryManifest, progress = stderrProgress, steps = DEVELOP_EDITION_STEPS }) {
|
|
58
|
+
const startedAt = Date.now();
|
|
59
|
+
// One line per phase, with the time since the resolve began, so the step is never silent for long.
|
|
60
|
+
const phase = (name) => progress(`[develop-edition] ${name} (+${Date.now() - startedAt}ms)`);
|
|
61
|
+
const { schemas, inputs, lock, index } = await resolveDevelopSnapshot({ id, pins, steps, phase });
|
|
51
62
|
await steps.assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(index), fetchManifest });
|
|
52
63
|
const authorizationLock = await steps.composeAuthorizationBase(id, lock, index);
|
|
53
64
|
phase('distribution lock resolved');
|
|
@@ -66,7 +77,7 @@ export async function resolveDevelopEdition({ id, pins, env = process.env, sealO
|
|
|
66
77
|
phase(`image content hashes computed (${imageLock.subjects.length} subjects)`);
|
|
67
78
|
await probe.settle();
|
|
68
79
|
phase('every develop image is present');
|
|
69
|
-
const sealedImageLock = await steps.
|
|
80
|
+
const sealedImageLock = await steps.sealImages({ channel: EditionChannel.Develop, imageLock, env, progress, ...sealOptions });
|
|
70
81
|
phase('images sealed');
|
|
71
82
|
// The unsealed lock becomes the one v5 grammar only here, once the images it
|
|
72
83
|
// owns are sealed: each biomeContentHash is recomputed from the frozen sources.
|