@xemahq/xema 0.45.1 → 0.46.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/biome.d.ts.map +1 -1
- package/dist/commands/biome.js +3 -0
- package/dist/commands/biome.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +1613 -846
- package/dist/edition/verify-image-evidence.bundle.provenance.json +516 -474
- package/dist/runtime/biome-publish-image-layers.d.ts +20 -0
- package/dist/runtime/biome-publish-image-layers.d.ts.map +1 -0
- package/dist/runtime/biome-publish-image-layers.js +64 -0
- package/dist/runtime/biome-publish-image-layers.js.map +1 -0
- package/dist/runtime/biome-publish.d.ts +4 -0
- package/dist/runtime/biome-publish.d.ts.map +1 -1
- package/dist/runtime/biome-publish.js +25 -5
- package/dist/runtime/biome-publish.js.map +1 -1
- package/dist/runtime/config/schema.d.ts +4 -3
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/config/schema.js +8 -2
- package/dist/runtime/config/schema.js.map +1 -1
- package/dist/runtime/installation/contract.d.ts +2 -2
- package/dist/runtime/installation/contract.d.ts.map +1 -1
- package/dist/runtime/installation/contract.js +1 -1
- package/dist/runtime/installation/contract.js.map +1 -1
- package/edition/README.md +2 -1
- package/edition/assemble-producer-input.mjs +2 -0
- package/edition/biome-content.mjs +6 -0
- package/edition/build-platform-service-index.mjs +2 -0
- package/edition/canonical-repository-url.mjs +12 -0
- package/edition/cosign-certificate-constraints.mjs +40 -0
- package/edition/customer-edition-verbs.mjs +6 -0
- package/edition/develop-coverage-readers.mjs +6 -4
- package/edition/develop-edition.mjs +20 -0
- package/edition/develop-record-ancestry.mjs +121 -0
- package/edition/develop-select-records.mjs +365 -0
- package/edition/distribution-evidence-set.mjs +14 -2
- package/edition/edition-config.mjs +27 -1
- package/edition/edition-protocol.mjs +8 -1
- package/edition/image-evidence.mjs +2 -12
- package/edition/kernel-signer-kind.mjs +23 -0
- package/edition/lib.mjs +104 -2
- package/edition/oci-evidence-verbs.mjs +2 -2
- package/edition/oci-image-source-evidence.mjs +84 -39
- package/edition/operation-grant-admission.mjs +98 -4
- package/edition/operation-requirements.mjs +118 -4
- package/edition/plan-image-evidence-refresh.mjs +20 -6
- package/edition/platform-service-class.mjs +23 -0
- package/edition/platform-service-ownership.mjs +23 -13
- package/edition/produce-distribution-completeness-evidence.mjs +2 -0
- package/edition/produce-image-source-evidence.mjs +9 -11
- package/edition/producer-evidence-bundle.mjs +12 -1
- package/edition/record-refused.mjs +11 -0
- package/edition/resolve-distribution.mjs +43 -1
- package/package.json +3 -3
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
export class OperationGrantAdmissionError extends Error {
|
|
15
15
|
/**
|
|
16
16
|
* @param {string} distributionId
|
|
17
|
-
* @param {ReadonlyArray<{code: string,
|
|
17
|
+
* @param {ReadonlyArray<{code: string, operation: string, message: string}>} refusals
|
|
18
18
|
*/
|
|
19
19
|
constructor(distributionId, refusals) {
|
|
20
20
|
super(
|
|
@@ -45,9 +45,11 @@ export function ungrantedOptionalOperationLines(distributionId, ungrantedOptiona
|
|
|
45
45
|
* {@link ungrantedOptionalOperationLines}; an ungranted optional operation never fails.
|
|
46
46
|
*/
|
|
47
47
|
export function assertOperationGrantsAdmitted(schemas, lock, { report } = {}) {
|
|
48
|
-
const { grants, refusals, ungrantedOptional } = schemas.admitOperationGrants(lock);
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
const { grants, refusals, ungrantedOptional, platformServiceRefusals } = schemas.admitOperationGrants(lock);
|
|
49
|
+
// A platform service's refused requirement fails exactly like a biome component's, through the same error.
|
|
50
|
+
const allRefusals = [...refusals, ...platformServiceRefusals];
|
|
51
|
+
if (allRefusals.length > 0) {
|
|
52
|
+
throw new OperationGrantAdmissionError(lock.distributionId, allRefusals);
|
|
51
53
|
}
|
|
52
54
|
if (report !== undefined) {
|
|
53
55
|
for (const line of ungrantedOptionalOperationLines(lock.distributionId, ungrantedOptional)) {report(line);}
|
|
@@ -86,6 +88,37 @@ export function assertOrganizationSweepsAdmitted(schemas, lock) {
|
|
|
86
88
|
return grants;
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/** Thrown when one or more declared exact audiences are not admitted. */
|
|
92
|
+
export class ExactAudienceAdmissionError extends Error {
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} distributionId
|
|
95
|
+
* @param {ReadonlyArray<{code: string, message: string}>} refusals
|
|
96
|
+
*/
|
|
97
|
+
constructor(distributionId, refusals) {
|
|
98
|
+
super(
|
|
99
|
+
`distribution "${distributionId}" declares audience requirements that are not admitted:\n` +
|
|
100
|
+
refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
|
|
101
|
+
);
|
|
102
|
+
this.name = 'ExactAudienceAdmissionError';
|
|
103
|
+
this.distributionId = distributionId;
|
|
104
|
+
this.refusals = refusals;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Admit every exact-audience requirement of `lock` through the kernel
|
|
110
|
+
* (`admitExactAudiences`), or throw {@link ExactAudienceAdmissionError} listing
|
|
111
|
+
* every refusal. Returns the admitted grants. The resolve and the seal call it
|
|
112
|
+
* beside {@link assertOperationGrantsAdmitted}.
|
|
113
|
+
*/
|
|
114
|
+
export function assertExactAudiencesAdmitted(schemas, lock) {
|
|
115
|
+
const { grants, refusals } = schemas.admitExactAudiences(lock);
|
|
116
|
+
if (refusals.length > 0) {
|
|
117
|
+
throw new ExactAudienceAdmissionError(lock.distributionId, refusals);
|
|
118
|
+
}
|
|
119
|
+
return grants;
|
|
120
|
+
}
|
|
121
|
+
|
|
89
122
|
/** Thrown when one or more requested installed operations are not admitted into the ceiling. */
|
|
90
123
|
export class InstalledOperationCeilingAdmissionError extends Error {
|
|
91
124
|
/**
|
|
@@ -147,3 +180,64 @@ export function assertStoreWorkloadCeilingAdmitted(schemas, lock) {
|
|
|
147
180
|
}
|
|
148
181
|
return ceiling;
|
|
149
182
|
}
|
|
183
|
+
|
|
184
|
+
/** Thrown when one or more declared organization-context relays are not admitted. */
|
|
185
|
+
export class OrgContextRelayAdmissionError extends Error {
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} distributionId
|
|
188
|
+
* @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
|
|
189
|
+
*/
|
|
190
|
+
constructor(distributionId, refusals) {
|
|
191
|
+
super(
|
|
192
|
+
`distribution "${distributionId}" declares organization-context relays that are not admitted:\n` +
|
|
193
|
+
refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
|
|
194
|
+
);
|
|
195
|
+
this.name = 'OrgContextRelayAdmissionError';
|
|
196
|
+
this.distributionId = distributionId;
|
|
197
|
+
this.refusals = refusals;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Admit every `orgContextRelayers` entry of `lock` through the kernel
|
|
203
|
+
* (`admitOrgContextRelays`), or throw {@link OrgContextRelayAdmissionError}
|
|
204
|
+
* listing every refusal. Returns the admitted relay rows.
|
|
205
|
+
*/
|
|
206
|
+
export function assertOrgContextRelaysAdmitted(schemas, lock) {
|
|
207
|
+
const { relays, refusals } = schemas.admitOrgContextRelays(lock);
|
|
208
|
+
if (refusals.length > 0) {
|
|
209
|
+
throw new OrgContextRelayAdmissionError(lock.distributionId, refusals);
|
|
210
|
+
}
|
|
211
|
+
return relays;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Thrown when one or more declared event subscription clauses are not admitted. */
|
|
215
|
+
export class EventSubscriptionAdmissionError extends Error {
|
|
216
|
+
/**
|
|
217
|
+
* @param {string} distributionId
|
|
218
|
+
* @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
|
|
219
|
+
*/
|
|
220
|
+
constructor(distributionId, refusals) {
|
|
221
|
+
super(
|
|
222
|
+
`distribution "${distributionId}" declares event subscriptions that are not admitted:\n` +
|
|
223
|
+
refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
|
|
224
|
+
);
|
|
225
|
+
this.name = 'EventSubscriptionAdmissionError';
|
|
226
|
+
this.distributionId = distributionId;
|
|
227
|
+
this.refusals = refusals;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Admit every `eventSubscriptionRequirements` clause of `lock` through the
|
|
233
|
+
* kernel (`admitEventSubscriptions`), or throw
|
|
234
|
+
* {@link EventSubscriptionAdmissionError} listing every refusal. Returns the
|
|
235
|
+
* admission: the first-party grants and the installed ceilings.
|
|
236
|
+
*/
|
|
237
|
+
export function assertEventSubscriptionsAdmitted(schemas, lock) {
|
|
238
|
+
const admission = schemas.admitEventSubscriptions(lock);
|
|
239
|
+
if (admission.refusals.length > 0) {
|
|
240
|
+
throw new EventSubscriptionAdmissionError(lock.distributionId, admission.refusals);
|
|
241
|
+
}
|
|
242
|
+
return admission;
|
|
243
|
+
}
|
|
@@ -6,6 +6,7 @@ import { compareStrings } from './string-order.mjs';
|
|
|
6
6
|
const requireFromHere = createRequire(import.meta.url);
|
|
7
7
|
|
|
8
8
|
let kernelPrivilegeGrammars;
|
|
9
|
+
let kernelAudienceGrammars;
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The kernel's ONE operation-id grammar (`OperationIdSchema`) and closed
|
|
@@ -15,16 +16,54 @@ let kernelPrivilegeGrammars;
|
|
|
15
16
|
function loadKernelPrivilegeGrammars() {
|
|
16
17
|
if (kernelPrivilegeGrammars) {return kernelPrivilegeGrammars;}
|
|
17
18
|
const mod = requireFromHere(kernelDistPath('distribution', 'index.js'));
|
|
18
|
-
if (
|
|
19
|
+
if (
|
|
20
|
+
typeof mod.OperationIdSchema?.safeParse !== 'function' ||
|
|
21
|
+
typeof mod.PlatformSweepSchema?.safeParse !== 'function' ||
|
|
22
|
+
typeof mod.DeclaredEventSubscriptionClauseSchema?.safeParse !== 'function' ||
|
|
23
|
+
typeof mod.canonicalEventSubscriptionClauses !== 'function'
|
|
24
|
+
) {
|
|
19
25
|
throw new Error(
|
|
20
26
|
`Built kernel distribution module at ${kernelDistPath('distribution', 'index.js')} does not export ` +
|
|
21
|
-
'OperationIdSchema and
|
|
27
|
+
'OperationIdSchema, PlatformSweepSchema, DeclaredEventSubscriptionClauseSchema and canonicalEventSubscriptionClauses — the dist is stale; rebuild kernel-contracts at a commit that carries them.',
|
|
22
28
|
);
|
|
23
29
|
}
|
|
24
30
|
kernelPrivilegeGrammars = mod;
|
|
25
31
|
return mod;
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The kernel's exact-audience and audience-namespace grammars, as ELEMENT schemas.
|
|
36
|
+
* The edition protocol exposes no subpath that exports the element grammars, so each is read
|
|
37
|
+
* from the one list schema the distribution subpath does export
|
|
38
|
+
* (`PlatformServiceSourceIdentitySchema.shape.requiresAudiences` / `.ownsAudienceNamespaces`):
|
|
39
|
+
* a value is graded as a one-element list, and the parsed element is returned.
|
|
40
|
+
*/
|
|
41
|
+
function loadKernelAudienceGrammars() {
|
|
42
|
+
if (kernelAudienceGrammars) {return kernelAudienceGrammars;}
|
|
43
|
+
const mod = requireFromHere(kernelDistPath('distribution', 'index.js'));
|
|
44
|
+
const shape = mod.PlatformServiceSourceIdentitySchema?.shape;
|
|
45
|
+
const elementSchema = (listField) => {
|
|
46
|
+
const list = shape?.[listField]?.unwrap?.();
|
|
47
|
+
if (typeof list?.safeParse !== 'function') {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Built kernel distribution module at ${kernelDistPath('distribution', 'index.js')} has no PlatformServiceSourceIdentitySchema.${listField} — ` +
|
|
50
|
+
'the dist is stale; rebuild kernel-contracts at a commit that carries the index-row audience fields.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
safeParse: (value) => {
|
|
55
|
+
const parsed = list.safeParse([value]);
|
|
56
|
+
return parsed.success ? { success: true, data: parsed.data[0] } : parsed;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
kernelAudienceGrammars = {
|
|
61
|
+
ExactAudienceSchema: elementSchema('requiresAudiences'),
|
|
62
|
+
AudienceNamespaceSchema: elementSchema('ownsAudienceNamespaces'),
|
|
63
|
+
};
|
|
64
|
+
return kernelAudienceGrammars;
|
|
65
|
+
}
|
|
66
|
+
|
|
28
67
|
/**
|
|
29
68
|
* One component's declared privilege list, as the index carries it: sorted,
|
|
30
69
|
* unique, every entry graded by the kernel schema. `null` when undeclared. An
|
|
@@ -32,7 +71,7 @@ function loadKernelPrivilegeGrammars() {
|
|
|
32
71
|
* reads the RAW manifest, and the kernel's component schema is not applied on
|
|
33
72
|
* this path.
|
|
34
73
|
*/
|
|
35
|
-
function declaredPrivilegeList(component, where, field, schema, what) {
|
|
74
|
+
export function declaredPrivilegeList(component, where, field, schema, what) {
|
|
36
75
|
const declared = component[field];
|
|
37
76
|
if (declared === undefined) {return null;}
|
|
38
77
|
if (!Array.isArray(declared) || declared.length === 0) {
|
|
@@ -45,7 +84,7 @@ function declaredPrivilegeList(component, where, field, schema, what) {
|
|
|
45
84
|
const parsed = schema.safeParse(value);
|
|
46
85
|
if (!parsed.success) {
|
|
47
86
|
throw new Error(
|
|
48
|
-
`${where} ${field}[${position}] ${JSON.stringify(value)} is not ${what
|
|
87
|
+
`${where} ${field}[${position}] ${JSON.stringify(value)} is not ${/^[aeiou]/.test(what) ? 'an' : 'a'} ${what}: ` +
|
|
49
88
|
parsed.error.issues.map((issue) => issue.message).join('; '),
|
|
50
89
|
);
|
|
51
90
|
}
|
|
@@ -104,6 +143,77 @@ export function componentOrganizationSweepRequirements(component, where) {
|
|
|
104
143
|
return declaredPrivilegeList(component, where, 'requiresOrganizationSweeps', PlatformSweepSchema, 'platform sweep');
|
|
105
144
|
}
|
|
106
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The exact audiences one workload component requires (`requiresAudiences`),
|
|
148
|
+
* graded by the kernel's exact-audience grammar (no wildcard, no prefix).
|
|
149
|
+
* Declared INTENT only; `admitExactAudiences` admits or refuses each.
|
|
150
|
+
*/
|
|
151
|
+
export function componentAudienceRequirements(component, where) {
|
|
152
|
+
const { ExactAudienceSchema } = loadKernelAudienceGrammars();
|
|
153
|
+
return declaredPrivilegeList(component, where, 'requiresAudiences', ExactAudienceSchema, 'exact audience');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The audience namespaces one workload component owns (`ownsAudienceNamespaces`),
|
|
158
|
+
* graded by the kernel's namespace grammar.
|
|
159
|
+
*/
|
|
160
|
+
export function componentOwnedAudienceNamespaces(component, where) {
|
|
161
|
+
const { AudienceNamespaceSchema } = loadKernelAudienceGrammars();
|
|
162
|
+
return declaredPrivilegeList(component, where, 'ownsAudienceNamespaces', AudienceNamespaceSchema, 'audience namespace');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The event subscription clauses one workload component declares
|
|
167
|
+
* (`subscribesEvents`), each graded by the kernel's clause grammar and returned
|
|
168
|
+
* in the kernel's canonical order, as the lock carries them
|
|
169
|
+
* (`eventSubscriptionRequirements`). An empty list, a repeated clause or an
|
|
170
|
+
* invalid one REFUSES naming the manifest; `null` when undeclared. Declared
|
|
171
|
+
* INTENT only; the lock admits it (`admitEventSubscriptions`).
|
|
172
|
+
*/
|
|
173
|
+
export function componentEventSubscriptionRequirements(component, where) {
|
|
174
|
+
const { DeclaredEventSubscriptionClauseSchema, canonicalEventSubscriptionClauses } = loadKernelPrivilegeGrammars();
|
|
175
|
+
const declared = component.subscribesEvents;
|
|
176
|
+
if (declared === undefined) {return null;}
|
|
177
|
+
if (!Array.isArray(declared) || declared.length === 0) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`${where} declares subscribesEvents that is not a non-empty array of event subscription clauses — ` +
|
|
180
|
+
'a list that names nothing states nothing, so omit the field',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const clauses = declared.map((value, position) => {
|
|
184
|
+
const parsed = DeclaredEventSubscriptionClauseSchema.safeParse(value);
|
|
185
|
+
if (!parsed.success) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`${where} subscribesEvents[${position}] ${JSON.stringify(value)} is not an event subscription clause: ` +
|
|
188
|
+
parsed.error.issues.map((issue) => issue.message).join('; '),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return parsed.data;
|
|
192
|
+
});
|
|
193
|
+
const canonical = canonicalEventSubscriptionClauses(clauses);
|
|
194
|
+
if (canonical.length !== clauses.length) {
|
|
195
|
+
throw new Error(`${where} subscribesEvents lists the same clause more than once`);
|
|
196
|
+
}
|
|
197
|
+
return canonical;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether one workload component declares `relaysOrgContext`: the privilege to
|
|
202
|
+
* relay an organization context its caller handed it. The only legal value is
|
|
203
|
+
* `true` (the kernel's component grammar); absent means it does not relay.
|
|
204
|
+
* Declared INTENT only; the lock admits it (`admitOrgContextRelays`).
|
|
205
|
+
*/
|
|
206
|
+
export function componentRelaysOrgContext(component, where) {
|
|
207
|
+
const declared = component.relaysOrgContext;
|
|
208
|
+
if (declared === undefined) {return false;}
|
|
209
|
+
if (declared !== true) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`${where} declares relaysOrgContext ${JSON.stringify(declared)} — the only legal value is true; omit the field to not relay`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
107
217
|
/**
|
|
108
218
|
* The privilege declarations a component may carry only if it is a workload
|
|
109
219
|
* principal in the lock.
|
|
@@ -112,7 +222,11 @@ export const PRINCIPAL_PRIVILEGE_FIELDS = Object.freeze([
|
|
|
112
222
|
'requiresOperations',
|
|
113
223
|
'optionalOperations',
|
|
114
224
|
'requestsInstalledOperations',
|
|
225
|
+
'relaysOrgContext',
|
|
226
|
+
'subscribesEvents',
|
|
115
227
|
'requiresOrganizationSweeps',
|
|
228
|
+
'requiresAudiences',
|
|
229
|
+
'ownsAudienceNamespaces',
|
|
116
230
|
]);
|
|
117
231
|
|
|
118
232
|
/**
|
|
@@ -72,8 +72,9 @@ import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.
|
|
|
72
72
|
import { compareStrings, loadKernelDistributionSchemas } from './lib.mjs';
|
|
73
73
|
import {
|
|
74
74
|
createRegistryClientResolver,
|
|
75
|
+
describeInvalidReferrer,
|
|
75
76
|
resolveImageSourceEvidenceReferrer,
|
|
76
|
-
|
|
77
|
+
writeInvalidReferrerReport,
|
|
77
78
|
} from './oci-image-source-evidence.mjs';
|
|
78
79
|
import {
|
|
79
80
|
loadProducerKernelSchemas,
|
|
@@ -169,7 +170,10 @@ export async function planImageEvidenceRefresh({
|
|
|
169
170
|
// planner: measured that day, ALL 68 locked coordinates were absent from
|
|
170
171
|
// ghcr, and the run died on the first of them and said nothing about the
|
|
171
172
|
// other 67.
|
|
172
|
-
let
|
|
173
|
+
let unusableReason = null;
|
|
174
|
+
// Every referrer of this subject that is not valid evidence, as reported
|
|
175
|
+
// by the resolver. All of them invalid is a decided UNUSABLE verdict.
|
|
176
|
+
const invalidReferrers = [];
|
|
173
177
|
{
|
|
174
178
|
const { authority, repository } = splitImageRef(subject.imageRef);
|
|
175
179
|
const client = clientFor(authority);
|
|
@@ -182,7 +186,7 @@ export async function planImageEvidenceRefresh({
|
|
|
182
186
|
// that cannot decide must never report "nothing to do" — so it still
|
|
183
187
|
// escapes and the run still exits 1.
|
|
184
188
|
if (error?.status !== 404) {throw error;}
|
|
185
|
-
|
|
189
|
+
unusableReason =
|
|
186
190
|
`the registry has no image at the locked coordinate ` +
|
|
187
191
|
`${subject.imageRef}:${subject.contentHash} — nothing was ever ` +
|
|
188
192
|
`published there, so no producer evidence can attest it and the ` +
|
|
@@ -195,8 +199,18 @@ export async function planImageEvidenceRefresh({
|
|
|
195
199
|
subjectDigest: image.digest,
|
|
196
200
|
schemas,
|
|
197
201
|
expectedContractAuthority,
|
|
198
|
-
|
|
202
|
+
onInvalidReferrer: (skipped) => {
|
|
203
|
+
invalidReferrers.push(skipped);
|
|
204
|
+
writeInvalidReferrerReport(skipped);
|
|
205
|
+
},
|
|
199
206
|
});
|
|
207
|
+
if (artifact === null && invalidReferrers.length > 0) {
|
|
208
|
+
unusableReason =
|
|
209
|
+
`every evidence referrer of ${subject.imageRef}@${image.digest} ` +
|
|
210
|
+
`(repo ${subject.repo}) is invalid under the required kernel contract ` +
|
|
211
|
+
`authority ${expectedContractAuthority.sourceCommit}: ` +
|
|
212
|
+
invalidReferrers.map(describeInvalidReferrer).join('; ');
|
|
213
|
+
}
|
|
200
214
|
if (artifact !== null) {
|
|
201
215
|
matches = artifact.records.filter(
|
|
202
216
|
(record) =>
|
|
@@ -207,11 +221,11 @@ export async function planImageEvidenceRefresh({
|
|
|
207
221
|
}
|
|
208
222
|
}
|
|
209
223
|
const verdict =
|
|
210
|
-
|
|
224
|
+
unusableReason !== null
|
|
211
225
|
? {
|
|
212
226
|
status: EvidenceRefreshStatus.Unusable,
|
|
213
227
|
remainingSeconds: null,
|
|
214
|
-
reason:
|
|
228
|
+
reason: unusableReason,
|
|
215
229
|
}
|
|
216
230
|
: classifySubject({ matches, horizonSeconds, now });
|
|
217
231
|
subjects.push({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
|
|
2
|
+
import { componentAudienceRequirements, componentOperationRequirements, componentOwnedAudienceNamespaces } from './operation-requirements.mjs';
|
|
2
3
|
import { compareStrings } from './string-order.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -54,6 +55,28 @@ export function sourceOwnedCapabilityDomains(sourceManifest, where) {
|
|
|
54
55
|
return { ownsCapabilityDomains: sorted };
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* What a platform service declares it requires and owns, as the index carries it, read from the
|
|
60
|
+
* same declaration as {@link sourceOwnedCapabilityDomains} (the `xema` block of its source manifest):
|
|
61
|
+
* `requiresOperations`, `requiresAudiences` and `ownsAudienceNamespaces`. Each is sorted, unique and
|
|
62
|
+
* non-empty, or no key at all. Declared INTENT: the lock admits or refuses each
|
|
63
|
+
* (`admitOperationGrants`, `admitExactAudiences`).
|
|
64
|
+
*
|
|
65
|
+
* @param {unknown} sourceManifest the parsed source manifest
|
|
66
|
+
* @param {string} where names the manifest in a refusal
|
|
67
|
+
*/
|
|
68
|
+
export function sourceDeclaredRequirements(sourceManifest, where) {
|
|
69
|
+
const xema = sourceManifest?.xema ?? {};
|
|
70
|
+
const requiresOperations = componentOperationRequirements(xema, where);
|
|
71
|
+
const requiresAudiences = componentAudienceRequirements(xema, where);
|
|
72
|
+
const ownsAudienceNamespaces = componentOwnedAudienceNamespaces(xema, where);
|
|
73
|
+
return {
|
|
74
|
+
...(requiresOperations === null ? {} : { requiresOperations }),
|
|
75
|
+
...(requiresAudiences === null ? {} : { requiresAudiences }),
|
|
76
|
+
...(ownsAudienceNamespaces === null ? {} : { ownsAudienceNamespaces }),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
/** The (name, tier, declared class) a declaration and an index entry must agree on. */
|
|
58
81
|
export function comparablePlatformService(entry) {
|
|
59
82
|
return { name: entry.name, tier: entry.tier, ...declaredInstallationClass(entry) };
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { sourcePath } from './edition-workspace.mjs';
|
|
2
2
|
import { readGitJsonAtCommit, serializeJson, sha256Hex } from './lib.mjs';
|
|
3
|
-
import { sourceOwnedCapabilityDomains } from './platform-service-class.mjs';
|
|
3
|
+
import { sourceDeclaredRequirements, sourceOwnedCapabilityDomains } from './platform-service-class.mjs';
|
|
4
|
+
|
|
5
|
+
/** The index-row fields re-derived from a service's own source manifest and compared on read. */
|
|
6
|
+
const DERIVED_ROW_FIELDS = Object.freeze([
|
|
7
|
+
'ownsCapabilityDomains',
|
|
8
|
+
'requiresOperations',
|
|
9
|
+
'requiresAudiences',
|
|
10
|
+
'ownsAudienceNamespaces',
|
|
11
|
+
]);
|
|
4
12
|
|
|
5
13
|
/** Why a platform service's capability-domain ownership was refused. */
|
|
6
14
|
export const PlatformServiceOwnershipFault = Object.freeze({
|
|
@@ -8,7 +16,7 @@ export const PlatformServiceOwnershipFault = Object.freeze({
|
|
|
8
16
|
ManifestUnavailable: 'manifest-unavailable',
|
|
9
17
|
/** The manifest bytes do not hash to the index entry's `sourceManifestHash`. */
|
|
10
18
|
ManifestHashMismatch: 'manifest-hash-mismatch',
|
|
11
|
-
/**
|
|
19
|
+
/** An index entry's ownership or requirement field differs from the source manifest's. */
|
|
12
20
|
OwnershipMismatch: 'ownership-mismatch',
|
|
13
21
|
});
|
|
14
22
|
|
|
@@ -38,7 +46,7 @@ export function readPinnedSourceManifest(entry) {
|
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/**
|
|
41
|
-
* Re-derive each platform service's `
|
|
49
|
+
* Re-derive each platform service's ownership and requirement fields (`DERIVED_ROW_FIELDS`) from its pinned
|
|
42
50
|
* source manifest and refuse the index when an entry disagrees. The index is a
|
|
43
51
|
* hand-editable file; the SERVICE's own manifest is the authority for which
|
|
44
52
|
* service owns a domain, and so for where grants land.
|
|
@@ -61,17 +69,19 @@ export function verifiedPlatformServiceIndex(index, { names, readManifest = read
|
|
|
61
69
|
`but the index pins sourceManifestHash ${entry.sourceManifestHash}`,
|
|
62
70
|
);
|
|
63
71
|
}
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
`
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
const where = `${entry.path} at ${entry.sourceCommit}`;
|
|
73
|
+
const derived = { ...sourceOwnedCapabilityDomains(manifest, where), ...sourceDeclaredRequirements(manifest, where) };
|
|
74
|
+
for (const field of DERIVED_ROW_FIELDS) {
|
|
75
|
+
if (serializeJson(entry[field] ?? null) !== serializeJson(derived[field] ?? null)) {
|
|
76
|
+
throw new PlatformServiceOwnershipError(
|
|
77
|
+
PlatformServiceOwnershipFault.OwnershipMismatch,
|
|
78
|
+
`platform service "${entry.name}": the index claims ${field} ${JSON.stringify(entry[field] ?? [])}, ` +
|
|
79
|
+
`its source manifest declares ${JSON.stringify(derived[field] ?? [])} ` +
|
|
80
|
+
`(sourceManifestHash ${entry.sourceManifestHash})`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
73
83
|
}
|
|
74
|
-
const
|
|
84
|
+
const rest = Object.fromEntries(Object.entries(entry).filter(([key]) => !DERIVED_ROW_FIELDS.includes(key)));
|
|
75
85
|
return { ...rest, ...derived };
|
|
76
86
|
});
|
|
77
87
|
return { ...index, services };
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
isEntryModule,
|
|
38
38
|
} from './edition-workspace.mjs';
|
|
39
39
|
import { canonicalJsonBytes } from './image-evidence.mjs';
|
|
40
|
+
import { oidcCertificateSignerKind } from './kernel-signer-kind.mjs';
|
|
40
41
|
import { serializeJson } from './lib.mjs';
|
|
41
42
|
import {
|
|
42
43
|
assertGitHubProducerContext,
|
|
@@ -52,6 +53,7 @@ const COMPLETENESS_BUNDLE_PATH = 'completeness/bundle.sigstore.json';
|
|
|
52
53
|
/** The keyless signer a GitHub workflow is: its certificate identity and issuer. */
|
|
53
54
|
export function githubWorkflowSigner({ repository, ref, path }) {
|
|
54
55
|
return {
|
|
56
|
+
kind: oidcCertificateSignerKind(),
|
|
55
57
|
certificateIdentity: `https://github.com/${repository}/${path}@${ref}`,
|
|
56
58
|
oidcIssuer: 'https://token.actions.githubusercontent.com',
|
|
57
59
|
githubWorkflow: { repository, ref, path },
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
26
26
|
import { pathToFileURL } from 'node:url';
|
|
27
27
|
|
|
28
|
+
import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
|
|
28
29
|
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
29
30
|
import {
|
|
30
31
|
WorkspaceRoot,
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
sha256Digest,
|
|
51
52
|
} from './image-evidence.mjs';
|
|
52
53
|
import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
|
|
54
|
+
import { oidcCertificateSignerKind } from './kernel-signer-kind.mjs';
|
|
53
55
|
import { serializeJson } from './lib.mjs';
|
|
54
56
|
import { assertStagedExactly, reserveSealing, sealAccepted } from './sealed-evidence-accept.mjs';
|
|
55
57
|
import { BUNDLE_MEDIA_TYPE } from './sigstore-bundle-media-type.mjs';
|
|
@@ -219,7 +221,7 @@ function artifactStem(subject) {
|
|
|
219
221
|
function assertSigner(signer) {
|
|
220
222
|
exactKeys(
|
|
221
223
|
signer,
|
|
222
|
-
['certificateIdentity', 'oidcIssuer', 'githubWorkflow'],
|
|
224
|
+
['kind', 'certificateIdentity', 'oidcIssuer', 'githubWorkflow'],
|
|
223
225
|
'image-source producer signer',
|
|
224
226
|
);
|
|
225
227
|
exactKeys(
|
|
@@ -238,6 +240,11 @@ function assertSigner(signer) {
|
|
|
238
240
|
throw new Error('image-source producer GitHub workflow is not canonical');
|
|
239
241
|
}
|
|
240
242
|
const expectedIdentity = `https://github.com/${repository}/${path}@${ref}`;
|
|
243
|
+
if (signer.kind !== oidcCertificateSignerKind()) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`image-source producer signer kind must be "${oidcCertificateSignerKind()}"`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
241
248
|
if (
|
|
242
249
|
signer.certificateIdentity !== expectedIdentity ||
|
|
243
250
|
signer.oidcIssuer !== 'https://token.actions.githubusercontent.com'
|
|
@@ -514,16 +521,7 @@ export function cosignVerifyAttestationArgs({ bundlePath, signer, certificateWor
|
|
|
514
521
|
'--new-bundle-format',
|
|
515
522
|
'--bundle',
|
|
516
523
|
bundlePath,
|
|
517
|
-
|
|
518
|
-
signer.certificateIdentity,
|
|
519
|
-
'--certificate-oidc-issuer',
|
|
520
|
-
signer.oidcIssuer,
|
|
521
|
-
'--certificate-github-workflow-repository',
|
|
522
|
-
signer.githubWorkflow.repository,
|
|
523
|
-
'--certificate-github-workflow-ref',
|
|
524
|
-
signer.githubWorkflow.ref,
|
|
525
|
-
'--certificate-github-workflow-sha',
|
|
526
|
-
certificateWorkflowSha,
|
|
524
|
+
...certificateConstraintArgs(signer, certificateWorkflowSha),
|
|
527
525
|
'--type',
|
|
528
526
|
predicateType,
|
|
529
527
|
];
|
|
@@ -93,6 +93,17 @@ export function readRawStatementAuthorityCommits({ directory }) {
|
|
|
93
93
|
return commits;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Evidence bytes that do not match the digests that describe them: a tampering
|
|
98
|
+
* signal. It is never an invalid-but-skippable referrer; it fails closed.
|
|
99
|
+
*/
|
|
100
|
+
export class EvidenceIntegrityError extends Error {
|
|
101
|
+
constructor(message) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.name = 'EvidenceIntegrityError';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
/** Read and structurally validate one producer evidence directory. */
|
|
97
108
|
export function readProducerEvidenceDirectory({ directory, schemas }) {
|
|
98
109
|
if (!isAbsolute(directory)) {
|
|
@@ -150,7 +161,7 @@ export function readProducerEvidenceDirectory({ directory, schemas }) {
|
|
|
150
161
|
sha256Digest(statementBytes) !== descriptor.statementDigest ||
|
|
151
162
|
sha256Digest(bundleBytes) !== descriptor.bundleDigest
|
|
152
163
|
) {
|
|
153
|
-
throw new
|
|
164
|
+
throw new EvidenceIntegrityError(`${key} artifacts do not match their descriptor`);
|
|
154
165
|
}
|
|
155
166
|
validateProducedBundle(statementBytes, bundleBytes);
|
|
156
167
|
const statement = schemas.parseImageProducerStatement(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// record-refused — the refusal class of `select-records`: the data says no (a record is missing, unsigned,
|
|
2
|
+
// contradicts its tag, or its commit is not the develop line). Leaf module, so the ancestry check and the
|
|
3
|
+
// selection can both subclass it. A registry, cosign or git that could not answer is a plain Error instead.
|
|
4
|
+
|
|
5
|
+
export class RecordRefused extends Error {
|
|
6
|
+
constructor(repo, message) {
|
|
7
|
+
super(`select-records: ${repo}: ${message}`);
|
|
8
|
+
this.name = 'RecordRefused';
|
|
9
|
+
this.repo = repo;
|
|
10
|
+
}
|
|
11
|
+
}
|