fix: close immutable promotion trust gaps

This commit is contained in:
DongHyeonka
2026-08-02 06:39:41 +09:00
parent 7c5ed80407
commit 92e5cace5c
16 changed files with 1228 additions and 274 deletions
+3
View File
@@ -137,6 +137,7 @@ jobs:
mkdir -p .release mkdir -p .release
tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" \ tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" \
dist \ dist \
pnpm-lock.yaml \
artifacts/performance/bundle.json \ artifacts/performance/bundle.json \
artifacts/quality/vite-module-inventory.json \ artifacts/quality/vite-module-inventory.json \
artifacts/release/build-manifest.json \ artifacts/release/build-manifest.json \
@@ -167,6 +168,7 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
env: env:
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }} CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml
VULNERABILITY_PROVIDER_COMMAND: ${{ vars.VULNERABILITY_PROVIDER_COMMAND }} VULNERABILITY_PROVIDER_COMMAND: ${{ vars.VULNERABILITY_PROVIDER_COMMAND }}
VULNERABILITY_REPORT_PATH: provider-evidence/vulnerability-report.json VULNERABILITY_REPORT_PATH: provider-evidence/vulnerability-report.json
steps: steps:
@@ -197,6 +199,7 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
env: env:
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }} CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml
PROVENANCE_PROVIDER_COMMAND: ${{ vars.PROVENANCE_PROVIDER_COMMAND }} PROVENANCE_PROVIDER_COMMAND: ${{ vars.PROVENANCE_PROVIDER_COMMAND }}
PROVENANCE_ATTESTATION_PATH: provider-evidence/provenance-attestation.json PROVENANCE_ATTESTATION_PATH: provider-evidence/provenance-attestation.json
steps: steps:
@@ -36,19 +36,26 @@ finding과 서명 성공으로 표현하면 local 검증과 release promotion을
5. inventory를 CycloneDX 1.6 SBOM으로 투영한다. component 수, lockfile digest, 5. inventory를 CycloneDX 1.6 SBOM으로 투영한다. component 수, lockfile digest,
SRI, license와 dependency edge가 inventory와 일치해야 한다. SRI, license와 dependency edge가 inventory와 일치해야 한다.
6. local in-toto/SLSA 형태 provenance statement는 source set, lockfile, SBOM과 6. local in-toto/SLSA 형태 provenance statement는 source set, lockfile, SBOM과
`dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 외부 attestation은 `dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 이 문서는 외부
provider, signer와 동일 dist subject digest가 있어야 한다. provenance를 대신할 수 없다.
7. vulnerability adapter는 `VULNERABILITY_REPORT_PATH`가 가리키는 7. `immutable_build`는 raw `pnpm-lock.yaml`, `dist`, build/module inventory와
machine-readable provider report를 검증한다. report의 lock digest, provider, 모든 local verification evidence를 한 번만 archive한다. Candidate manifest는
severity와 exception owner/reviewer/reason/expiry가 유효해야 한다. raw lock bytes SHA-256, dependency inventory lock digest와 manifest
8. provider report가 없으면 local inventory/license/SBOM/coherence는 `PASS`, `lockfileSha256`의 exact 일치를 요구한다.
promotion은 `FAIL_UNVERIFIED`다. 빈 finding을 만들어 vulnerability PASS로 8. provider job은 동일 archive를 각각 받아 외부 command를 실행한다.
표시하지 않는다. Vulnerability report는 raw lock digest와 `distSha256`, provenance attestation은
9. secret scan은 source, scripts, tests, tracked config/schema, public, `dist` `{name: "dist", digest.sha256}`를 포함한다. 두 문서 모두 strict schema
별도 trust path/key ID로 선택한 실제 Ed25519 public key 서명을 통과해야 한다.
9. provider report나 trusted key가 없으면 local
inventory/license/SBOM/coherence는 `PASS`, promotion은
`FAIL_UNVERIFIED`다. 저장소 generator나 fixture가 production용 빈 finding 또는
signed PASS를 만들지 않는다.
10. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`
generated release metadata를 검사한다. allowlist는 test path에만 허용하며 generated release metadata를 검사한다. allowlist는 test path에만 허용하며
owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고 owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고
rule, path, line과 fingerprint만 남긴다. rule, path, line과 fingerprint만 남긴다.
10. `SOURCE_DATE_EPOCH`를 지원하고 같은 source/lock/config의 production build를 11. `SOURCE_DATE_EPOCH`를 지원하고 supply-chain timestamp도 build manifest의
동일 epoch에 결합한다. 같은 source/lock/config의 production build를
두 번 실행해 전체 dist digest 일치를 검증한 뒤 일반 build를 복원한다. 두 번 실행해 전체 dist digest 일치를 검증한 뒤 일반 build를 복원한다.
## 실행 경계와 증적 ## 실행 경계와 증적
@@ -61,8 +68,10 @@ package.json + frozen pnpm-lock.yaml + installed graph
source/config/lock + production dist source/config/lock + production dist
-> local provenance statement -> local provenance statement
-> optional vulnerability/attestation provider inputs -> immutable archive + candidate manifest + distSha256
-> LOCAL PASS | promotion PASS/FAIL_UNVERIFIED -> external vulnerability provider + external provenance provider
-> read-only local revalidation + signature/digest verification
-> promotion PASS | FAIL_UNVERIFIED
``` ```
- policy: `config/security/` - policy: `config/security/`
@@ -82,8 +91,9 @@ source/config/lock + production dist
- ordering-only digest, removal, integrity tamper, baseline tamper, high-risk - ordering-only digest, removal, integrity tamper, baseline tamper, high-risk
self approval, denied license, critical vulnerability와 만료 exception, self approval, denied license, critical vulnerability와 만료 exception,
provider/digest 오류, SBOM/provenance 불일치 fixture를 검사한다. provider/digest 오류, SBOM/provenance 불일치 fixture를 검사한다.
- synthetic provider/attestation fixture는 promotion `PASS`를 증명한 후 기본 - isolated temporary candidate/PEM/report fixture는 실제 environment path wiring을
`FAIL_UNVERIFIED` 상태를 복원한다. 통해 valid immutable 입력만 promotion `PASS`임을 증명한다. Production artifact를
덮어쓰거나 generator를 provider 모드로 재실행하지 않는다.
- frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다. - frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다.
- source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고 - source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고
scoped test allowlist만 통과한다. scoped test allowlist만 통과한다.
@@ -97,7 +107,7 @@ attestation transparency/retention과 비밀 관리를 결정해야 한다.
## Rollback ## Rollback
외부 scanner/attestor adapter는 환경 입력을 제거하면 즉시 외부 scanner/attestor command, report path 또는 trusted key 설정을 제거하면 즉시
`FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM, `FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM,
secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로 secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로
promotion을 PASS로 변경하지 않는다. promotion을 PASS로 변경하지 않는다.
+19
View File
@@ -32,6 +32,16 @@ full commit ID와 commit timestamp를 한 번 읽어 `SOURCE_DATE_EPOCH`를 유
release manifest도 같은 build ID, commit SHA, release ID와 timestamp를 가져야 release manifest도 같은 build ID, commit SHA, release ID와 timestamp를 가져야
한다. 한다.
Release tier의 `immutable_build``dist`의 hidden `.vite` entries, raw
`pnpm-lock.yaml`, build/module inventory와 local release/security evidence를 한
번 생성해 run ID/attempt로 구분된 archive로 게시한다. Raw lock bytes의 SHA-256은
dependency inventory와 candidate manifest 양쪽의 `lockfileSha256`과 같아야 한다.
Vulnerability/provenance provider job은 같은 archive를 각각 내려받고
`CANDIDATE_LOCKFILE_PATH`, `CANDIDATE_DIST_SHA256`를 외부 command에 전달한다.
Promotion은 같은 archive와 두 report를 별도 경로로 내려받은 뒤 local evidence를
read-only로 다시 계산하고 Ed25519 signature/digest를 확인한다. Promotion job에는
build/rebuild command가 없으며 검증한 archive 자체를 그대로 승격한다.
Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. 이 Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. 이
workflow를 required check로 전환하기 전에 staging instance에서 workflow를 required check로 전환하기 전에 staging instance에서
`permissions`, `gitea.run_attempt`, `actions/upload-artifact@v4`를 포함한 한 `permissions`, `gitea.run_attempt`, `actions/upload-artifact@v4`를 포함한 한
@@ -132,6 +142,15 @@ Repository variables required by higher tiers:
- `HOSTING_BASE_URL` for live header verification - `HOSTING_BASE_URL` for live header verification
- `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document - `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document
- `MIN_ELIGIBLE_SAMPLES` after the baseline decision - `MIN_ELIGIBLE_SAMPLES` after the baseline decision
- `VULNERABILITY_PROVIDER_COMMAND` and `PROVENANCE_PROVIDER_COMMAND` for the
independently operated scanner and attestor commands
- `VULNERABILITY_PUBLIC_KEY_PATH`, `VULNERABILITY_KEY_ID`,
`PROVENANCE_PUBLIC_KEY_PATH`, and `PROVENANCE_KEY_ID` for separately managed
trusted Ed25519 verification material
If any external provider command, report, trust path, or key ID is absent,
promotion remains unavailable with `FAIL_UNVERIFIED`; there is no local
generator/restore fallback.
Branch protection must mark each `FE-GATE-* / <name>` check required for its Branch protection must mark each `FE-GATE-* / <name>` check required for its
declared tier. This repository cannot configure server-side protection by declared tier. This repository cannot configure server-side protection by
+18 -3
View File
@@ -36,14 +36,29 @@ Promotion reads the provider files named by `VULNERABILITY_REPORT_PATH` and
exact lockfile digest and candidate `distSha256`; the provenance attestation exact lockfile digest and candidate `distSha256`; the provenance attestation
must name `dist` with that same digest. Both documents use strict schemas and must name `dist` with that same digest. Both documents use strict schemas and
Ed25519 signatures verified with separately configured trusted public keys and Ed25519 signatures verified with separately configured trusted public keys and
key IDs. key IDs (`VULNERABILITY_PUBLIC_KEY_PATH`, `VULNERABILITY_KEY_ID`,
`PROVENANCE_PUBLIC_KEY_PATH`, and `PROVENANCE_KEY_ID`). Keys of another curve,
including Ed448, are rejected even if a document labels its algorithm
`Ed25519`.
`immutable_build` archives the raw `pnpm-lock.yaml`, `dist` (including hidden
`.vite` files), the build manifest, module inventory, release verification,
secret-scan result, and local supply-chain evidence once. The candidate
manifest hashes the raw lockfile bytes and requires that digest to equal the
dependency inventory's `lockfileSha256`. The two provider jobs download this
same archive separately and receive `CANDIDATE_LOCKFILE_PATH` and
`CANDIDATE_DIST_SHA256`; configured `VULNERABILITY_PROVIDER_COMMAND` and
`PROVENANCE_PROVIDER_COMMAND` must emit the signed reports.
If either provider input is absent, local verification remains meaningful but If either provider input is absent, local verification remains meaningful but
`artifacts/security/supply-chain-verification.json` records `artifacts/security/supply-chain-verification.json` records
`promotionStatus: FAIL_UNVERIFIED`. `verify:provider-evidence` and `promotionStatus: FAIL_UNVERIFIED`. `verify:provider-evidence` and
`verify:promotion` then exit non-zero. Promotion recomputes the candidate file `verify:promotion` then exit non-zero. Promotion recomputes the candidate file
set and digests and never rebuilds it. Scanner or signing outages are not set and digests, then read-only revalidates the archived executable schemas,
converted to an empty PASS. raw lockfile, module inventory, build outputs, release coherence, SBOM,
provenance, security scan and supply-chain coherence. It never rebuilds or
rewrites candidate evidence. Scanner or signing outages are not converted to
an empty PASS.
Approved vulnerability exceptions require vulnerability/package identity, Approved vulnerability exceptions require vulnerability/package identity,
owner, a different reviewer, reason and expiry. Expired or self-approved owner, a different reviewer, reason and expiry. Expired or self-approved
+1 -1
View File
@@ -73,7 +73,7 @@
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts", "generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check", "check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
"generate:supply-chain": "node scripts/generate-supply-chain.ts", "generate:supply-chain": "node scripts/generate-supply-chain.ts",
"verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-release-candidate.ts", "verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-archived-local-evidence.ts && node scripts/verify-release-candidate.ts",
"verify:provider-evidence": "node scripts/verify-provider-evidence.ts", "verify:provider-evidence": "node scripts/verify-provider-evidence.ts",
"verify:promotion": "node scripts/verify-supply-chain-promotion.ts", "verify:promotion": "node scripts/verify-supply-chain-promotion.ts",
"update:dependency-baseline": "node scripts/update-dependency-baseline.ts", "update:dependency-baseline": "node scripts/update-dependency-baseline.ts",
+6
View File
@@ -191,8 +191,14 @@ for (const requiredToken of [
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"', 'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
"VULNERABILITY_REPORT_PATH:", "VULNERABILITY_REPORT_PATH:",
"PROVENANCE_ATTESTATION_PATH:", "PROVENANCE_ATTESTATION_PATH:",
"CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml",
"VULNERABILITY_PROVIDER_COMMAND:",
"PROVENANCE_PROVIDER_COMMAND:",
"VULNERABILITY_PUBLIC_KEY_PATH:", "VULNERABILITY_PUBLIC_KEY_PATH:",
"VULNERABILITY_KEY_ID:",
"PROVENANCE_PUBLIC_KEY_PATH:", "PROVENANCE_PUBLIC_KEY_PATH:",
"PROVENANCE_KEY_ID:",
" pnpm-lock.yaml \\",
"release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}", "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}",
"corepack pnpm verify:provider-evidence", "corepack pnpm verify:provider-evidence",
"corepack pnpm verify:promotion", "corepack pnpm verify:promotion",
+187 -105
View File
@@ -1,30 +1,199 @@
import { generateKeyPairSync, sign } from "node:crypto"; import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { providerEvidenceSignaturePayload } from "./lib/provider-evidence.ts";
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
import { import {
evaluatePromotionEvidence, createReleaseCandidateManifest,
providerEvidenceSignaturePayload, RELEASE_CANDIDATE_EVIDENCE_PATHS,
} from "./lib/provider-evidence.ts"; RELEASE_CANDIDATE_MANIFEST_PATH,
} from "./lib/release-candidate.ts";
const candidateDistSha256 = "1".repeat(64); const fixtureRoot = await mkdtemp(
const lockfileSha256 = "2".repeat(64); path.join(tmpdir(), "supply-chain-provider-fixture-"),
const vulnerabilityKeys = generateKeyPairSync("ed25519"); );
const provenanceKeys = generateKeyPairSync("ed25519"); try {
const trust = { const rawLockfile = "lockfileVersion: '9.0'\n";
vulnerabilityTrust: { const lockfileSha256 = createHash("sha256")
keyId: "fixture-vulnerability-key", .update(rawLockfile)
publicKey: vulnerabilityKeys.publicKey, .digest("hex");
await mkdir(path.join(fixtureRoot, "dist"), { recursive: true });
await writeFile(path.join(fixtureRoot, "dist/app.js"), "immutable\n");
await writeFile(path.join(fixtureRoot, "pnpm-lock.yaml"), rawLockfile);
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
if (file === "pnpm-lock.yaml") continue;
await mkdir(path.dirname(path.join(fixtureRoot, file)), {
recursive: true,
});
const value =
file === "artifacts/release/dependency-inventory.json"
? { lockfileSha256 }
: { fixture: file };
await writeFile(
path.join(fixtureRoot, file),
`${JSON.stringify(value)}\n`,
);
}
const candidate = await createReleaseCandidateManifest(fixtureRoot);
await writeFile(
path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
`${JSON.stringify(candidate)}\n`,
);
const validEnvironment = await writeProviderEnvironment(
fixtureRoot,
"valid",
candidate.distSha256,
candidate.lockfileSha256,
);
const wrongEnvironment = await writeProviderEnvironment(
fixtureRoot,
"wrong",
"3".repeat(64),
candidate.lockfileSha256,
);
const acceptLocalEvidence = async () => ({
status: "PASS" as const,
failures: [] as const,
});
const fixtures = {
absent: await verifyPromotionInputs({
repositoryRoot: fixtureRoot,
environment: {},
verifyLocalEvidence: acceptLocalEvidence,
}),
validImmutable: await verifyPromotionInputs({
repositoryRoot: fixtureRoot,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
}),
wrongDigest: await verifyPromotionInputs({
repositoryRoot: fixtureRoot,
environment: wrongEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
}),
postAttestationMutation: null as Awaited<
ReturnType<typeof verifyPromotionInputs>
> | null,
};
await writeFile(path.join(fixtureRoot, "dist/app.js"), "mutated\n");
fixtures.postAttestationMutation = await verifyPromotionInputs({
repositoryRoot: fixtureRoot,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
});
const passed =
fixtures.validImmutable.status === "PASS" &&
fixtures.absent.status === "FAIL_UNVERIFIED" &&
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
fixtures.postAttestationMutation.status === "FAIL_UNVERIFIED";
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-provider-fixtures.json",
`${JSON.stringify(
{
schemaVersion: 1,
fixtures: Object.fromEntries(
Object.entries(fixtures).map(([name, result]) => [
name,
{ status: result?.status, failures: result?.failures },
]),
),
passingFixtureCount: Object.values(fixtures).filter(
(result) => result?.status === "PASS",
).length,
status: passed ? "PASS" : "FAIL",
}, },
provenanceTrust: { null,
keyId: "fixture-provenance-key", 2,
publicKey: provenanceKeys.publicKey, )}\n`,
);
if (!passed) {
process.stderr.write(
"Supply-chain provider fixtures failed closed incorrectly\n",
);
process.exitCode = 1;
} else {
process.stdout.write(
"Supply-chain provider fixtures: only the valid immutable fixture PASS\n",
);
}
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
async function writeProviderEnvironment(
repositoryRoot: string,
name: string,
distDigest: string,
lockfileSha256: string,
): Promise<NodeJS.ProcessEnv> {
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const directory = `provider/${name}`;
await mkdir(path.join(repositoryRoot, directory), { recursive: true });
const vulnerability = signedEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: lockfileSha256,
scannedDistSha256: distDigest,
findings: [],
}, },
}; "fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
);
const provenance = signedEvidence(
{
schemaVersion: 1,
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: { name: "dist", digest: { sha256: distDigest } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
);
await Promise.all([
writeFile(
path.join(repositoryRoot, directory, "vulnerability.json"),
`${JSON.stringify(vulnerability)}\n`,
),
writeFile(
path.join(repositoryRoot, directory, "provenance.json"),
`${JSON.stringify(provenance)}\n`,
),
writeFile(
path.join(repositoryRoot, directory, "vulnerability.pem"),
vulnerabilityKeys.publicKey
.export({ type: "spki", format: "pem" })
.toString(),
),
writeFile(
path.join(repositoryRoot, directory, "provenance.pem"),
provenanceKeys.publicKey
.export({ type: "spki", format: "pem" })
.toString(),
),
]);
return {
VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`,
PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`,
VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`,
VULNERABILITY_KEY_ID: "fixture-vulnerability-key",
PROVENANCE_PUBLIC_KEY_PATH: `${directory}/provenance.pem`,
PROVENANCE_KEY_ID: "fixture-provenance-key",
};
}
function signedEvidence( function signedEvidence(
value: Record<string, unknown>, value: Record<string, unknown>,
keyId: string, keyId: string,
privateKey: typeof vulnerabilityKeys.privateKey, privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
) { ) {
return { return {
...value, ...value,
@@ -39,90 +208,3 @@ function signedEvidence(
}, },
}; };
} }
function evidenceFor(distDigest: string) {
return {
vulnerabilityReport: signedEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: lockfileSha256,
scannedDistSha256: distDigest,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
),
provenanceAttestation: signedEvidence(
{
schemaVersion: 1,
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: { name: "dist", digest: { sha256: distDigest } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
),
};
}
const base = {
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
currentDistSha256: candidateDistSha256,
localStatus: "PASS",
...trust,
};
const validEvidence = evidenceFor(candidateDistSha256);
const fixtures = {
absent: evaluatePromotionEvidence({
...base,
vulnerabilityReport: null,
provenanceAttestation: null,
}),
validImmutable: evaluatePromotionEvidence({ ...base, ...validEvidence }),
wrongDigest: evaluatePromotionEvidence({
...base,
...evidenceFor("3".repeat(64)),
}),
postAttestationMutation: evaluatePromotionEvidence({
...base,
...validEvidence,
currentDistSha256: "4".repeat(64),
}),
};
const passed =
fixtures.validImmutable.status === "PASS" &&
fixtures.absent.status === "FAIL_UNVERIFIED" &&
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
fixtures.postAttestationMutation.status === "FAIL_UNVERIFIED";
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-provider-fixtures.json",
`${JSON.stringify(
{
schemaVersion: 1,
fixtures: Object.fromEntries(
Object.entries(fixtures).map(([name, result]) => [
name,
{ status: result.status, failures: result.failures },
]),
),
passingFixtureCount: Object.values(fixtures).filter(
(result) => result.status === "PASS",
).length,
status: passed ? "PASS" : "FAIL",
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write("Supply-chain provider fixtures failed closed incorrectly\n");
process.exit(1);
}
process.stdout.write(
"Supply-chain provider fixtures: only the valid immutable fixture PASS\n",
);
+9 -1
View File
@@ -8,6 +8,7 @@ import {
import { import {
bundlePerformanceArtifactSchema, bundlePerformanceArtifactSchema,
buildManifestArtifactSchema,
dependencyDiffArtifactSchema, dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema, dependencyInventoryArtifactSchema,
licenseReportArtifactSchema, licenseReportArtifactSchema,
@@ -34,6 +35,7 @@ import {
parseRepositoryFileInventoryPolicy, parseRepositoryFileInventoryPolicy,
} from "./lib/repository-file-inventory.ts"; } from "./lib/repository-file-inventory.ts";
import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts"; import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts";
import { deterministicSupplyChainGeneratedAt } from "./lib/supply-chain-time.ts";
type Document = Record<string, unknown>; type Document = Record<string, unknown>;
@@ -138,6 +140,9 @@ export async function buildDependencyInventory() {
} }
const packageJson = await jsonDocument("package.json"); const packageJson = await jsonDocument("package.json");
const buildManifest = buildManifestArtifactSchema.parse(
await jsonDocument("artifacts/release/build-manifest.json"),
);
const secretScanPolicy = documentValue( const secretScanPolicy = documentValue(
JSON.parse(await readFile("config/security/secret-scan-policy.json", "utf8")), JSON.parse(await readFile("config/security/secret-scan-policy.json", "utf8")),
"secret scan policy", "secret scan policy",
@@ -332,7 +337,10 @@ const verification = {
}; };
const bundleReport = { const bundleReport = {
schemaVersion: 1, schemaVersion: 1,
generatedAt: new Date().toISOString(), generatedAt: deterministicSupplyChainGeneratedAt({
generatedAt: buildManifest.generatedAt,
sourceDateEpoch: buildManifest.buildContext.sourceDateEpoch,
}),
context: { context: {
nodeVersion: process.version, nodeVersion: process.version,
packageManager: String(packageJson.packageManager ?? ""), packageManager: String(packageJson.packageManager ?? ""),
+533
View File
@@ -0,0 +1,533 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { z, type ZodType } from "zod";
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../../src/features/installed-contract-contributions.ts";
import {
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../../src/features/installed-feature-contracts.ts";
import {
buildManifestArtifactSchema,
bundlePerformanceArtifactSchema,
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
licenseReportArtifactSchema,
provenanceArtifactSchema,
releaseManifestArtifactSchema,
releaseVerificationArtifactSchema,
runtimeConfigArtifactSchema,
sbomArtifactSchema,
supplyChainVerificationArtifactSchema,
vulnerabilityReportArtifactSchema,
} from "../contracts/release-artifacts.ts";
import {
CANONICAL_VITE_MANIFEST_PATH,
verifyBuildManifestOutputs,
} from "./build-manifest-outputs.ts";
import { assertMatchesJsonSchema } from "./json-schema.ts";
import type { ReleaseCandidateManifest } from "./release-candidate.ts";
import { collectDistOutputs, distSha256 } from "./release-candidate.ts";
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./repository-file-inventory.ts";
import {
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
verifySupplyChainCoherence,
} from "./supply-chain.ts";
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
export const supplyChainCoherenceReportSchema = z
.object({
schemaVersion: z.literal(1),
status: z.enum(["PASS", "FAIL"]),
dependencyCount: z.int().nonnegative(),
lockfileSha256: sha256,
distSha256: sha256,
sbomSha256: sha256,
failures: z.array(z.string()),
})
.strict();
export type SupplyChainCoherenceReport = z.infer<
typeof supplyChainCoherenceReportSchema
>;
export async function verifyLocalSupplyChainEvidence(
repositoryRoot = process.cwd(),
): Promise<SupplyChainCoherenceReport> {
const failures: string[] = [];
const inventory = await parseArtifact(
repositoryRoot,
"artifacts/release/dependency-inventory.json",
dependencyInventoryArtifactSchema,
"dependency inventory",
failures,
);
const sbom = await parseArtifact(
repositoryRoot,
"artifacts/release/sbom.cdx.json",
sbomArtifactSchema,
"SBOM",
failures,
);
const provenance = await parseArtifact(
repositoryRoot,
"artifacts/release/provenance.json",
provenanceArtifactSchema,
"local provenance",
failures,
);
const verification = await parseArtifact(
repositoryRoot,
"artifacts/security/supply-chain-verification.json",
supplyChainVerificationArtifactSchema,
"supply-chain verification",
failures,
);
for (const [schemaPath, artifact, label] of [
[
"schemas/artifacts/dependency-inventory.schema.json",
inventory,
"dependency inventory",
],
[
"schemas/artifacts/supply-chain-verification.schema.json",
verification,
"supply-chain verification",
],
] as const) {
if (!artifact) continue;
try {
assertMatchesJsonSchema(
await readJson(repositoryRoot, schemaPath),
artifact,
label,
);
} catch {
failures.push(`${label} JSON Schema mismatch`);
}
}
let lockfileText = "";
let lockfileSha256 = "0".repeat(64);
try {
const rawLockfile = await readFile(
path.join(repositoryRoot, "pnpm-lock.yaml"),
);
lockfileText = rawLockfile.toString("utf8");
lockfileSha256 = createHash("sha256").update(rawLockfile).digest("hex");
} catch {
failures.push("raw pnpm-lock.yaml is missing or unreadable");
}
let distDigest = "0".repeat(64);
try {
distDigest = distSha256(await collectDistOutputs(repositoryRoot));
} catch {
failures.push("candidate dist is missing or unreadable");
}
const sbomSha256 = sbom ? supplyChainDigest(sbom) : "0".repeat(64);
if (inventory && sbom && provenance) {
failures.push(
...verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
).failures,
);
}
if (
!inventory ||
!verification ||
inventory.lockfileSha256 !== lockfileSha256 ||
verification.lockfileSha256 !== lockfileSha256
) {
failures.push("inventory/verification lockfile digest mismatch");
}
if (
!verification ||
verification.localStatus !== "PASS" ||
verification.failures.length > 0 ||
verification.distSha256 !== distDigest ||
verification.sbomSha256 !== sbomSha256
) {
failures.push("verification digest/status set is incoherent");
}
if (inventory && sbom && provenance && verification) {
try {
const policy = parseRepositoryFileInventoryPolicy(
await readJson(
repositoryRoot,
"config/security/secret-scan-policy.json",
),
);
const repositoryInventory = await buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: policy.trackedRoots,
generatedRoots: policy.generatedRoots,
optionalRoots: policy.optionalRoots,
});
const sourceSetSha256 = await digestReleaseInputFiles(
repositoryInventory.trackedFiles,
(file) => readFile(path.join(repositoryRoot, file)),
);
if (
verification.sourceSetSha256 !== sourceSetSha256 ||
provenance.predicate.materials.sourceSetSha256 !== sourceSetSha256 ||
provenance.predicate.materials.sbomSha256 !== sbomSha256
) {
failures.push("source/SBOM provenance materials are incoherent");
}
} catch {
failures.push("release source inventory is unavailable or unreadable");
}
}
const lockRows = parsePnpmLockfilePackages(lockfileText);
const inventoryRows = inventory?.dependencies ?? [];
const inventoryByIdentity = new Map<string, (typeof inventoryRows)[number]>(
inventoryRows.map(
(entry) => [`${entry.name}@${entry.version}`, entry] as const,
),
);
if (lockRows.length !== inventoryRows.length) {
failures.push("transitive dependency count differs from lockfile");
}
for (const lockRow of lockRows) {
const identity = `${lockRow.name}@${lockRow.version}`;
const dependency = inventoryByIdentity.get(identity);
if (
!dependency ||
dependency.integrity !== lockRow.integrity ||
!isValidSha512Integrity(lockRow.integrity)
) {
failures.push(`lockfile inventory integrity mismatch: ${identity}`);
}
}
return supplyChainCoherenceReportSchema.parse({
schemaVersion: 1,
status: failures.length === 0 ? "PASS" : "FAIL",
dependencyCount: inventoryRows.length,
lockfileSha256,
distSha256: distDigest,
sbomSha256,
failures,
});
}
export async function verifyArchivedLocalEvidence(input: Readonly<{
repositoryRoot?: string;
candidate: ReleaseCandidateManifest;
}>): Promise<Readonly<{
status: "PASS" | "FAIL";
failures: readonly string[];
}>> {
const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd());
const failures: string[] = [];
const supplyReport = await verifyLocalSupplyChainEvidence(repositoryRoot);
failures.push(...supplyReport.failures);
if (supplyReport.lockfileSha256 !== input.candidate.lockfileSha256) {
failures.push("candidate/raw lockfile digest mismatch");
}
const buildManifest = await parseArtifact(
repositoryRoot,
"artifacts/release/build-manifest.json",
buildManifestArtifactSchema,
"build manifest",
failures,
);
const release = await parseArtifact(
repositoryRoot,
"dist/release-manifest.json",
releaseManifestArtifactSchema,
"release manifest",
failures,
);
const runtime = await parseArtifact(
repositoryRoot,
"dist/config.json",
runtimeConfigArtifactSchema,
"runtime config",
failures,
);
const storedRelease = await parseArtifact(
repositoryRoot,
"artifacts/release/verification.json",
releaseVerificationArtifactSchema,
"release verification",
failures,
);
const storedSupply = await parseArtifact(
repositoryRoot,
"artifacts/security/supply-chain-coherence.json",
supplyChainCoherenceReportSchema,
"supply-chain coherence",
failures,
);
await validateSupportingArtifacts(repositoryRoot, failures);
if (buildManifest) {
try {
assertMatchesJsonSchema(
await readJson(
repositoryRoot,
"schemas/artifacts/build-manifest.schema.json",
),
buildManifest,
"build manifest",
);
} catch {
failures.push("build manifest JSON Schema mismatch");
}
failures.push(
...(await verifyBuildManifestOutputs(buildManifest, { repositoryRoot })),
);
}
if (release && runtime) {
if (!runtime.BUILD_ID || !runtime.RELEASE_ID) {
failures.push("runtime release identity is missing");
} else {
const apiContractVersion =
release.schemaVersion === 1 && "API_CONTRACT_VERSION" in runtime
? runtime.API_CONTRACT_VERSION
: undefined;
if (release.schemaVersion === 1 && apiContractVersion === undefined) {
failures.push("runtime API contract identity is missing");
}
const coherence = await verifyReleaseRuntimeCoherence({
release,
runtime: {
BUILD_ID: runtime.BUILD_ID,
RELEASE_ID: runtime.RELEASE_ID,
CONFIG_SCHEMA_VERSION: runtime.CONFIG_SCHEMA_VERSION,
...(apiContractVersion === undefined
? {}
: { API_CONTRACT_VERSION: apiContractVersion }),
},
contractPackages: EXPECTED_CONTRACT_SET_PACKAGES,
});
failures.push(...coherence.mismatches.map((item) => `release:${item}`));
}
await verifyReleaseOutputs(
repositoryRoot,
release,
buildManifest,
failures,
);
}
if (
!storedRelease ||
!storedRelease.passed ||
!storedRelease.artifact.checked ||
!storedRelease.artifact.compatible ||
storedRelease.artifact.mismatches.length > 0 ||
storedRelease.fixtures.length === 0 ||
storedRelease.fixtures.some((fixture) => !fixture.passed) ||
storedRelease.artifact.releaseId !== release?.releaseId ||
storedRelease.generatedAt !== release?.builtAt
) {
failures.push("stored release verification is not a coherent PASS");
}
if (
!storedSupply ||
storedSupply.status !== "PASS" ||
storedSupply.failures.length > 0 ||
storedSupply.dependencyCount !== supplyReport.dependencyCount ||
storedSupply.lockfileSha256 !== supplyReport.lockfileSha256 ||
storedSupply.distSha256 !== supplyReport.distSha256 ||
storedSupply.sbomSha256 !== supplyReport.sbomSha256
) {
failures.push("stored supply-chain coherence is not a recomputed PASS");
}
await verifySecretScan(repositoryRoot, failures);
return Object.freeze({
status: failures.length === 0 ? "PASS" : "FAIL",
failures: Object.freeze([...new Set(failures)]),
});
}
async function validateSupportingArtifacts(
repositoryRoot: string,
failures: string[],
): Promise<void> {
const bundle = await parseArtifact(
repositoryRoot,
"artifacts/performance/bundle.json",
bundlePerformanceArtifactSchema,
"bundle report",
failures,
);
if (bundle) {
try {
const actual = await collectDistOutputs(repositoryRoot);
if (JSON.stringify(bundle.outputs) !== JSON.stringify(actual)) {
failures.push("bundle report does not describe current dist bytes");
}
} catch {
failures.push("bundle report dist inputs are unreadable");
}
}
const dependencyDiff = await parseArtifact(
repositoryRoot,
"artifacts/security/dependency-diff.json",
dependencyDiffArtifactSchema,
"dependency diff",
failures,
);
if (dependencyDiff && dependencyDiff.reviewFailures.length > 0) {
failures.push("dependency review evidence is not PASS");
}
const license = await parseArtifact(
repositoryRoot,
"artifacts/security/license-report.json",
licenseReportArtifactSchema,
"license report",
failures,
);
if (license && (license.status !== "PASS" || license.failures.length > 0)) {
failures.push("license report is not PASS");
}
const vulnerability = await parseArtifact(
repositoryRoot,
"artifacts/security/vulnerability-report.json",
vulnerabilityReportArtifactSchema,
"local vulnerability report",
failures,
);
if (
vulnerability &&
(vulnerability.status !== "FAIL_UNVERIFIED" ||
vulnerability.provider !== "UNCONFIGURED")
) {
failures.push("local vulnerability report may not satisfy promotion");
}
const provenance = await parseArtifact(
repositoryRoot,
"artifacts/release/provenance.json",
provenanceArtifactSchema,
"local provenance",
failures,
);
if (
provenance?.predicate.runDetails.metadata.invocationId !== "LOCAL_UNSIGNED"
) {
failures.push("local provenance must remain LOCAL_UNSIGNED");
}
}
async function verifyReleaseOutputs(
repositoryRoot: string,
release: z.infer<typeof releaseManifestArtifactSchema>,
buildManifest: z.infer<typeof buildManifestArtifactSchema> | null,
failures: string[],
): Promise<void> {
let viteManifest: Record<string, unknown> = {};
try {
const raw = await readFile(
path.join(repositoryRoot, CANONICAL_VITE_MANIFEST_PATH),
"utf8",
);
viteManifest = asRecord(JSON.parse(raw), "Vite manifest");
if (createHash("sha256").update(raw).digest("hex") !== release.assetManifestHash) {
failures.push("release asset manifest hash mismatch");
}
} catch {
failures.push("Vite manifest is missing or invalid");
}
if (
buildManifest &&
(buildManifest.buildId !== release.buildId ||
buildManifest.commitSha !== release.commitSha ||
buildManifest.releaseId !== release.releaseId ||
buildManifest.generatedAt !== release.builtAt)
) {
failures.push("build/release identity mismatch");
}
const runtimeContracts: Readonly<Record<string, { moduleId: string }>> =
ROUTE_RUNTIME_CONTRACT;
for (const definition of Object.values(ROUTE_REGISTRY)) {
const runtime = runtimeContracts[definition.routeId];
const viteEntry = Object.values(viteManifest).find(
(entry) =>
isRecord(entry) &&
entry.name === runtime?.moduleId &&
entry.isDynamicEntry === true,
);
const file = isRecord(viteEntry) ? viteEntry.file : null;
if (
typeof file !== "string" ||
release.routeChunks[definition.chunkId] !== file ||
buildManifest?.outputs.routeChunks[definition.chunkId] !== file
) {
failures.push(`release route chunk mismatch: ${definition.chunkId}`);
}
}
}
async function verifySecretScan(
repositoryRoot: string,
failures: string[],
): Promise<void> {
try {
const sarif = asRecord(
await readJson(repositoryRoot, "artifacts/security/scan.sarif"),
"secret scan SARIF",
);
const runs = Array.isArray(sarif.runs) ? sarif.runs : [];
if (
sarif.version !== "2.1.0" ||
runs.length !== 1 ||
!isRecord(runs[0]) ||
!Array.isArray(runs[0].results) ||
runs[0].results.length !== 0
) {
failures.push("secret scan SARIF is not an empty PASS");
}
} catch {
failures.push("secret scan SARIF is missing or invalid");
}
}
async function parseArtifact<T>(
repositoryRoot: string,
file: string,
schema: ZodType<T>,
label: string,
failures: string[],
): Promise<T | null> {
try {
return schema.parse(await readJson(repositoryRoot, file));
} catch {
failures.push(`${label} executable schema mismatch`);
return null;
}
}
async function readJson(repositoryRoot: string, file: string): Promise<unknown> {
return JSON.parse(await readFile(path.join(repositoryRoot, file), "utf8"));
}
function asRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) throw new TypeError(`${label} must be a JSON object`);
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
+52 -13
View File
@@ -1,5 +1,6 @@
import { createPublicKey } from "node:crypto"; import { createPublicKey } from "node:crypto";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path";
import { import {
evaluatePromotionEvidence, evaluatePromotionEvidence,
@@ -10,38 +11,63 @@ import {
releaseCandidateManifestSchema, releaseCandidateManifestSchema,
verifyReleaseCandidate, verifyReleaseCandidate,
} from "./release-candidate.ts"; } from "./release-candidate.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
export type VerifyPromotionInputsOptions = Readonly<{
environment?: NodeJS.ProcessEnv;
repositoryRoot?: string;
verifyLocalEvidence?: LocalEvidenceVerifier;
}>;
export async function verifyPromotionInputs( export async function verifyPromotionInputs(
environment: NodeJS.ProcessEnv = process.env, options: VerifyPromotionInputsOptions = {},
) { ) {
const manifestDocument = await requiredJson(RELEASE_CANDIDATE_MANIFEST_PATH); const environment = options.environment ?? process.env;
const manifest = releaseCandidateManifestSchema.parse(manifestDocument); const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
const candidate = await verifyReleaseCandidate(manifestDocument); const manifestDocument = await requiredJson(
const localVerification = await requiredJson( repositoryRoot,
"artifacts/security/supply-chain-verification.json", RELEASE_CANDIDATE_MANIFEST_PATH,
); );
const manifest = releaseCandidateManifestSchema.parse(manifestDocument);
const candidate = await verifyReleaseCandidate(
manifestDocument,
repositoryRoot,
);
const localEvidence = await (
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
)({ repositoryRoot, candidate: manifest });
const vulnerabilityReport = await optionalJson( const vulnerabilityReport = await optionalJson(
repositoryRoot,
environment.VULNERABILITY_REPORT_PATH, environment.VULNERABILITY_REPORT_PATH,
); );
const provenanceAttestation = await optionalJson( const provenanceAttestation = await optionalJson(
repositoryRoot,
environment.PROVENANCE_ATTESTATION_PATH, environment.PROVENANCE_ATTESTATION_PATH,
); );
const result = evaluatePromotionEvidence({ const result = evaluatePromotionEvidence({
candidate: manifest, candidate: manifest,
currentDistSha256: candidate.currentDistSha256 ?? "", currentDistSha256: candidate.currentDistSha256 ?? "",
localStatus: localVerification.localStatus, localStatus: localEvidence.status,
vulnerabilityReport, vulnerabilityReport,
provenanceAttestation, provenanceAttestation,
vulnerabilityTrust: await readTrust( vulnerabilityTrust: await readTrust(
repositoryRoot,
environment.VULNERABILITY_PUBLIC_KEY_PATH, environment.VULNERABILITY_PUBLIC_KEY_PATH,
environment.VULNERABILITY_KEY_ID, environment.VULNERABILITY_KEY_ID,
), ),
provenanceTrust: await readTrust( provenanceTrust: await readTrust(
repositoryRoot,
environment.PROVENANCE_PUBLIC_KEY_PATH, environment.PROVENANCE_PUBLIC_KEY_PATH,
environment.PROVENANCE_KEY_ID, environment.PROVENANCE_KEY_ID,
), ),
}); });
const failures = [...candidate.failures, ...result.failures]; const failures = [
...candidate.failures,
...localEvidence.failures,
...result.failures,
];
return Object.freeze({ return Object.freeze({
schemaVersion: 1 as const, schemaVersion: 1 as const,
status: status:
@@ -57,6 +83,7 @@ export async function verifyPromotionInputs(
} }
async function readTrust( async function readTrust(
repositoryRoot: string,
publicKeyPath: string | undefined, publicKeyPath: string | undefined,
keyId: string | undefined, keyId: string | undefined,
): Promise<ProviderTrust | null> { ): Promise<ProviderTrust | null> {
@@ -64,24 +91,36 @@ async function readTrust(
try { try {
return Object.freeze({ return Object.freeze({
keyId, keyId,
publicKey: createPublicKey(await readFile(publicKeyPath, "utf8")), publicKey: createPublicKey(
await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"),
),
}); });
} catch { } catch {
return null; return null;
} }
} }
async function optionalJson(file: string | undefined): Promise<unknown> { async function optionalJson(
repositoryRoot: string,
file: string | undefined,
): Promise<unknown> {
if (!file) return null; if (!file) return null;
try { try {
return JSON.parse(await readFile(file, "utf8")) as unknown; return JSON.parse(
await readFile(path.resolve(repositoryRoot, file), "utf8"),
) as unknown;
} catch { } catch {
return null; return null;
} }
} }
async function requiredJson(file: string): Promise<Record<string, unknown>> { async function requiredJson(
const value: unknown = JSON.parse(await readFile(file, "utf8")); repositoryRoot: string,
file: string,
): Promise<Record<string, unknown>> {
const value: unknown = JSON.parse(
await readFile(path.join(repositoryRoot, file), "utf8"),
);
if (!value || typeof value !== "object" || Array.isArray(value)) { if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${file} must be a JSON object`); throw new TypeError(`${file} must be a JSON object`);
} }
+9 -3
View File
@@ -1,4 +1,4 @@
import { verify, type KeyLike } from "node:crypto"; import { verify, type KeyObject } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
@@ -56,7 +56,7 @@ export const providerVerificationArtifactSchema = z
export type ProviderTrust = Readonly<{ export type ProviderTrust = Readonly<{
keyId: string; keyId: string;
publicKey: KeyLike; publicKey: KeyObject;
}>; }>;
export type PromotionEvidenceResult = Readonly<{ export type PromotionEvidenceResult = Readonly<{
@@ -182,7 +182,13 @@ function signatureMatches(
>, >,
trust: ProviderTrust | null, trust: ProviderTrust | null,
): boolean { ): boolean {
if (!trust || evidence.signature.keyId !== trust.keyId) return false; if (
!trust ||
evidence.signature.keyId !== trust.keyId ||
trust.publicKey.asymmetricKeyType !== "ed25519"
) {
return false;
}
try { try {
return verify( return verify(
null, null,
+13 -1
View File
@@ -34,6 +34,7 @@ export const RELEASE_CANDIDATE_MANIFEST_PATH =
"artifacts/release/release-candidate.json"; "artifacts/release/release-candidate.json";
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([ export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
"pnpm-lock.yaml",
"artifacts/performance/bundle.json", "artifacts/performance/bundle.json",
"artifacts/quality/vite-module-inventory.json", "artifacts/quality/vite-module-inventory.json",
"artifacts/release/build-manifest.json", "artifacts/release/build-manifest.json",
@@ -113,10 +114,21 @@ export async function createReleaseCandidateManifest(
"utf8", "utf8",
), ),
) as { lockfileSha256?: unknown }; ) as { lockfileSha256?: unknown };
const rawLockfileSha256 = evidence.find(
(file) => file.path === "pnpm-lock.yaml",
)?.sha256;
if (
typeof rawLockfileSha256 !== "string" ||
dependencyInventory.lockfileSha256 !== rawLockfileSha256
) {
throw new Error(
"raw pnpm-lock digest mismatch with dependency inventory",
);
}
return releaseCandidateManifestSchema.parse({ return releaseCandidateManifestSchema.parse({
schemaVersion: 1, schemaVersion: 1,
distSha256: distSha256(outputs), distSha256: distSha256(outputs),
lockfileSha256: dependencyInventory.lockfileSha256, lockfileSha256: rawLockfileSha256,
bundleSha256: supplyChainDigest(files), bundleSha256: supplyChainDigest(files),
files, files,
}); });
+24
View File
@@ -0,0 +1,24 @@
export function deterministicSupplyChainGeneratedAt(input: Readonly<{
generatedAt: string;
sourceDateEpoch: string | null;
}>): string {
const generatedAtMs = Date.parse(input.generatedAt);
if (!Number.isFinite(generatedAtMs)) {
throw new TypeError("build manifest generatedAt must be an ISO timestamp");
}
if (input.sourceDateEpoch !== null) {
if (!/^(?:0|[1-9]\d*)$/u.test(input.sourceDateEpoch)) {
throw new TypeError("SOURCE_DATE_EPOCH must be whole seconds");
}
const epoch = Number(input.sourceDateEpoch);
if (
!Number.isSafeInteger(epoch) ||
new Date(epoch * 1000).toISOString() !== input.generatedAt
) {
throw new TypeError(
"build manifest generatedAt must match SOURCE_DATE_EPOCH",
);
}
}
return new Date(generatedAtMs).toISOString();
}
+19
View File
@@ -0,0 +1,19 @@
import { readFile } from "node:fs/promises";
import { verifyArchivedLocalEvidence } from "./lib/local-release-evidence.ts";
import {
RELEASE_CANDIDATE_MANIFEST_PATH,
releaseCandidateManifestSchema,
} from "./lib/release-candidate.ts";
const candidate = releaseCandidateManifestSchema.parse(
JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")),
);
const result = await verifyArchivedLocalEvidence({ candidate });
if (result.status !== "PASS") {
process.stderr.write(
`Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write("Archived local evidence verification: PASS\n");
+14 -128
View File
@@ -1,138 +1,24 @@
import { createHash } from "node:crypto"; import { mkdir } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { import {
isValidSha512Integrity, supplyChainCoherenceReportSchema,
parsePnpmLockfilePackages, verifyLocalSupplyChainEvidence,
supplyChainDigest, } from "./lib/local-release-evidence.ts";
verifySupplyChainCoherence, import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
} from "./lib/supply-chain.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
import {
collectDistOutputs,
distSha256,
} from "./lib/release-candidate.ts";
type Document = Record<string, unknown>; const report = await verifyLocalSupplyChainEvidence();
function isRecord(value: unknown): value is Document {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function parseDocument(text: string, label: string): Document {
const parsed: unknown = JSON.parse(text);
if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object`);
return parsed;
}
function recordRows(value: unknown): Document[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
async function readDocument(file: string): Promise<Document> {
return parseDocument(await readFile(file, "utf8"), file);
}
const inventory = await readDocument(
"artifacts/release/dependency-inventory.json",
);
const sbom = await readDocument("artifacts/release/sbom.cdx.json");
const provenance = await readDocument("artifacts/release/provenance.json");
const verification = await readDocument(
"artifacts/security/supply-chain-verification.json",
);
const artifactSchemaFailures: string[] = [];
for (const [schemaPath, artifact, label] of [
[
"schemas/artifacts/dependency-inventory.schema.json",
inventory,
"dependency inventory",
],
[
"schemas/artifacts/supply-chain-verification.schema.json",
verification,
"supply-chain verification",
],
] as const) {
try {
assertMatchesJsonSchema(await readDocument(schemaPath), artifact, label);
} catch {
artifactSchemaFailures.push(`${label} JSON Schema mismatch`);
}
}
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
const lockfileSha256 = createHash("sha256")
.update(lockfileText)
.digest("hex");
const outputs = await collectDistOutputs();
const distDigest = distSha256(outputs);
const coherence = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
);
const failures: string[] = [
...artifactSchemaFailures,
...coherence.failures,
];
if (
inventory.lockfileSha256 !== lockfileSha256 ||
verification.lockfileSha256 !== lockfileSha256
) {
failures.push("inventory/verification lockfile digest mismatch");
}
if (
verification.distSha256 !== distDigest ||
verification.sbomSha256 !== supplyChainDigest(sbom)
) {
failures.push("verification digest set is incoherent");
}
const lockRows = parsePnpmLockfilePackages(lockfileText);
const inventoryRows = recordRows(inventory.dependencies);
const inventoryByIdentity = new Map<string, Document>(
inventoryRows.map(
(entry) => [
`${String(entry.name ?? "")}@${String(entry.version ?? "")}`,
entry,
] as const,
),
);
if (lockRows.length !== inventoryRows.length) {
failures.push("transitive dependency count differs from lockfile");
}
for (const lockRow of lockRows) {
const identity = `${lockRow.name}@${lockRow.version}`;
const dependency = inventoryByIdentity.get(identity);
if (
!dependency ||
dependency.integrity !== lockRow.integrity ||
!isValidSha512Integrity(lockRow.integrity)
) {
failures.push(`lockfile inventory integrity mismatch: ${identity}`);
}
}
const report = {
schemaVersion: 1,
status: failures.length === 0 ? "PASS" : "FAIL",
dependencyCount: inventoryRows.length,
lockfileSha256,
distSha256: distDigest,
sbomSha256: supplyChainDigest(sbom),
failures,
};
await mkdir("artifacts/security", { recursive: true }); await mkdir("artifacts/security", { recursive: true });
await writeFile( await writeValidatedJsonArtifact({
"artifacts/security/supply-chain-coherence.json", path: "artifacts/security/supply-chain-coherence.json",
`${JSON.stringify(report, null, 2)}\n`, schema: supplyChainCoherenceReportSchema,
); value: report,
if (failures.length > 0) { });
if (report.status !== "PASS") {
process.stderr.write( process.stderr.write(
`Supply-chain artifact coherence failed:\n- ${failures.join("\n- ")}\n`, `Supply-chain artifact coherence failed:\n- ${report.failures.join("\n- ")}\n`,
); );
process.exit(1); process.exit(1);
} }
process.stdout.write( process.stdout.write(
`Supply-chain artifact coherence: PASS (${inventoryRows.length} dependencies)\n`, `Supply-chain artifact coherence: PASS (${report.dependencyCount} dependencies)\n`,
); );
+294 -2
View File
@@ -1,4 +1,4 @@
import { generateKeyPairSync, sign } from "node:crypto"; import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -29,6 +29,8 @@ import {
RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_EVIDENCE_PATHS,
verifyReleaseCandidate, verifyReleaseCandidate,
} from "../../scripts/lib/release-candidate.ts"; } from "../../scripts/lib/release-candidate.ts";
import { deterministicSupplyChainGeneratedAt } from "../../scripts/lib/supply-chain-time.ts";
import { verifyPromotionInputs } from "../../scripts/lib/promotion-verifier.ts";
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
const dependency = { const dependency = {
@@ -64,23 +66,240 @@ function signedProviderEvidence(
}; };
} }
async function createMinimalCandidateTree(root: string) {
const rawLockfile = "lockfileVersion: '9.0'\n";
const rawLockfileSha256 = createHash("sha256")
.update(rawLockfile)
.digest("hex");
await mkdir(path.join(root, "dist"), { recursive: true });
await writeFile(path.join(root, "dist/app.js"), "immutable\n");
await writeFile(path.join(root, "pnpm-lock.yaml"), rawLockfile);
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
if (file === "pnpm-lock.yaml") continue;
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
const value =
file === "artifacts/release/dependency-inventory.json"
? { lockfileSha256: rawLockfileSha256 }
: file === "artifacts/security/supply-chain-verification.json"
? { localStatus: "PASS" }
: { fixture: file };
await writeFile(path.join(root, file), `${JSON.stringify(value)}\n`);
}
const manifest = await createReleaseCandidateManifest(root);
await writeFile(
path.join(root, "artifacts/release/release-candidate.json"),
`${JSON.stringify(manifest)}\n`,
);
return manifest;
}
async function writeProviderEnvironment(
root: string,
distDigest: string,
candidateLockfileSha256: string,
) {
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const vulnerabilityReport = signedProviderEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: candidateLockfileSha256,
scannedDistSha256: distDigest,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
);
const provenanceAttestation = signedProviderEvidence(
{
schemaVersion: 1,
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: { name: "dist", digest: { sha256: distDigest } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
);
await mkdir(path.join(root, "provider"), { recursive: true });
await Promise.all([
writeFile(
path.join(root, "provider/vulnerability.json"),
`${JSON.stringify(vulnerabilityReport)}\n`,
),
writeFile(
path.join(root, "provider/provenance.json"),
`${JSON.stringify(provenanceAttestation)}\n`,
),
writeFile(
path.join(root, "provider/vulnerability.pem"),
vulnerabilityKeys.publicKey
.export({ type: "spki", format: "pem" })
.toString(),
),
writeFile(
path.join(root, "provider/provenance.pem"),
provenanceKeys.publicKey
.export({ type: "spki", format: "pem" })
.toString(),
),
]);
return {
VULNERABILITY_REPORT_PATH: "provider/vulnerability.json",
PROVENANCE_ATTESTATION_PATH: "provider/provenance.json",
VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem",
VULNERABILITY_KEY_ID: "fixture-vulnerability-key",
PROVENANCE_PUBLIC_KEY_PATH: "provider/provenance.pem",
PROVENANCE_KEY_ID: "fixture-provenance-key",
} satisfies NodeJS.ProcessEnv;
}
describe("supply-chain policy", () => { describe("supply-chain policy", () => {
it("wires candidate files, PEM trust, env report paths, and mutation checks", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-"));
try {
const manifest = await createMinimalCandidateTree(root);
const validEnvironment = await writeProviderEnvironment(
root,
manifest.distSha256,
manifest.lockfileSha256,
);
const acceptLocalEvidence = async () => ({
status: "PASS" as const,
failures: [] as const,
});
const valid = await verifyPromotionInputs({
repositoryRoot: root,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
});
const absent = await verifyPromotionInputs({
repositoryRoot: root,
environment: {},
verifyLocalEvidence: acceptLocalEvidence,
});
const wrongEnvironment = await writeProviderEnvironment(
root,
"3".repeat(64),
manifest.lockfileSha256,
);
const wrongDigest = await verifyPromotionInputs({
repositoryRoot: root,
environment: wrongEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
});
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
const postAttestationMutation = await verifyPromotionInputs({
repositoryRoot: root,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
});
expect({
valid: valid.status,
absent: absent.status,
wrongDigest: wrongDigest.status,
postAttestationMutation: postAttestationMutation.status,
}).toEqual({
valid: "PASS",
absent: "FAIL_UNVERIFIED",
wrongDigest: "FAIL_UNVERIFIED",
postAttestationMutation: "FAIL_UNVERIFIED",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects a self-consistent candidate that merely claims localStatus PASS", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-local-status-"));
try {
const manifest = await createMinimalCandidateTree(root);
const environment = await writeProviderEnvironment(
root,
manifest.distSha256,
manifest.lockfileSha256,
);
const localVerificationPath = path.join(
root,
"artifacts/security/supply-chain-verification.json",
);
const before = await readFile(localVerificationPath, "utf8");
const result = await verifyPromotionInputs({
repositoryRoot: root,
environment,
});
expect(result.status).toBe("FAIL_UNVERIFIED");
expect(result.failures).toEqual(
expect.arrayContaining([
expect.stringMatching(/executable schema mismatch/u),
"local supply-chain evidence is not PASS",
]),
);
expect(await readFile(localVerificationPath, "utf8")).toBe(before);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("derives a stable supply-chain timestamp from the immutable build epoch", () => {
const input = {
generatedAt: "2026-08-01T00:00:00.000Z",
sourceDateEpoch: "1785542400",
};
expect(deterministicSupplyChainGeneratedAt(input)).toBe(
"2026-08-01T00:00:00.000Z",
);
expect(deterministicSupplyChainGeneratedAt(input)).toBe(
deterministicSupplyChainGeneratedAt({ ...input }),
);
expect(() =>
deterministicSupplyChainGeneratedAt({
generatedAt: "not-a-time",
sourceDateEpoch: "1785542400",
}),
).toThrow(/generatedAt/u);
expect(() =>
deterministicSupplyChainGeneratedAt({
generatedAt: "2026-08-01T00:00:00.000Z",
sourceDateEpoch: "1785542401",
}),
).toThrow(/SOURCE_DATE_EPOCH/u);
});
it("rejects release candidate dist bytes changed after manifest creation", async () => { it("rejects release candidate dist bytes changed after manifest creation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "release-candidate-")); const root = await mkdtemp(path.join(tmpdir(), "release-candidate-"));
try { try {
const rawLockfile = "lockfileVersion: '9.0'\n";
const rawLockfileSha256 = createHash("sha256")
.update(rawLockfile)
.digest("hex");
await mkdir(path.join(root, "dist/.vite"), { recursive: true }); await mkdir(path.join(root, "dist/.vite"), { recursive: true });
await writeFile(path.join(root, "dist/app.js"), "immutable\n"); await writeFile(path.join(root, "dist/app.js"), "immutable\n");
await writeFile(path.join(root, "dist/.vite/metadata.json"), "{}\n"); await writeFile(path.join(root, "dist/.vite/metadata.json"), "{}\n");
await writeFile(path.join(root, "pnpm-lock.yaml"), rawLockfile);
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) { for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
if (file === "pnpm-lock.yaml") continue;
await mkdir(path.dirname(path.join(root, file)), { recursive: true }); await mkdir(path.dirname(path.join(root, file)), { recursive: true });
await writeFile( await writeFile(
path.join(root, file), path.join(root, file),
file === "artifacts/release/dependency-inventory.json" file === "artifacts/release/dependency-inventory.json"
? `${JSON.stringify({ lockfileSha256 })}\n` ? `${JSON.stringify({ lockfileSha256: rawLockfileSha256 })}\n`
: `${file}\n`, : `${file}\n`,
); );
} }
const manifest = await createReleaseCandidateManifest(root); const manifest = await createReleaseCandidateManifest(root);
expect(manifest.lockfileSha256).toBe(rawLockfileSha256);
expect(manifest.files).toContainEqual(
expect.objectContaining({
path: "pnpm-lock.yaml",
sha256: rawLockfileSha256,
}),
);
expect(await createReleaseCandidateManifest(root)).toEqual(manifest);
expect((await verifyReleaseCandidate(manifest, root)).failures).toEqual( expect((await verifyReleaseCandidate(manifest, root)).failures).toEqual(
[], [],
); );
@@ -100,6 +319,30 @@ describe("supply-chain policy", () => {
} }
}); });
it("rejects a dependency inventory digest that differs from raw pnpm-lock bytes", async () => {
const root = await mkdtemp(path.join(tmpdir(), "release-lockfile-"));
try {
await mkdir(path.join(root, "dist"), { recursive: true });
await writeFile(path.join(root, "dist/app.js"), "immutable\n");
await writeFile(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
if (file === "pnpm-lock.yaml") continue;
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
await writeFile(
path.join(root, file),
file === "artifacts/release/dependency-inventory.json"
? `${JSON.stringify({ lockfileSha256 })}\n`
: `${file}\n`,
);
}
await expect(createReleaseCandidateManifest(root)).rejects.toThrow(
/raw pnpm-lock digest mismatch/u,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("fails promotion when external provider evidence is absent", () => { it("fails promotion when external provider evidence is absent", () => {
const result = evaluatePromotionEvidence({ const result = evaluatePromotionEvidence({
candidate: { candidate: {
@@ -285,6 +528,55 @@ describe("supply-chain policy", () => {
); );
}); });
it("rejects Ed448 keys mislabeled as Ed25519 evidence", () => {
const vulnerabilityKeys = generateKeyPairSync("ed448");
const provenanceKeys = generateKeyPairSync("ed448");
const vulnerabilityReport = signedProviderEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: lockfileSha256,
scannedDistSha256: candidateDistSha256,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
);
const provenanceAttestation = signedProviderEvidence(
{
schemaVersion: 1,
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: {
name: "dist",
digest: { sha256: candidateDistSha256 },
},
},
"fixture-provenance-key",
provenanceKeys.privateKey,
);
expect(
evaluatePromotionEvidence({
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
currentDistSha256: candidateDistSha256,
localStatus: "PASS",
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust: {
keyId: "fixture-vulnerability-key",
publicKey: vulnerabilityKeys.publicKey,
},
provenanceTrust: {
keyId: "fixture-provenance-key",
publicKey: provenanceKeys.publicKey,
},
}).status,
).toBe("FAIL_UNVERIFIED");
});
it.each([ it.each([
["empty", []], ["empty", []],
["empty entry", [""]], ["empty entry", [""]],