@xemahq/xema 0.27.0 → 0.28.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/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 +143 -39
- package/dist/edition/verify-image-evidence.bundle.provenance.json +45 -39
- package/edition/README.md +7 -9
- package/edition/edition-artifact.mjs +14 -0
- package/edition/edition-config.mjs +17 -19
- package/edition/edition-contract.json +11 -4
- package/edition/edition-workspace.mjs +12 -1
- package/edition/evidence-checkout.mjs +22 -1
- package/edition/evidence-trust-policy.mjs +116 -78
- package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
- package/edition/image-evidence.mjs +8 -0
- package/edition/lib.mjs +3 -0
- package/edition/oras.mjs +1 -1
- package/edition/produce-image-delivery-entitlement-evidence.mjs +12 -12
- package/edition/release-channel-guard.mjs +11 -1
- package/edition/release-edition-artifact.mjs +2 -3
- package/edition/verify-image-evidence.mjs +40 -12
- package/package.json +2 -2
package/edition/README.md
CHANGED
|
@@ -55,7 +55,7 @@ Read strictly: every key is required, an unknown key or a duplicate key is refus
|
|
|
55
55
|
"chartPublishers": [{ "index": "<chart-set-index-name>", "repo": "<owner>/<repo>" }],
|
|
56
56
|
"sourceRepositoryUrlPrefix": "https://git.example.com/acme/",
|
|
57
57
|
"sourceAuthUsername": "x-access-token",
|
|
58
|
-
"evidenceTrustPolicy": { "
|
|
58
|
+
"evidenceTrustPolicy": { "policy": "image-evidence-trust-policy.json", "pin": "image-evidence-trust-policy.pin.json" }
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
@@ -64,7 +64,7 @@ Read strictly: every key is required, an unknown key or a duplicate key is refus
|
|
|
64
64
|
- `chartPublishers`: the chart-set indexes the pin covers; `repo` is recorded in `chart-set.lock.json`.
|
|
65
65
|
- `sourceRepositoryUrlPrefix`: `https://<host>/<organisation>/`, the prefix every first-party `repositoryUrl` must equal (with `<repo>.git`).
|
|
66
66
|
- `sourceAuthUsername`: the non-empty git username first-party source tokens authenticate as (GitHub: `x-access-token`; other forges differ). Required, no default.
|
|
67
|
-
- `evidenceTrustPolicy` (mandatory): `{
|
|
67
|
+
- `evidenceTrustPolicy` (mandatory): `{policy, pin}`, two data-root-relative POSIX paths (no `..`, regular no-follow files), or `null`, the explicit statement that this data root declares no policy. Any other key is refused as unknown (the former `required` switch included): no configuration admits a release edition without an armed policy. A data root that declares `null` serves develop editions only. See below.
|
|
68
68
|
|
|
69
69
|
## Operating notes
|
|
70
70
|
|
|
@@ -82,20 +82,18 @@ Read strictly: every key is required, an unknown key or a duplicate key is refus
|
|
|
82
82
|
The descriptor's `evidenceTrustPolicy` has exactly two states, and sealing fails closed (also documented in `edition-contract.json` under `evidenceTrust`, which xema-deploy vendors):
|
|
83
83
|
|
|
84
84
|
- `armed`: the data root declares a policy, the policy hashes to its data-root pin, this CLI's verifier is the pinned one, and the policy is ARMED on the channel of the edition.
|
|
85
|
-
- `not-armed`:
|
|
85
|
+
- `not-armed`: a develop edition only, whose data root declares no policy or one that is not armed on the develop channel. A release edition never records `not-armed`.
|
|
86
86
|
|
|
87
87
|
Arming is judged for ONE channel, the channel of the edition being checked, sealed or written: a channel is ARMED when the policy authorizes a signer for each of the three predicates on that channel and pins the cosign and the git binary. The signers of the other channel count for nothing, so a policy with release signers only is ARMED for a release edition. The develop channel admits not-armed: a develop edition is written when its channel has no signer, and prints one line on stderr (`develop channel: trust policy not armed, evidence is not signature-verified`). A verification of signed develop evidence is satisfied by develop signers only.
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
A release edition requires an armed evidence trust policy, with no switch: while the policy is not armed on the release channel (none declared, or declared but UNARMED or PARTIALLY ARMED there), writing a release descriptor or artifact (`xema edition artifact`) is REFUSED with `EvidenceTrustRequiredError`, code `EVIDENCE_TRUST_REQUIRED_UNARMED`, message "a release edition requires an armed evidence trust policy". The descriptor builder itself refuses a release descriptor that is not `armed`, so no route writes one. A channel that is PARTIALLY ARMED is refused on either channel. Both `artifact` and `develop resolve` (and `develop check-existing`) hold the policy to its pin and the verifier to the pinned one before recording a state: a policy edited after pinning or a drifted verifier is refused.
|
|
90
90
|
|
|
91
|
-
`xema edition check` runs one evidence-trust gate
|
|
92
|
-
`image-evidence trust policy: NOT ARMED` and passes, but refuses a data root that holds an
|
|
91
|
+
`xema edition check` runs one evidence-trust gate, and it FAILS when the editions tree holds a release edition while the policy is not ARMED on the release channel (none declared, UNARMED or PARTIAL): the refusal names the release editions and the repair, and states that no release edition of the data root can be published as an artifact (written by `xema edition artifact`) until the policy is armed on the release channel. A release edition is an edition directory (one holding `xema-distribution.json`) that holds a release lock (`distribution.lock.json` or `authorization.lock.json`); a develop resolve writes nothing into an editions tree. A tree holding no release edition passes for develop-only use with the same statement as a notice. `xema edition seal` is not gated by the policy: nothing sealed leaves without `xema edition artifact`. With no policy declared the gate also refuses a data root that holds an
|
|
93
92
|
undeclared policy (a top-level `*.json` the kernel parses as an offline trust policy, or a
|
|
94
93
|
`trust/` directory). With one declared it parses the policy with the kernel schema, checks the
|
|
95
94
|
Sigstore trusted root the policy itself names (`trustedRootPath`, resolved against the data root,
|
|
96
95
|
and `trustedRootDigest`), checks the pin file, and classifies the authority configuration of each channel: no
|
|
97
|
-
signer on the channel is UNARMED (on the release channel it
|
|
98
|
-
fail-closed; on the develop channel it passes with the not-armed line), mixed is PARTIAL (refused on either channel, with
|
|
96
|
+
signer on the channel is UNARMED (on the release channel it fails when the tree holds a release edition and otherwise passes with the notice; on the develop channel it passes with the not-armed line), mixed is PARTIAL (refused on either channel, with
|
|
99
97
|
what is unmet), complete is ARMED.
|
|
100
98
|
|
|
101
99
|
The pin file (strict JSON, exact keys) is reviewed data:
|
|
@@ -214,7 +212,7 @@ Common to all verbs: root flags `--data-root`, `--editions-root`, `--sources-roo
|
|
|
214
212
|
| `xema edition evidence oci backfill-plan` | `--distribution <id>… --producer-repository-root <abs dir>…`, roots `--data-root --editions-root --kernel`, env the registry credential variables the data root declares | prints `{"schemaVersion":1,"kind":"oci-backfill-plan","items":[{"status":"missing-git\|present\|missing","subjectKind","subjectId","imageRepository","subjectDigest","artifactDigest","attach"?}]}`; a `missing` item carries its attach plan; exit 2 unless every item is `present` |
|
|
215
213
|
| `xema edition index runtime-image-source --repin-contract-authority` | roots `--data-root --sources-root --kernel --source-hash` | replaces ONLY `contractAuthority` of the committed `runtime-image-source-index.json` with the authority trusted from the `--kernel` checkout (clean, at the expected origin, else refused) and validates and rewrites it; every repository pin and subject is identical before and after. It writes the move on stdout and the owed follow-ups on stderr: `xema edition repin-evidence-trust`, re-resolving every edition (each `images.lock.json` carries the authority), and that signed producer statements made under the old authority no longer verify |
|
|
216
214
|
| `xema edition source-closure` | `--repo <repo> --path <dir> --commit <40 hex>`, roots `--sources-root --source-hash --kernel` (the hash implementation is held to the kernel) | `{"schemaVersion":1,"repo","directory","commit","treeAlgorithm","treeHash","buildable","lockfileBlobId","closure":[{"directory","treeHash"}]}`: the facts a content coordinate is framed from |
|
|
217
|
-
| `xema edition verify-evidence <id>` | see Evidence trust; `--descriptor <absolute path of the edition descriptor being verified>` (required: its `distributionId` must be the requested id and its `provenance.channel` is the channel whose signers may vouch for the evidence; it is held outside the graded checkouts like `--state`); plus `[--json]` (stdout is then exactly one `{"schema":"xema.edition-evidence-verification/v1","distributionId","channel","releaseSequence","statements":[{"predicateType","subject","statementDigest"}]}` document, where `statementDigest` is the digest of the verified statement, not of the image; the human text goes to stderr, and a failed verification prints no JSON);
|
|
215
|
+
| `xema edition verify-evidence <id>` | see Evidence trust; `--descriptor <absolute path of the edition descriptor being verified>` (required: its `distributionId` must be the requested id and its `provenance.channel` is the channel whose signers may vouch for the evidence; it is held outside the graded checkouts like `--state`); plus `[--json]` (stdout is then exactly one `{"schema":"xema.edition-evidence-verification/v1","distributionId","channel","releaseSequence","statements":[{"predicateType","subject","statementDigest"}]}` document, where `statementDigest` is the digest of the verified statement, not of the image; the human text goes to stderr, and a failed verification prints no JSON); plus, given together or not at all, `--entitlement <evidence-root-relative path of an entitlement.json> --entitlement-workflow-sha <40-hex git sha> --customer <id> --mode <mode>` and optionally `--target-registry <registry>`: the delivery entitlement `accept` wrote is verified with the release (`--entitlement-workflow-sha` is the exact commit of the workflow run that signed the entitlement, the one its certificate is held to; it is stated by the caller and never read from the evidence; the statement and bundle are read from the directory holding `entitlement.json`), and the verification document then ends with the entitlement statement | the in-repository gate and the release-checkout grading are this verifier; there is no second one. Its result is its exit status, the advanced replay state and, with `--json`, the verification document on stdout: it writes no "already verified" file and has no file-output flag. A delivery entitlement verifies only as part of this run: a refused entitlement refuses the whole verification and the replay state does not advance. `entitlement.json` is `{schemaVersion: 2, distributionId, customerId, mode, targetRegistry, idempotencyKey, distributionStatementDigest, evidence}` with exactly those keys (`evidence` is the Sigstore evidence descriptor); every field but `evidence` is an unauthenticated convenience that must agree with the flags and the verified completeness statement, and the signed statement stays the authority |
|
|
218
216
|
|
|
219
217
|
The signing plan (stdout of every `stage`):
|
|
220
218
|
|
|
@@ -66,6 +66,13 @@ export function assertEngineImage(image) {
|
|
|
66
66
|
*/
|
|
67
67
|
export function editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage }) {
|
|
68
68
|
assertCanonicalDistributionId(distributionId);
|
|
69
|
+
// Every writer of a descriptor passes here: no route writes a release
|
|
70
|
+
// edition that is not armed, whatever state its caller computed.
|
|
71
|
+
if (channel === EditionChannel.Release && evidenceTrustPolicy !== EvidenceTrustState.Armed) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`a release edition requires an armed evidence trust policy; "${distributionId}" was to be written ${JSON.stringify(evidenceTrustPolicy)}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
69
76
|
return parseEditionDescriptor({
|
|
70
77
|
schema: EDITION_DESCRIPTOR_SCHEMA,
|
|
71
78
|
distributionId,
|
|
@@ -124,6 +131,13 @@ export function parseEditionDescriptor(value) {
|
|
|
124
131
|
if (!Object.values(EvidenceTrustState).includes(value.evidenceTrustPolicy)) {
|
|
125
132
|
throw new Error(`${EDITION_DESCRIPTOR_FILE}: evidenceTrustPolicy must be one of ${Object.values(EvidenceTrustState).join(' | ')}`);
|
|
126
133
|
}
|
|
134
|
+
// The reader holds the writer's rule: a release edition that is not armed is
|
|
135
|
+
// not an edition this plane writes, so it is not one it reads.
|
|
136
|
+
if (value.provenance.channel === EditionChannel.Release && value.evidenceTrustPolicy !== EvidenceTrustState.Armed) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${EDITION_DESCRIPTOR_FILE}: a release edition requires an armed evidence trust policy; "${value.distributionId}" records ${JSON.stringify(value.evidenceTrustPolicy)}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
127
141
|
exactKeys(value.engine, ['image'], `${EDITION_DESCRIPTOR_FILE} engine`);
|
|
128
142
|
assertEngineImage(value.engine.image);
|
|
129
143
|
return value;
|
|
@@ -99,7 +99,7 @@ function requirePublishers(config) {
|
|
|
99
99
|
);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
const EVIDENCE_TRUST_POLICY_KEYS = Object.freeze(['pin', 'policy'
|
|
102
|
+
const EVIDENCE_TRUST_POLICY_KEYS = Object.freeze(['pin', 'policy']);
|
|
103
103
|
|
|
104
104
|
function isDataRootRelativePath(value) {
|
|
105
105
|
return (
|
|
@@ -110,33 +110,31 @@ function isDataRootRelativePath(value) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* The MANDATORY `evidenceTrustPolicy` declaration
|
|
114
|
-
*
|
|
115
|
-
* policy is
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* `xema edition check` refuses (EvidenceTrustRequiredError), so the refusal
|
|
119
|
-
* names the missing policy rather than a malformed file.
|
|
113
|
+
* The MANDATORY `evidenceTrustPolicy` declaration: `{policy, pin}` (two
|
|
114
|
+
* data-root-relative paths) or `null`, the explicit statement that this data
|
|
115
|
+
* root declares no policy. There is no switch that admits an ungated release:
|
|
116
|
+
* a release edition is written only against a policy ARMED on the release
|
|
117
|
+
* channel, so a data root that declares none serves develop editions only.
|
|
120
118
|
*/
|
|
121
119
|
function requireEvidenceTrustPolicy(config) {
|
|
122
120
|
if (!Object.hasOwn(config, 'evidenceTrustPolicy')) {
|
|
123
121
|
throw new EditionWorkspaceConfigError(
|
|
124
|
-
`${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is required: {"
|
|
125
|
-
'or
|
|
122
|
+
`${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is required: {"policy": ..., "pin": ...}, ` +
|
|
123
|
+
'or null to state that this data root declares no policy (no release edition can then be sealed from it)',
|
|
126
124
|
);
|
|
127
125
|
}
|
|
128
126
|
const value = config.evidenceTrustPolicy;
|
|
129
|
-
|
|
127
|
+
if (value === null) {return Object.freeze({ policy: null, pin: null });}
|
|
128
|
+
const shapeError = (detail) =>
|
|
130
129
|
new EditionWorkspaceConfigError(
|
|
131
|
-
`${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is exactly
|
|
130
|
+
`${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is exactly null or {policy, pin} with both paths data-root-relative${detail}`,
|
|
132
131
|
);
|
|
133
|
-
if (
|
|
132
|
+
if (typeof value !== 'object' || Array.isArray(value)) {throw shapeError('');}
|
|
134
133
|
const keys = Object.keys(value).sort();
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
for (const key of ['policy', 'pin']) {
|
|
134
|
+
const unknown = keys.filter((key) => !EVIDENCE_TRUST_POLICY_KEYS.includes(key));
|
|
135
|
+
if (unknown.length > 0) {throw shapeError(`; unknown key ${unknown.map((key) => `"${key}"`).join(', ')}`);}
|
|
136
|
+
if (keys.join() !== EVIDENCE_TRUST_POLICY_KEYS.join()) {throw shapeError('');}
|
|
137
|
+
for (const key of EVIDENCE_TRUST_POLICY_KEYS) {
|
|
140
138
|
if (!isDataRootRelativePath(value[key])) {
|
|
141
139
|
throw new EditionWorkspaceConfigError(
|
|
142
140
|
`${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy.${key}" must be a data-root-relative POSIX path without "." or ".." segments; got ${JSON.stringify(value[key])}`,
|
|
@@ -146,7 +144,7 @@ function requireEvidenceTrustPolicy(config) {
|
|
|
146
144
|
if (value.policy === value.pin) {
|
|
147
145
|
throw new EditionWorkspaceConfigError(`${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy.policy" and ".pin" must be different files`);
|
|
148
146
|
}
|
|
149
|
-
return Object.freeze({
|
|
147
|
+
return Object.freeze({ policy: value.policy, pin: value.pin });
|
|
150
148
|
}
|
|
151
149
|
|
|
152
150
|
/** Validate a parsed config document; returns the frozen, typed config. */
|
|
@@ -4,8 +4,15 @@
|
|
|
4
4
|
"repositoryPrefix": "xema-edition-",
|
|
5
5
|
"descriptorFile": "xema-edition.json",
|
|
6
6
|
"descriptorSchema": "xema.edition/v1",
|
|
7
|
-
"locks": [
|
|
8
|
-
|
|
7
|
+
"locks": [
|
|
8
|
+
"distribution.lock.json",
|
|
9
|
+
"images.lock.json",
|
|
10
|
+
"authorization.lock.json",
|
|
11
|
+
"chart-set.lock.json"
|
|
12
|
+
],
|
|
13
|
+
"offeringLayers": {
|
|
14
|
+
"catalogue": "catalogue.json"
|
|
15
|
+
},
|
|
9
16
|
"provenancePins": {
|
|
10
17
|
"catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against; every other pin is a 40-hex commit"
|
|
11
18
|
},
|
|
@@ -13,11 +20,11 @@
|
|
|
13
20
|
"evidenceTrust": {
|
|
14
21
|
"states": {
|
|
15
22
|
"armed": "the data root declares an evidence trust policy that is pinned and ARMED on the channel of the edition",
|
|
16
|
-
"not-armed": "a
|
|
23
|
+
"not-armed": "a develop edition only: its data root declares no policy, or one that is not armed on the develop channel. A release edition never records not-armed"
|
|
17
24
|
},
|
|
18
25
|
"refusal": {
|
|
19
26
|
"code": "EVIDENCE_TRUST_REQUIRED_UNARMED",
|
|
20
|
-
"when": "
|
|
27
|
+
"when": "a release edition is written while the data root's evidence trust policy is not armed on the release channel (none declared, or declared but UNARMED or PARTIALLY ARMED there): no release descriptor or artifact is written, and no configuration key admits one. Arming is judged per channel: a develop edition admits not-armed"
|
|
21
28
|
}
|
|
22
29
|
}
|
|
23
30
|
}
|
|
@@ -171,6 +171,17 @@ export function isEntryModule(moduleUrl) {
|
|
|
171
171
|
* root flags from it, so the entry's own parser sees only its own arguments.
|
|
172
172
|
*/
|
|
173
173
|
export function configureEditionWorkspaceFromProcess(needs) {
|
|
174
|
-
|
|
174
|
+
let rest;
|
|
175
|
+
try {
|
|
176
|
+
rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), needs);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// An entry calls this before it has a `main` to catch anything: a missing
|
|
179
|
+
// or malformed root is the operator's mistake, answered with the roots the
|
|
180
|
+
// command takes, not with a stack trace.
|
|
181
|
+
process.stderr.write(
|
|
182
|
+
`${error.message}\nusage: this command takes ${needs.map((name) => `${WORKSPACE_FLAGS[name]} <dir>`).join(' ')}\n`,
|
|
183
|
+
);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
175
186
|
process.argv.splice(2, process.argv.length - 2, ...rest);
|
|
176
187
|
}
|
|
@@ -386,6 +386,24 @@ export function gradeTrustedCheckouts({ grades, trustedGit, expected }) {
|
|
|
386
386
|
group.requirements.push({ root: canonicalRoot, label, requiredPaths });
|
|
387
387
|
byRepository.set(location.repositoryRoot, group);
|
|
388
388
|
}
|
|
389
|
+
// A re-grade covers exactly the roots first graded: a root the first grade
|
|
390
|
+
// never saw has no HEAD to be held to, and is refused rather than re-graded
|
|
391
|
+
// unchecked.
|
|
392
|
+
if (expected !== undefined) {
|
|
393
|
+
const regraded = new Set(
|
|
394
|
+
[...byRepository.values()].flatMap((group) => group.requirements.map(({ root }) => root)),
|
|
395
|
+
);
|
|
396
|
+
for (const root of regraded) {
|
|
397
|
+
if (typeof expected.get(root)?.commit !== 'string') {
|
|
398
|
+
throw new Error(`${root} was not graded first, so its HEAD cannot be held unchanged`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
for (const root of expected.keys()) {
|
|
402
|
+
if (!regraded.has(root)) {
|
|
403
|
+
throw new Error(`${root} was graded first and is missing from the re-grade`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
389
407
|
const result = new Map();
|
|
390
408
|
for (const [repositoryRoot, group] of byRepository) {
|
|
391
409
|
assertTrustedCheckoutLocation(group.location, group.labels.join(' + '));
|
|
@@ -393,9 +411,12 @@ export function gradeTrustedCheckouts({ grades, trustedGit, expected }) {
|
|
|
393
411
|
repositoryRoot,
|
|
394
412
|
requirements: group.requirements,
|
|
395
413
|
trustedGit,
|
|
396
|
-
expectedCommit: expected?.get(group.requirements[0].root)
|
|
414
|
+
expectedCommit: expected?.get(group.requirements[0].root).commit,
|
|
397
415
|
});
|
|
398
416
|
for (const { root } of group.requirements) {
|
|
417
|
+
if (expected !== undefined && expected.get(root).commit !== graded.commit) {
|
|
418
|
+
throw new Error(`${repositoryRoot} HEAD changed during verification`);
|
|
419
|
+
}
|
|
399
420
|
result.set(root, graded);
|
|
400
421
|
}
|
|
401
422
|
}
|
|
@@ -1,40 +1,36 @@
|
|
|
1
|
-
// The evidence-trust part of `xema edition check
|
|
1
|
+
// The evidence-trust part of `xema edition check`, and the state an edition
|
|
2
|
+
// records in its descriptor.
|
|
2
3
|
//
|
|
3
|
-
// A data root
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// refuses every state in which the answer would be a silent one:
|
|
4
|
+
// A data root DECLARES an evidence trust policy (`evidenceTrustPolicy: {policy,
|
|
5
|
+
// pin}` in `edition-workspace.json`) or states that it declares none (`null`).
|
|
6
|
+
// No switch admits an ungated release:
|
|
7
7
|
//
|
|
8
|
-
// *
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
8
|
+
// * a RELEASE edition is written only against a policy ARMED on the release
|
|
9
|
+
// channel. No policy, UNARMED or PARTIALLY ARMED there -> refused
|
|
10
|
+
// (EvidenceTrustRequiredError, code EVIDENCE_TRUST_REQUIRED_UNARMED);
|
|
11
|
+
// * a DEVELOP edition admits not-armed and says so in one line; a policy
|
|
12
|
+
// PARTIALLY ARMED on the develop channel is refused;
|
|
13
|
+
// * `xema edition check` FAILS when the editions tree holds a release
|
|
14
|
+
// edition (an edition directory holding a release lock) and the policy is
|
|
15
|
+
// not ARMED on the release channel (none declared, UNARMED or PARTIAL):
|
|
16
|
+
// that edition cannot be published as an artifact. A tree holding no
|
|
17
|
+
// release edition passes for develop-only use, with a notice saying so;
|
|
17
18
|
// * no policy declared but a policy file is present at the top level or under
|
|
18
|
-
// `trust/`
|
|
19
|
-
//
|
|
20
|
-
// * declared
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// judged PER CHANNEL (trust-policy-arming.mjs): on
|
|
26
|
-
// the release channel it is unarmed (passes,
|
|
27
|
-
// delivery fail-closed) or armed; the develop
|
|
28
|
-
// channel admits not-armed and says so in one
|
|
29
|
-
// line; a channel that is partially armed is refused.
|
|
19
|
+
// `trust/` -> refused: a policy nothing reads is a policy someone believes
|
|
20
|
+
// is enforced;
|
|
21
|
+
// * declared -> the policy parses with the kernel schema, its pinned Sigstore
|
|
22
|
+
// root matches, the data-root pin records the policy's digest AND this
|
|
23
|
+
// CLI's built verifier (a missing bundle is refused, never skipped), and
|
|
24
|
+
// the authority configuration is judged PER CHANNEL
|
|
25
|
+
// (trust-policy-arming.mjs); a channel that is partially armed is refused.
|
|
30
26
|
|
|
31
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
27
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
32
28
|
import { join } from 'node:path';
|
|
33
29
|
|
|
34
30
|
import { locateBuiltVerifier, readBuiltVerifierDigests } from './built-verifier.mjs';
|
|
35
31
|
import { EDITION_WORKSPACE_CONFIG_FILE, editionWorkspaceConfig } from './edition-config.mjs';
|
|
36
32
|
import { EDITION_CONTRACT, EditionChannel, EvidenceTrustState } from './edition-constants.mjs';
|
|
37
|
-
import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
|
|
33
|
+
import { dataRoot as configuredDataRoot, editionsRoot as configuredEditionsRoot } from './edition-workspace.mjs';
|
|
38
34
|
import {
|
|
39
35
|
assertPinnedPolicy,
|
|
40
36
|
assertPinnedVerifier,
|
|
@@ -42,6 +38,7 @@ import {
|
|
|
42
38
|
readEvidenceTrustPin,
|
|
43
39
|
} from './evidence-trust-pin.mjs';
|
|
44
40
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
41
|
+
import { heldReleaseLocks } from './release-channel-guard.mjs';
|
|
45
42
|
import {
|
|
46
43
|
TrustPolicyArmingState,
|
|
47
44
|
assertArmingChannel,
|
|
@@ -51,25 +48,72 @@ import {
|
|
|
51
48
|
const TAG = '[dist:check]';
|
|
52
49
|
|
|
53
50
|
/**
|
|
54
|
-
* The stable code of the refusal to write a
|
|
55
|
-
*
|
|
56
|
-
*
|
|
51
|
+
* The stable code of the refusal to write a RELEASE edition without an armed
|
|
52
|
+
* evidence trust policy. Consumers (xema-deploy, workflows) match on the code,
|
|
53
|
+
* never on the message; it is also listed in edition-contract.json.
|
|
57
54
|
*/
|
|
58
55
|
export const EVIDENCE_TRUST_REQUIRED_UNARMED = EDITION_CONTRACT.evidenceTrust.refusal.code;
|
|
59
56
|
|
|
60
57
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
58
|
+
* What `xema edition check` says of a data root whose policy is not armed on
|
|
59
|
+
* the release channel. Sealing is not gated (nothing sealed leaves without
|
|
60
|
+
* `xema edition artifact`); publishing is.
|
|
61
|
+
*/
|
|
62
|
+
export const NO_RELEASE_ARTIFACT_NOTICE =
|
|
63
|
+
'no release edition of this data root can be published as an artifact (written by `xema edition artifact`) until the policy is armed on the release channel';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The release editions of the configured editions tree: the edition directories
|
|
67
|
+
* (those holding `xema-distribution.json`) that hold a release lock. An edition
|
|
68
|
+
* that is only defined, with no lock resolved yet, is not one.
|
|
69
|
+
*/
|
|
70
|
+
export function releaseEditionIds() {
|
|
71
|
+
const root = configuredEditionsRoot();
|
|
72
|
+
if (!existsSync(root)) {return [];}
|
|
73
|
+
return readdirSync(root, { withFileTypes: true })
|
|
74
|
+
.filter(
|
|
75
|
+
(entry) =>
|
|
76
|
+
entry.isDirectory() &&
|
|
77
|
+
existsSync(join(root, entry.name, 'xema-distribution.json')) &&
|
|
78
|
+
heldReleaseLocks(join(root, entry.name)).length > 0,
|
|
79
|
+
)
|
|
80
|
+
.map((entry) => entry.name)
|
|
81
|
+
.sort();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The verdict of the check for a policy that is not armed on the release
|
|
86
|
+
* channel: red when the tree holds a release edition (it names them and the
|
|
87
|
+
* repair), green with the notice when it holds none.
|
|
88
|
+
*/
|
|
89
|
+
function notArmedOnReleaseVerdict(what, repair) {
|
|
90
|
+
const releaseEditions = releaseEditionIds();
|
|
91
|
+
if (releaseEditions.length === 0) {
|
|
92
|
+
console.warn(
|
|
93
|
+
`${TAG} image-evidence trust policy: NOT ARMED on the release channel — ${what}. ${configuredEditionsRoot()} holds no release edition, ` +
|
|
94
|
+
`so this passes for develop-only use: ${NO_RELEASE_ARTIFACT_NOTICE}.`,
|
|
95
|
+
);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
console.error(
|
|
99
|
+
`${TAG} image-evidence trust policy FAILED: ${what}, and ${configuredEditionsRoot()} holds ${String(releaseEditions.length)} release edition(s) ` +
|
|
100
|
+
`(${releaseEditions.join(', ')}): ${NO_RELEASE_ARTIFACT_NOTICE}. ${repair}`,
|
|
101
|
+
);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A release edition requires an armed evidence trust policy and this data root
|
|
107
|
+
* has none: it declares no policy, or declares one that is UNARMED or PARTIALLY
|
|
108
|
+
* ARMED on the release channel. Writing the release descriptor or artifact is
|
|
109
|
+
* refused with this error; a release edition never records `not-armed`.
|
|
65
110
|
*/
|
|
66
111
|
export class EvidenceTrustRequiredError extends Error {
|
|
67
112
|
/** @param {string} dataRoot @param {string} reason what is not armed, in words */
|
|
68
113
|
constructor(dataRoot, reason) {
|
|
69
114
|
super(
|
|
70
|
-
|
|
71
|
-
'arm the
|
|
72
|
-
'or set required to false to run this data root NOT ARMED.',
|
|
115
|
+
`a release edition requires an armed evidence trust policy, but ${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} ${reason}: ` +
|
|
116
|
+
'declare a policy ({"policy": ..., "pin": ...}) and arm it on the release channel (`xema edition check` says what is unmet).',
|
|
73
117
|
);
|
|
74
118
|
this.name = 'EvidenceTrustRequiredError';
|
|
75
119
|
this.code = EVIDENCE_TRUST_REQUIRED_UNARMED;
|
|
@@ -93,23 +137,21 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
|
|
|
93
137
|
export const DEVELOP_NOT_ARMED_NOTICE = 'develop channel: trust policy not armed, evidence is not signature-verified';
|
|
94
138
|
|
|
95
139
|
/**
|
|
96
|
-
* The evidence state an edition of `channel` records in its descriptor
|
|
97
|
-
*
|
|
98
|
-
*
|
|
140
|
+
* The evidence state an edition of `channel` records in its descriptor. Arming
|
|
141
|
+
* is judged for that ONE channel (`assessTrustPolicyArming`): the signers of
|
|
142
|
+
* the other channel count for nothing.
|
|
99
143
|
*
|
|
100
144
|
* * `armed` - the data root declares a policy, the policy is pinned (the
|
|
101
145
|
* policy and this CLI's verifier match the data-root pin) and
|
|
102
146
|
* ARMED on `channel`;
|
|
103
|
-
* * `not-armed` - a
|
|
104
|
-
* policy
|
|
105
|
-
* `required` says, and it says so in one stderr line.
|
|
147
|
+
* * `not-armed` - a DEVELOP edition only: its channel is not armed (no
|
|
148
|
+
* policy, or UNARMED there), and it says so in one stderr line.
|
|
106
149
|
*
|
|
107
|
-
* Every other state is refused
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* either channel.
|
|
150
|
+
* Every other state is refused. A RELEASE edition that is not ARMED on the
|
|
151
|
+
* release channel (no policy, UNARMED or PARTIAL there) throws
|
|
152
|
+
* EvidenceTrustRequiredError: this function never returns `not-armed` for it.
|
|
153
|
+
* A policy edited after pinning, a drifted verifier, and a policy PARTIALLY
|
|
154
|
+
* ARMED on `channel` are refused on either channel.
|
|
113
155
|
*
|
|
114
156
|
* @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string} }} options
|
|
115
157
|
*/
|
|
@@ -118,25 +160,24 @@ export async function descriptorEvidenceTrust({ channel, locateVerifier = locate
|
|
|
118
160
|
const root = configuredDataRoot();
|
|
119
161
|
const schemas = await loadKernelDistributionSchemas();
|
|
120
162
|
assertArmingChannel(schemas, channel);
|
|
121
|
-
const
|
|
122
|
-
|
|
163
|
+
const release = channel === EditionChannel.Release;
|
|
164
|
+
const developNotArmed = () => {
|
|
165
|
+
console.error(DEVELOP_NOT_ARMED_NOTICE);
|
|
123
166
|
return EvidenceTrustState.NotArmed;
|
|
124
167
|
};
|
|
125
168
|
if (declaration.policy === null) {
|
|
126
|
-
if (
|
|
127
|
-
return
|
|
169
|
+
if (release) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
|
|
170
|
+
return developNotArmed();
|
|
128
171
|
}
|
|
129
172
|
const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
|
|
130
173
|
const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
|
|
174
|
+
if (state === TrustPolicyArmingState.ARMED) {return EvidenceTrustState.Armed;}
|
|
131
175
|
if (state === TrustPolicyArmingState.PARTIAL) {
|
|
132
|
-
if (
|
|
176
|
+
if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`);}
|
|
133
177
|
throw new Error(`the declared evidence trust policy is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')}); an edition cannot be written against it`);
|
|
134
178
|
}
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
throw new EvidenceTrustRequiredError(root, `its declared policy is UNARMED on the ${channel} channel`);
|
|
138
|
-
}
|
|
139
|
-
return notArmed();
|
|
179
|
+
if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is UNARMED on the ${channel} channel`);}
|
|
180
|
+
return developNotArmed();
|
|
140
181
|
}
|
|
141
182
|
|
|
142
183
|
export const UNDECLARED_TRUST_DIRECTORY = 'trust';
|
|
@@ -184,21 +225,19 @@ export async function checkEvidenceTrustPolicy({
|
|
|
184
225
|
const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
|
|
185
226
|
const schemas = await loadKernelDistributionSchemas();
|
|
186
227
|
if (declaration.policy === null) {
|
|
187
|
-
if (declaration.required) {throw new EvidenceTrustRequiredError(dataRoot, 'declares no policy');}
|
|
188
228
|
const undeclared = undeclaredPolicyArtifacts(dataRoot, schemas);
|
|
189
229
|
if (undeclared.length > 0) {
|
|
190
230
|
console.error(
|
|
191
231
|
`${TAG} image-evidence trust policy FAILED: ${dataRoot} holds ${undeclared.join(', ')} ` +
|
|
192
|
-
`but ${EDITION_WORKSPACE_CONFIG_FILE} declares
|
|
193
|
-
'Declare it ({"
|
|
232
|
+
`but ${EDITION_WORKSPACE_CONFIG_FILE} declares no evidenceTrustPolicy, so nothing reads it. ` +
|
|
233
|
+
'Declare it ({"policy": ..., "pin": ...}) or remove it.',
|
|
194
234
|
);
|
|
195
235
|
return false;
|
|
196
236
|
}
|
|
197
|
-
|
|
198
|
-
`${
|
|
199
|
-
|
|
237
|
+
return notArmedOnReleaseVerdict(
|
|
238
|
+
`${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} declares no evidenceTrustPolicy (its develop editions are not evidence-gated)`,
|
|
239
|
+
'Declare a policy ({"policy": ..., "pin": ...}), authorize the release signers in it, pin it with `xema edition repin-evidence-trust`, and re-run `xema edition check` until it reports ARMED on the release channel.',
|
|
200
240
|
);
|
|
201
|
-
return true;
|
|
202
241
|
}
|
|
203
242
|
|
|
204
243
|
const { policy, policyDigest } = loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier });
|
|
@@ -213,8 +252,9 @@ export async function checkEvidenceTrustPolicy({
|
|
|
213
252
|
`${policy.trustedCosignBinaryDigests.length} cosign / ` +
|
|
214
253
|
`${policy.trustedGitBinaryDigests.length} git binary pins.`,
|
|
215
254
|
);
|
|
216
|
-
// Each channel is judged on its own signers. The
|
|
217
|
-
//
|
|
255
|
+
// Each channel is judged on its own signers. The develop channel admits
|
|
256
|
+
// not-armed and says so; a release channel that is not armed fails the
|
|
257
|
+
// check as soon as the tree holds a release edition.
|
|
218
258
|
const partial = (channel, unmet) => {
|
|
219
259
|
console.error(
|
|
220
260
|
`${TAG} image-evidence trust policy is PARTIALLY ARMED on the ${channel} channel — this mixed state is invalid ` +
|
|
@@ -232,20 +272,18 @@ export async function checkEvidenceTrustPolicy({
|
|
|
232
272
|
const release = assessTrustPolicyArming(policy, schemas, EditionChannel.Release);
|
|
233
273
|
if (release.state === TrustPolicyArmingState.PARTIAL) {
|
|
234
274
|
partial(EditionChannel.Release, release.unmet);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
console.error(`${TAG} ${refusal.code}: ${refusal.message}`);
|
|
275
|
+
console.error(
|
|
276
|
+
`${TAG} ${NO_RELEASE_ARTIFACT_NOTICE}. Complete the release channel in ${declaration.policy} (every line above), ` +
|
|
277
|
+
're-pin with `xema edition repin-evidence-trust`, and re-run `xema edition check`.',
|
|
278
|
+
);
|
|
240
279
|
return false;
|
|
241
280
|
}
|
|
242
281
|
if (develop.state === TrustPolicyArmingState.UNARMED) {console.error(`${TAG} ${DEVELOP_NOT_ARMED_NOTICE}`);}
|
|
243
282
|
if (release.state === TrustPolicyArmingState.UNARMED) {
|
|
244
|
-
|
|
245
|
-
`${
|
|
246
|
-
|
|
283
|
+
return notArmedOnReleaseVerdict(
|
|
284
|
+
`${declaration.policy} is declared but UNARMED on the release channel (it authorizes no release signer)`,
|
|
285
|
+
`Authorize the release signers and pin the cosign and git binaries in ${declaration.policy}, re-pin with \`xema edition repin-evidence-trust\`, and re-run \`xema edition check\` until it reports ARMED on the release channel.`,
|
|
247
286
|
);
|
|
248
|
-
return true;
|
|
249
287
|
}
|
|
250
288
|
console.log(`${TAG} image-evidence trust policy: ARMED on the release channel${develop.armed ? ' and on the develop channel' : ''}.`);
|
|
251
289
|
return true;
|