fix: close immutable promotion trust gaps
This commit is contained in:
@@ -137,6 +137,7 @@ jobs:
|
||||
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" \
|
||||
dist \
|
||||
pnpm-lock.yaml \
|
||||
artifacts/performance/bundle.json \
|
||||
artifacts/quality/vite-module-inventory.json \
|
||||
artifacts/release/build-manifest.json \
|
||||
@@ -167,6 +168,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
|
||||
CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml
|
||||
VULNERABILITY_PROVIDER_COMMAND: ${{ vars.VULNERABILITY_PROVIDER_COMMAND }}
|
||||
VULNERABILITY_REPORT_PATH: provider-evidence/vulnerability-report.json
|
||||
steps:
|
||||
@@ -197,6 +199,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
|
||||
CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml
|
||||
PROVENANCE_PROVIDER_COMMAND: ${{ vars.PROVENANCE_PROVIDER_COMMAND }}
|
||||
PROVENANCE_ATTESTATION_PATH: provider-evidence/provenance-attestation.json
|
||||
steps:
|
||||
|
||||
@@ -36,19 +36,26 @@ finding과 서명 성공으로 표현하면 local 검증과 release promotion을
|
||||
5. inventory를 CycloneDX 1.6 SBOM으로 투영한다. component 수, lockfile digest,
|
||||
SRI, license와 dependency edge가 inventory와 일치해야 한다.
|
||||
6. local in-toto/SLSA 형태 provenance statement는 source set, lockfile, SBOM과
|
||||
`dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 외부 attestation은
|
||||
provider, signer와 동일 dist subject digest가 있어야 한다.
|
||||
7. vulnerability adapter는 `VULNERABILITY_REPORT_PATH`가 가리키는
|
||||
machine-readable provider report를 검증한다. report의 lock digest, provider,
|
||||
severity와 exception owner/reviewer/reason/expiry가 유효해야 한다.
|
||||
8. provider report가 없으면 local inventory/license/SBOM/coherence는 `PASS`,
|
||||
promotion은 `FAIL_UNVERIFIED`다. 빈 finding을 만들어 vulnerability PASS로
|
||||
표시하지 않는다.
|
||||
9. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`와
|
||||
`dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 이 문서는 외부
|
||||
provenance를 대신할 수 없다.
|
||||
7. `immutable_build`는 raw `pnpm-lock.yaml`, `dist`, build/module inventory와
|
||||
모든 local verification evidence를 한 번만 archive한다. Candidate manifest는
|
||||
raw lock bytes SHA-256, dependency inventory lock digest와 manifest
|
||||
`lockfileSha256`의 exact 일치를 요구한다.
|
||||
8. 두 provider job은 동일 archive를 각각 받아 외부 command를 실행한다.
|
||||
Vulnerability report는 raw lock digest와 `distSha256`, provenance attestation은
|
||||
`{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에만 허용하며
|
||||
owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고
|
||||
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를 복원한다.
|
||||
|
||||
## 실행 경계와 증적
|
||||
@@ -61,8 +68,10 @@ package.json + frozen pnpm-lock.yaml + installed graph
|
||||
|
||||
source/config/lock + production dist
|
||||
-> local provenance statement
|
||||
-> optional vulnerability/attestation provider inputs
|
||||
-> LOCAL PASS | promotion PASS/FAIL_UNVERIFIED
|
||||
-> immutable archive + candidate manifest + distSha256
|
||||
-> external vulnerability provider + external provenance provider
|
||||
-> read-only local revalidation + signature/digest verification
|
||||
-> promotion PASS | FAIL_UNVERIFIED
|
||||
```
|
||||
|
||||
- policy: `config/security/`
|
||||
@@ -82,8 +91,9 @@ source/config/lock + production dist
|
||||
- ordering-only digest, removal, integrity tamper, baseline tamper, high-risk
|
||||
self approval, denied license, critical vulnerability와 만료 exception,
|
||||
provider/digest 오류, SBOM/provenance 불일치 fixture를 검사한다.
|
||||
- synthetic provider/attestation fixture는 promotion `PASS`를 증명한 후 기본
|
||||
`FAIL_UNVERIFIED` 상태를 복원한다.
|
||||
- isolated temporary candidate/PEM/report fixture는 실제 environment path wiring을
|
||||
통해 valid immutable 입력만 promotion `PASS`임을 증명한다. Production artifact를
|
||||
덮어쓰거나 generator를 provider 모드로 재실행하지 않는다.
|
||||
- frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다.
|
||||
- source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고
|
||||
scoped test allowlist만 통과한다.
|
||||
@@ -97,7 +107,7 @@ attestation transparency/retention과 비밀 관리를 결정해야 한다.
|
||||
|
||||
## Rollback
|
||||
|
||||
외부 scanner/attestor adapter는 환경 입력을 제거하면 즉시
|
||||
외부 scanner/attestor command, report path 또는 trusted key 설정을 제거하면 즉시
|
||||
`FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM,
|
||||
secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로
|
||||
promotion을 PASS로 변경하지 않는다.
|
||||
|
||||
@@ -32,6 +32,16 @@ full commit ID와 commit timestamp를 한 번 읽어 `SOURCE_DATE_EPOCH`를 유
|
||||
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 이상이다. 이
|
||||
workflow를 required check로 전환하기 전에 staging instance에서
|
||||
`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
|
||||
- `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document
|
||||
- `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
|
||||
declared tier. This repository cannot configure server-side protection by
|
||||
|
||||
@@ -36,14 +36,29 @@ Promotion reads the provider files named by `VULNERABILITY_REPORT_PATH` and
|
||||
exact lockfile digest and candidate `distSha256`; the provenance attestation
|
||||
must name `dist` with that same digest. Both documents use strict schemas 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
|
||||
`artifacts/security/supply-chain-verification.json` records
|
||||
`promotionStatus: FAIL_UNVERIFIED`. `verify:provider-evidence` and
|
||||
`verify:promotion` then exit non-zero. Promotion recomputes the candidate file
|
||||
set and digests and never rebuilds it. Scanner or signing outages are not
|
||||
converted to an empty PASS.
|
||||
set and digests, then read-only revalidates the archived executable schemas,
|
||||
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,
|
||||
owner, a different reviewer, reason and expiry. Expired or self-approved
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@
|
||||
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
|
||||
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
|
||||
"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:promotion": "node scripts/verify-supply-chain-promotion.ts",
|
||||
"update:dependency-baseline": "node scripts/update-dependency-baseline.ts",
|
||||
|
||||
@@ -191,8 +191,14 @@ for (const requiredToken of [
|
||||
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
|
||||
"VULNERABILITY_REPORT_PATH:",
|
||||
"PROVENANCE_ATTESTATION_PATH:",
|
||||
"CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml",
|
||||
"VULNERABILITY_PROVIDER_COMMAND:",
|
||||
"PROVENANCE_PROVIDER_COMMAND:",
|
||||
"VULNERABILITY_PUBLIC_KEY_PATH:",
|
||||
"VULNERABILITY_KEY_ID:",
|
||||
"PROVENANCE_PUBLIC_KEY_PATH:",
|
||||
"PROVENANCE_KEY_ID:",
|
||||
" pnpm-lock.yaml \\",
|
||||
"release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}",
|
||||
"corepack pnpm verify:provider-evidence",
|
||||
"corepack pnpm verify:promotion",
|
||||
|
||||
@@ -1,30 +1,199 @@
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
||||
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 {
|
||||
evaluatePromotionEvidence,
|
||||
providerEvidenceSignaturePayload,
|
||||
} from "./lib/provider-evidence.ts";
|
||||
createReleaseCandidateManifest,
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
} from "./lib/release-candidate.ts";
|
||||
|
||||
const candidateDistSha256 = "1".repeat(64);
|
||||
const lockfileSha256 = "2".repeat(64);
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const trust = {
|
||||
vulnerabilityTrust: {
|
||||
keyId: "fixture-vulnerability-key",
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: "fixture-provenance-key",
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
},
|
||||
};
|
||||
const fixtureRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "supply-chain-provider-fixture-"),
|
||||
);
|
||||
try {
|
||||
const rawLockfile = "lockfileVersion: '9.0'\n";
|
||||
const lockfileSha256 = createHash("sha256")
|
||||
.update(rawLockfile)
|
||||
.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",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\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(
|
||||
value: Record<string, unknown>,
|
||||
keyId: string,
|
||||
privateKey: typeof vulnerabilityKeys.privateKey,
|
||||
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
||||
) {
|
||||
return {
|
||||
...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",
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
import {
|
||||
bundlePerformanceArtifactSchema,
|
||||
buildManifestArtifactSchema,
|
||||
dependencyDiffArtifactSchema,
|
||||
dependencyInventoryArtifactSchema,
|
||||
licenseReportArtifactSchema,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
parseRepositoryFileInventoryPolicy,
|
||||
} from "./lib/repository-file-inventory.ts";
|
||||
import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts";
|
||||
import { deterministicSupplyChainGeneratedAt } from "./lib/supply-chain-time.ts";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
@@ -138,6 +140,9 @@ export async function buildDependencyInventory() {
|
||||
}
|
||||
|
||||
const packageJson = await jsonDocument("package.json");
|
||||
const buildManifest = buildManifestArtifactSchema.parse(
|
||||
await jsonDocument("artifacts/release/build-manifest.json"),
|
||||
);
|
||||
const secretScanPolicy = documentValue(
|
||||
JSON.parse(await readFile("config/security/secret-scan-policy.json", "utf8")),
|
||||
"secret scan policy",
|
||||
@@ -332,7 +337,10 @@ const verification = {
|
||||
};
|
||||
const bundleReport = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt: deterministicSupplyChainGeneratedAt({
|
||||
generatedAt: buildManifest.generatedAt,
|
||||
sourceDateEpoch: buildManifest.buildContext.sourceDateEpoch,
|
||||
}),
|
||||
context: {
|
||||
nodeVersion: process.version,
|
||||
packageManager: String(packageJson.packageManager ?? ""),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createPublicKey } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
@@ -10,38 +11,63 @@ import {
|
||||
releaseCandidateManifestSchema,
|
||||
verifyReleaseCandidate,
|
||||
} 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(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
options: VerifyPromotionInputsOptions = {},
|
||||
) {
|
||||
const manifestDocument = await requiredJson(RELEASE_CANDIDATE_MANIFEST_PATH);
|
||||
const manifest = releaseCandidateManifestSchema.parse(manifestDocument);
|
||||
const candidate = await verifyReleaseCandidate(manifestDocument);
|
||||
const localVerification = await requiredJson(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
const environment = options.environment ?? process.env;
|
||||
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
|
||||
const manifestDocument = await requiredJson(
|
||||
repositoryRoot,
|
||||
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(
|
||||
repositoryRoot,
|
||||
environment.VULNERABILITY_REPORT_PATH,
|
||||
);
|
||||
const provenanceAttestation = await optionalJson(
|
||||
repositoryRoot,
|
||||
environment.PROVENANCE_ATTESTATION_PATH,
|
||||
);
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: manifest,
|
||||
currentDistSha256: candidate.currentDistSha256 ?? "",
|
||||
localStatus: localVerification.localStatus,
|
||||
localStatus: localEvidence.status,
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: await readTrust(
|
||||
repositoryRoot,
|
||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||
environment.VULNERABILITY_KEY_ID,
|
||||
),
|
||||
provenanceTrust: await readTrust(
|
||||
repositoryRoot,
|
||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||
environment.PROVENANCE_KEY_ID,
|
||||
),
|
||||
});
|
||||
const failures = [...candidate.failures, ...result.failures];
|
||||
const failures = [
|
||||
...candidate.failures,
|
||||
...localEvidence.failures,
|
||||
...result.failures,
|
||||
];
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
status:
|
||||
@@ -57,6 +83,7 @@ export async function verifyPromotionInputs(
|
||||
}
|
||||
|
||||
async function readTrust(
|
||||
repositoryRoot: string,
|
||||
publicKeyPath: string | undefined,
|
||||
keyId: string | undefined,
|
||||
): Promise<ProviderTrust | null> {
|
||||
@@ -64,24 +91,36 @@ async function readTrust(
|
||||
try {
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey: createPublicKey(await readFile(publicKeyPath, "utf8")),
|
||||
publicKey: createPublicKey(
|
||||
await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"),
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function optionalJson(file: string | undefined): Promise<unknown> {
|
||||
async function optionalJson(
|
||||
repositoryRoot: string,
|
||||
file: string | undefined,
|
||||
): Promise<unknown> {
|
||||
if (!file) return null;
|
||||
try {
|
||||
return JSON.parse(await readFile(file, "utf8")) as unknown;
|
||||
return JSON.parse(
|
||||
await readFile(path.resolve(repositoryRoot, file), "utf8"),
|
||||
) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requiredJson(file: string): Promise<Record<string, unknown>> {
|
||||
const value: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||
async function requiredJson(
|
||||
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)) {
|
||||
throw new TypeError(`${file} must be a JSON object`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { verify, type KeyLike } from "node:crypto";
|
||||
import { verify, type KeyObject } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -56,7 +56,7 @@ export const providerVerificationArtifactSchema = z
|
||||
|
||||
export type ProviderTrust = Readonly<{
|
||||
keyId: string;
|
||||
publicKey: KeyLike;
|
||||
publicKey: KeyObject;
|
||||
}>;
|
||||
|
||||
export type PromotionEvidenceResult = Readonly<{
|
||||
@@ -182,7 +182,13 @@ function signatureMatches(
|
||||
>,
|
||||
trust: ProviderTrust | null,
|
||||
): boolean {
|
||||
if (!trust || evidence.signature.keyId !== trust.keyId) return false;
|
||||
if (
|
||||
!trust ||
|
||||
evidence.signature.keyId !== trust.keyId ||
|
||||
trust.publicKey.asymmetricKeyType !== "ed25519"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return verify(
|
||||
null,
|
||||
|
||||
@@ -34,6 +34,7 @@ export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
||||
"artifacts/release/release-candidate.json";
|
||||
|
||||
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"pnpm-lock.yaml",
|
||||
"artifacts/performance/bundle.json",
|
||||
"artifacts/quality/vite-module-inventory.json",
|
||||
"artifacts/release/build-manifest.json",
|
||||
@@ -113,10 +114,21 @@ export async function createReleaseCandidateManifest(
|
||||
"utf8",
|
||||
),
|
||||
) 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({
|
||||
schemaVersion: 1,
|
||||
distSha256: distSha256(outputs),
|
||||
lockfileSha256: dependencyInventory.lockfileSha256,
|
||||
lockfileSha256: rawLockfileSha256,
|
||||
bundleSha256: supplyChainDigest(files),
|
||||
files,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
@@ -1,138 +1,24 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
isValidSha512Integrity,
|
||||
parsePnpmLockfilePackages,
|
||||
supplyChainDigest,
|
||||
verifySupplyChainCoherence,
|
||||
} from "./lib/supply-chain.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
import {
|
||||
collectDistOutputs,
|
||||
distSha256,
|
||||
} from "./lib/release-candidate.ts";
|
||||
supplyChainCoherenceReportSchema,
|
||||
verifyLocalSupplyChainEvidence,
|
||||
} from "./lib/local-release-evidence.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
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,
|
||||
};
|
||||
const report = await verifyLocalSupplyChainEvidence();
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/security/supply-chain-coherence.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
if (failures.length > 0) {
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/security/supply-chain-coherence.json",
|
||||
schema: supplyChainCoherenceReportSchema,
|
||||
value: report,
|
||||
});
|
||||
if (report.status !== "PASS") {
|
||||
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.stdout.write(
|
||||
`Supply-chain artifact coherence: PASS (${inventoryRows.length} dependencies)\n`,
|
||||
`Supply-chain artifact coherence: PASS (${report.dependencyCount} dependencies)\n`,
|
||||
);
|
||||
|
||||
@@ -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 { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
verifyReleaseCandidate,
|
||||
} 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 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", () => {
|
||||
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 () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "release-candidate-"));
|
||||
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 writeFile(path.join(root, "dist/app.js"), "immutable\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) {
|
||||
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`
|
||||
? `${JSON.stringify({ lockfileSha256: rawLockfileSha256 })}\n`
|
||||
: `${file}\n`,
|
||||
);
|
||||
}
|
||||
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(
|
||||
[],
|
||||
);
|
||||
@@ -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", () => {
|
||||
const result = evaluatePromotionEvidence({
|
||||
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([
|
||||
["empty", []],
|
||||
["empty entry", [""]],
|
||||
|
||||
Reference in New Issue
Block a user