@xemahq/xema 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/appliance-base/distribution.lock.json +3936 -1515
- package/dist/assets/appliance-base/images.lock.json +878 -1161
- package/dist/assets/appliance-lean/distribution.lock.json +3663 -916
- package/dist/assets/appliance-lean/images.lock.json +825 -924
- package/dist/assets/boot-graph.json +398 -93
- package/dist/assets/distribution-assets.manifest.json +10 -10
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +5 -3
- package/dist/commands/edition.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +0 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +4386 -2503
- package/dist/edition/verify-image-evidence.bundle.provenance.json +590 -488
- package/dist/runtime/auth/cli-application.d.ts +18 -0
- package/dist/runtime/auth/cli-application.d.ts.map +1 -0
- package/dist/runtime/auth/cli-application.js +39 -0
- package/dist/runtime/auth/cli-application.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +3 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +5 -1
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +21 -0
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +0 -18
- package/dist/runtime/launch.js.map +1 -1
- package/edition/README.md +20 -3
- package/edition/biome-content.mjs +5 -2
- package/edition/build-runtime-image-source-index.mjs +79 -5
- package/edition/canonical-evidence-json.mjs +82 -0
- package/edition/catalogue-evidence.mjs +8 -5
- package/edition/customer-edition-verbs.mjs +9 -3
- package/edition/develop-coverage-run.mjs +2 -1
- package/edition/develop-coverage.mjs +12 -1
- package/edition/develop-edition-resolve.mjs +3 -2
- package/edition/develop-edition.mjs +15 -3
- package/edition/develop-record-verify.mjs +216 -0
- package/edition/develop-select-records.mjs +24 -225
- package/edition/develop-verify-image-record.mjs +90 -0
- package/edition/dist-check.mjs +22 -2
- package/edition/distribution-evidence-set.mjs +30 -2
- package/edition/edition-artifact.mjs +110 -22
- package/edition/edition-ceiling.mjs +180 -0
- package/edition/edition-config.mjs +91 -0
- package/edition/edition-constants.mjs +21 -3
- package/edition/edition-contract-parse.mjs +88 -3
- package/edition/edition-contract.json +47 -4
- package/edition/edition-image-seal.mjs +9 -2
- package/edition/edition-kind.mjs +10 -0
- package/edition/edition-protocol.mjs +7 -1
- package/edition/edition-publisher.mjs +21 -0
- package/edition/edition-seal.mjs +257 -0
- package/edition/evidence-grammars.mjs +2 -0
- package/edition/evidence-trust-pin.mjs +66 -10
- package/edition/evidence-trust-policy.mjs +39 -29
- package/edition/external-source-walk.mjs +16 -3
- package/edition/fleet-coordinate-finality.mjs +18 -5
- package/edition/fulcio-certificate-extensions.mjs +231 -0
- package/edition/image-evidence.mjs +34 -79
- package/edition/locked-bundle-signer.mjs +224 -0
- package/edition/oci-evidence-verbs.mjs +7 -1
- package/edition/pinned-cosign.mjs +70 -0
- package/edition/plan-image-evidence-refresh.mjs +3 -1
- package/edition/producer-evidence-bundle.mjs +3 -1
- package/edition/producer-evidence-index-file.mjs +4 -0
- package/edition/release-edition-artifact.mjs +3 -3
- package/edition/repin-evidence-trust.mjs +6 -1
- package/edition/resolve-distribution.mjs +47 -6
- package/edition/resolve-edition.mjs +1 -1
- package/edition/resolve-images.mjs +53 -7
- package/edition/seal-image-digests.mjs +19 -8
- package/edition/sealed-distribution-lock.mjs +2 -2
- package/edition/third-party-admission.mjs +91 -0
- package/edition/third-party-bundle-registry.mjs +147 -0
- package/edition/third-party-certificate-extensions.mjs +21 -0
- package/edition/third-party-image-pins.mjs +41 -0
- package/edition/third-party-pins-trusted.mjs +24 -0
- package/edition/third-party-producer-evidence.mjs +76 -0
- package/edition/third-party-source-check.mjs +28 -0
- package/edition/third-party-source-context.mjs +41 -0
- package/edition/third-party-source-evidence.mjs +255 -0
- package/edition/third-party-source-flags.mjs +134 -0
- package/edition/third-party-source-pins.mjs +259 -0
- package/edition/third-party-source-uniqueness.mjs +54 -0
- package/edition/third-party-source-walk.mjs +115 -0
- package/edition/third-party-source-writer.mjs +229 -0
- package/edition/third-party-subjects.mjs +127 -0
- package/edition/trust-policy-image-ownership.mjs +85 -0
- package/edition/verify-catalogue-evidence.mjs +2 -0
- package/edition/verify-image-evidence.mjs +2 -0
- package/edition/verify-image-platforms.mjs +33 -9
- package/package.json +5 -5
- package/dist/runtime/auth/client-bootstrap.d.ts +0 -11
- package/dist/runtime/auth/client-bootstrap.d.ts.map +0 -1
- package/dist/runtime/auth/client-bootstrap.js +0 -60
- package/dist/runtime/auth/client-bootstrap.js.map +0 -1
|
@@ -78,7 +78,7 @@ import { resolveDispatchedRuntimeComponents } from './dispatched-runtime-compone
|
|
|
78
78
|
import { fleetChartSetLockPath, writeEditionChartSet } from './edition-chart-set.mjs';
|
|
79
79
|
import { workspaceSection } from './edition-config.mjs';
|
|
80
80
|
import { editionPinnedInputs } from './edition-pinned-inputs.mjs';
|
|
81
|
-
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, editionsRoot, isEntryModule, kernelPackageDir } from './edition-workspace.mjs';
|
|
81
|
+
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule, kernelPackageDir } from './edition-workspace.mjs';
|
|
82
82
|
import { walkDeclaredSources } from './external-source-walk.mjs';
|
|
83
83
|
import {
|
|
84
84
|
biomeTierOrder,
|
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
validateImmutableDistributionLock,
|
|
96
96
|
validateServiceDependencyOwnerParity,
|
|
97
97
|
} from './lib.mjs';
|
|
98
|
+
import { BundleSignerPolicyAbsentError, loadBundleSignerSelection } from './locked-bundle-signer.mjs';
|
|
98
99
|
import {
|
|
99
100
|
assertInstalledOperationCeilingAdmitted,
|
|
100
101
|
assertOperationGrantsAdmitted,
|
|
@@ -112,6 +113,7 @@ import { commandFor } from './remedy-command.mjs';
|
|
|
112
113
|
import { soleResourceProvider } from './required-resource-provider.mjs';
|
|
113
114
|
import { readStorePublisherTrust } from './store-publisher-trust.mjs';
|
|
114
115
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
116
|
+
import { isThirdPartyOciEntry } from './third-party-subjects.mjs';
|
|
115
117
|
import { RuntimeSubjectKind } from './workspace-sections.mjs';
|
|
116
118
|
|
|
117
119
|
/**
|
|
@@ -254,7 +256,14 @@ export function loadEditionManifest(parseDistribution, id) {
|
|
|
254
256
|
if (!existsSync(path)) {
|
|
255
257
|
throw new Error(`distribution "${id}" not found: ${path} does not exist`);
|
|
256
258
|
}
|
|
257
|
-
|
|
259
|
+
return parseEditionManifest(parseDistribution, id, readStrictJsonFile(path), path);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The edition model's checks on one raw definition read from `source` (a file path, or a Git object): the refused
|
|
264
|
+
* fields, the kernel's validation and the id match. The one parse of a definition, whoever read the bytes.
|
|
265
|
+
*/
|
|
266
|
+
export function parseEditionManifest(parseDistribution, id, raw, source) {
|
|
258
267
|
if (raw !== null && typeof raw === 'object' && Object.hasOwn(raw, REFUSED_EXTENDS_FIELD)) {
|
|
259
268
|
throw new EditionModelError(
|
|
260
269
|
`edition "${id}": "${REFUSED_EXTENDS_FIELD}" is not part of the edition model; ` +
|
|
@@ -271,7 +280,7 @@ export function loadEditionManifest(parseDistribution, id) {
|
|
|
271
280
|
// `sourcePins` reaches the kernel unchanged: its Distribution schema validates it.
|
|
272
281
|
const manifest = parseDistribution(raw);
|
|
273
282
|
if (manifest.id !== id) {
|
|
274
|
-
throw new Error(`distribution at ${
|
|
283
|
+
throw new Error(`distribution at ${source} declares id "${manifest.id}", but it was addressed as "${id}"`);
|
|
275
284
|
}
|
|
276
285
|
return manifest;
|
|
277
286
|
}
|
|
@@ -618,6 +627,11 @@ export function mergeExternalSourceEntries(
|
|
|
618
627
|
* emitting `source: undefined` would make it read as first-party — so this
|
|
619
628
|
* fails rather than choosing either.
|
|
620
629
|
*/
|
|
630
|
+
/** A stamped entry of a declared OCI source: third-party, an OCI bundle source and no git record. */
|
|
631
|
+
function isThirdPartyOciSourceEntry(entry, sourceRecords) {
|
|
632
|
+
return !sourceRecords.has(entry.source) && isThirdPartyOciEntry(entry);
|
|
633
|
+
}
|
|
634
|
+
|
|
621
635
|
function requireSourceRecord(entry, sourceRecords) {
|
|
622
636
|
const record = sourceRecords.get(entry.source);
|
|
623
637
|
if (record === undefined) {
|
|
@@ -1780,6 +1794,23 @@ export function resolveDistributionFromInputs(
|
|
|
1780
1794
|
// shipped provider. See `nexusBinder`.
|
|
1781
1795
|
const nexusBindings = nexusBinder(id, [...selected.values()], schemas);
|
|
1782
1796
|
|
|
1797
|
+
// (5c') Bundle signers — every OCI bundle row (a biome's, a host module's) states the signer the data root's
|
|
1798
|
+
// trust policy selects for it. See `locked-bundle-signer.mjs`.
|
|
1799
|
+
const bundleSignerOf = (e, bundleSource, componentKey) => {
|
|
1800
|
+
const subject = { biomeId: e.id, ...(componentKey === undefined ? {} : { componentKey }), origin: e.origin, repo: e.repo, bundleSource };
|
|
1801
|
+
if (composed?.bundleSigners === undefined) {
|
|
1802
|
+
throw new BundleSignerPolicyAbsentError(
|
|
1803
|
+
`${componentKey === undefined ? `biome "${e.id}"` : `host module "${componentKey}" of biome "${e.id}"`} is an OCI bundle, but this resolve was given no bundle signer selection`,
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
return composed.bundleSigners.signerFor(subject);
|
|
1807
|
+
};
|
|
1808
|
+
const bundleSignerFor = (e) => (e.bundleSource?.kind === 'oci' ? { bundleSigner: bundleSignerOf(e, e.bundleSource) } : {});
|
|
1809
|
+
const hostModulesOf = (e) =>
|
|
1810
|
+
(Array.isArray(e.hostModules) ? e.hostModules : []).map((hostModule) =>
|
|
1811
|
+
hostModule.bundleSource?.kind === 'oci' ? { ...hostModule, bundleSigner: bundleSignerOf(e, hostModule.bundleSource, hostModule.componentKey) } : hostModule,
|
|
1812
|
+
);
|
|
1813
|
+
|
|
1783
1814
|
// (5d) Dispatched runtime components — every `runtime-image` edge of a
|
|
1784
1815
|
// shipped biome must resolve to a runtime component of a biome in the index.
|
|
1785
1816
|
// The target biome is not rostered (it enters no `biomes[]`, `requires` or
|
|
@@ -1950,7 +1981,7 @@ export function resolveDistributionFromInputs(
|
|
|
1950
1981
|
// (the connector adapters loaded by `biome-adapter-host`). Projected
|
|
1951
1982
|
// verbatim from the index, which resolved each component's bundle source
|
|
1952
1983
|
// against the biome manifest.
|
|
1953
|
-
hostModules:
|
|
1984
|
+
hostModules: hostModulesOf(e),
|
|
1954
1985
|
// The BIOME-LEVEL bundle source, for a biome whose bundle no host module
|
|
1955
1986
|
// above names — the field a `installPolicy: required` service-only
|
|
1956
1987
|
// biome needs for the runtime's required-biome reconciler to fetch,
|
|
@@ -1964,6 +1995,7 @@ export function resolveDistributionFromInputs(
|
|
|
1964
1995
|
// fact none of their entries carries. A lock in which SOME biome carries
|
|
1965
1996
|
// it still is the one v5 grammar once sealed.
|
|
1966
1997
|
...(e.bundleSource === undefined ? {} : { bundleSource: e.bundleSource }),
|
|
1998
|
+
...bundleSignerFor(e),
|
|
1967
1999
|
serviceDependencies: Array.isArray(e.serviceDependencies)
|
|
1968
2000
|
? e.serviceDependencies
|
|
1969
2001
|
: [],
|
|
@@ -2125,7 +2157,11 @@ export function resolveDistributionFromInputs(
|
|
|
2125
2157
|
// scopes on — and the lock records the full pinned `LockedBiomeSource`,
|
|
2126
2158
|
// so a reader of the artifact never has to resolve a handle against a
|
|
2127
2159
|
// manifest it does not have.
|
|
2128
|
-
|
|
2160
|
+
//
|
|
2161
|
+
// A third-party OCI biome is stamped too (the edition fence keys on the stamp) but is NOT in `sourceRecords`:
|
|
2162
|
+
// the lock's `LockedBiomeSource` is git-only, so such a row carries no `source` and is held to its pin instead
|
|
2163
|
+
// (`third-party-subjects.mjs`).
|
|
2164
|
+
...(e.source === undefined || isThirdPartyOciSourceEntry(e, sourceRecords)
|
|
2129
2165
|
? {}
|
|
2130
2166
|
: { source: requireSourceRecord(e, sourceRecords) }),
|
|
2131
2167
|
}))
|
|
@@ -2353,7 +2389,12 @@ export async function resolveDistributionWithIndex(id, { materialize, inputs, re
|
|
|
2353
2389
|
id,
|
|
2354
2390
|
index,
|
|
2355
2391
|
schemas,
|
|
2356
|
-
{
|
|
2392
|
+
{
|
|
2393
|
+
...composed,
|
|
2394
|
+
sourceRecords,
|
|
2395
|
+
storePublisherTrust,
|
|
2396
|
+
bundleSigners: inputs?.bundleSigners ?? loadBundleSignerSelection({ dataRoot: dataRoot(), schemas }),
|
|
2397
|
+
},
|
|
2357
2398
|
new Date().toISOString(),
|
|
2358
2399
|
platformServiceIndex,
|
|
2359
2400
|
);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// node resolve-edition.mjs <root flags> <edition-id> --trusted-write-v3
|
|
8
8
|
// write the whole lock set into the editions root. The flag is the
|
|
9
9
|
// operator's explicit acknowledgement that the images lock is rewritten
|
|
10
|
-
// UNSEALED (a resolve never produces an `imageDigest`; `xema edition
|
|
10
|
+
// UNSEALED (a resolve never produces an `imageDigest`; `xema edition resolve-digests`
|
|
11
11
|
// writes them afterwards). Without it nothing is written.
|
|
12
12
|
// node resolve-edition.mjs <root flags> <edition-id> --check
|
|
13
13
|
// resolve the whole lock set into a scratch copy of the editions root and
|
|
@@ -72,6 +72,14 @@ import {
|
|
|
72
72
|
} from './lib.mjs';
|
|
73
73
|
import { assertReleaseContent } from './release-channel-guard.mjs';
|
|
74
74
|
import { composeEdition } from './resolve-distribution.mjs';
|
|
75
|
+
import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
|
|
76
|
+
import {
|
|
77
|
+
assertOciBundleSourceOrigin,
|
|
78
|
+
attestedContentHash,
|
|
79
|
+
isThirdPartyOciRow,
|
|
80
|
+
thirdPartyPinForRow,
|
|
81
|
+
thirdPartySubjectCandidates,
|
|
82
|
+
} from './third-party-subjects.mjs';
|
|
75
83
|
|
|
76
84
|
if (isEntryModule(import.meta.url)) {
|
|
77
85
|
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache]);
|
|
@@ -418,7 +426,7 @@ export function resolveImageStructure(
|
|
|
418
426
|
enums,
|
|
419
427
|
requiredPlatforms,
|
|
420
428
|
declaredSources,
|
|
421
|
-
{ biomeIndex, supplementalSources } = {},
|
|
429
|
+
{ biomeIndex, supplementalSources, thirdPartyPins } = {},
|
|
422
430
|
) {
|
|
423
431
|
if (!Array.isArray(requiredPlatforms) || requiredPlatforms.length === 0) {
|
|
424
432
|
throw new Error(
|
|
@@ -440,6 +448,9 @@ export function resolveImageStructure(
|
|
|
440
448
|
);
|
|
441
449
|
}
|
|
442
450
|
const subjects = new Map();
|
|
451
|
+
// Third-party OCI subjects come from their pin and are never built, hashed or read from a checkout, so they are kept
|
|
452
|
+
// apart from every git-backed subject (see `third-party-subjects.mjs`).
|
|
453
|
+
const thirdPartySubjects = new Map();
|
|
443
454
|
const webBiomes = [];
|
|
444
455
|
const platformServiceBindings = [];
|
|
445
456
|
const repositoryPins = new Map(
|
|
@@ -479,6 +490,15 @@ export function resolveImageStructure(
|
|
|
479
490
|
};
|
|
480
491
|
|
|
481
492
|
for (const biome of lock.biomes) {
|
|
493
|
+
const biomeLabel = `distribution "${lock.distributionId}" biome "${biome.id}"`;
|
|
494
|
+
assertOciBundleSourceOrigin(biome, biomeLabel);
|
|
495
|
+
if (isThirdPartyOciRow(biome)) {
|
|
496
|
+
const pin = thirdPartyPinForRow({ row: biome, pins: thirdPartyPins, declaredSources, label: biomeLabel });
|
|
497
|
+
for (const candidate of thirdPartySubjectCandidates({ row: biome, pin, enums, requiredPlatforms, label: biomeLabel })) {
|
|
498
|
+
addSubject(thirdPartySubjects, candidate);
|
|
499
|
+
}
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
482
502
|
// WHERE THIS BIOME'S IMAGES LIVE, and WHICH COMMIT THEY ARE BUILT FROM.
|
|
483
503
|
// Both answers move together when a biome is drawn from an external source,
|
|
484
504
|
// because both are facts about that source rather than about this fleet.
|
|
@@ -662,7 +682,7 @@ export function resolveImageStructure(
|
|
|
662
682
|
// distribution resolve if it dispatches, and the first-party index carries
|
|
663
683
|
// none, so it is not looked up here.
|
|
664
684
|
const dispatchers = lock.biomes
|
|
665
|
-
.filter((biome) => biome.source === undefined && biome.target === 'server')
|
|
685
|
+
.filter((biome) => biome.source === undefined && !isThirdPartyOciRow(biome) && biome.target === 'server')
|
|
666
686
|
.map((biome) => {
|
|
667
687
|
const entries = biomeIndex.filter(
|
|
668
688
|
(entry) =>
|
|
@@ -892,13 +912,29 @@ export function resolveImageStructure(
|
|
|
892
912
|
platformServiceBindings.sort((left, right) =>
|
|
893
913
|
compareStrings(left.platformService, right.platformService),
|
|
894
914
|
);
|
|
915
|
+
const orderedThirdPartySubjects = [...thirdPartySubjects.values()]
|
|
916
|
+
.map((subject) => ({ ...subject, owners: subject.owners.sort(compareOwner) }))
|
|
917
|
+
.sort(compareSubject);
|
|
918
|
+
for (const subject of orderedThirdPartySubjects) {
|
|
919
|
+
if (subjects.has(subjectKey(subject))) {
|
|
920
|
+
throw new Error(
|
|
921
|
+
`third-party runtime image subject "${subject.kind}:${subject.id}" has the identity of another subject of distribution "${lock.distributionId}"`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
895
925
|
return {
|
|
896
926
|
subjects: orderedSubjects,
|
|
927
|
+
thirdPartySubjects: orderedThirdPartySubjects,
|
|
897
928
|
webBiomes,
|
|
898
929
|
platformServiceBindings,
|
|
899
930
|
};
|
|
900
931
|
}
|
|
901
932
|
|
|
933
|
+
/** Every image subject of a structure, first-party and third-party, in the lock's order. */
|
|
934
|
+
export function structureSubjects(structure) {
|
|
935
|
+
return [...structure.subjects, ...structure.thirdPartySubjects].sort(compareSubject);
|
|
936
|
+
}
|
|
937
|
+
|
|
902
938
|
/**
|
|
903
939
|
* THE COMPOSED-PROCESS IMAGE OF ONE BUILD — where a producing repository
|
|
904
940
|
* declares it, and how an edition addresses it.
|
|
@@ -1775,7 +1811,11 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed } = {
|
|
|
1775
1811
|
schemas,
|
|
1776
1812
|
requiredPlatforms,
|
|
1777
1813
|
declaredSources,
|
|
1778
|
-
{
|
|
1814
|
+
{
|
|
1815
|
+
biomeIndex,
|
|
1816
|
+
supplementalSources: supplementalSourcesFor(runtimeSourceIndex),
|
|
1817
|
+
thirdPartyPins: loadThirdPartySourcePins(dataRoot()),
|
|
1818
|
+
},
|
|
1779
1819
|
),
|
|
1780
1820
|
schemas,
|
|
1781
1821
|
requiredPlatforms,
|
|
@@ -1909,8 +1949,11 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed } = {
|
|
|
1909
1949
|
'refusing image-lock write: a source repository commit changed while hashing',
|
|
1910
1950
|
);
|
|
1911
1951
|
}
|
|
1952
|
+
// After the ledger check and the hashing: a third-party subject has no ledger row and no checkout, and its
|
|
1953
|
+
// `contentHash` is the publisher's statement, so none of the above read it.
|
|
1912
1954
|
return {
|
|
1913
|
-
subjects,
|
|
1955
|
+
subjects: structure.thirdPartySubjects.length === 0 ? subjects : [...subjects, ...structure.thirdPartySubjects].sort(compareSubject),
|
|
1956
|
+
thirdPartySubjects: structure.thirdPartySubjects,
|
|
1914
1957
|
platformServiceBindings: structure.platformServiceBindings,
|
|
1915
1958
|
webBiomes,
|
|
1916
1959
|
contractAuthority: runtimeSourceIndex.contractAuthority,
|
|
@@ -1920,7 +1963,7 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed } = {
|
|
|
1920
1963
|
|
|
1921
1964
|
/** One edition's COMPLETE image lock: its rows, parsed and validated as the kernel's image lock. */
|
|
1922
1965
|
export async function resolveImages(id, { inputs, lock, onSubjectHashed } = {}) {
|
|
1923
|
-
const { subjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock, onSubjectHashed });
|
|
1966
|
+
const { subjects, thirdPartySubjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock, onSubjectHashed });
|
|
1924
1967
|
const schemas = await loadKernelDistributionSchemas();
|
|
1925
1968
|
const imageLock = validateImmutableImageLock(
|
|
1926
1969
|
schemas.parseImageLock({
|
|
@@ -1934,7 +1977,7 @@ export async function resolveImages(id, { inputs, lock, onSubjectHashed } = {})
|
|
|
1934
1977
|
}),
|
|
1935
1978
|
schemas,
|
|
1936
1979
|
);
|
|
1937
|
-
return { imageLock, commits };
|
|
1980
|
+
return { imageLock, commits, thirdPartySubjects };
|
|
1938
1981
|
}
|
|
1939
1982
|
|
|
1940
1983
|
/**
|
|
@@ -1954,7 +1997,7 @@ export async function writeResolvedImages(
|
|
|
1954
1997
|
}
|
|
1955
1998
|
// The RELEASE lock is published-only: a develop edition resolves through
|
|
1956
1999
|
// `develop-edition.mjs` and never reaches this writer.
|
|
1957
|
-
const { imageLock, commits } = await resolveImages(id);
|
|
2000
|
+
const { imageLock, commits, thirdPartySubjects } = await resolveImages(id);
|
|
1958
2001
|
const outPath = distributionOutputPath(
|
|
1959
2002
|
editionsRoot(),
|
|
1960
2003
|
id,
|
|
@@ -1969,6 +2012,9 @@ export async function writeResolvedImages(
|
|
|
1969
2012
|
for (const [repo, commit] of commits) {
|
|
1970
2013
|
console.log(` source ${repo}@${commit}`);
|
|
1971
2014
|
}
|
|
2015
|
+
for (const subject of thirdPartySubjects) {
|
|
2016
|
+
console.log(` third-party ${subject.kind}:${subject.id} ${subject.imageRef} ${attestedContentHash(subject)}`);
|
|
2017
|
+
}
|
|
1972
2018
|
}
|
|
1973
2019
|
return { imageLock, commits };
|
|
1974
2020
|
}
|
|
@@ -121,7 +121,7 @@ import { parseCustomerDefinition } from './customer-definition.mjs';
|
|
|
121
121
|
import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, DEFINITION_FLAG } from './customer-flags.mjs';
|
|
122
122
|
import { EditionChannel } from './edition-constants.mjs';
|
|
123
123
|
import { editionPinnedInputs } from './edition-pinned-inputs.mjs';
|
|
124
|
-
import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, editionsRoot, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
|
|
124
|
+
import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
|
|
125
125
|
import {
|
|
126
126
|
assertCoordinatesFinal,
|
|
127
127
|
collectFleetPlatformRequirements,
|
|
@@ -137,6 +137,8 @@ import { externalSourceTrees, sourceTreeLocator } from './resolve-images.mjs';
|
|
|
137
137
|
import { readSealedDistributionLock } from './sealed-distribution-lock.mjs';
|
|
138
138
|
import { committedRepositoryPins, createPinnedSourceAuthority } from './source-authority.mjs';
|
|
139
139
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
140
|
+
import { pinnedManifestDigests, thirdPartyImagePins } from './third-party-image-pins.mjs';
|
|
141
|
+
import { loadTrustedThirdPartySourcePins } from './third-party-pins-trusted.mjs';
|
|
140
142
|
import {
|
|
141
143
|
loadImageLockForVerification,
|
|
142
144
|
verifyDistributionPlatforms,
|
|
@@ -569,14 +571,14 @@ export async function sealEditionLocks({ distributionId, root, digests = new Map
|
|
|
569
571
|
* The decision, with no files: an UNSEALED lock is sealed (refused under `check`); a SEALED lock is VERIFIED -
|
|
570
572
|
* every hash recomputed, a mismatch refused naming the biome.
|
|
571
573
|
*/
|
|
572
|
-
export async function sealOrVerifyLock({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, check = false, lockPath = 'distribution.lock.json' }) {
|
|
574
|
+
export async function sealOrVerifyLock({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, bundleSigners, check = false, lockPath = 'distribution.lock.json' }) {
|
|
573
575
|
if ('unsealedSchema' in lock) {
|
|
574
576
|
if (check) {
|
|
575
|
-
throw new Error(`${lockPath} is unsealed (no biomeContentHash); run \`xema edition
|
|
577
|
+
throw new Error(`${lockPath} is unsealed (no biomeContentHash); run \`xema edition resolve-digests ${lock.distributionId}\``);
|
|
576
578
|
}
|
|
577
|
-
return { sealed: true, lock: await sealLock({ schemas, unsealed: lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys }) };
|
|
579
|
+
return { sealed: true, lock: await sealLock({ schemas, unsealed: lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, bundleSigners }) };
|
|
578
580
|
}
|
|
579
|
-
await verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys });
|
|
581
|
+
await verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, bundleSigners });
|
|
580
582
|
return { sealed: false, lock };
|
|
581
583
|
}
|
|
582
584
|
|
|
@@ -586,7 +588,7 @@ async function main() {
|
|
|
586
588
|
parseSealArgs(args);
|
|
587
589
|
if (!distributionId || rest.length > 0) {
|
|
588
590
|
throw new Error(
|
|
589
|
-
'usage: xema edition
|
|
591
|
+
'usage: xema edition resolve-digests <distributionId> ' +
|
|
590
592
|
`[--check | --if-published | --publication-verdict] ` +
|
|
591
593
|
`[--definition <file> [--catalogue-username <name> --catalogue-token-env <VAR>]]`,
|
|
592
594
|
);
|
|
@@ -700,12 +702,16 @@ async function main() {
|
|
|
700
702
|
? undefined
|
|
701
703
|
: parseCustomerDefinition(await adoptCatalogueKernel(catalogue), readStrictJsonFile(catalogueFlags.get(DEFINITION_FLAG))).definition.storePublisherAnchorKeys;
|
|
702
704
|
const imageLock = await loadImageLockForVerification(distributionId, root);
|
|
705
|
+
// A pinned third-party subject (its repository is in the data root's third-party source pins) is verified and sealed
|
|
706
|
+
// BY the pin's manifest digest: its contentHash is publisher-attested and is not a tag.
|
|
707
|
+
const thirdPartyPins = loadTrustedThirdPartySourcePins(dataRoot());
|
|
708
|
+
const pinnedImages = thirdPartyImagePins({ pins: thirdPartyPins, subjects: imageLock.subjects });
|
|
703
709
|
|
|
704
710
|
// Throws on ANY unresolvable subject or missing required platform, before a
|
|
705
711
|
// single byte of the lock is touched.
|
|
706
712
|
let report;
|
|
707
713
|
try {
|
|
708
|
-
report = await verifyDistributionPlatforms({ imageLock, distributionsRoot: root });
|
|
714
|
+
report = await verifyDistributionPlatforms({ imageLock, distributionsRoot: root, pinnedDigests: pinnedManifestDigests(pinnedImages) });
|
|
709
715
|
} catch (error) {
|
|
710
716
|
// RECONCILE: a coordinate this pass just re-pinned has not been built yet.
|
|
711
717
|
// Skip the EDITION — never some of its subjects — and leave the lock
|
|
@@ -839,14 +845,19 @@ async function main() {
|
|
|
839
845
|
// introduced and the repository holding every edition's lock is where the
|
|
840
846
|
// question is answerable at all.
|
|
841
847
|
const { editions, withoutLock } = readFleetImageLocks(root);
|
|
842
|
-
const fleetRequirements = collectFleetPlatformRequirements({ editions });
|
|
848
|
+
const fleetRequirements = collectFleetPlatformRequirements({ editions, isThirdPartySubject: (subject) => thirdPartyPins.byRepository(subject.repo) !== undefined });
|
|
843
849
|
try {
|
|
844
850
|
const finality = assertCoordinatesFinal({
|
|
845
851
|
distributionId,
|
|
846
852
|
writes,
|
|
847
853
|
verified: report.subjects,
|
|
848
854
|
fleetRequirements,
|
|
855
|
+
thirdPartyKeys: new Set(pinnedImages.keys()),
|
|
849
856
|
});
|
|
857
|
+
// Third-party subjects are NOT graded by the first-party fleet verdict, and are named here rather than skipped silently.
|
|
858
|
+
for (const key of finality.thirdParty) {
|
|
859
|
+
console.log(`[${label}] finality: ${key} third-party (pinned by digest), not a first-party fleet coordinate.`);
|
|
860
|
+
}
|
|
850
861
|
// The CORPUS, not a confirmation: how many coordinates were graded against
|
|
851
862
|
// how many editions, and which editions made no claim. Without the
|
|
852
863
|
// denominators a pass cannot be told from a pass over nothing, and the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// sealed-distribution-lock — the one predicate for "this committed distribution.lock.json is the SEALED grammar".
|
|
2
|
-
// `xema edition artifact` and the `xema edition
|
|
2
|
+
// `xema edition artifact` and the `xema edition resolve-digests --publication-verdict` gate both ask it, so the verdict can
|
|
3
3
|
// never say PUBLISHABLE for a lock the artifact verb then refuses.
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
@@ -15,7 +15,7 @@ export function readSealedDistributionLock({ schemas, lockPath, distributionId }
|
|
|
15
15
|
} catch (error) {
|
|
16
16
|
throw new Error(
|
|
17
17
|
`${lockPath} is not a sealed distribution lock, so nothing is published from it. ` +
|
|
18
|
-
`Run \`xema edition
|
|
18
|
+
`Run \`xema edition resolve-digests ${distributionId}\` first. (${error.message})`,
|
|
19
19
|
{ cause: error },
|
|
20
20
|
);
|
|
21
21
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Admission of third-party image subjects (XEMA-1611 design section 4), beside `externalSourceRepositoryPins` and
|
|
2
|
+
// `verifyDistributionImageEvidenceSet` of `image-evidence.mjs`. Pure over data: the pins come from the data root's
|
|
3
|
+
// `third-party-source-pins.json` (held to the evidence trust pin by the entry), the statement and bundle from a
|
|
4
|
+
// descriptor `verifySigstoreEvidenceDescriptor` has just verified.
|
|
5
|
+
|
|
6
|
+
import { assertCertificateExtensions } from './third-party-certificate-extensions.mjs';
|
|
7
|
+
|
|
8
|
+
/** The declared source kind that names a third-party pin. */
|
|
9
|
+
const OCI_SOURCE_KIND = 'oci';
|
|
10
|
+
const FIRST_PARTY_LAYOUT_PREFIX = /^(?:repos|submodules)\//u;
|
|
11
|
+
|
|
12
|
+
const repositoryPathOf = (repositoryUrl) => new URL(repositoryUrl).pathname.replace(/^\//u, '').replace(/\.git$/u, '');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The repository pins of the edition's declared OCI sources, derived from their third-party source pins:
|
|
16
|
+
* `repo` (the pinned `owner/name`), `repositoryUrl` and `sourceCommit`. Every declared OCI source must be pinned (a
|
|
17
|
+
* missing or empty pin file refuses, with the loader's reason), and the pin must not collide with an index pin or a git
|
|
18
|
+
* source, nor sit under the first-party layout (`repos/`, `submodules/`). A declared source that ships no image yields no
|
|
19
|
+
* pin; every subject of a source must agree on the pinned commit.
|
|
20
|
+
*
|
|
21
|
+
* @returns {Map<string, {repo: string, repositoryUrl: string, sourceCommit: string}>}
|
|
22
|
+
*/
|
|
23
|
+
export function thirdPartySourceRepositoryPins({ distribution, imageLock, repositoryPins, thirdPartyPins }) {
|
|
24
|
+
const sources = distribution.sources ?? [];
|
|
25
|
+
const derived = new Map();
|
|
26
|
+
for (const source of sources) {
|
|
27
|
+
if (source.kind !== OCI_SOURCE_KIND) {continue;}
|
|
28
|
+
const pin = thirdPartyPins.byName(source.name);
|
|
29
|
+
if (pin === undefined) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`edition source "${source.name}" is kind "${OCI_SOURCE_KIND}", but ${thirdPartyPins.reason ?? `the data root's third-party source pins hold no pin named "${source.name}"`}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (FIRST_PARTY_LAYOUT_PREFIX.test(pin.repository)) {
|
|
35
|
+
throw new Error(`third-party source "${pin.name}" pins repository "${pin.repository}", which is under the first-party layout (repos/ or submodules/)`);
|
|
36
|
+
}
|
|
37
|
+
if (repositoryPins.has(pin.repository)) {
|
|
38
|
+
throw new Error(`third-party source "${pin.name}" repository "${pin.repository}" shadows a repository pin the runtime image source index already commits`);
|
|
39
|
+
}
|
|
40
|
+
const shadowed = sources.find((candidate) => candidate.kind === 'git' && (candidate.name === pin.repository || candidate.name === pin.name));
|
|
41
|
+
if (shadowed !== undefined) {
|
|
42
|
+
throw new Error(`third-party source "${pin.name}" (${pin.repository}) collides with the git source "${shadowed.name}" of the same edition`);
|
|
43
|
+
}
|
|
44
|
+
const subjects = imageLock.subjects.filter((subject) => subject.repo === pin.repository);
|
|
45
|
+
if (subjects.length === 0) {continue;}
|
|
46
|
+
const wrong = subjects.filter((subject) => subject.sourceCommit !== pin.sourceCommit);
|
|
47
|
+
if (wrong.length > 0) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`third-party source "${pin.name}" is pinned at ${pin.sourceCommit}, but images.lock.json builds ${wrong.map((subject) => `${subject.kind}:${subject.id}`).join(', ')} at ${[...new Set(wrong.map((subject) => subject.sourceCommit))].join(', ')}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
derived.set(pin.repository, { repo: pin.repository, repositoryUrl: pin.repositoryUrl, sourceCommit: pin.sourceCommit });
|
|
53
|
+
}
|
|
54
|
+
return derived;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The pins behind `thirdPartySourceRepositoryPins`, by repository. */
|
|
58
|
+
export const thirdPartySourcesByRepository = (derived, thirdPartyPins) =>
|
|
59
|
+
new Map([...derived.keys()].map((repo) => [repo, thirdPartyPins.byRepository(repo)]));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a third-party subject must satisfy beyond a first-party one, after its evidence has verified:
|
|
63
|
+
* * [d9-1] the signing row's workflow repository, the subject's `repo` and the path of the predicate's
|
|
64
|
+
* `source.repositoryUrl` are one repository;
|
|
65
|
+
* * the subject is the pinned image (reference, content hash, sealed digest when sealed);
|
|
66
|
+
* * (d) the leaf certificate states the pinned repository id, owner id and a GitHub-hosted runner.
|
|
67
|
+
*/
|
|
68
|
+
export function assertThirdPartySubjectAdmission({ subject, source, statement, identityPolicy, bundle }) {
|
|
69
|
+
const label = `third-party subject "${subject.kind}:${subject.id}" (source "${source.name}")`;
|
|
70
|
+
const claimedRepository = repositoryPathOf(statement.predicate.source.repositoryUrl);
|
|
71
|
+
const signerRepository = identityPolicy.githubWorkflow.repository;
|
|
72
|
+
if (signerRepository !== subject.repo || claimedRepository !== subject.repo) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`${label}: the signing row's workflow repository (${signerRepository}), the subject repo (${subject.repo}) and the predicate source.repositoryUrl repository (${claimedRepository}) must be one repository`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const image = source.images.find((candidate) => candidate.subjectKind === subject.kind && candidate.subjectId === subject.id);
|
|
78
|
+
if (image === undefined) {
|
|
79
|
+
throw new Error(`${label}: the pin pins no image for it`);
|
|
80
|
+
}
|
|
81
|
+
if (subject.imageRef !== image.imageRef) {
|
|
82
|
+
throw new Error(`${label}: images.lock.json names ${subject.imageRef}, but the pin pins ${image.imageRef}`);
|
|
83
|
+
}
|
|
84
|
+
if (subject.contentHash !== image.sourceTreeHash) {
|
|
85
|
+
throw new Error(`${label}: images.lock.json has contentHash ${subject.contentHash}, but the pin records sourceTreeHash ${image.sourceTreeHash}`);
|
|
86
|
+
}
|
|
87
|
+
if (subject.imageDigest !== undefined && subject.imageDigest !== image.manifestDigest) {
|
|
88
|
+
throw new Error(`${label}: images.lock.json seals ${subject.imageDigest}, but the pin records manifestDigest ${image.manifestDigest}`);
|
|
89
|
+
}
|
|
90
|
+
assertCertificateExtensions({ bundle, source, label });
|
|
91
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The registry side of the third-party writer (XEMA-1611 design section 3 (a)): the biome bundle, by digest, and the
|
|
2
|
+
// sigstore bundle of its SLSA provenance, read as an OCI referrer. Nothing here is trusted: every byte it returns is
|
|
3
|
+
// verified by cosign (`third-party-source-evidence.mjs`) before it is committed.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
|
|
8
|
+
import { COSIGN_ENV, decodeExactUtf8, sha256Digest, validatePinnedTrustedRoot } from './image-evidence.mjs';
|
|
9
|
+
import { splitImageRef } from './registry-credentials.mjs';
|
|
10
|
+
import { BUNDLE_MEDIA_TYPE } from './sigstore-bundle-media-type.mjs';
|
|
11
|
+
import { SLSA_PROVENANCE_V1, decodeJsonStatement, digestOfOciRef } from './third-party-source-evidence.mjs';
|
|
12
|
+
import { assertPreparedExecutable } from './trusted-executable.mjs';
|
|
13
|
+
|
|
14
|
+
const BIOME_MANIFEST_FILE = 'xema-biome.json';
|
|
15
|
+
const BIOME_GENERATED_INDEX_FILE = 'xema-biome.index.json';
|
|
16
|
+
const OCI_TITLE_ANNOTATION = 'org.opencontainers.image.title';
|
|
17
|
+
const MAX_BIOME_MANIFEST_BYTES = 4 * 1024 * 1024;
|
|
18
|
+
const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
const REGISTRY_COSIGN_TIMEOUT_MS = 120_000;
|
|
20
|
+
/** What `cosign verify-attestation --type` calls the SLSA v1 provenance predicate. */
|
|
21
|
+
const COSIGN_SLSA_PROVENANCE_ALIAS = 'slsaprovenance1';
|
|
22
|
+
|
|
23
|
+
const repositoryOf = (ociRef) => splitImageRef(ociRef.slice(0, ociRef.indexOf('@')));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The biome bundle manifest JSON (`xema-biome.json`) of an OCI artifact, fetched by digest.
|
|
27
|
+
*
|
|
28
|
+
* @returns {Promise<{manifest: object, bytes: Buffer}>}
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchBundleManifest({ clientFor, ociRef }) {
|
|
31
|
+
const bytes = await fetchTitledLayer({ clientFor, ociRef, title: BIOME_MANIFEST_FILE });
|
|
32
|
+
return { manifest: decodeJsonStatement(bytes, `${ociRef} ${BIOME_MANIFEST_FILE}`), bytes };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The committed generated index (`xema-biome.index.json`) of an OCI biome bundle, fetched by digest: the authority for
|
|
37
|
+
* the biomes the bundle requires, read from the same bundle bytes as its manifest, so it cannot describe another revision.
|
|
38
|
+
*/
|
|
39
|
+
export async function fetchBundleGeneratedIndex({ clientFor, ociRef }) {
|
|
40
|
+
const bytes = await fetchTitledLayer({ clientFor, ociRef, title: BIOME_GENERATED_INDEX_FILE });
|
|
41
|
+
return decodeJsonStatement(bytes, `${ociRef} ${BIOME_GENERATED_INDEX_FILE}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The one layer of the bundle manifest at `ociRef` (by digest) annotated with `title`. */
|
|
45
|
+
async function fetchTitledLayer({ clientFor, ociRef, title }) {
|
|
46
|
+
const { authority, repository } = repositoryOf(ociRef);
|
|
47
|
+
const client = clientFor(authority);
|
|
48
|
+
const { document } = await client.getManifestByDigest(repository, digestOfOciRef(ociRef));
|
|
49
|
+
const layers = (document.layers ?? []).filter((layer) => layer.annotations?.[OCI_TITLE_ANNOTATION] === title);
|
|
50
|
+
if (layers.length !== 1) {
|
|
51
|
+
throw new Error(`${ociRef} has ${layers.length} layers titled ${title}; a biome bundle has exactly one`);
|
|
52
|
+
}
|
|
53
|
+
const bytes = await client.getBlob(repository, layers[0].digest, MAX_BIOME_MANIFEST_BYTES);
|
|
54
|
+
if (sha256Digest(bytes) !== layers[0].digest) {
|
|
55
|
+
throw new Error(`${ociRef} layer titled ${title} does not match its descriptor`);
|
|
56
|
+
}
|
|
57
|
+
return bytes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The image names a biome manifest ships: its service and worker components with an `oci-image` artifact, named the way
|
|
62
|
+
* the index builder names them (a service by `protocol.serviceName`, a worker by its component `key`).
|
|
63
|
+
*/
|
|
64
|
+
export function bundleImageNames(manifest) {
|
|
65
|
+
const components = Array.isArray(manifest?.xema?.components) ? manifest.xema.components : [];
|
|
66
|
+
return new Set(
|
|
67
|
+
components
|
|
68
|
+
.filter((component) => (component?.kind === 'service' || component?.kind === 'worker') && component.artifact?.kind === 'oci-image')
|
|
69
|
+
.map((component) => (component.kind === 'service' ? component.protocol?.serviceName : component.key)),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function provenanceOf(bundleBytes, bundleDigest) {
|
|
74
|
+
const bundle = JSON.parse(decodeExactUtf8(bundleBytes, 'sigstore bundle'));
|
|
75
|
+
if (bundle?.dsseEnvelope === undefined) {
|
|
76
|
+
return null; // a message-signature bundle (the artifact signature): not an attestation
|
|
77
|
+
}
|
|
78
|
+
const payload = Buffer.from(bundle.dsseEnvelope.payload, 'base64');
|
|
79
|
+
const statement = decodeJsonStatement(payload, 'DSSE payload');
|
|
80
|
+
if (statement.predicateType !== SLSA_PROVENANCE_V1) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (`sha256:${statement.subject?.[0]?.digest?.sha256}` !== bundleDigest) {
|
|
84
|
+
throw new Error(`a SLSA provenance referrer attests ${statement.subject?.[0]?.digest?.sha256}, not ${bundleDigest}`);
|
|
85
|
+
}
|
|
86
|
+
return { statementBytes: payload, bundleBytes };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The SLSA provenance attestation of the bundle: the one sigstore-bundle referrer of `ociRef` whose DSSE statement is a
|
|
91
|
+
* `https://slsa.dev/provenance/v1` attestation over exactly this digest. None, or more than one, is refused.
|
|
92
|
+
*
|
|
93
|
+
* @returns {Promise<{statementBytes: Buffer, bundleBytes: Buffer}>}
|
|
94
|
+
*/
|
|
95
|
+
export async function fetchBundleProvenance({ clientFor, ociRef }) {
|
|
96
|
+
const { authority, repository } = repositoryOf(ociRef);
|
|
97
|
+
const client = clientFor(authority);
|
|
98
|
+
const subjectDigest = digestOfOciRef(ociRef);
|
|
99
|
+
const referrers = await client.listReferrers(repository, subjectDigest, BUNDLE_MEDIA_TYPE);
|
|
100
|
+
const found = [];
|
|
101
|
+
for (const referrer of referrers) {
|
|
102
|
+
const { document } = await client.getManifestByDigest(repository, referrer.digest);
|
|
103
|
+
for (const layer of document.layers ?? []) {
|
|
104
|
+
if (layer.mediaType !== BUNDLE_MEDIA_TYPE) {continue;}
|
|
105
|
+
const bytes = await client.getBlob(repository, layer.digest, MAX_BUNDLE_BYTES);
|
|
106
|
+
if (sha256Digest(bytes) !== layer.digest) {
|
|
107
|
+
throw new Error(`${ociRef} referrer ${referrer.digest} layer does not match its descriptor`);
|
|
108
|
+
}
|
|
109
|
+
const provenance = provenanceOf(bytes, subjectDigest);
|
|
110
|
+
if (provenance !== null) {found.push(provenance);}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (found.length === 0) {
|
|
114
|
+
throw new Error(`${ociRef} has no SLSA provenance attestation (${SLSA_PROVENANCE_V1}) among its ${referrers.length} sigstore-bundle referrers; the bundle must be published with provenance`);
|
|
115
|
+
}
|
|
116
|
+
if (found.length > 1) {
|
|
117
|
+
throw new Error(`${ociRef} has ${found.length} SLSA provenance attestations; exactly one is required, so the evidence is unambiguous`);
|
|
118
|
+
}
|
|
119
|
+
return found[0];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const tail = (error) => String(error?.stderr ?? error?.message ?? error).trim().split('\n').slice(-4).join(' | ').slice(0, 1024);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* (a) against the registry: keyless `cosign verify` and `cosign verify-attestation --type slsaprovenance1` of the bundle
|
|
126
|
+
* artifact under EXACTLY the signer row's identity and issuer, the row's workflow repository, ref and the stated
|
|
127
|
+
* commit, the pinned trusted root and the trusted cosign binary. `--offline` verifies the tlog inclusion proof carried
|
|
128
|
+
* with the signature; the tlog is never ignored. The registry credential is the operator's (HOME / DOCKER_CONFIG).
|
|
129
|
+
*/
|
|
130
|
+
export function verifyBundleOnRegistry({ trustedCosign, policy, policyRoot, row, workflowSha, ociRef, env = process.env }) {
|
|
131
|
+
const root = validatePinnedTrustedRoot(policyRoot, policy);
|
|
132
|
+
const common = ['--offline', '--trusted-root', root.path, ...certificateConstraintArgs(row, workflowSha)];
|
|
133
|
+
const environment = {
|
|
134
|
+
...COSIGN_ENV,
|
|
135
|
+
...(env.HOME === undefined ? {} : { HOME: env.HOME }),
|
|
136
|
+
...(env.DOCKER_CONFIG === undefined ? {} : { DOCKER_CONFIG: env.DOCKER_CONFIG }),
|
|
137
|
+
};
|
|
138
|
+
const run = (command, args) => {
|
|
139
|
+
try {
|
|
140
|
+
execFileSync(assertPreparedExecutable(trustedCosign, 'cosign'), [command, ...args], { env: environment, stdio: ['ignore', 'ignore', 'pipe'], timeout: REGISTRY_COSIGN_TIMEOUT_MS });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Error(`cosign ${command} refused ${ociRef} under row "${row.policyId}": ${tail(error)}`);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
run('verify', [...common, ociRef]);
|
|
146
|
+
run('verify-attestation', ['--type', COSIGN_SLSA_PROVENANCE_ALIAS, ...common, ociRef]);
|
|
147
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// (d) of the third-party checks: the Fulcio extensions of a bundle's leaf certificate, held to a source pin. Its own module
|
|
2
|
+
// (it imports no evidence module) because admission in `image-evidence.mjs` and the pin verifier both use it.
|
|
3
|
+
|
|
4
|
+
import { FulcioExtensionOid, GITHUB_HOSTED_RUNNER_ENVIRONMENT, readFulcioExtensionsFromBundle } from './fulcio-certificate-extensions.mjs';
|
|
5
|
+
|
|
6
|
+
/** The leaf certificate of a parsed Sigstore `bundle` states the pinned repository id, owner id and a GitHub-hosted runner. */
|
|
7
|
+
export function assertCertificateExtensions({ bundle, source, label }) {
|
|
8
|
+
const read = readFulcioExtensionsFromBundle(bundle);
|
|
9
|
+
const expectations = [
|
|
10
|
+
[FulcioExtensionOid.SourceRepositoryIdentifier, 'Source Repository Identifier', read.sourceRepositoryIdentifier, source.repositoryId, 'repositoryId'],
|
|
11
|
+
[FulcioExtensionOid.SourceRepositoryOwnerIdentifier, 'Source Repository Owner Identifier', read.sourceRepositoryOwnerIdentifier, source.repositoryOwnerId, 'repositoryOwnerId'],
|
|
12
|
+
[FulcioExtensionOid.RunnerEnvironment, 'Runner Environment', read.runnerEnvironment, GITHUB_HOSTED_RUNNER_ENVIRONMENT, GITHUB_HOSTED_RUNNER_ENVIRONMENT],
|
|
13
|
+
];
|
|
14
|
+
for (const [oid, name, actual, expected, pinned] of expectations) {
|
|
15
|
+
if (actual !== expected) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`${label} certificate ${name} (${oid}) is ${JSON.stringify(actual)}, but the pin requires ${JSON.stringify(expected)} (${pinned})`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|