@xemahq/xema 0.26.0 → 0.27.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/README.md +70 -37
- package/dist/assets/distribution-assets.manifest.json +1 -2
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/dev.js +1 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +7 -1
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/edition.d.ts +3 -0
- package/dist/commands/edition.d.ts.map +1 -0
- package/dist/commands/edition.js +46 -0
- package/dist/commands/edition.js.map +1 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/serve.js +1 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/up.js +1 -0
- package/dist/commands/up.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +5 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/dev/infra-preflight.d.ts +3 -2
- package/dist/dev/infra-preflight.d.ts.map +1 -1
- package/dist/dev/infra-preflight.js +18 -8
- package/dist/dev/infra-preflight.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +54179 -0
- package/dist/edition/verify-image-evidence.bundle.provenance.json +2871 -0
- package/dist/runtime/config/schema.d.ts +1 -0
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/custody/local-custody.d.ts +12 -0
- package/dist/runtime/custody/local-custody.d.ts.map +1 -0
- package/dist/runtime/custody/local-custody.js +22 -0
- package/dist/runtime/custody/local-custody.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.js +55 -0
- package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
- package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
- package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-http-client.js +154 -0
- package/dist/runtime/custody/openbao-http-client.js.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.js +48 -0
- package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.js +86 -0
- package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
- package/dist/runtime/edition/run-entry.d.ts +9 -0
- package/dist/runtime/edition/run-entry.d.ts.map +1 -0
- package/dist/runtime/edition/run-entry.js +36 -0
- package/dist/runtime/edition/run-entry.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +60 -0
- package/dist/runtime/edition/verb-map.d.ts.map +1 -0
- package/dist/runtime/edition/verb-map.js +146 -0
- package/dist/runtime/edition/verb-map.js.map +1 -0
- package/dist/runtime/env-seeder.d.ts +2 -0
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +18 -3
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts +2 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +31 -7
- package/dist/runtime/launch.js.map +1 -1
- package/dist/runtime/paths.d.ts +1 -2
- package/dist/runtime/paths.d.ts.map +1 -1
- package/dist/runtime/paths.js +0 -10
- package/dist/runtime/paths.js.map +1 -1
- package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
- package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
- package/dist/runtime/runner/chart-local-provenance.js +114 -0
- package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
- package/dist/runtime/runner/chart-preflight.d.ts +8 -8
- package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
- package/dist/runtime/runner/chart-preflight.js +30 -69
- package/dist/runtime/runner/chart-preflight.js.map +1 -1
- package/dist/runtime/runner/chart-pull.d.ts +14 -0
- package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
- package/dist/runtime/runner/chart-pull.js +96 -0
- package/dist/runtime/runner/chart-pull.js.map +1 -0
- package/dist/runtime/runner/chart-resolver.d.ts +4 -12
- package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
- package/dist/runtime/runner/chart-resolver.js +16 -62
- package/dist/runtime/runner/chart-resolver.js.map +1 -1
- package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +55 -99
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/dist/runtime/runner/helm-cli.d.ts +3 -1
- package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
- package/dist/runtime/runner/helm-cli.js +9 -7
- package/dist/runtime/runner/helm-cli.js.map +1 -1
- package/dist/runtime/runner/helm-runner.d.ts +4 -4
- package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
- package/dist/runtime/runner/helm-runner.js +18 -16
- package/dist/runtime/runner/helm-runner.js.map +1 -1
- package/dist/runtime/sources/kinds/git.d.ts +1 -2
- package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
- package/dist/runtime/sources/kinds/git.js +8 -5
- package/dist/runtime/sources/kinds/git.js.map +1 -1
- package/dist/runtime/sources/manifest-walk.d.ts +3 -1
- package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
- package/dist/runtime/sources/manifest-walk.js +8 -3
- package/dist/runtime/sources/manifest-walk.js.map +1 -1
- package/dist/runtime/sources/schema.d.ts +3 -0
- package/dist/runtime/sources/schema.d.ts.map +1 -1
- package/dist/runtime/sources/schema.js +6 -1
- package/dist/runtime/sources/schema.js.map +1 -1
- package/dist/runtime/supervisor.d.ts +2 -0
- package/dist/runtime/supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor.js +1 -0
- package/dist/runtime/supervisor.js.map +1 -1
- package/edition/README.md +315 -0
- package/edition/assemble-producer-input.mjs +1251 -0
- package/edition/authorization-base.mjs +347 -0
- package/edition/biome-content.mjs +268 -0
- package/edition/build-biome-index.mjs +175 -0
- package/edition/build-platform-service-index.mjs +288 -0
- package/edition/build-runtime-image-source-index.mjs +821 -0
- package/edition/built-verifier.mjs +55 -0
- package/edition/catalogue-edition.mjs +49 -0
- package/edition/catalogue-fetch.mjs +64 -0
- package/edition/catalogue.mjs +135 -0
- package/edition/chart-set-source-commit.mjs +92 -0
- package/edition/check-committed-image-evidence.mjs +383 -0
- package/edition/checkout-indexed-source-repositories.mjs +105 -0
- package/edition/comment-blanker.mjs +75 -0
- package/edition/connector-source-authority.mjs +60 -0
- package/edition/customer-definition.mjs +183 -0
- package/edition/customer-flags.mjs +7 -0
- package/edition/develop-customer-edition.mjs +99 -0
- package/edition/develop-edition-resolve.mjs +65 -0
- package/edition/develop-edition.mjs +274 -0
- package/edition/develop-freeze.mjs +173 -0
- package/edition/develop-image-coordinate.mjs +54 -0
- package/edition/develop-seal.mjs +77 -0
- package/edition/develop-snapshot-inputs.mjs +104 -0
- package/edition/dist-check.mjs +986 -0
- package/edition/distribution-evidence-set.mjs +987 -0
- package/edition/distribution-paths.mjs +191 -0
- package/edition/edition-artifact.mjs +270 -0
- package/edition/edition-biome-manifests.mjs +85 -0
- package/edition/edition-channel.mjs +6 -0
- package/edition/edition-chart-set.mjs +93 -0
- package/edition/edition-config.mjs +207 -0
- package/edition/edition-constants.mjs +57 -0
- package/edition/edition-contract.json +23 -0
- package/edition/edition-list.mjs +35 -0
- package/edition/edition-locks.mjs +69 -0
- package/edition/edition-protocol.mjs +228 -0
- package/edition/edition-workspace.mjs +176 -0
- package/edition/evidence-checkout.mjs +418 -0
- package/edition/evidence-grammars.mjs +16 -0
- package/edition/evidence-trust-pin.mjs +304 -0
- package/edition/evidence-trust-policy.mjs +256 -0
- package/edition/external-source-walk.mjs +463 -0
- package/edition/first-party-source-ids.mjs +59 -0
- package/edition/fleet-coordinate-finality.mjs +350 -0
- package/edition/git-source-auth.mjs +52 -0
- package/edition/image-evidence-replay-state.mjs +242 -0
- package/edition/image-evidence.mjs +1225 -0
- package/edition/kernel-contract-authority.mjs +443 -0
- package/edition/lib.mjs +3018 -0
- package/edition/no-follow-file.mjs +94 -0
- package/edition/oci-evidence-verbs.mjs +392 -0
- package/edition/oci-image-source-evidence.mjs +239 -0
- package/edition/oras.mjs +648 -0
- package/edition/pin-source.mjs +227 -0
- package/edition/plan-image-evidence-refresh.mjs +408 -0
- package/edition/platform-lock-readability.mjs +892 -0
- package/edition/platform-service-class.mjs +18 -0
- package/edition/platform-service-source-authority.mjs +105 -0
- package/edition/positive-closure.mjs +162 -0
- package/edition/produce-distribution-completeness-evidence.mjs +311 -0
- package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
- package/edition/produce-image-source-evidence.mjs +765 -0
- package/edition/producer-evidence-bundle.mjs +157 -0
- package/edition/registry-credentials.mjs +193 -0
- package/edition/release-catalogue.mjs +39 -0
- package/edition/release-channel-guard.mjs +99 -0
- package/edition/release-edition-artifact.mjs +158 -0
- package/edition/remedy-command.mjs +89 -0
- package/edition/repin-evidence-trust.mjs +101 -0
- package/edition/required-resource-provider.mjs +58 -0
- package/edition/resolve-chart-set.mjs +415 -0
- package/edition/resolve-customer-edition.mjs +288 -0
- package/edition/resolve-distribution.mjs +2318 -0
- package/edition/resolve-edition.mjs +186 -0
- package/edition/resolve-images.mjs +1606 -0
- package/edition/seal-image-digests.mjs +855 -0
- package/edition/sealed-evidence-accept.mjs +82 -0
- package/edition/source-authority.mjs +725 -0
- package/edition/source-closure.mjs +116 -0
- package/edition/strict-json.mjs +121 -0
- package/edition/string-order.mjs +5 -0
- package/edition/trust-policy-arming.mjs +120 -0
- package/edition/trusted-executable.mjs +166 -0
- package/edition/verifier-binding.mjs +45 -0
- package/edition/verifier-files.mjs +8 -0
- package/edition/verifier-kernel-schemas.mjs +22 -0
- package/edition/verifier-provenance.mjs +64 -0
- package/edition/verify-image-evidence.mjs +430 -0
- package/edition/verify-image-platforms.mjs +495 -0
- package/edition/why.mjs +56 -0
- package/edition/workspace-sections.mjs +195 -0
- package/examples/06-biome-sources.yaml +1 -0
- package/package.json +14 -6
- package/dist/commands/dist.d.ts +0 -16
- package/dist/commands/dist.d.ts.map +0 -1
- package/dist/commands/dist.js +0 -182
- package/dist/commands/dist.js.map +0 -1
- package/dist/runtime/distribution/available-biome.d.ts +0 -10
- package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
- package/dist/runtime/distribution/available-biome.js +0 -156
- package/dist/runtime/distribution/available-biome.js.map +0 -1
- package/dist/runtime/distribution/compose.d.ts +0 -23
- package/dist/runtime/distribution/compose.d.ts.map +0 -1
- package/dist/runtime/distribution/compose.js +0 -48
- package/dist/runtime/distribution/compose.js.map +0 -1
- package/dist/runtime/runner/chart-trust.d.ts +0 -57
- package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
- package/dist/runtime/runner/chart-trust.js +0 -270
- package/dist/runtime/runner/chart-trust.js.map +0 -1
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// assemble-producer-input — producer-input assembly for detached image-source
|
|
3
|
+
// evidence (plan phase E1).
|
|
4
|
+
//
|
|
5
|
+
// xema edition evidence assemble-input \
|
|
6
|
+
// --output <absolute-path> \
|
|
7
|
+
// --image-repository <registry>/<namespace>/<service> \
|
|
8
|
+
// --manifest-digest sha256:<64-hex> \
|
|
9
|
+
// --subject-kind service --subject-id <service> \
|
|
10
|
+
// --source-repo repos/<repo> \
|
|
11
|
+
// --source-path repos/<repo>/<biome-or-package-path> \
|
|
12
|
+
// --source-commit <40-hex> \
|
|
13
|
+
// --provenance <absolute-predicate-json> \
|
|
14
|
+
// [--ttl-seconds <1..policy-max>] \
|
|
15
|
+
// [--source-manifest-hash <64-hex>] [--source-tree-hash <64-hex>]
|
|
16
|
+
//
|
|
17
|
+
// Emits the EXACT producer input manifest `produce-image-source-evidence.mjs`
|
|
18
|
+
// accepts. The assembled manifest is validated with the producer's own gates —
|
|
19
|
+
// `assertProducerInput`, the full kernel statement schema, and (from the
|
|
20
|
+
// GITHUB_* environment) `assertGitHubProducerContext` — BEFORE it is written:
|
|
21
|
+
// the assembler never emits a manifest the producer would reject.
|
|
22
|
+
//
|
|
23
|
+
// Field derivation:
|
|
24
|
+
// * contractAuthority — the verifier-embedded kernel contract authority
|
|
25
|
+
// (single source of truth; the offline verifier only
|
|
26
|
+
// ever accepts that exact object).
|
|
27
|
+
// * sourceHashAlgorithm — read from `@xemahq/distribution-source-hash` via
|
|
28
|
+
// lib.mjs `imageInputSourceHashAlgorithm()` — the one
|
|
29
|
+
// algorithm an image lock may name.
|
|
30
|
+
// * producerWorkflowSha — GITHUB_SHA of the running producer workflow.
|
|
31
|
+
// * signer — derived from the running workflow's context:
|
|
32
|
+
// certificateIdentity is exactly
|
|
33
|
+
// https://github.com/<repo>/<workflow-path>@<ref>.
|
|
34
|
+
// * builder — builderId is the building workflow; buildType and
|
|
35
|
+
// the definition/parameters digests are read off the
|
|
36
|
+
// BuildKit SLSA v1 predicate the build exported
|
|
37
|
+
// (`--provenance`) — the same bytes the workflow
|
|
38
|
+
// cosign-attests, so the evidence cannot describe a
|
|
39
|
+
// different build than the one that ran.
|
|
40
|
+
// * sourceManifestHash — the kernel computeBiomeSourceManifestHash of the git-blob-at-commit,
|
|
41
|
+
// the exact runtime-image-source-index algorithm
|
|
42
|
+
// (lib.mjs `readGitJsonAtCommit` + `serializeJson`).
|
|
43
|
+
// * sourceTreeHash — computed here with lib.mjs `hashImageBuildInputs`
|
|
44
|
+
// under `imageInputSourceHashAlgorithm()` — the SAME
|
|
45
|
+
// functions and package the lock resolver and every
|
|
46
|
+
// producer's `compute-image-tag.mjs` use. The request
|
|
47
|
+
// is reconstructed exactly as the producing build saw
|
|
48
|
+
// it: the service directory comes from the subject's
|
|
49
|
+
// own manifest at the build commit
|
|
50
|
+
// (resolve-images `subjectServicePath`), and the
|
|
51
|
+
// vendored cross-repo inputs + build-step directories
|
|
52
|
+
// come from the producing checkout's committed
|
|
53
|
+
// `tooling/release/cross-repo-build-inputs.json` AT
|
|
54
|
+
// THAT COMMIT — never the live distribution index,
|
|
55
|
+
// which may already pin newer source revisions.
|
|
56
|
+
// * manifestDigest cross-check, manifestMediaType, platforms — read from the
|
|
57
|
+
// registry with the fail-closed OCI client at the canonical
|
|
58
|
+
// `<imageRepository>:<sourceTreeHash>` coordinate: the digest served there
|
|
59
|
+
// must be exactly the caller's `--manifest-digest`, and platforms are
|
|
60
|
+
// proven by their hash-verified image configs (verify-image-platforms
|
|
61
|
+
// `resolvePublishedPlatforms`).
|
|
62
|
+
// * sbomDigest, provenanceDigest — the cosign attestation artifacts the
|
|
63
|
+
// producing workflow attached to the delivered manifest digest: the
|
|
64
|
+
// `sha256-<digest>.att` manifest's DSSE envelope layers, classified by
|
|
65
|
+
// their `predicateType` annotation and bound to the delivered digest
|
|
66
|
+
// through their decoded in-toto statement subject. A missing attestation
|
|
67
|
+
// fails fast naming the cosign `--type` flag that emits it.
|
|
68
|
+
// MULTIPLICITY IS EXPECTED: a multi-arch index is the product of one
|
|
69
|
+
// build per platform, and lanes attest accordingly — one SLSA + one SBOM
|
|
70
|
+
// attestation per architecture, all bound to the index digest (plus
|
|
71
|
+
// further copies whenever a lane re-runs, because cosign attest APPENDS).
|
|
72
|
+
// The assembler therefore selects ONE deterministic witness per type:
|
|
73
|
+
// the provenance layer whose statement names the PRIMARY build's own
|
|
74
|
+
// builder id + invocation id (the identity of the caller's `--provenance`
|
|
75
|
+
// predicate, so the statement's artifact section and build section
|
|
76
|
+
// describe the SAME build), and — no caller-side SBOM reference exists —
|
|
77
|
+
// the smallest SBOM layer digest. A subject with no layer describing the
|
|
78
|
+
// primary build fails fast.
|
|
79
|
+
//
|
|
80
|
+
// The runtime image source index, the registry credential declaration and the
|
|
81
|
+
// declared trust policy are read from the data root (`--data-root`), and the
|
|
82
|
+
// subject's source objects from the source checkouts under `--sources-root`
|
|
83
|
+
// at their aggregator-relative paths.
|
|
84
|
+
//
|
|
85
|
+
// Registry credentials come from image-registry-credentials.json (the file
|
|
86
|
+
// names environment variables, never secrets).
|
|
87
|
+
|
|
88
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
89
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
90
|
+
|
|
91
|
+
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
92
|
+
import {
|
|
93
|
+
WorkspaceRoot,
|
|
94
|
+
configureEditionWorkspaceFromArgv,
|
|
95
|
+
dataRoot,
|
|
96
|
+
isEntryModule,
|
|
97
|
+
sourcePath as sourceCheckoutPath,
|
|
98
|
+
} from './edition-workspace.mjs';
|
|
99
|
+
import { COMMIT_PATTERN, GITHUB_REF_PATTERN, GITHUB_REPOSITORY_PATTERN, SHA256_DIGEST_PATTERN, SHA256_HEX_PATTERN } from './evidence-grammars.mjs';
|
|
100
|
+
import { firstPartySourceId } from './first-party-source-ids.mjs';
|
|
101
|
+
import { canonicalJson } from './image-evidence.mjs';
|
|
102
|
+
import {
|
|
103
|
+
assertGitCommitExists,
|
|
104
|
+
compareStrings,
|
|
105
|
+
hashImageBuildInputs,
|
|
106
|
+
imageInputSourceHashAlgorithm,
|
|
107
|
+
loadKernelDistributionSchemas,
|
|
108
|
+
readGitJsonAtCommit,
|
|
109
|
+
serializeJson,
|
|
110
|
+
sha256Hex,
|
|
111
|
+
} from './lib.mjs';
|
|
112
|
+
import { OrasRegistry } from './oras.mjs';
|
|
113
|
+
import { assertGitHubProducerContext, assertProducerInput, buildImageSourceStatements, readPinnedContractAuthority, sandboxedGit } from './produce-image-source-evidence.mjs';
|
|
114
|
+
import {
|
|
115
|
+
loadRegistryCredentialDeclarations,
|
|
116
|
+
resolveRegistryCredential,
|
|
117
|
+
splitImageRef,
|
|
118
|
+
} from './registry-credentials.mjs';
|
|
119
|
+
import {
|
|
120
|
+
COMPOSED_PROCESS_DECLARATION_FILE,
|
|
121
|
+
COMPOSED_PROCESS_IMAGE_PATH,
|
|
122
|
+
composedProcessSubjectId,
|
|
123
|
+
subjectServicePath,
|
|
124
|
+
} from './resolve-images.mjs';
|
|
125
|
+
import {
|
|
126
|
+
platformIdentity,
|
|
127
|
+
resolvePublishedPlatforms,
|
|
128
|
+
} from './verify-image-platforms.mjs';
|
|
129
|
+
|
|
130
|
+
const SOURCE_REPO_PATTERN = /^(?:repos|submodules)\/[a-zA-Z0-9_.-]+$/;
|
|
131
|
+
// The kernel's `CanonicalIdentifierSchema` shape (runId / subject id).
|
|
132
|
+
const CANONICAL_IDENTIFIER_PATTERN =
|
|
133
|
+
/^[a-zA-Z0-9](?:[a-zA-Z0-9._:/@+-]{0,254}[a-zA-Z0-9])?$/;
|
|
134
|
+
// cosign v2 attaches every attestation of a subject as one layer of the
|
|
135
|
+
// single `sha256-<subject-digest>.att` manifest; each layer is a DSSE
|
|
136
|
+
// envelope whose `predicateType` annotation names the attestation.
|
|
137
|
+
const ATTESTATION_TAG_SUFFIX = '.att';
|
|
138
|
+
const DSSE_ENVELOPE_LAYER_MEDIA_TYPE = 'application/vnd.dsse.envelope.v1+json';
|
|
139
|
+
const IN_TOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
|
|
140
|
+
const PREDICATE_TYPE_ANNOTATION = 'predicateType';
|
|
141
|
+
const SBOM_PREDICATE_TYPE = 'https://spdx.dev/Document';
|
|
142
|
+
const PROVENANCE_PREDICATE_TYPE = 'https://slsa.dev/provenance/v1';
|
|
143
|
+
// The third mandatory deploy predicate (see the producing workflows). It
|
|
144
|
+
// legitimately shares the `.att` manifest but occupies no producer-input slot.
|
|
145
|
+
const DEPLOY_SOURCE_PREDICATE_TYPE =
|
|
146
|
+
'https://xema.dev/attestations/deploy-source/v1';
|
|
147
|
+
const MAX_ATTESTATION_LAYER_BYTES = 64 * 1024 * 1024;
|
|
148
|
+
// The vendoring record the producing workflow consumed — committed in the
|
|
149
|
+
// producing repository, read AT THE BUILD COMMIT (see header).
|
|
150
|
+
const CROSS_REPO_RECORD_PATH = 'tooling/release/cross-repo-build-inputs.json';
|
|
151
|
+
|
|
152
|
+
function canonicalRelativePath(value, label) {
|
|
153
|
+
if (
|
|
154
|
+
typeof value !== 'string' ||
|
|
155
|
+
value.length === 0 ||
|
|
156
|
+
value !== value.normalize('NFC') ||
|
|
157
|
+
value.startsWith('/') ||
|
|
158
|
+
value.endsWith('/') ||
|
|
159
|
+
value
|
|
160
|
+
.split('/')
|
|
161
|
+
.some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
162
|
+
) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`${label} must be an NFC-normalized repository-relative path without traversal`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function assertWorkflowContext(env) {
|
|
171
|
+
const repository = env.GITHUB_REPOSITORY;
|
|
172
|
+
const ref = env.GITHUB_REF;
|
|
173
|
+
const sha = env.GITHUB_SHA;
|
|
174
|
+
const workflowRef = env.GITHUB_WORKFLOW_REF;
|
|
175
|
+
if (!GITHUB_REPOSITORY_PATTERN.test(repository ?? '')) {
|
|
176
|
+
throw new Error('GITHUB_REPOSITORY is not an exact owner/repo slug');
|
|
177
|
+
}
|
|
178
|
+
if (!GITHUB_REF_PATTERN.test(ref ?? '')) {
|
|
179
|
+
throw new Error('GITHUB_REF is not an exact branch or tag ref');
|
|
180
|
+
}
|
|
181
|
+
if (!COMMIT_PATTERN.test(sha ?? '')) {
|
|
182
|
+
throw new Error('GITHUB_SHA is not an exact 40-hex commit');
|
|
183
|
+
}
|
|
184
|
+
const prefix = `${repository}/`;
|
|
185
|
+
const suffix = `@${ref}`;
|
|
186
|
+
if (
|
|
187
|
+
typeof workflowRef !== 'string' ||
|
|
188
|
+
!workflowRef.startsWith(prefix) ||
|
|
189
|
+
!workflowRef.endsWith(suffix) ||
|
|
190
|
+
workflowRef.length <= prefix.length + suffix.length
|
|
191
|
+
) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`GITHUB_WORKFLOW_REF "${workflowRef}" is not exactly <repository>/<workflow-path>@<ref> for this run`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const path = workflowRef.slice(
|
|
197
|
+
prefix.length,
|
|
198
|
+
workflowRef.length - suffix.length,
|
|
199
|
+
);
|
|
200
|
+
if (
|
|
201
|
+
!path.startsWith('.github/workflows/') ||
|
|
202
|
+
path.split('/').some((segment) => !segment || segment === '..')
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`GITHUB_WORKFLOW_REF names "${path}", which is not an exact workflow file under .github/workflows/`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const runId = env.GITHUB_RUN_ID;
|
|
209
|
+
if (!CANONICAL_IDENTIFIER_PATTERN.test(runId ?? '')) {
|
|
210
|
+
throw new Error('GITHUB_RUN_ID is not a usable run identifier');
|
|
211
|
+
}
|
|
212
|
+
const runAttempt = Number(env.GITHUB_RUN_ATTEMPT);
|
|
213
|
+
if (
|
|
214
|
+
!Number.isSafeInteger(runAttempt) ||
|
|
215
|
+
runAttempt < 1 ||
|
|
216
|
+
String(runAttempt) !== env.GITHUB_RUN_ATTEMPT
|
|
217
|
+
) {
|
|
218
|
+
throw new Error('GITHUB_RUN_ATTEMPT is not a positive integer');
|
|
219
|
+
}
|
|
220
|
+
return { repository, ref, sha, path, workflowRef, runId, runAttempt };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The build identity section of the producer input. `buildType` and the two
|
|
225
|
+
* digests are read off the BuildKit SLSA v1 predicate the producing workflow
|
|
226
|
+
* exported from the image it just pushed — the same bytes it cosign-attests
|
|
227
|
+
* and the deploy plane re-verifies — so the evidence can never describe a
|
|
228
|
+
* different build than the one that ran.
|
|
229
|
+
*/
|
|
230
|
+
export function deriveBuilder({ provenancePath, sourceCommit }) {
|
|
231
|
+
if (!isAbsolute(provenancePath)) {
|
|
232
|
+
throw new Error('--provenance must be an absolute path');
|
|
233
|
+
}
|
|
234
|
+
let predicate;
|
|
235
|
+
try {
|
|
236
|
+
predicate = JSON.parse(readFileSync(provenancePath, 'utf8'));
|
|
237
|
+
} catch (error) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`cannot read the exported BuildKit provenance predicate at ${provenancePath}: ${error.message}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const label = 'exported BuildKit SLSA v1 provenance predicate';
|
|
243
|
+
if (
|
|
244
|
+
typeof predicate !== 'object' ||
|
|
245
|
+
predicate === null ||
|
|
246
|
+
Array.isArray(predicate)
|
|
247
|
+
) {
|
|
248
|
+
throw new Error(`${label} is not a JSON object`);
|
|
249
|
+
}
|
|
250
|
+
const buildDefinition = predicate.buildDefinition;
|
|
251
|
+
if (
|
|
252
|
+
typeof buildDefinition !== 'object' ||
|
|
253
|
+
buildDefinition === null ||
|
|
254
|
+
Array.isArray(buildDefinition)
|
|
255
|
+
) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`${label} has no buildDefinition object — SLSA v1 (BuildKit >= v0.28) is required; ` +
|
|
258
|
+
'pin the BuildKit image and pass provenance: mode=max,version=v1 to docker/build-push-action',
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
// SLSA v1 names the build type INSIDE buildDefinition; a top-level buildType
|
|
262
|
+
// is the retired v0.2 shape.
|
|
263
|
+
const buildType = buildDefinition.buildType;
|
|
264
|
+
if (typeof buildType !== 'string' || buildType.length === 0) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`${label} names no buildDefinition.buildType — is this SLSA v1 output?`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const revision =
|
|
270
|
+
predicate.runDetails?.metadata?.buildkit_metadata?.vcs?.revision;
|
|
271
|
+
if (revision !== sourceCommit) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`${label} records VCS revision "${String(revision)}", not source commit ${sourceCommit} — ` +
|
|
274
|
+
'the exported predicate must describe exactly the build this evidence names',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const parameters = {};
|
|
278
|
+
if ('externalParameters' in buildDefinition) {
|
|
279
|
+
parameters.externalParameters = buildDefinition.externalParameters;
|
|
280
|
+
}
|
|
281
|
+
if ('internalParameters' in buildDefinition) {
|
|
282
|
+
parameters.internalParameters = buildDefinition.internalParameters;
|
|
283
|
+
}
|
|
284
|
+
if (Object.keys(parameters).length === 0) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`${label} carries neither externalParameters nor internalParameters — nothing binds the build's inputs`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
buildType,
|
|
291
|
+
buildDefinitionDigest: `sha256:${sha256Hex(canonicalJson(buildDefinition))}`,
|
|
292
|
+
parametersDigest: `sha256:${sha256Hex(canonicalJson(parameters))}`,
|
|
293
|
+
// The parsed predicate itself: the registry scan below binds the
|
|
294
|
+
// statement's provenance witness to this exact build's identity.
|
|
295
|
+
predicate,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The identity the PRIMARY build's SLSA attestation must carry: builder id +
|
|
301
|
+
* invocation id, read off a decoded SLSA v1 predicate. Both spellings of the
|
|
302
|
+
* invocation key occur in the wild — BuildKit's own rendering writes
|
|
303
|
+
* `invocationId`, lanes whose predicates passed through other renderings
|
|
304
|
+
* carry `invocationID` — so both are read, never rewritten.
|
|
305
|
+
*/
|
|
306
|
+
function slsaBuildIdentity(predicate, label) {
|
|
307
|
+
const builderId = predicate?.runDetails?.builder?.id;
|
|
308
|
+
if (typeof builderId !== 'string' || builderId.length === 0) {
|
|
309
|
+
throw new Error(`${label} records no runDetails.builder.id — the builder identity is what binds an attestation to one build`);
|
|
310
|
+
}
|
|
311
|
+
const metadata = predicate?.runDetails?.metadata;
|
|
312
|
+
const invocationId =
|
|
313
|
+
typeof metadata?.invocationId === 'string'
|
|
314
|
+
? metadata.invocationId
|
|
315
|
+
: typeof metadata?.invocationID === 'string'
|
|
316
|
+
? metadata.invocationID
|
|
317
|
+
: undefined;
|
|
318
|
+
if (invocationId === undefined || invocationId.length === 0) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`${label} records no runDetails.metadata.invocationId/invocationID — without an invocation id one build's attestation cannot be told from another's`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return { builderId, invocationId };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The vendoring record the producing checkout committed — read at the BUILD
|
|
328
|
+
* commit, because it pins the cross-repo bytes the build actually consumed.
|
|
329
|
+
* Absent record (a repository with no cross-repo inputs) returns null.
|
|
330
|
+
*
|
|
331
|
+
* EVERY field this function returns is validated HERE, before any consumer
|
|
332
|
+
* filters it. An entry that drops out of the source-hash closure does not fail
|
|
333
|
+
* — it produces a DIFFERENT `sourceTreeHash`, and the only symptom is the
|
|
334
|
+
* registry cross-check reporting "the image was not published from this source
|
|
335
|
+
* identity" hundreds of lines later, naming neither the record nor the entry.
|
|
336
|
+
* A malformed vendoring record is unusable, full stop; it must say so here.
|
|
337
|
+
*/
|
|
338
|
+
export function readCrossRepoRecord(repoDir, commit, repo) {
|
|
339
|
+
const label = `${repo}@${commit}:${CROSS_REPO_RECORD_PATH}`;
|
|
340
|
+
let present = true;
|
|
341
|
+
try {
|
|
342
|
+
sandboxedGit(repoDir, [
|
|
343
|
+
'cat-file',
|
|
344
|
+
'-e',
|
|
345
|
+
`${commit}:${CROSS_REPO_RECORD_PATH}`,
|
|
346
|
+
]);
|
|
347
|
+
} catch {
|
|
348
|
+
present = false;
|
|
349
|
+
}
|
|
350
|
+
if (!present) {return null;}
|
|
351
|
+
let record;
|
|
352
|
+
try {
|
|
353
|
+
record = readGitJsonAtCommit(repoDir, CROSS_REPO_RECORD_PATH, commit);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
throw new Error(`${label} is not readable JSON: ${error.message}`);
|
|
356
|
+
}
|
|
357
|
+
if (record?.schemaVersion !== 1 || !Array.isArray(record.inputs)) {
|
|
358
|
+
throw new Error(`${label} is not a schemaVersion 1 cross-repo record`);
|
|
359
|
+
}
|
|
360
|
+
// Build-step context directories ride straight into `hashImageBuildInputs`.
|
|
361
|
+
// They were consumed with `?? []` and NO validation at all, so a record
|
|
362
|
+
// carrying a non-array (or a traversing path) silently hashed a different
|
|
363
|
+
// closure than the build consumed.
|
|
364
|
+
const buildStepContextDirectories = record.buildStepContextDirectories ?? [];
|
|
365
|
+
if (!Array.isArray(buildStepContextDirectories)) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`${label} buildStepContextDirectories must be an array of repository-relative directories`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
for (const directory of buildStepContextDirectories) {
|
|
371
|
+
canonicalRelativePath(
|
|
372
|
+
directory,
|
|
373
|
+
`${label} buildStepContextDirectories entry`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
// Shape FIRST, selection second. The `consumerService` filter used to run
|
|
377
|
+
// BEFORE this validation, so an entry MISSING `consumerService` compared
|
|
378
|
+
// `undefined !== null && undefined !== id` — true — and was silently skipped
|
|
379
|
+
// by the very check that exists to reject it.
|
|
380
|
+
for (const input of record.inputs) {
|
|
381
|
+
if (
|
|
382
|
+
input === null ||
|
|
383
|
+
typeof input !== 'object' ||
|
|
384
|
+
Array.isArray(input) ||
|
|
385
|
+
(input.consumerService !== null &&
|
|
386
|
+
typeof input.consumerService !== 'string') ||
|
|
387
|
+
(typeof input.consumerService === 'string' &&
|
|
388
|
+
input.consumerService.length === 0) ||
|
|
389
|
+
typeof input.sourceRepo !== 'string' ||
|
|
390
|
+
!SOURCE_REPO_PATTERN.test(input.sourceRepo) ||
|
|
391
|
+
!COMMIT_PATTERN.test(input.sourceCommit ?? '') ||
|
|
392
|
+
typeof input.sourcePath !== 'string' ||
|
|
393
|
+
typeof input.destinationPath !== 'string'
|
|
394
|
+
) {
|
|
395
|
+
throw new Error(`${label} carries a malformed input ${serializeJson(input ?? null)}`);
|
|
396
|
+
}
|
|
397
|
+
canonicalRelativePath(input.sourcePath, `${label} input sourcePath`);
|
|
398
|
+
canonicalRelativePath(
|
|
399
|
+
input.destinationPath,
|
|
400
|
+
`${label} input destinationPath`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return { inputs: record.inputs, buildStepContextDirectories };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The subject's source identity, computed with the exact lock algorithms:
|
|
408
|
+
* sourceManifestHash (the runtime-image-source-index algorithm),
|
|
409
|
+
* sourceTreeHash (the ImageInputV2 build-input closure), and the
|
|
410
|
+
* repositoryUrl the verifier binds (the runtime-image-source-index pin).
|
|
411
|
+
*/
|
|
412
|
+
/**
|
|
413
|
+
* The `platform-service-index.json` entry a `platform`-kind subject is built
|
|
414
|
+
* from.
|
|
415
|
+
*
|
|
416
|
+
* `platform` subjects are the fleet's only repository-ROOT subjects: their lock
|
|
417
|
+
* entry is synthesized in `resolve-images.mjs` from
|
|
418
|
+
* `distributionLock.platformServices[].source`, with `path === repo` and
|
|
419
|
+
* `sourceManifestPath === '<repo>/package.json'` — they are whole repositories
|
|
420
|
+
* (the five generic submodules), not biome directories.
|
|
421
|
+
*
|
|
422
|
+
* `deriveSubjectSourceIdentity` recognized only two shapes: a supplemental
|
|
423
|
+
* subject declared in the runtime index, or a biome subject whose path names a
|
|
424
|
+
* directory inside the repository. A `platform` subject is neither, so EVERY one
|
|
425
|
+
* of them failed deterministically — before any registry hop — on "is not a
|
|
426
|
+
* supplemental subject, so its source path must name a biome directory". All
|
|
427
|
+
* five generic submodules were therefore unable to produce image-source evidence
|
|
428
|
+
* at all.
|
|
429
|
+
*
|
|
430
|
+
* The index is the SAME authority `resolve-images.mjs` resolved the lock's
|
|
431
|
+
* platform subjects from, so resolving through it here is what makes the
|
|
432
|
+
* assembler's `sourceTreeHash` equal the lock's `contentHash` — the equality the
|
|
433
|
+
* whole producer contract rests on. `imageKey` (not `name`) is the subject id,
|
|
434
|
+
* because that is the field `resolve-images.mjs` uses for `subjectId`.
|
|
435
|
+
*/
|
|
436
|
+
function findPlatformServiceSource({ readPlatformServiceIndex, kind, id, repo }) {
|
|
437
|
+
if (typeof readPlatformServiceIndex !== 'function') {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`subject ${kind}:${id} is a platform subject, which is resolved from ` +
|
|
440
|
+
'platform-service-index.json, but no reader for that index was provided',
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const matches = readPlatformServiceIndex().services.filter(
|
|
444
|
+
(candidate) => candidate.imageKey === id && candidate.repo === repo,
|
|
445
|
+
);
|
|
446
|
+
if (matches.length !== 1) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`subject ${kind}:${id} matches ${matches.length} entries of ` +
|
|
449
|
+
`platform-service-index.json for repository "${repo}"; exactly one is ` +
|
|
450
|
+
'required — a platform subject is resolved from that index, which is the ' +
|
|
451
|
+
'same authority resolve-images.mjs built the lock from',
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return matches[0];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function deriveSubjectSourceIdentity({
|
|
458
|
+
runtimeIndex,
|
|
459
|
+
// Lazy on purpose: platform-service-index.json is needed ONLY to resolve a
|
|
460
|
+
// `platform` subject, and requiring it for every subject made the file an
|
|
461
|
+
// unconditional input of a derivation that mostly does not read it.
|
|
462
|
+
readPlatformServiceIndex,
|
|
463
|
+
// The kernel enum member, passed in rather than written as a literal: the
|
|
464
|
+
// subject kind is a closed set the kernel owns, and a local copy is a second
|
|
465
|
+
// source of truth waiting to drift.
|
|
466
|
+
platformSubjectKind,
|
|
467
|
+
// The kernel's `RuntimeImageSubjectKind.ComposedProcess`, passed in for the
|
|
468
|
+
// reason `platformSubjectKind` is. Undefined: no subject is a composed one.
|
|
469
|
+
composedProcessSubjectKind = undefined,
|
|
470
|
+
declaration,
|
|
471
|
+
}) {
|
|
472
|
+
const { kind, id, repo, path, sourceCommit } = declaration;
|
|
473
|
+
if (typeof platformSubjectKind !== 'string' || !platformSubjectKind) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
'the kernel platform subject kind is required to resolve a source identity',
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
const pin = runtimeIndex.repositoryPins.find(
|
|
479
|
+
(candidate) => candidate.repo === repo,
|
|
480
|
+
);
|
|
481
|
+
if (!pin) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`subject ${kind}:${id} names source repository "${repo}", which has no ` +
|
|
484
|
+
'repository pin in runtime-image-source-index.json — it is not an image source repository',
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
const repoDir = sourceCheckoutPath(repo);
|
|
488
|
+
assertGitCommitExists(repoDir, sourceCommit);
|
|
489
|
+
// Supplemental subjects (host shell, independently sourced platform images)
|
|
490
|
+
// declare their source manifest in the runtime index; biome subjects carry
|
|
491
|
+
// `<biome>/xema-biome.json`.
|
|
492
|
+
const supplemental = runtimeIndex.supplementalSubjects.find(
|
|
493
|
+
(candidate) => candidate.kind === kind && candidate.id === id,
|
|
494
|
+
);
|
|
495
|
+
let manifestRepoRelativePath;
|
|
496
|
+
let subjectForServicePath = { kind, id, repo, path };
|
|
497
|
+
if (supplemental) {
|
|
498
|
+
if (supplemental.repo !== repo || supplemental.path !== path) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`subject ${kind}:${id} is a supplemental subject of ${supplemental.repo}:${supplemental.path} ` +
|
|
501
|
+
`in runtime-image-source-index.json, not of ${repo}:${path}`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (!supplemental.sourceManifestPath.startsWith(`${repo}/`)) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`subject ${kind}:${id} sourceManifestPath "${supplemental.sourceManifestPath}" ` +
|
|
507
|
+
`escapes its repository "${repo}"`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
manifestRepoRelativePath = supplemental.sourceManifestPath.slice(
|
|
511
|
+
repo.length + 1,
|
|
512
|
+
);
|
|
513
|
+
subjectForServicePath = {
|
|
514
|
+
...subjectForServicePath,
|
|
515
|
+
sourceManifestPath: manifestRepoRelativePath,
|
|
516
|
+
};
|
|
517
|
+
} else if (composedProcessSubjectKind !== undefined && kind === composedProcessSubjectKind) {
|
|
518
|
+
// The composed-process image of one build: a Dockerfile-only image at the
|
|
519
|
+
// repository's `infra/composed-process/`, whose declaration is its
|
|
520
|
+
// `image-build-inputs.json` — exactly the shape resolve-images.mjs names
|
|
521
|
+
// in the lock (`readComposedProcessDeclarations`).
|
|
522
|
+
if (path !== `${repo}/${COMPOSED_PROCESS_IMAGE_PATH}`) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`subject ${kind}:${id} declares source path "${path}", but a composed-process ` +
|
|
525
|
+
`image is built from "${repo}/${COMPOSED_PROCESS_IMAGE_PATH}"`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const expectedId = composedProcessSubjectId(firstPartySourceId(repo));
|
|
529
|
+
if (id !== expectedId) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
`subject ${kind}:${id} is not the composed-process subject of "${repo}" ("${expectedId}")`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
manifestRepoRelativePath = `${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
|
|
535
|
+
subjectForServicePath = {
|
|
536
|
+
...subjectForServicePath,
|
|
537
|
+
sourceManifestPath: manifestRepoRelativePath,
|
|
538
|
+
};
|
|
539
|
+
} else if (kind === platformSubjectKind) {
|
|
540
|
+
// The third shape: a whole-repository platform service. See
|
|
541
|
+
// `findPlatformServiceSource`.
|
|
542
|
+
const service = findPlatformServiceSource({
|
|
543
|
+
readPlatformServiceIndex,
|
|
544
|
+
kind,
|
|
545
|
+
id,
|
|
546
|
+
repo,
|
|
547
|
+
});
|
|
548
|
+
if (path !== repo) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
`subject ${kind}:${id} declares source path "${path}", but a platform ` +
|
|
551
|
+
`subject is the whole repository "${repo}" — resolve-images.mjs sets ` +
|
|
552
|
+
'path === repo for it, and any other path hashes a closure the lock never named',
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
if (!service.path.startsWith(`${repo}/`)) {
|
|
556
|
+
throw new Error(
|
|
557
|
+
`subject ${kind}:${id} platform-service-index path "${service.path}" ` +
|
|
558
|
+
`escapes its repository "${repo}"`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
manifestRepoRelativePath = service.path.slice(repo.length + 1);
|
|
562
|
+
// Setting `sourceManifestPath` is what makes `subjectServicePath` return the
|
|
563
|
+
// repository ROOT instead of reading a `xema-biome.json` that does not
|
|
564
|
+
// exist — exactly the path whose build-input closure produced the lock's
|
|
565
|
+
// contentHash.
|
|
566
|
+
subjectForServicePath = {
|
|
567
|
+
...subjectForServicePath,
|
|
568
|
+
sourceManifestPath: manifestRepoRelativePath,
|
|
569
|
+
};
|
|
570
|
+
} else {
|
|
571
|
+
if (path === repo) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`subject ${kind}:${id} is not a supplemental subject, so its source path ` +
|
|
574
|
+
'must name a biome directory inside the repository, not the repository root',
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
manifestRepoRelativePath = `${path.slice(repo.length + 1)}/xema-biome.json`;
|
|
578
|
+
}
|
|
579
|
+
const { computeBiomeSourceManifestHash } = await loadKernelDistributionSchemas();
|
|
580
|
+
const sourceManifestHash = await computeBiomeSourceManifestHash(
|
|
581
|
+
readGitJsonAtCommit(repoDir, manifestRepoRelativePath, sourceCommit),
|
|
582
|
+
);
|
|
583
|
+
const servicePath = subjectServicePath(subjectForServicePath, sourceCommit);
|
|
584
|
+
// Vendored cross-repo inputs EXACTLY as the build consumed them: the record
|
|
585
|
+
// committed in the producing checkout at the build commit. A `null`
|
|
586
|
+
// consumerService vendors for EVERY service of the consumer repository.
|
|
587
|
+
// `readCrossRepoRecord` has already proven EVERY entry well-formed, so the
|
|
588
|
+
// filter below can only ever narrow a valid set — it can no longer be the
|
|
589
|
+
// thing that hides an invalid one.
|
|
590
|
+
const record = readCrossRepoRecord(repoDir, sourceCommit, repo);
|
|
591
|
+
const crossRepoInputs = [];
|
|
592
|
+
for (const input of record?.inputs ?? []) {
|
|
593
|
+
// A composed image carries EVERY service of its build, so it vendors every
|
|
594
|
+
// input the record declares — the same set resolve-images.mjs hashes.
|
|
595
|
+
const composed =
|
|
596
|
+
composedProcessSubjectKind !== undefined && kind === composedProcessSubjectKind;
|
|
597
|
+
if (!composed && input.consumerService !== null && input.consumerService !== id) {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const sourceRepoDir = sourceCheckoutPath(input.sourceRepo);
|
|
601
|
+
assertGitCommitExists(sourceRepoDir, input.sourceCommit);
|
|
602
|
+
crossRepoInputs.push({
|
|
603
|
+
destinationPath: input.destinationPath,
|
|
604
|
+
sourceRepoDir,
|
|
605
|
+
sourcePath: input.sourcePath,
|
|
606
|
+
sourceCommit: input.sourceCommit,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const sourceTreeHash = hashImageBuildInputs(
|
|
610
|
+
{
|
|
611
|
+
repoDir,
|
|
612
|
+
service: id,
|
|
613
|
+
servicePath,
|
|
614
|
+
commit: sourceCommit,
|
|
615
|
+
crossRepoInputs,
|
|
616
|
+
buildStepDirectories: [...(record?.buildStepContextDirectories ?? [])],
|
|
617
|
+
},
|
|
618
|
+
imageInputSourceHashAlgorithm(),
|
|
619
|
+
);
|
|
620
|
+
return {
|
|
621
|
+
repositoryUrl: pin.repositoryUrl,
|
|
622
|
+
sourceManifestHash,
|
|
623
|
+
sourceTreeHash,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The SBOM + provenance digests carried by the subject's cosign attestation
|
|
629
|
+
* manifest (`sha256-<digest>.att`): the DSSE envelope layer digests, bound to
|
|
630
|
+
* the delivered digest through their decoded statement subject. Fail-closed on
|
|
631
|
+
* every gap, naming the pipeline step that must emit the missing artifact.
|
|
632
|
+
*
|
|
633
|
+
* A merged multi-arch index legitimately carries SEVERAL attestations of each
|
|
634
|
+
* type — one per platform build, plus one more per lane re-run, because
|
|
635
|
+
* `cosign attest` appends. Every candidate is validated (envelope shape,
|
|
636
|
+
* predicate type, subject binding) and then ONE witness per type is selected
|
|
637
|
+
* deterministically:
|
|
638
|
+
*
|
|
639
|
+
* * SLSA provenance — the candidate whose statement names the SAME build as
|
|
640
|
+
* the caller's exported `--provenance` predicate (equal builder id AND
|
|
641
|
+
* invocation id), so the statement's artifact and build sections describe
|
|
642
|
+
* one and the same build. Ties (identical re-attestations of that build)
|
|
643
|
+
* resolve to the smallest layer digest; no match fails loudly.
|
|
644
|
+
* * SBOM — no caller-side reference exists, so the smallest layer digest
|
|
645
|
+
* among the validated candidates. Content-addressed, stable, and every
|
|
646
|
+
* candidate already proved it is an SBOM attestation bound to the
|
|
647
|
+
* delivered digest.
|
|
648
|
+
*/
|
|
649
|
+
async function resolveCosignAttestationDigests({
|
|
650
|
+
client,
|
|
651
|
+
repository,
|
|
652
|
+
imageRepository,
|
|
653
|
+
manifestDigest,
|
|
654
|
+
label,
|
|
655
|
+
referenceProvenance,
|
|
656
|
+
}) {
|
|
657
|
+
const attestationTag = `sha256-${manifestDigest.slice('sha256:'.length)}${ATTESTATION_TAG_SUFFIX}`;
|
|
658
|
+
let attestation;
|
|
659
|
+
try {
|
|
660
|
+
attestation = await client.getManifestByTag(repository, attestationTag);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
`${label} has no cosign attestation manifest at tag ${attestationTag}: ${error.message}. ` +
|
|
664
|
+
'The producing workflow must `cosign attest` the SBOM (--type spdxjson) and the SLSA ' +
|
|
665
|
+
`provenance (--type slsaprovenance1) against the exact delivered digest ${manifestDigest}. ` +
|
|
666
|
+
'If this digest is a multi-arch index merged AFTER the build attested the pre-merge image ' +
|
|
667
|
+
'(a separate multi-arch merge workflow), the merge step must re-run those attest calls on the merged ' +
|
|
668
|
+
'index.',
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
if (attestation.isIndex) {
|
|
672
|
+
throw new Error(
|
|
673
|
+
`${label} attestation tag ${attestationTag} resolved to an index; the assembler expects the ` +
|
|
674
|
+
'single-manifest / multi-layer shape cosign v2.6.3 writes',
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
const layers = Array.isArray(attestation.document.layers)
|
|
678
|
+
? attestation.document.layers
|
|
679
|
+
: [];
|
|
680
|
+
if (layers.length === 0) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
`${label} attestation manifest ${attestation.digest} carries no layers`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
const deliveredHex = manifestDigest.slice('sha256:'.length);
|
|
686
|
+
const sbomCandidates = [];
|
|
687
|
+
const provenanceCandidates = [];
|
|
688
|
+
for (const layer of layers) {
|
|
689
|
+
if (layer?.mediaType !== DSSE_ENVELOPE_LAYER_MEDIA_TYPE) {
|
|
690
|
+
throw new Error(
|
|
691
|
+
`${label} attestation manifest ${attestation.digest} carries a ` +
|
|
692
|
+
`"${String(layer?.mediaType)}" layer; only ${DSSE_ENVELOPE_LAYER_MEDIA_TYPE} ` +
|
|
693
|
+
'layers are admissible',
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
const predicateType = layer.annotations?.[PREDICATE_TYPE_ANNOTATION];
|
|
697
|
+
if (typeof predicateType !== 'string' || predicateType.length === 0) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`${label} attestation layer ${layer.digest} has no "${PREDICATE_TYPE_ANNOTATION}" annotation`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (predicateType === DEPLOY_SOURCE_PREDICATE_TYPE) {continue;}
|
|
703
|
+
if (
|
|
704
|
+
predicateType !== SBOM_PREDICATE_TYPE &&
|
|
705
|
+
predicateType !== PROVENANCE_PREDICATE_TYPE
|
|
706
|
+
) {
|
|
707
|
+
throw new Error(
|
|
708
|
+
`${label} attestation layer ${layer.digest} has unrecognized predicate type ` +
|
|
709
|
+
`"${predicateType}" — only ${SBOM_PREDICATE_TYPE}, ${PROVENANCE_PREDICATE_TYPE} and ` +
|
|
710
|
+
`${DEPLOY_SOURCE_PREDICATE_TYPE} may hang off a producer subject`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
// Fetching the envelope proves it is stored and content-addressed, and the
|
|
714
|
+
// decoded statement must bind EXACTLY the delivered digest: an attestation
|
|
715
|
+
// that names any other subject describes a different image.
|
|
716
|
+
const bytes = await client.getBlob(
|
|
717
|
+
repository,
|
|
718
|
+
layer.digest,
|
|
719
|
+
MAX_ATTESTATION_LAYER_BYTES,
|
|
720
|
+
);
|
|
721
|
+
let envelope;
|
|
722
|
+
try {
|
|
723
|
+
envelope = JSON.parse(bytes.toString('utf8'));
|
|
724
|
+
} catch {
|
|
725
|
+
throw new Error(
|
|
726
|
+
`${label} attestation layer ${layer.digest} is not valid JSON`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
if (envelope?.payloadType !== IN_TOTO_PAYLOAD_TYPE) {
|
|
730
|
+
throw new Error(
|
|
731
|
+
`${label} attestation layer ${layer.digest} is not an ${IN_TOTO_PAYLOAD_TYPE} DSSE envelope`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
let statement;
|
|
735
|
+
try {
|
|
736
|
+
statement = JSON.parse(
|
|
737
|
+
Buffer.from(String(envelope.payload), 'base64').toString('utf8'),
|
|
738
|
+
);
|
|
739
|
+
} catch {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`${label} attestation layer ${layer.digest} carries no decodable in-toto statement`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (statement?.predicateType !== predicateType) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`${label} attestation layer ${layer.digest} annotates "${predicateType}" ` +
|
|
747
|
+
`but its statement declares "${String(statement?.predicateType)}"`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
const expectedSubject = {
|
|
751
|
+
name: imageRepository,
|
|
752
|
+
digest: { sha256: deliveredHex },
|
|
753
|
+
};
|
|
754
|
+
if (
|
|
755
|
+
!Array.isArray(statement.subject) ||
|
|
756
|
+
statement.subject.length !== 1 ||
|
|
757
|
+
JSON.stringify(statement.subject[0]) !== JSON.stringify(expectedSubject)
|
|
758
|
+
) {
|
|
759
|
+
throw new Error(
|
|
760
|
+
`${label} attestation layer ${layer.digest} binds ` +
|
|
761
|
+
`${serializeJson(statement.subject ?? null)}, not the delivered subject ` +
|
|
762
|
+
`${serializeJson(expectedSubject)} — the producing workflow must attest the exact ` +
|
|
763
|
+
'digest the canonical coordinate serves',
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
if (predicateType === SBOM_PREDICATE_TYPE) {
|
|
767
|
+
sbomCandidates.push({ digest: layer.digest, statement });
|
|
768
|
+
} else {
|
|
769
|
+
provenanceCandidates.push({ digest: layer.digest, statement });
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (sbomCandidates.length === 0) {
|
|
773
|
+
throw new Error(
|
|
774
|
+
`${label} carries no SBOM attestation (${SBOM_PREDICATE_TYPE}) — the producing workflow must run ` +
|
|
775
|
+
`cosign attest --yes --type spdxjson --predicate <spdx.json> ${imageRepository}@${manifestDigest}`,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
if (provenanceCandidates.length === 0) {
|
|
779
|
+
throw new Error(
|
|
780
|
+
`${label} carries no SLSA provenance attestation (${PROVENANCE_PREDICATE_TYPE}) — the producing ` +
|
|
781
|
+
`workflow must run cosign attest --yes --type slsaprovenance1 --predicate <slsa-provenance.json> ` +
|
|
782
|
+
`${imageRepository}@${manifestDigest}`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
// The provenance witness must describe the SAME build the statement's build
|
|
786
|
+
// section describes — the caller's exported predicate. A multi-platform
|
|
787
|
+
// index carries one attestation per platform build; picking any other
|
|
788
|
+
// build's would attest the index with a different source's provenance.
|
|
789
|
+
const referenceIdentity = slsaBuildIdentity(
|
|
790
|
+
referenceProvenance,
|
|
791
|
+
'the exported --provenance predicate',
|
|
792
|
+
);
|
|
793
|
+
const matches = provenanceCandidates.filter((candidate) => {
|
|
794
|
+
try {
|
|
795
|
+
const identity = slsaBuildIdentity(
|
|
796
|
+
candidate.statement.predicate,
|
|
797
|
+
`attestation layer ${candidate.digest}`,
|
|
798
|
+
);
|
|
799
|
+
return (
|
|
800
|
+
identity.builderId === referenceIdentity.builderId &&
|
|
801
|
+
identity.invocationId === referenceIdentity.invocationId
|
|
802
|
+
);
|
|
803
|
+
} catch {
|
|
804
|
+
// A candidate whose predicate names no build identity can never be the
|
|
805
|
+
// primary build's witness; it stays admissible for nothing.
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
if (matches.length === 0) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
`${label} carries ${provenanceCandidates.length} SLSA provenance attestation(s) but none ` +
|
|
812
|
+
`describes the primary build (builder "${referenceIdentity.builderId}", invocation ` +
|
|
813
|
+
`"${referenceIdentity.invocationId}") — the lane must re-attest the merged index with ` +
|
|
814
|
+
'that build\'s own predicate (mirror release-arm64-host-web.yaml\'s attest step)',
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
matches.sort((left, right) => compareStrings(left.digest, right.digest));
|
|
818
|
+
const provenanceDigest = matches[0].digest;
|
|
819
|
+
const sbomsByDigest = [...sbomCandidates].sort((left, right) =>
|
|
820
|
+
compareStrings(left.digest, right.digest),
|
|
821
|
+
);
|
|
822
|
+
const sbomDigest = sbomsByDigest[0].digest;
|
|
823
|
+
return { sbomDigest, provenanceDigest };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Registry side of one subject, resolved at the canonical
|
|
828
|
+
* `<imageRepository>:<sourceTreeHash>` coordinate: the served manifest digest
|
|
829
|
+
* (which must be exactly the caller's), its media type, the hash-proven
|
|
830
|
+
* platform set, and the SBOM/provenance attestation digests.
|
|
831
|
+
* `referenceProvenance` is the caller's parsed exported BuildKit predicate —
|
|
832
|
+
* the identity anchor the provenance-witness selection binds to.
|
|
833
|
+
*/
|
|
834
|
+
export async function resolveImageArtifactEvidence({
|
|
835
|
+
imageRepository,
|
|
836
|
+
manifestDigest,
|
|
837
|
+
sourceTreeHash,
|
|
838
|
+
referenceProvenance,
|
|
839
|
+
distributionsRoot = dataRoot(),
|
|
840
|
+
env = process.env,
|
|
841
|
+
fetchImpl = globalThis.fetch,
|
|
842
|
+
registryFor = null,
|
|
843
|
+
}) {
|
|
844
|
+
if (!SHA256_DIGEST_PATTERN.test(manifestDigest ?? '')) {
|
|
845
|
+
throw new Error('--manifest-digest must be an exact sha256:<64-hex> digest');
|
|
846
|
+
}
|
|
847
|
+
if (!SHA256_HEX_PATTERN.test(sourceTreeHash ?? '')) {
|
|
848
|
+
throw new Error('sourceTreeHash must be 64 lowercase sha256 hex characters');
|
|
849
|
+
}
|
|
850
|
+
if (
|
|
851
|
+
typeof referenceProvenance !== 'object' ||
|
|
852
|
+
referenceProvenance === null ||
|
|
853
|
+
Array.isArray(referenceProvenance)
|
|
854
|
+
) {
|
|
855
|
+
throw new Error(
|
|
856
|
+
'referenceProvenance (the parsed exported BuildKit SLSA v1 predicate) is required — ' +
|
|
857
|
+
'it is what binds the selected provenance attestation to the primary build',
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
const { authority, repository } = splitImageRef(imageRepository);
|
|
861
|
+
const declarations = loadRegistryCredentialDeclarations(distributionsRoot);
|
|
862
|
+
const credential = resolveRegistryCredential({ authority, declarations, env });
|
|
863
|
+
const client = registryFor
|
|
864
|
+
? registryFor({ authority, credential })
|
|
865
|
+
: new OrasRegistry({ credential, fetchImpl });
|
|
866
|
+
const coordinate = `${imageRepository}:${sourceTreeHash}`;
|
|
867
|
+
const top = await client.getManifestByTag(repository, sourceTreeHash);
|
|
868
|
+
if (top.digest !== manifestDigest) {
|
|
869
|
+
throw new Error(
|
|
870
|
+
`${coordinate} serves ${top.digest}, not the caller-declared ${manifestDigest} — ` +
|
|
871
|
+
'the image was not published from this source identity (wrong build commit, ' +
|
|
872
|
+
'or the cross-repo record at the build commit differs from the one this source carries)',
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
// Proves EVERY platform by its own hash-verified image config and refuses
|
|
876
|
+
// attestation children as platforms — exactly the evidence the producer
|
|
877
|
+
// input carries.
|
|
878
|
+
const published = await resolvePublishedPlatforms({
|
|
879
|
+
client,
|
|
880
|
+
repository,
|
|
881
|
+
tag: sourceTreeHash,
|
|
882
|
+
coordinate,
|
|
883
|
+
});
|
|
884
|
+
if (published.indexDigest !== top.digest) {
|
|
885
|
+
throw new Error(
|
|
886
|
+
`${coordinate} did not resolve to the exact published manifest this evidence names`,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
const label = `${imageRepository}@${top.digest}`;
|
|
890
|
+
const { sbomDigest, provenanceDigest } =
|
|
891
|
+
await resolveCosignAttestationDigests({
|
|
892
|
+
client,
|
|
893
|
+
repository,
|
|
894
|
+
imageRepository,
|
|
895
|
+
manifestDigest: top.digest,
|
|
896
|
+
label,
|
|
897
|
+
referenceProvenance,
|
|
898
|
+
});
|
|
899
|
+
const platforms = published.platforms
|
|
900
|
+
.map((entry) => ({
|
|
901
|
+
...entry.platform,
|
|
902
|
+
manifestDigest: entry.manifestDigest,
|
|
903
|
+
}))
|
|
904
|
+
// The kernel's canonical platform order: os/architecture/variant,
|
|
905
|
+
// code-point comparison (assertPlatforms re-checks this on verification).
|
|
906
|
+
.sort((left, right) =>
|
|
907
|
+
compareStrings(platformIdentity(left), platformIdentity(right)),
|
|
908
|
+
);
|
|
909
|
+
return {
|
|
910
|
+
manifestDigest: top.digest,
|
|
911
|
+
manifestMediaType: top.mediaType,
|
|
912
|
+
platforms,
|
|
913
|
+
sbomDigest,
|
|
914
|
+
provenanceDigest,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* The producer's DECLARED evidence lifetime, in seconds.
|
|
920
|
+
*
|
|
921
|
+
* This used to default to the trust policy's `maxProducerTtlSeconds` (the
|
|
922
|
+
* CEILING), which is wrong in two independent ways:
|
|
923
|
+
*
|
|
924
|
+
* 1. It collapses two numbers that must move independently. The ceiling is the
|
|
925
|
+
* VERIFIER's tolerance; this is the PRODUCER's intent. With them equal,
|
|
926
|
+
* `assertTimeWindow`'s `expires - issued > maxTtlSeconds` sits exactly on
|
|
927
|
+
* the boundary, so lowering the policy ceiling by one second retroactively
|
|
928
|
+
* invalidates every bundle ever signed — the ceiling could never be
|
|
929
|
+
* tightened without re-producing the entire fleet's evidence.
|
|
930
|
+
* 2. "As long as permitted" is not a decision. The offline verifier has NO
|
|
931
|
+
* revocation channel, so this TTL *is* the revocation window for a
|
|
932
|
+
* mis-issued or leaked bundle. Taking the maximum by default makes that
|
|
933
|
+
* window as wide as the policy allows, by omission rather than by choice.
|
|
934
|
+
*
|
|
935
|
+
* 72h is the shortest value that still spans a weekend: a bundle produced late
|
|
936
|
+
* Friday is still valid Monday morning, so a release or delivery that slips
|
|
937
|
+
* over a weekend never needs an out-of-band refresh. Everything shorter buys
|
|
938
|
+
* marginal revocation latency and pays for it in operational surprise.
|
|
939
|
+
*
|
|
940
|
+
* A producer that genuinely needs a different lifetime passes `--ttl-seconds`;
|
|
941
|
+
* the ceiling still bounds it, and the value is asserted against the ceiling
|
|
942
|
+
* below so this constant can never quietly exceed the policy.
|
|
943
|
+
*/
|
|
944
|
+
export const DEFAULT_PRODUCER_TTL_SECONDS = 3 * 24 * 60 * 60;
|
|
945
|
+
|
|
946
|
+
function readProducerTtlSeconds(distributionsRoot) {
|
|
947
|
+
const declared = editionWorkspaceConfig().evidenceTrustPolicy.policy;
|
|
948
|
+
if (declared === null) {
|
|
949
|
+
throw new Error(
|
|
950
|
+
`${distributionsRoot} declares no evidenceTrustPolicy in edition-workspace.json; the producer TTL ceiling comes from it`,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
const policyPath = join(distributionsRoot, declared);
|
|
954
|
+
let policy;
|
|
955
|
+
try {
|
|
956
|
+
policy = JSON.parse(readFileSync(policyPath, 'utf8'));
|
|
957
|
+
} catch (error) {
|
|
958
|
+
throw new Error(
|
|
959
|
+
`cannot read the committed trust policy at ${policyPath}: ${error.message}`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
if (
|
|
963
|
+
policy?.schemaVersion !== 1 ||
|
|
964
|
+
!Number.isSafeInteger(policy.maxProducerTtlSeconds) ||
|
|
965
|
+
policy.maxProducerTtlSeconds < 1
|
|
966
|
+
) {
|
|
967
|
+
throw new Error(
|
|
968
|
+
`${policyPath} does not declare a positive integer maxProducerTtlSeconds`,
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
return policy.maxProducerTtlSeconds;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function rfc3339(timestamp) {
|
|
975
|
+
return new Date(timestamp).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Assemble the complete producer input manifest for ONE image subject and
|
|
980
|
+
* prove — before anything is written — that it satisfies the producer's exact
|
|
981
|
+
* field contract, the kernel statement schema, and the running GitHub context
|
|
982
|
+
* the producer action will compare it against.
|
|
983
|
+
*/
|
|
984
|
+
export async function assembleProducerInput({
|
|
985
|
+
imageRepository,
|
|
986
|
+
manifestDigest,
|
|
987
|
+
subjectKind,
|
|
988
|
+
subjectId,
|
|
989
|
+
sourceRepo,
|
|
990
|
+
sourcePath,
|
|
991
|
+
sourceCommit,
|
|
992
|
+
provenancePath,
|
|
993
|
+
sourceManifestHash: declaredSourceManifestHash,
|
|
994
|
+
sourceTreeHash: declaredSourceTreeHash,
|
|
995
|
+
ttlSeconds,
|
|
996
|
+
distributionsRoot = dataRoot(),
|
|
997
|
+
env = process.env,
|
|
998
|
+
fetchImpl = globalThis.fetch,
|
|
999
|
+
registryFor = null,
|
|
1000
|
+
now = Date.now(),
|
|
1001
|
+
}) {
|
|
1002
|
+
const schemas = await loadKernelDistributionSchemas();
|
|
1003
|
+
const contractAuthority = readPinnedContractAuthority({
|
|
1004
|
+
dataRoot: distributionsRoot,
|
|
1005
|
+
schemas,
|
|
1006
|
+
});
|
|
1007
|
+
const subjectKinds = Object.values(schemas.RuntimeImageSubjectKind);
|
|
1008
|
+
if (!subjectKinds.includes(subjectKind)) {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
`--subject-kind "${String(subjectKind)}" must be one of ${subjectKinds.join(', ')}`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
if (!CANONICAL_IDENTIFIER_PATTERN.test(subjectId ?? '')) {
|
|
1014
|
+
throw new Error('--subject-id is not a canonical identifier');
|
|
1015
|
+
}
|
|
1016
|
+
if (!SOURCE_REPO_PATTERN.test(sourceRepo ?? '')) {
|
|
1017
|
+
throw new Error(
|
|
1018
|
+
`--source-repo "${String(sourceRepo)}" must be an aggregator-plane repository (repos/<name> or submodules/<name>)`,
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
canonicalRelativePath(sourcePath, '--source-path');
|
|
1022
|
+
if (sourcePath !== sourceRepo && !sourcePath.startsWith(`${sourceRepo}/`)) {
|
|
1023
|
+
throw new Error(`--source-path must stay inside ${sourceRepo}`);
|
|
1024
|
+
}
|
|
1025
|
+
if (!COMMIT_PATTERN.test(sourceCommit ?? '')) {
|
|
1026
|
+
throw new Error('--source-commit must be an exact 40-hex commit');
|
|
1027
|
+
}
|
|
1028
|
+
const context = assertWorkflowContext(env);
|
|
1029
|
+
const runtimeIndex = schemas.parseRuntimeImageSourceIndex(
|
|
1030
|
+
JSON.parse(
|
|
1031
|
+
readFileSync(
|
|
1032
|
+
join(distributionsRoot, 'runtime-image-source-index.json'),
|
|
1033
|
+
'utf8',
|
|
1034
|
+
),
|
|
1035
|
+
),
|
|
1036
|
+
);
|
|
1037
|
+
const source = await deriveSubjectSourceIdentity({
|
|
1038
|
+
runtimeIndex,
|
|
1039
|
+
// Parsed through the kernel schema, not read raw: a `platform` subject's
|
|
1040
|
+
// whole source identity is resolved from this index, so a malformed index
|
|
1041
|
+
// must fail rather than produce a subtly wrong closure.
|
|
1042
|
+
readPlatformServiceIndex: () =>
|
|
1043
|
+
schemas.parsePlatformServiceSourceIndex(
|
|
1044
|
+
JSON.parse(
|
|
1045
|
+
readFileSync(
|
|
1046
|
+
join(distributionsRoot, 'platform-service-index.json'),
|
|
1047
|
+
'utf8',
|
|
1048
|
+
),
|
|
1049
|
+
),
|
|
1050
|
+
),
|
|
1051
|
+
platformSubjectKind: schemas.RuntimeImageSubjectKind.Platform,
|
|
1052
|
+
composedProcessSubjectKind: schemas.RuntimeImageSubjectKind.ComposedProcess,
|
|
1053
|
+
declaration: {
|
|
1054
|
+
kind: subjectKind,
|
|
1055
|
+
id: subjectId,
|
|
1056
|
+
repo: sourceRepo,
|
|
1057
|
+
path: sourcePath,
|
|
1058
|
+
sourceCommit,
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
1061
|
+
if (
|
|
1062
|
+
declaredSourceManifestHash !== undefined &&
|
|
1063
|
+
declaredSourceManifestHash !== source.sourceManifestHash
|
|
1064
|
+
) {
|
|
1065
|
+
throw new Error(
|
|
1066
|
+
`--source-manifest-hash "${String(declaredSourceManifestHash)}" does not match the ` +
|
|
1067
|
+
`${source.sourceManifestHash} computed from ${sourceRepo}@${sourceCommit} — ` +
|
|
1068
|
+
'the declared source identity is not the one this manifest hashes',
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
if (
|
|
1072
|
+
declaredSourceTreeHash !== undefined &&
|
|
1073
|
+
declaredSourceTreeHash !== source.sourceTreeHash
|
|
1074
|
+
) {
|
|
1075
|
+
throw new Error(
|
|
1076
|
+
`--source-tree-hash "${String(declaredSourceTreeHash)}" does not match the ` +
|
|
1077
|
+
`${source.sourceTreeHash} computed from the build-input closure of ` +
|
|
1078
|
+
`${sourceRepo}@${sourceCommit} — the declared coordinate is not the one this source builds`,
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
const builder = deriveBuilder({ provenancePath, sourceCommit });
|
|
1082
|
+
const artifact = await resolveImageArtifactEvidence({
|
|
1083
|
+
imageRepository,
|
|
1084
|
+
manifestDigest,
|
|
1085
|
+
sourceTreeHash: source.sourceTreeHash,
|
|
1086
|
+
referenceProvenance: builder.predicate,
|
|
1087
|
+
distributionsRoot,
|
|
1088
|
+
env,
|
|
1089
|
+
fetchImpl,
|
|
1090
|
+
registryFor,
|
|
1091
|
+
});
|
|
1092
|
+
const maxTtlSeconds = readProducerTtlSeconds(distributionsRoot);
|
|
1093
|
+
// The DEFAULT bows to the policy ceiling; an EXPLICIT `--ttl-seconds` above
|
|
1094
|
+
// it still fails below. Tightening `maxProducerTtlSeconds` is a legitimate
|
|
1095
|
+
// operator decision, and it must not break every producer until someone
|
|
1096
|
+
// edits a constant in this file — but a caller who NAMES a lifetime the
|
|
1097
|
+
// verifier will reject has made a mistake, and gets told so.
|
|
1098
|
+
const effectiveTtlSeconds =
|
|
1099
|
+
ttlSeconds ?? Math.min(DEFAULT_PRODUCER_TTL_SECONDS, maxTtlSeconds);
|
|
1100
|
+
if (
|
|
1101
|
+
!Number.isSafeInteger(effectiveTtlSeconds) ||
|
|
1102
|
+
effectiveTtlSeconds < 1 ||
|
|
1103
|
+
effectiveTtlSeconds > maxTtlSeconds
|
|
1104
|
+
) {
|
|
1105
|
+
throw new Error(
|
|
1106
|
+
`--ttl-seconds must be an integer between 1 and the trust policy's maxProducerTtlSeconds (${maxTtlSeconds})`,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
const input = {
|
|
1110
|
+
schemaVersion: 1,
|
|
1111
|
+
contractAuthority: { ...contractAuthority },
|
|
1112
|
+
sourceHashAlgorithm: imageInputSourceHashAlgorithm(),
|
|
1113
|
+
producerWorkflowSha: context.sha,
|
|
1114
|
+
signer: {
|
|
1115
|
+
certificateIdentity: `https://github.com/${context.repository}/${context.path}@${context.ref}`,
|
|
1116
|
+
oidcIssuer: 'https://token.actions.githubusercontent.com',
|
|
1117
|
+
githubWorkflow: {
|
|
1118
|
+
repository: context.repository,
|
|
1119
|
+
ref: context.ref,
|
|
1120
|
+
path: context.path,
|
|
1121
|
+
},
|
|
1122
|
+
},
|
|
1123
|
+
builder: {
|
|
1124
|
+
builderId: `https://github.com/${context.repository}/${context.path}`,
|
|
1125
|
+
buildType: builder.buildType,
|
|
1126
|
+
buildDefinitionDigest: builder.buildDefinitionDigest,
|
|
1127
|
+
parametersDigest: builder.parametersDigest,
|
|
1128
|
+
},
|
|
1129
|
+
runId: context.runId,
|
|
1130
|
+
runAttempt: context.runAttempt,
|
|
1131
|
+
issuedAt: rfc3339(now),
|
|
1132
|
+
expiresAt: rfc3339(now + effectiveTtlSeconds * 1000),
|
|
1133
|
+
expectedSubjects: [`${subjectKind}:${subjectId}`],
|
|
1134
|
+
subjects: [
|
|
1135
|
+
{
|
|
1136
|
+
kind: subjectKind,
|
|
1137
|
+
id: subjectId,
|
|
1138
|
+
repositoryUrl: source.repositoryUrl,
|
|
1139
|
+
repo: sourceRepo,
|
|
1140
|
+
path: sourcePath,
|
|
1141
|
+
sourceCommit,
|
|
1142
|
+
sourceManifestHash: source.sourceManifestHash,
|
|
1143
|
+
sourceTreeHash: source.sourceTreeHash,
|
|
1144
|
+
imageRepository,
|
|
1145
|
+
manifestDigest: artifact.manifestDigest,
|
|
1146
|
+
manifestMediaType: artifact.manifestMediaType,
|
|
1147
|
+
platforms: artifact.platforms,
|
|
1148
|
+
sbomDigest: artifact.sbomDigest,
|
|
1149
|
+
provenanceDigest: artifact.provenanceDigest,
|
|
1150
|
+
},
|
|
1151
|
+
],
|
|
1152
|
+
};
|
|
1153
|
+
// Prove the output satisfies the producer BEFORE anything reads it: the
|
|
1154
|
+
// exact field contract, then the kernel statement schema the producer builds
|
|
1155
|
+
// (the same parse produce-image-source-evidence.mjs runs before signing),
|
|
1156
|
+
// then the GitHub context the producer action compares against.
|
|
1157
|
+
assertProducerInput(input, contractAuthority);
|
|
1158
|
+
buildImageSourceStatements(input, schemas, contractAuthority);
|
|
1159
|
+
assertGitHubProducerContext(input, {
|
|
1160
|
+
repository: context.repository,
|
|
1161
|
+
ref: context.ref,
|
|
1162
|
+
sha: context.sha,
|
|
1163
|
+
workflowRef: context.workflowRef,
|
|
1164
|
+
});
|
|
1165
|
+
return input;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function requireValue(flags, name) {
|
|
1169
|
+
const value = flags.get(name);
|
|
1170
|
+
if (value === undefined) {
|
|
1171
|
+
throw new Error(`--${name} is required`);
|
|
1172
|
+
}
|
|
1173
|
+
return value;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function main() {
|
|
1177
|
+
const args = configureEditionWorkspaceFromArgv(process.argv.slice(2), [
|
|
1178
|
+
WorkspaceRoot.Data,
|
|
1179
|
+
WorkspaceRoot.Sources,
|
|
1180
|
+
WorkspaceRoot.Kernel,
|
|
1181
|
+
]);
|
|
1182
|
+
const flags = new Map();
|
|
1183
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
1184
|
+
const flag = args[index];
|
|
1185
|
+
if (!flag.startsWith('--')) {
|
|
1186
|
+
throw new Error(`unexpected argument "${flag}"`);
|
|
1187
|
+
}
|
|
1188
|
+
const name = flag.slice(2);
|
|
1189
|
+
const value = args[index + 1];
|
|
1190
|
+
if (value === undefined || value.startsWith('--')) {
|
|
1191
|
+
throw new Error(`--${name} requires a value`);
|
|
1192
|
+
}
|
|
1193
|
+
if (flags.has(name)) {
|
|
1194
|
+
throw new Error(`--${name} was given twice`);
|
|
1195
|
+
}
|
|
1196
|
+
flags.set(name, value);
|
|
1197
|
+
}
|
|
1198
|
+
const known = new Set([
|
|
1199
|
+
'output',
|
|
1200
|
+
'image-repository',
|
|
1201
|
+
'manifest-digest',
|
|
1202
|
+
'subject-kind',
|
|
1203
|
+
'subject-id',
|
|
1204
|
+
'source-repo',
|
|
1205
|
+
'source-path',
|
|
1206
|
+
'source-commit',
|
|
1207
|
+
'source-manifest-hash',
|
|
1208
|
+
'source-tree-hash',
|
|
1209
|
+
'provenance',
|
|
1210
|
+
'ttl-seconds',
|
|
1211
|
+
]);
|
|
1212
|
+
for (const name of flags.keys()) {
|
|
1213
|
+
if (!known.has(name)) {
|
|
1214
|
+
throw new Error(`unknown option "--${name}"`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
const output = resolve(requireValue(flags, 'output'));
|
|
1218
|
+
const input = await assembleProducerInput({
|
|
1219
|
+
imageRepository: requireValue(flags, 'image-repository'),
|
|
1220
|
+
manifestDigest: requireValue(flags, 'manifest-digest'),
|
|
1221
|
+
subjectKind: requireValue(flags, 'subject-kind'),
|
|
1222
|
+
subjectId: requireValue(flags, 'subject-id'),
|
|
1223
|
+
sourceRepo: requireValue(flags, 'source-repo'),
|
|
1224
|
+
sourcePath: requireValue(flags, 'source-path'),
|
|
1225
|
+
sourceCommit: requireValue(flags, 'source-commit'),
|
|
1226
|
+
provenancePath: resolve(requireValue(flags, 'provenance')),
|
|
1227
|
+
sourceManifestHash: flags.get('source-manifest-hash'),
|
|
1228
|
+
sourceTreeHash: flags.get('source-tree-hash'),
|
|
1229
|
+
ttlSeconds: flags.has('ttl-seconds')
|
|
1230
|
+
? Number(flags.get('ttl-seconds'))
|
|
1231
|
+
: undefined,
|
|
1232
|
+
});
|
|
1233
|
+
// The output directory is caller scratch space — create it deterministically
|
|
1234
|
+
// (the producer action's input-manifest must be repository-relative, so the
|
|
1235
|
+
// caller cannot sidestep this by pointing at RUNNER_TEMP). `wx` below still
|
|
1236
|
+
// refuses to overwrite an existing manifest.
|
|
1237
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
1238
|
+
writeFileSync(output, serializeJson(input), { flag: 'wx', mode: 0o600 });
|
|
1239
|
+
console.log(
|
|
1240
|
+
`[assemble-producer-input] wrote ${output} for subject ` +
|
|
1241
|
+
`${input.subjects[0].kind}:${input.subjects[0].id} ` +
|
|
1242
|
+
`(${input.subjects[0].imageRepository}@${input.subjects[0].manifestDigest})`,
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (isEntryModule(import.meta.url)) {
|
|
1247
|
+
main().catch((error) => {
|
|
1248
|
+
console.error(`[assemble-producer-input] ${error.message}`);
|
|
1249
|
+
process.exitCode = 1;
|
|
1250
|
+
});
|
|
1251
|
+
}
|