@xemahq/xema 0.55.0 → 0.57.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/edition/verify-image-evidence.bundle.mjs +7 -4
- package/dist/edition/verify-image-evidence.bundle.provenance.json +61 -61
- package/edition/README.md +7 -1
- package/edition/biome-content.mjs +4 -4
- package/edition/candidate-edition.mjs +8 -3
- package/edition/customer-edition-verbs.mjs +6 -4
- package/edition/develop-edition-resolve.mjs +14 -7
- package/edition/develop-edition.mjs +18 -2
- package/edition/develop-git-bundles.mjs +201 -0
- package/edition/external-source-walk.mjs +37 -15
- package/edition/locked-bundle-signer.mjs +52 -8
- package/edition/previous-seal-registry.mjs +149 -0
- package/edition/resolve-distribution.mjs +84 -21
- package/edition/resolve-images.mjs +25 -12
- package/edition/third-party-bundle-registry.mjs +73 -17
- package/edition/third-party-source-evidence.mjs +39 -14
- package/edition/third-party-source-writer.mjs +2 -18
- package/edition/trust-policy-arming.mjs +17 -6
- package/package.json +3 -2
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
|
|
63
63
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
64
64
|
import { join } from 'node:path';
|
|
65
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
65
66
|
|
|
66
67
|
import { adapterHostRow, assertAdapterBundlesMatchHost, fetchRegistryManifest } from './adapter-bundled-versions.mjs';
|
|
67
68
|
import {
|
|
@@ -75,6 +76,7 @@ import {
|
|
|
75
76
|
writeAuthorizationLock,
|
|
76
77
|
} from './authorization-base.mjs';
|
|
77
78
|
import { RefusalError, captureRefusal, isRefusal, throwCollectedRefusals } from './collected-refusals.mjs';
|
|
79
|
+
import { developGitBundleKey, developGitBundleSubjects } from './develop-git-bundles.mjs';
|
|
78
80
|
import { resolveDispatchedRuntimeComponents } from './dispatched-runtime-components.mjs';
|
|
79
81
|
import { fleetChartSetLockPath, writeEditionChartSet } from './edition-chart-set.mjs';
|
|
80
82
|
import { workspaceSection } from './edition-config.mjs';
|
|
@@ -96,7 +98,14 @@ import {
|
|
|
96
98
|
validateImmutableDistributionLock,
|
|
97
99
|
validateServiceDependencyOwnerParity,
|
|
98
100
|
} from './lib.mjs';
|
|
99
|
-
import {
|
|
101
|
+
import {
|
|
102
|
+
BundleSignerPolicyAbsentError,
|
|
103
|
+
BundleSignerRefusedError,
|
|
104
|
+
DevelopGitBundleError,
|
|
105
|
+
GitSourcedBundle,
|
|
106
|
+
GitSourcedHostModuleBundleError,
|
|
107
|
+
loadBundleSignerSelection,
|
|
108
|
+
} from './locked-bundle-signer.mjs';
|
|
100
109
|
import { admitDistributionLock } from './operation-grant-admission.mjs';
|
|
101
110
|
import { PUBLISHED_PIN_SOURCE, pinnedSource } from './pin-source.mjs';
|
|
102
111
|
import { verifiedPlatformServiceIndex } from './platform-service-ownership.mjs';
|
|
@@ -1792,7 +1801,14 @@ export function resolveUnadmittedDistributionFromInputs(
|
|
|
1792
1801
|
// trust policy selects for it. See `locked-bundle-signer.mjs`.
|
|
1793
1802
|
const bundleSignerRefusals = [];
|
|
1794
1803
|
const bundleSignerOf = (e, bundleSource, componentKey) => {
|
|
1795
|
-
const subject = {
|
|
1804
|
+
const subject = {
|
|
1805
|
+
biomeId: e.id,
|
|
1806
|
+
...(componentKey === undefined ? {} : { componentKey }),
|
|
1807
|
+
origin: e.origin,
|
|
1808
|
+
repo: e.repo,
|
|
1809
|
+
...(isGitSourced(e) ? { source: requireSourceRecord(e, sourceRecords) } : {}),
|
|
1810
|
+
bundleSource,
|
|
1811
|
+
};
|
|
1796
1812
|
if (composed?.bundleSigners === undefined) {
|
|
1797
1813
|
throw new BundleSignerPolicyAbsentError(
|
|
1798
1814
|
`${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`,
|
|
@@ -1801,12 +1817,45 @@ export function resolveUnadmittedDistributionFromInputs(
|
|
|
1801
1817
|
// Every row is selected before any is refused, so one run names them all; the lock is not built from a refused row.
|
|
1802
1818
|
return captureRefusal(bundleSignerRefusals, () => composed.bundleSigners.signerFor(subject), isRefusal);
|
|
1803
1819
|
};
|
|
1804
|
-
// A row drawn from a GIT source
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1820
|
+
// A row drawn from a GIT source is built from the pinned source, and the manifest's biome-level `bundleSource` names an
|
|
1821
|
+
// artifact its release lane publishes, not one proven to be built from this commit. So, at the copy point:
|
|
1822
|
+
// - with `composed.developGitBundles` (a VENDOR DEVELOP resolve, XEMA-2456) the row carries the bundle the develop
|
|
1823
|
+
// resolve verified for it (`develop-git-bundles.mjs`: the develop signer at the attested commit, content-bound to
|
|
1824
|
+
// the frozen one), by digest, and its develop bundle signer. A git-sourced OCI row the resolve did not verify is
|
|
1825
|
+
// refused, never stripped;
|
|
1826
|
+
// - otherwise (release, customer, dist-check) it carries neither, as before; the release refuses the declaration.
|
|
1827
|
+
// An OCI host-module bundle on a git-sourced biome is refused below in every channel. (An entry of a declared `oci`
|
|
1828
|
+
// source has a stamp but no record: not git.)
|
|
1807
1829
|
const isGitSourced = (e) => e.source !== undefined && sourceRecords.has(e.source);
|
|
1808
|
-
const
|
|
1809
|
-
|
|
1830
|
+
const developGitBundleOf = (e) => {
|
|
1831
|
+
if (composed?.developGitBundles === undefined || !isGitSourced(e) || e.bundleSource?.kind !== 'oci') {return undefined;}
|
|
1832
|
+
const verified = composed.developGitBundles.get(developGitBundleKey(e.source, e.id));
|
|
1833
|
+
if (verified === undefined) {
|
|
1834
|
+
throw new DevelopGitBundleError(`biome "${e.id}" (git source "${e.source}") declares an OCI bundle (${e.bundleSource.ociRef}) that this develop resolve did not verify`);
|
|
1835
|
+
}
|
|
1836
|
+
return verified;
|
|
1837
|
+
};
|
|
1838
|
+
const bundleSourceOf = (e) => {
|
|
1839
|
+
if (e.bundleSource === undefined) {return {};}
|
|
1840
|
+
if (!isGitSourced(e)) {return { bundleSource: e.bundleSource };}
|
|
1841
|
+
const verified = developGitBundleOf(e);
|
|
1842
|
+
return verified === undefined ? {} : { bundleSource: verified.bundleSource };
|
|
1843
|
+
};
|
|
1844
|
+
const bundleSignerFor = (e) => {
|
|
1845
|
+
if (e.bundleSource?.kind !== 'oci') {return {};}
|
|
1846
|
+
if (!isGitSourced(e)) {return { bundleSigner: bundleSignerOf(e, e.bundleSource) };}
|
|
1847
|
+
const verified = developGitBundleOf(e);
|
|
1848
|
+
if (verified === undefined) {return {};}
|
|
1849
|
+
const bundleSigner = bundleSignerOf(e, verified.bundleSource);
|
|
1850
|
+
// A bundle carried from a pinned lock (an offline re-seal) was verified under the pinned signer; a develop row rotated
|
|
1851
|
+
// since then would put into the lock a signer the bundle was never verified under, so any difference is refused.
|
|
1852
|
+
if (verified.pinnedBundleSigner !== undefined && !isDeepStrictEqual(verified.pinnedBundleSigner, bundleSigner)) {
|
|
1853
|
+
throw new DevelopGitBundleError(
|
|
1854
|
+
`biome "${e.id}" (git source "${e.source}"): the pinned edition verified ${verified.bundleSource.ociRef} under bundle signer ${JSON.stringify(verified.pinnedBundleSigner)}, but the current trust policy selects ${JSON.stringify(bundleSigner)}; resolve the develop edition to verify the bundle under the current row`,
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
return { bundleSigner };
|
|
1858
|
+
};
|
|
1810
1859
|
const hostModuleOf = (e, hostModule) => {
|
|
1811
1860
|
if (isGitSourced(e)) {
|
|
1812
1861
|
// The lock schema requires a host module's bundleSource, so an OCI one cannot be omitted: it is refused, in every
|
|
@@ -2405,7 +2454,7 @@ export async function prepareDistributionWithIndex(id, { materialize, inputs, re
|
|
|
2405
2454
|
validateImmutableBiomeIndex(inputs?.biomeIndex ?? pinned.biomeIndex),
|
|
2406
2455
|
platformServiceIndex,
|
|
2407
2456
|
);
|
|
2408
|
-
const { entries, sourceRecords } = await walkDeclaredSources({
|
|
2457
|
+
const { entries, sourceRecords, checkouts } = await walkDeclaredSources({
|
|
2409
2458
|
sources: composed.declaredSources,
|
|
2410
2459
|
...(materialize === undefined ? {} : { materialize }),
|
|
2411
2460
|
});
|
|
@@ -2416,19 +2465,33 @@ export async function prepareDistributionWithIndex(id, { materialize, inputs, re
|
|
|
2416
2465
|
inputs !== undefined && Object.hasOwn(inputs, 'storePublisherTrust')
|
|
2417
2466
|
? inputs.storePublisherTrust
|
|
2418
2467
|
: readStorePublisherTrust();
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
}
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2468
|
+
// A VENDOR DEVELOP resolve hands `inputs.developGitBundles` (develop-git-bundles.mjs); nothing else does, so a release,
|
|
2469
|
+
// customer or dist-check resolve keeps a git-sourced row bundle-less, exactly as before.
|
|
2470
|
+
const developGitBundles = inputs?.developGitBundles;
|
|
2471
|
+
const bundleSigners =
|
|
2472
|
+
inputs?.bundleSigners ??
|
|
2473
|
+
loadBundleSignerSelection({
|
|
2474
|
+
dataRoot: dataRoot(),
|
|
2475
|
+
schemas,
|
|
2476
|
+
gitSourcedBundle: developGitBundles === undefined ? GitSourcedBundle.Refused : GitSourcedBundle.DevelopVerified,
|
|
2477
|
+
});
|
|
2478
|
+
const resolvedAt = new Date().toISOString();
|
|
2479
|
+
const resolveWith = (verified) =>
|
|
2480
|
+
resolveUnadmittedDistributionFromInputs(
|
|
2481
|
+
id,
|
|
2482
|
+
index,
|
|
2483
|
+
schemas,
|
|
2484
|
+
{ ...composed, sourceRecords, storePublisherTrust, bundleSigners, ...(verified === undefined ? {} : { developGitBundles: verified }) },
|
|
2485
|
+
resolvedAt,
|
|
2486
|
+
platformServiceIndex,
|
|
2487
|
+
);
|
|
2488
|
+
// Selection never reads a bundle, so the first pass selects exactly the biomes the second ships: only THEIR bundles are
|
|
2489
|
+
// verified (a source's unselected biome is never fetched), and the second pass refuses any git-sourced OCI row the
|
|
2490
|
+
// verifier did not answer for.
|
|
2491
|
+
const selectedOnly = resolveWith(undefined);
|
|
2492
|
+
if (developGitBundles === undefined) {return { lock: selectedOnly, index, schemas };}
|
|
2493
|
+
const subjects = developGitBundleSubjects({ lock: selectedOnly, index });
|
|
2494
|
+
const lock = subjects.length === 0 ? selectedOnly : resolveWith(await developGitBundles.verify({ subjects, checkouts, schemas }));
|
|
2432
2495
|
return { lock, index, schemas };
|
|
2433
2496
|
}
|
|
2434
2497
|
|
|
@@ -73,7 +73,7 @@ import {
|
|
|
73
73
|
} from './lib.mjs';
|
|
74
74
|
import { assertReleaseContent } from './release-channel-guard.mjs';
|
|
75
75
|
import { composeEdition } from './resolve-distribution.mjs';
|
|
76
|
-
import { readStrictJsonFile } from './strict-json.mjs';
|
|
76
|
+
import { parseStrictJson, readStrictJsonFile } from './strict-json.mjs';
|
|
77
77
|
import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
|
|
78
78
|
import {
|
|
79
79
|
assertOciBundleSourceOrigin,
|
|
@@ -2089,22 +2089,35 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed, reco
|
|
|
2089
2089
|
}
|
|
2090
2090
|
|
|
2091
2091
|
export const PREVIOUS_SEAL_USAGE =
|
|
2092
|
-
'exactly one of `--previous-
|
|
2093
|
-
'
|
|
2092
|
+
'exactly one of `--previous-seal-signer <certificate identity>` (the CLI reads the images.lock layer of the latest edition ' +
|
|
2093
|
+
'signed by that identity in this edition\'s registry repository; run under the per-edition concurrency group), ' +
|
|
2094
|
+
'`--previous-lock <images.lock.json> --previous-lock-sha256 <hex>` (the same layer, taken by the caller) or `--first-seal` ' +
|
|
2095
|
+
'(only for a distribution that has never had an edition)';
|
|
2094
2096
|
|
|
2095
2097
|
/**
|
|
2096
|
-
* The previous seal an images-lock write carries composed artifacts forward from, as an EXPLICIT input
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
2099
|
-
*
|
|
2098
|
+
* The previous seal an images-lock write carries composed artifacts forward from, as an EXPLICIT input of exactly one kind:
|
|
2099
|
+
* - `registry` (`--previous-seal-signer`): `{ find }`, where `find()` returns the latest signed edition's images.lock bytes
|
|
2100
|
+
* and their sha256 (previous-seal-registry.mjs);
|
|
2101
|
+
* - the previous lock with the sha256 of its bytes (taken by the caller from the signed, published edition's images.lock
|
|
2102
|
+
* layer);
|
|
2103
|
+
* - `--first-seal` (null: generation 1).
|
|
2104
|
+
* None, more than one kind, or half of the pair is refused; the bytes must hash to the given digest, and the lock is parsed
|
|
2105
|
+
* with the kernel's `parseImageLock` and must be this edition's own (`distributionId`).
|
|
2100
2106
|
*/
|
|
2101
|
-
export async function resolvePreviousSeal({ distributionId, previousLockPath, previousLockSha256, firstSeal = false }) {
|
|
2107
|
+
export async function resolvePreviousSeal({ distributionId, previousLockPath, previousLockSha256, firstSeal = false, registry = undefined }) {
|
|
2102
2108
|
const given = [previousLockPath, previousLockSha256].filter((value) => value !== undefined);
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2109
|
+
const kinds = [registry !== undefined, given.length > 0, firstSeal].filter(Boolean).length;
|
|
2110
|
+
if (kinds > 1) {
|
|
2111
|
+
throw new Error(`more than one previous seal is given (--previous-seal-signer, --previous-lock/--previous-lock-sha256, --first-seal); give ${PREVIOUS_SEAL_USAGE}`);
|
|
2112
|
+
}
|
|
2113
|
+
if (firstSeal) {return null;}
|
|
2114
|
+
if (registry !== undefined) {
|
|
2115
|
+
const { bytes, imagesLockSha256, edition } = await registry.find();
|
|
2116
|
+
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
2117
|
+
if (actual !== imagesLockSha256) {
|
|
2118
|
+
throw new Error(`the images lock of ${edition} has sha256 ${actual}, not its layer digest ${imagesLockSha256}`);
|
|
2106
2119
|
}
|
|
2107
|
-
return
|
|
2120
|
+
return parsePreviousLock(distributionId, parseStrictJson(bytes.toString('utf8'), `${edition} images.lock.json`), `the images lock of ${edition}`);
|
|
2108
2121
|
}
|
|
2109
2122
|
if (given.length !== 2) {
|
|
2110
2123
|
throw new Error(
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
|
|
7
|
-
import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
|
|
7
|
+
import { certificateConstraintArgs, certificateIdentityArgs } from './cosign-certificate-constraints.mjs';
|
|
8
8
|
import { COSIGN_ENV, decodeExactUtf8, sha256Digest, validatePinnedTrustedRoot } from './image-evidence.mjs';
|
|
9
9
|
import { splitImageRef } from './registry-credentials.mjs';
|
|
10
10
|
import { BUNDLE_MEDIA_TYPE } from './sigstore-bundle-media-type.mjs';
|
|
@@ -119,29 +119,85 @@ export async function fetchBundleProvenance({ clientFor, ociRef }) {
|
|
|
119
119
|
return found[0];
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
const tail = (error) => String(error?.stderr ?? error?.message ?? error).trim().split('\n').slice(-4).join(' | ').slice(0, 1024);
|
|
123
|
-
|
|
124
122
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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).
|
|
123
|
+
* The `<repository>@sha256:<hex>` a bundle reference names: a digest reference as it is, a tag reference resolved at
|
|
124
|
+
* the registry (the manifest the tag serves, its bytes verified against the digest the registry described).
|
|
129
125
|
*/
|
|
130
|
-
export function
|
|
126
|
+
export async function bundleDigestRef({ clientFor, ociRef }) {
|
|
127
|
+
if (ociRef.includes('@')) {return ociRef;}
|
|
128
|
+
const colon = ociRef.lastIndexOf(':');
|
|
129
|
+
if (colon <= ociRef.lastIndexOf('/')) {
|
|
130
|
+
throw new Error(`bundle reference ${JSON.stringify(ociRef)} names neither a tag nor a digest`);
|
|
131
|
+
}
|
|
132
|
+
const name = ociRef.slice(0, colon);
|
|
133
|
+
const { authority, repository } = splitImageRef(name);
|
|
134
|
+
const { digest } = await clientFor(authority).getManifestByTag(repository, ociRef.slice(colon + 1));
|
|
135
|
+
return `${name}@${digest}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const tail = (error) => String(error?.stderr ?? error?.message ?? error).trim().split('\n').slice(-4).join(' | ').slice(0, 1024);
|
|
139
|
+
|
|
140
|
+
/** The cosign run of a registry verification: the trusted binary, the operator's registry credential (HOME / DOCKER_CONFIG). */
|
|
141
|
+
function registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env }) {
|
|
131
142
|
const root = validatePinnedTrustedRoot(policyRoot, policy);
|
|
132
|
-
const common = ['--offline', '--trusted-root', root.path, ...certificateConstraintArgs(row, workflowSha)];
|
|
133
143
|
const environment = {
|
|
134
144
|
...COSIGN_ENV,
|
|
135
145
|
...(env.HOME === undefined ? {} : { HOME: env.HOME }),
|
|
136
146
|
...(env.DOCKER_CONFIG === undefined ? {} : { DOCKER_CONFIG: env.DOCKER_CONFIG }),
|
|
137
147
|
};
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
return {
|
|
149
|
+
trustArgs: ['--offline', '--trusted-root', root.path],
|
|
150
|
+
run(command, args) {
|
|
151
|
+
try {
|
|
152
|
+
return execFileSync(assertPreparedExecutable(trustedCosign, 'cosign'), [command, ...args], {
|
|
153
|
+
env: environment,
|
|
154
|
+
encoding: 'utf8',
|
|
155
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
156
|
+
timeout: REGISTRY_COSIGN_TIMEOUT_MS,
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw new Error(`cosign ${command} refused ${ociRef} under row "${row.policyId}": ${tail(error)}`);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
144
162
|
};
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* (a) against the registry: keyless `cosign verify` and `cosign verify-attestation --type slsaprovenance1` of the bundle
|
|
167
|
+
* artifact under EXACTLY the signer row's identity and issuer, the row's workflow repository, ref and the stated
|
|
168
|
+
* commit, the pinned trusted root and the trusted cosign binary. `--offline` verifies the tlog inclusion proof carried
|
|
169
|
+
* with the signature; the tlog is never ignored. The registry credential is the operator's (HOME / DOCKER_CONFIG).
|
|
170
|
+
*/
|
|
171
|
+
export function verifyBundleOnRegistry({ trustedCosign, policy, policyRoot, row, workflowSha, ociRef, env = process.env }) {
|
|
172
|
+
const cosign = registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env });
|
|
173
|
+
const common = [...cosign.trustArgs, ...certificateConstraintArgs(row, workflowSha)];
|
|
174
|
+
cosign.run('verify', [...common, ociRef]);
|
|
175
|
+
cosign.run('verify-attestation', ['--type', COSIGN_SLSA_PROVENANCE_ALIAS, ...common, ociRef]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const WORKFLOW_SHA = /^[0-9a-f]{40}$/u;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The commit the row's workflow signed the bundle artifact at, as cosign states it: `cosign verify` under the row's
|
|
182
|
+
* identity, issuer, workflow repository and ref (no commit: it is what is asked), and the `githubWorkflowSha` claim of
|
|
183
|
+
* every signature cosign verified. Exactly one commit is required; none, or two, is refused. The caller then holds the
|
|
184
|
+
* signature AND the provenance to that commit with `verifyBundleOnRegistry`.
|
|
185
|
+
*/
|
|
186
|
+
export function verifiedBundleSignerCommit({ trustedCosign, policy, policyRoot, row, ociRef, env = process.env }) {
|
|
187
|
+
const cosign = registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env });
|
|
188
|
+
const output = cosign.run('verify', [...cosign.trustArgs, ...certificateIdentityArgs(row), '--output', 'json', ociRef]);
|
|
189
|
+
let verified;
|
|
190
|
+
try {
|
|
191
|
+
verified = JSON.parse(output);
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error(`cosign verify of ${ociRef} under row "${row.policyId}" printed no JSON list of verified signatures`);
|
|
194
|
+
}
|
|
195
|
+
const commits = [...new Set((Array.isArray(verified) ? verified : []).map((signature) => signature?.optional?.githubWorkflowSha))];
|
|
196
|
+
if (commits.length !== 1 || !WORKFLOW_SHA.test(String(commits[0]))) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`${ociRef} carries signatures by row "${row.policyId}" (${row.certificateIdentity}) stating ${commits.length} workflow commits ` +
|
|
199
|
+
`(${commits.map(String).join(', ')}); exactly one 40-hex commit is required`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return commits[0];
|
|
147
203
|
}
|
|
@@ -66,31 +66,56 @@ export function parseProvenanceStatement(value) {
|
|
|
66
66
|
const readEvidenceFile = (root, path, label, maxBytes) => readBoundedNoFollowFile(root, path, label, maxBytes);
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* pinned repository and workflow. Throws naming the row and the field.
|
|
69
|
+
* A keyless signer row of `channel`, held to everything such a row must be: the channel signed from that channel's ref, the
|
|
70
|
+
* GitHub Actions issuer and the exact identity of its own workflow. Throws naming the row and the field.
|
|
72
71
|
*/
|
|
73
|
-
export function
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
throw new Error(`${label}: the trust policy has ${rows.length} rows with policyId "${policyId}"; exactly one is required`);
|
|
77
|
-
}
|
|
78
|
-
const [row] = rows;
|
|
79
|
-
const ref = schemas.EVIDENCE_CHANNEL_REFS[THIRD_PARTY_EVIDENCE_CHANNEL];
|
|
80
|
-
if (row.channel !== THIRD_PARTY_EVIDENCE_CHANNEL || row.githubWorkflow?.ref !== ref) {
|
|
72
|
+
export function assertChannelSignerRow({ row, channel, schemas, label }) {
|
|
73
|
+
const ref = schemas.EVIDENCE_CHANNEL_REFS[channel];
|
|
74
|
+
if (row.channel !== channel || row.githubWorkflow?.ref !== ref) {
|
|
81
75
|
throw new Error(
|
|
82
|
-
`${label}: row "${policyId}" is not a ${
|
|
76
|
+
`${label}: row "${row.policyId}" is not a ${channel} row signed from ${ref} ` +
|
|
83
77
|
`(channel ${JSON.stringify(row.channel)}, ref ${JSON.stringify(row.githubWorkflow?.ref)})`,
|
|
84
78
|
);
|
|
85
79
|
}
|
|
86
80
|
if (row.oidcIssuer !== GITHUB_ACTIONS_OIDC_ISSUER) {
|
|
87
|
-
throw new Error(`${label}: row "${policyId}" oidcIssuer ${JSON.stringify(row.oidcIssuer)} is not ${GITHUB_ACTIONS_OIDC_ISSUER}`);
|
|
81
|
+
throw new Error(`${label}: row "${row.policyId}" oidcIssuer ${JSON.stringify(row.oidcIssuer)} is not ${GITHUB_ACTIONS_OIDC_ISSUER}`);
|
|
88
82
|
}
|
|
89
83
|
const { repository, path } = row.githubWorkflow;
|
|
90
84
|
const identity = `https://github.com/${repository}/${path}@${ref}`;
|
|
91
85
|
if (row.certificateIdentity !== identity) {
|
|
92
|
-
throw new Error(`${label}: row "${policyId}" certificateIdentity ${JSON.stringify(row.certificateIdentity)} is not the exact workflow identity ${identity}`);
|
|
86
|
+
throw new Error(`${label}: row "${row.policyId}" certificateIdentity ${JSON.stringify(row.certificateIdentity)} is not the exact workflow identity ${identity}`);
|
|
93
87
|
}
|
|
88
|
+
return row;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The ONE `channel` row that signs the SLSA provenance of `repository`'s biome bundles, held to `assertChannelSignerRow`.
|
|
93
|
+
* None, or several, is refused naming the repository and the channel.
|
|
94
|
+
*/
|
|
95
|
+
export function bundleSignerRowFor({ policy, repository, channel, schemas, label }) {
|
|
96
|
+
const rows = policy.policies.filter(
|
|
97
|
+
(row) => row.channel === channel && row.predicateTypes?.includes(SLSA_PROVENANCE_V1) && row.githubWorkflow?.repository === repository,
|
|
98
|
+
);
|
|
99
|
+
if (rows.length !== 1) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${label}: the trust policy has ${rows.length} ${channel} rows authorizing ${SLSA_PROVENANCE_V1} for ${repository}; exactly one bundle-signer row is required`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return assertChannelSignerRow({ row: rows[0], channel, schemas, label });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The trust-policy row `policyId` names, held to everything a third-party signer row must be: one row, the release
|
|
109
|
+
* channel signed from refs/heads/main, the GitHub Actions issuer, the exact identity of its own workflow, and the
|
|
110
|
+
* pinned repository and workflow. Throws naming the row and the field.
|
|
111
|
+
*/
|
|
112
|
+
export function thirdPartySignerRow({ policy, policyId, source, schemas, label }) {
|
|
113
|
+
const rows = policy.policies.filter((row) => row.policyId === policyId);
|
|
114
|
+
if (rows.length !== 1) {
|
|
115
|
+
throw new Error(`${label}: the trust policy has ${rows.length} rows with policyId "${policyId}"; exactly one is required`);
|
|
116
|
+
}
|
|
117
|
+
const row = assertChannelSignerRow({ row: rows[0], channel: THIRD_PARTY_EVIDENCE_CHANNEL, schemas, label });
|
|
118
|
+
const { repository, path } = row.githubWorkflow;
|
|
94
119
|
if (repository !== source.repository) {
|
|
95
120
|
throw new Error(`${label}: row "${policyId}" signs for ${repository}, but the source is pinned to ${source.repository}`);
|
|
96
121
|
}
|
|
@@ -16,8 +16,8 @@ import { bundleImageNames, fetchBundleManifest, fetchBundleProvenance, verifyBun
|
|
|
16
16
|
import {
|
|
17
17
|
PROVENANCE_BUNDLE_FILE,
|
|
18
18
|
PROVENANCE_STATEMENT_FILE,
|
|
19
|
-
SLSA_PROVENANCE_V1,
|
|
20
19
|
THIRD_PARTY_EVIDENCE_CHANNEL,
|
|
20
|
+
bundleSignerRowFor,
|
|
21
21
|
digestOfOciRef,
|
|
22
22
|
provenanceDirectoryForDigest,
|
|
23
23
|
thirdPartySignerRow,
|
|
@@ -28,22 +28,6 @@ import { assertThirdPartyUniqueness } from './third-party-source-uniqueness.mjs'
|
|
|
28
28
|
|
|
29
29
|
const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
30
30
|
|
|
31
|
-
/** The one release-channel row that signs the biome bundle's provenance for `repository`. */
|
|
32
|
-
function bundleSignerRow(policy, repository) {
|
|
33
|
-
const rows = policy.policies.filter(
|
|
34
|
-
(row) =>
|
|
35
|
-
row.channel === THIRD_PARTY_EVIDENCE_CHANNEL &&
|
|
36
|
-
row.predicateTypes.includes(SLSA_PROVENANCE_V1) &&
|
|
37
|
-
row.githubWorkflow?.repository === repository,
|
|
38
|
-
);
|
|
39
|
-
if (rows.length !== 1) {
|
|
40
|
-
throw new Error(
|
|
41
|
-
`the trust policy has ${rows.length} ${THIRD_PARTY_EVIDENCE_CHANNEL} rows authorizing ${SLSA_PROVENANCE_V1} for ${repository}; exactly one bundle-signer row is required`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
return rows[0];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
31
|
/** The one release-channel row whose imageRepositories lists `imageRef`. */
|
|
48
32
|
function imageSignerRow(policy, schemas, imageRef) {
|
|
49
33
|
const rows = policyCandidates(policy, schemas.IMAGE_PRODUCER_PREDICATE_TYPE, THIRD_PARTY_EVIDENCE_CHANNEL, imageRef);
|
|
@@ -116,7 +100,7 @@ async function collectImage({ image, request, context, clientFor, services }) {
|
|
|
116
100
|
export async function planThirdPartySourceWrite({ request, context, clientFor, withProducerWorkflowPin, dataRoot, verifyOnRegistry = verifyBundleOnRegistry }) {
|
|
117
101
|
const { policy, schemas } = context;
|
|
118
102
|
const repositoryUrl = `https://github.com/${request.repository}`;
|
|
119
|
-
const row =
|
|
103
|
+
const row = bundleSignerRowFor({ policy, repository: request.repository, channel: THIRD_PARTY_EVIDENCE_CHANNEL, schemas, label: `third-party source "${request.name}" bundle signer` });
|
|
120
104
|
const workflowPath = row.githubWorkflow.path;
|
|
121
105
|
// The row must be exact (release channel, refs/heads/main, the GitHub issuer, its own workflow identity) before cosign runs.
|
|
122
106
|
thirdPartySignerRow({ policy, policyId: row.policyId, source: { repository: request.repository, producerWorkflow: { path: workflowPath } }, schemas, label: `third-party source "${request.name}" bundle signer` });
|
|
@@ -38,6 +38,13 @@
|
|
|
38
38
|
// channel neither arm nor disarm the other: a policy that authorizes the three
|
|
39
39
|
// stages on the release channel and pins both binaries is ARMED for a release
|
|
40
40
|
// edition whatever it says about develop.
|
|
41
|
+
//
|
|
42
|
+
// ONLY A STAGE ROW ARMS A CHANNEL: a row counts toward a channel's arming when it
|
|
43
|
+
// authorizes one of the stage predicates judged here. A bundle-signer row (SLSA
|
|
44
|
+
// provenance of a third-party bundle, e.g. the develop row of a git-sourced
|
|
45
|
+
// biome) authorizes bundle verification and arms no stage, so a channel whose
|
|
46
|
+
// only rows are bundle signers is UNARMED, exactly like a channel with no row.
|
|
47
|
+
// One stage row on the channel still makes it PARTIAL until every stage is met.
|
|
41
48
|
|
|
42
49
|
export const TrustPolicyArmingState = Object.freeze({
|
|
43
50
|
ARMED: 'armed',
|
|
@@ -81,8 +88,11 @@ export function assessTrustPolicyArming(policy, schemas, channel, { consumesCata
|
|
|
81
88
|
stages.push(['edition-catalogue', schemas.EDITION_CATALOGUE_PREDICATE_TYPE]);
|
|
82
89
|
}
|
|
83
90
|
}
|
|
84
|
-
const
|
|
85
|
-
const
|
|
91
|
+
const stagePredicateTypes = stages.map(([, predicateType]) => predicateType);
|
|
92
|
+
const stageRows = policy.policies.filter(
|
|
93
|
+
(candidate) => candidate.channel === channel && candidate.predicateTypes.some((predicateType) => stagePredicateTypes.includes(predicateType)),
|
|
94
|
+
);
|
|
95
|
+
const authorized = stages.filter(([, predicateType]) => stageRows.some((candidate) => candidate.predicateTypes.includes(predicateType)));
|
|
86
96
|
|
|
87
97
|
if (policy.policies.length === 0) {
|
|
88
98
|
unmet.push(
|
|
@@ -120,12 +130,13 @@ export function assessTrustPolicyArming(policy, schemas, channel, { consumesCata
|
|
|
120
130
|
policy.policies.length === 0 &&
|
|
121
131
|
policy.trustedCosignBinaryDigests.length === 0 &&
|
|
122
132
|
policy.trustedGitBinaryDigests.length === 0;
|
|
123
|
-
// The channel
|
|
124
|
-
// binary pins are
|
|
125
|
-
|
|
133
|
+
// The channel has no stage row while the policy has rows (another channel's,
|
|
134
|
+
// or bundle signers): those rows and the binary pins are not half of this
|
|
135
|
+
// channel's arming.
|
|
136
|
+
const noStageRow = stageRows.length === 0 && policy.policies.length > 0;
|
|
126
137
|
const state = armed
|
|
127
138
|
? TrustPolicyArmingState.ARMED
|
|
128
|
-
: bootstrap ||
|
|
139
|
+
: bootstrap || noStageRow
|
|
129
140
|
? TrustPolicyArmingState.UNARMED
|
|
130
141
|
: TrustPolicyArmingState.PARTIAL;
|
|
131
142
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/xema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/xema-dev/xema-cli.git",
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"typecheck": "tsc --noEmit"
|
|
101
101
|
},
|
|
102
102
|
"xemaRelease": {
|
|
103
|
-
"sourceDigest": "sha256:
|
|
103
|
+
"sourceDigest": "sha256:23c811648b74b4daf999382a1d0f6aa4cad8bfaad8b3f150e51326d709a4982b",
|
|
104
|
+
"sha": "b7d08af5d37faac60b04770fd2b0701811b96459"
|
|
104
105
|
}
|
|
105
106
|
}
|