feat: verify frontend supply chain

This commit is contained in:
donghyeon-ka
2026-07-26 17:37:51 +09:00
parent a64708f3de
commit 8b4f875c1c
35 changed files with 8910 additions and 141 deletions
+30 -5
View File
@@ -58,7 +58,10 @@
"gates": {
"FE-GATE-001": {
"name": "manifest-lockfile",
"steps": [{ "script": "verify:lockfile", "expect": "pass" }],
"steps": [
{ "script": "verify:lockfile", "expect": "pass" },
{ "script": "check:frozen-lockfile:fixture", "expect": "pass" }
],
"logPath": "artifacts/quality/install.txt",
"evidence": ["artifacts/quality/install.txt"],
"retentionClass": "merge-cycle"
@@ -239,14 +242,32 @@
"FE-GATE-013": {
"name": "security",
"steps": [
{ "script": "verify:reproducible-build", "expect": "pass" },
{ "script": "build:release", "expect": "pass" },
{ "script": "verify:supply-chain", "expect": "pass" },
{ "script": "check:supply-chain:fixtures", "expect": "pass" },
{
"script": "check:supply-chain:provider-fixtures",
"expect": "pass"
},
{ "script": "scan:security:fixture", "expect": "fail" },
{ "script": "check:browser-security", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-013.txt",
"evidence": [
"artifacts/security/scan.sarif",
"artifacts/security/scan-fixture.sarif",
"artifacts/release/dependency-inventory.json",
"artifacts/security/dependency-diff.json"
"artifacts/release/sbom.cdx.json",
"artifacts/release/provenance.json",
"artifacts/release/reproducible-build.json",
"artifacts/security/dependency-diff.json",
"artifacts/security/license-report.json",
"artifacts/security/vulnerability-report.json",
"artifacts/security/supply-chain-verification.json",
"artifacts/security/supply-chain-coherence.json",
"artifacts/security/supply-chain-fixtures.json",
"artifacts/security/supply-chain-provider-fixtures.json"
],
"retentionClass": "release-coherence"
},
@@ -260,11 +281,15 @@
"FE-GATE-015": {
"name": "release-coherence",
"steps": [
{ "script": "build", "expect": "pass" },
{ "script": "verify:release", "expect": "pass" }
{ "script": "build:release", "expect": "pass" },
{ "script": "verify:release", "expect": "pass" },
{ "script": "verify:supply-chain:promotion", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-015.txt",
"evidence": ["artifacts/release/verification.json"],
"evidence": [
"artifacts/release/verification.json",
"artifacts/security/promotion-verification.json"
],
"retentionClass": "release-coherence"
},
"FE-GATE-016": {
@@ -0,0 +1,7 @@
{
"schemaVersion": 1,
"snapshotDigest": "ce4fa9b7944f27553067228bd6c9e73e7dc05875c283255b50d7eb3ad2923f6d",
"owner": "frontend-platform",
"reason": "RP-11-initial-transitive-inventory",
"approvedAt": "2026-07-26T08:27:17.874Z"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
{
"schemaVersion": 1,
"changes": []
}
+24
View File
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"allowedLicenses": [
"(MIT OR CC0-1.0)",
"0BSD",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"BlueOak-1.0.0",
"CC-BY-4.0",
"CC0-1.0",
"ISC",
"MIT",
"MIT-0",
"MPL-2.0"
],
"deniedLicensePatterns": [
"(^|\\s)AGPL",
"(^|\\s)GPL",
"SSPL",
"BUSL"
],
"unknownLicensePolicy": "allow-only-unmaterialized-platform-optional"
}
+30
View File
@@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"trackedRoots": [
"src",
"scripts",
"tests",
"config",
"public",
"schemas",
".storybook",
"package.json",
"pnpm-lock.yaml",
"vite.config.js",
"vitest.config.js",
"playwright.config.js"
],
"generatedRoots": ["dist", "artifacts/release"],
"excludedPaths": [
"tests/fixtures/security/secret-detection/forbidden"
],
"allowlist": [
{
"path": "tests/fixtures/security/secret-detection/allowed/test-credentials.ts",
"ruleId": "assigned-secret",
"owner": "frontend-platform",
"reason": "Synthetic credential verifies the scoped test-only allowlist.",
"expiresAt": "2027-07-26T00:00:00.000Z"
}
]
}
@@ -0,0 +1,4 @@
{
"schemaVersion": 1,
"exceptions": []
}
@@ -0,0 +1,8 @@
{
"schemaVersion": 1,
"providerMode": "external-file",
"inputEnvironment": "VULNERABILITY_REPORT_PATH",
"blockAtSeverity": "high",
"allowedSeverities": ["unknown", "low", "moderate", "high", "critical"],
"missingProviderStatus": "FAIL_UNVERIFIED"
}
@@ -0,0 +1,103 @@
# VD-09: 공급망 inventory, license, vulnerability, SBOM과 provenance
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-supply-chain-verification`
- 재검토: 조직 vulnerability scanner, signing/attestation provider와 dependency
exception 승인 체계가 선정될 때
## 배경
기존 release script는 `package.json`의 직접 dependency 이름과 버전, lockfile
전체 digest, `dist` checksum만 기록했다. 전이 dependency, 패키지별 integrity와
license, 실제 baseline diff가 없었고 `highRiskUnreviewed: []`는 계산 결과가 아닌
고정값이었다. secret scan도 `src``dist`만 검사해 config, scripts, test와
generated release metadata를 놓쳤다.
반면 저장소에는 조직이 선택한 vulnerability source, severity exception 승인자,
signing identity와 attestation 저장소가 없다. 외부 provider가 없는 상태를 빈
finding과 서명 성공으로 표현하면 local 검증과 release promotion을 혼동한다.
## 결정
1. `pnpm-lock.yaml`의 모든 `packages` row와 `pnpm list --depth Infinity`의 실제
graph를 결합해 직접/전이, production/development, required/platform-optional,
version, SHA-512 SRI, license와 dependency edge를 기록한다.
2. inventory row 수는 lockfile package row 수와 같아야 한다. 누락된 전이
dependency, malformed integrity와 non-optional `NOASSERTION`은 local gate를
실패시킨다.
3. license는 설치된 package manifest에서 읽고 closed allow/deny policy로
검사한다. 현재 OS에 materialize되지 않은 platform optional만
`NOASSERTION`과 그 이유를 명시적으로 허용한다.
4. 승인 dependency baseline과 approval digest를 보존하고 현재 lock inventory와
actual add/remove/change/upgrade diff를 계산한다. 새 direct production
dependency는 owner와 서로 다른 reviewer, reason과 rollback evidence가
필요하다.
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`
generated release metadata를 검사한다. allowlist는 test path에만 허용하며
owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고
rule, path, line과 fingerprint만 남긴다.
10. `SOURCE_DATE_EPOCH`를 지원하고 같은 source/lock/config의 production build를
두 번 실행해 전체 dist digest 일치를 검증한 뒤 일반 build를 복원한다.
## 실행 경계와 증적
```text
package.json + frozen pnpm-lock.yaml + installed graph
-> deterministic dependency inventory
-> license policy + approved actual baseline diff
-> CycloneDX SBOM
source/config/lock + production dist
-> local provenance statement
-> optional vulnerability/attestation provider inputs
-> LOCAL PASS | promotion PASS/FAIL_UNVERIFIED
```
- policy: `config/security/`
- generator: `scripts/generate-supply-chain.mjs`
- coherence: `scripts/verify-supply-chain-artifacts.mjs`
- secret scan: `scripts/security-scan.mjs`
- reproducibility: `scripts/verify-reproducible-build.mjs`
- inventory: `artifacts/release/dependency-inventory.json`
- SBOM/provenance: `artifacts/release/sbom.cdx.json`,
`artifacts/release/provenance.json`
- local/promotion status:
`artifacts/security/supply-chain-verification.json`
## 검증
- 현재 lockfile의 561개 package row와 inventory row가 양방향 일치한다.
- 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` 상태를 복원한다.
- frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다.
- source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고
scoped test allowlist만 통과한다.
## 한계와 재검토 조건
로컬 manifest license는 법률 검토가 아니며 vulnerability report도 외부 scanner가
제공한 데이터의 최신성 자체를 보증하지 않는다. 실제 프로젝트는 provider 버전,
database freshness, network outage, exception 승인 조직, signing identity,
attestation transparency/retention과 비밀 관리를 결정해야 한다.
## Rollback
외부 scanner/attestor adapter는 환경 입력을 제거하면 즉시
`FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM,
secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로
promotion을 PASS로 변경하지 않는다.
@@ -39,16 +39,15 @@ recovery 계약, 제거 가능한 reference 수직 슬라이스, form/page, desi
i18n 실행 경계와 diagnostics/telemetry production wiring은 구현됐다. 현재 선행 해결
대상은 다음과 같다.
1. 공급망의 transitive inventory/license/vulnerability/SBOM/provenance 검증
2. optional adapter의 opt-in 경계와 제거 가능한 recipe
1. optional adapter의 opt-in 경계와 제거 가능한 recipe
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다.
> application API, 서버 상태, 폼, 라우팅, 페이지, 디자인 시스템 테스트 증적의
> 표준 수직 경로는 갖춰졌다. 현재 남은 저장소 내부 범위는 공급망 검증과
> opt-in adapter recipe이며 실제 hosting·IdP·운영 provider는 프로젝트 통합
> 범위다.
> application API, 서버 상태, 폼, 라우팅, 페이지, 디자인 시스템, 테스트
> local 공급망 증적의 표준 수직 경로는 갖춰졌다. 현재 남은 저장소 내부 범위는
> opt-in adapter recipe이며 실제 hosting·IdP·취약점/서명/운영 provider는
> 프로젝트 통합 범위다.
## 3. 판정 기준
@@ -89,7 +88,7 @@ i18n 실행 경계와 diagnostics/telemetry production wiring은 구현됐다.
| UI 회귀 검증 | 준비됨 | dev-only Storybook interaction/axe와 pinned Chromium visual baseline 4종 | cloud review와 다중 OS/device는 프로젝트 선택 |
| 샘플 제거 | 준비됨 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 9단계 검증 | 새 contribution도 같은 제거 gate에 포함 |
| registry·compatibility 집행 | 준비됨 | 10개 registry type/reference/consumer/orphan, 승인 digest와 actual semantic diff, breaking evidence | public 계약 변경 시 baseline review 유지 |
| 공급망 검사 | 부분 준비 | lockfile·문서·gate는 있으나 실제 transitive 취약점/license/SBOM 깊이가 부족 | pinned scanner와 policy exception/증적 연결 |
| 공급망 검사 | 준비됨/프로젝트 선택 | 561개 transitive inventory/integrity/license, actual diff, CycloneDX, local provenance, secret/reproducible build gate | 실제 vulnerability scanner와 signed attestation 없이는 promotion `FAIL_UNVERIFIED` |
| realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 |
## 5. 우선순위별 발견 사항
@@ -892,6 +892,31 @@ owner와 만료 시한이 있는 quarantine만 허용한다.
RP-11은 P1 최종 저장소 기준선이다. scanner outage를 무검증 승인으로 우회하지
않고 promotion을 보류한다.
**구현 증거 (2026-07-26)**
- VD-09에서 frozen pnpm graph와 lockfile을 local SSOT로, package manifest
license policy와 CycloneDX 1.6을 local evidence로 채택했다. 외부 vulnerability
report와 signed attestation이 없으면 promotion은 `FAIL_UNVERIFIED`다.
- 현재 직접 35개, 전체 전이 561개 dependency의 name/version, direct/scope/
optional, SHA-512 integrity, license와 dependency edge를 deterministic
inventory로 생성한다. lockfile row와 inventory가 양방향 일치하지 않으면
실패한다.
- 승인 baseline digest와 actual add/remove/change/upgrade diff를 계산하고 새
direct production dependency에는 owner와 다른 reviewer, reason과 rollback을
요구한다.
- CycloneDX SBOM component/edge와 local in-toto/SLSA 형태 provenance의
source/lock/SBOM/dist digest를 coherence gate로 다시 계산한다.
- license allow/deny, vulnerability severity와 독립·만료 exception 정책,
provider lock digest와 attestation subject를 machine-readable하게 검증한다.
provider fixture는 promotion PASS를 증명한 뒤 unconfigured
`FAIL_UNVERIFIED`를 복원한다.
- secret scan을 source/scripts/tests/config/schema/public/dist/generated release
metadata로 확장하고 원문 대신 rule/path/line/fingerprint만 SARIF에 남긴다.
test-only allowlist도 owner/reason/expiry를 강제한다.
- `SOURCE_DATE_EPOCH` 기반 동일 build 2회 digest, 실제 frozen install mismatch,
transitive omission/integrity/baseline/self-review/license/vulnerability/
provider/SBOM/provenance/secret negative fixture를 blocking gate에 연결했다.
## 9. P1 exit gate
- 현실적인 form의 validation/dirty/pending/422/conflict가 작동한다.
+42 -13
View File
@@ -1,18 +1,47 @@
# Build and supply-chain gate
Merge and release controls:
## Local blocking controls
- frozen `pnpm-lock.yaml` installation; drift is blocking
- clean production build with hashed assets and build manifest
- machine-readable bundle sizes and checksums
- source plus built-asset credential-pattern scan
- direct dependency inventory and lockfile digest
- base/head dependency diff review record
- `pnpm install --frozen-lockfile` and a real manifest/lock mismatch fixture
- all direct and transitive lockfile rows with package SHA-512 integrity
- production/development, direct/transitive and platform-optional classification
- package-manifest license allow/deny policy
- approved inventory baseline digest and actual add/remove/change/upgrade diff
- independent review for new direct production dependencies
- CycloneDX 1.6 SBOM and inventory component/edge coherence
- source/lock/SBOM/dist-linked local provenance statement
- source, scripts, tests, tracked config/schema, public, built asset and generated
release metadata secret scan
- two-build `SOURCE_DATE_EPOCH` reproducibility check
Organization-specific vulnerability severity, denied-license list, SBOM format,
and scanner selection remain policy inputs. An approved suppression must record
reason, owner, expiry, affected package, and compensating control. Expired
suppressions are blocking.
The canonical commands are:
`artifacts/security/dependency-diff.json` is a local baseline. CI replaces it
with the actual base/head direct and transitive lockfile diff before release.
```bash
corepack pnpm verify:lockfile
corepack pnpm verify:reproducible-build
corepack pnpm build:release
corepack pnpm verify:supply-chain
corepack pnpm check:supply-chain:fixtures
```
`config/security/dependency-baseline.json` is the approved local baseline.
Changing it requires `DEPENDENCY_BASELINE_OWNER` and
`DEPENDENCY_BASELINE_REASON`; editing the digest or hardcoding an empty diff is
rejected.
## External promotion controls
The vulnerability adapter reads the file named by
`VULNERABILITY_REPORT_PATH`. It requires a provider, the exact lockfile digest,
severity findings and valid independent, unexpired exception evidence.
`PROVENANCE_ATTESTATION_PATH` must name a provider, signer and the exact built
dist subject digest.
If either provider input is absent, local verification remains meaningful but
`artifacts/security/supply-chain-verification.json` records
`promotionStatus: FAIL_UNVERIFIED`. `verify:supply-chain:promotion` then exits
non-zero. 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
exceptions are blocking.
+8
View File
@@ -58,8 +58,16 @@
"check:coverage:fixture": "node scripts/check-risk-coverage.mjs --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature",
"verify:lockfile": "corepack pnpm install --frozen-lockfile",
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.mjs",
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
"verify:supply-chain": "node scripts/verify-supply-chain-artifacts.mjs",
"update:dependency-baseline": "node scripts/update-dependency-baseline.mjs",
"check:supply-chain:fixtures": "node scripts/check-supply-chain-fixtures.mjs",
"check:supply-chain:provider-fixtures": "node scripts/check-supply-chain-provider-fixtures.mjs",
"verify:supply-chain:promotion": "node scripts/verify-supply-chain-promotion.mjs",
"verify:reproducible-build": "node scripts/verify-reproducible-build.mjs",
"scan:security": "node scripts/security-scan.mjs",
"scan:security:fixture": "node scripts/security-scan.mjs --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif",
"check:browser-security": "node scripts/check-browser-security.mjs",
"check:registries": "node scripts/check-registries.mjs",
"check:registries:structure": "node scripts/check-registries.mjs --no-baseline",
@@ -0,0 +1,60 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://clean-architecture-frontend.local/schemas/dependency-inventory.schema.json",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"packageManager",
"lockfileSha256",
"dependencyCount",
"directDependencyCount",
"dependencies"
],
"properties": {
"schemaVersion": { "const": 2 },
"packageManager": { "type": "string", "minLength": 1 },
"lockfileSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"dependencyCount": { "type": "integer", "minimum": 1 },
"directDependencyCount": { "type": "integer", "minimum": 1 },
"dependencies": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"version",
"direct",
"scope",
"optional",
"license",
"integrity",
"dependencies"
],
"properties": {
"name": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"direct": { "type": "boolean" },
"scope": {
"enum": ["production", "development"]
},
"optional": { "type": "boolean" },
"license": { "type": "string", "minLength": 1 },
"integrity": {
"type": "string",
"pattern": "^sha512-"
},
"dependencies": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}
}
}
}
@@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://clean-architecture-frontend.local/schemas/supply-chain-verification.schema.json",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"localStatus",
"promotionStatus",
"lockfileSha256",
"sourceSetSha256",
"distSha256",
"sbomSha256",
"dependencyDiff",
"highRiskReview",
"vulnerabilityStatus",
"provenanceAttestationStatus",
"failures"
],
"properties": {
"schemaVersion": { "const": 1 },
"localStatus": { "enum": ["PASS", "FAIL"] },
"promotionStatus": {
"enum": ["PASS", "FAIL_UNVERIFIED"]
},
"lockfileSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sourceSetSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"distSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sbomSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"dependencyDiff": { "type": "object" },
"highRiskReview": { "type": "array" },
"vulnerabilityStatus": {
"enum": ["PASS", "FAIL", "FAIL_UNVERIFIED"]
},
"provenanceAttestationStatus": {
"enum": ["PASS", "FAIL_UNVERIFIED"]
},
"failures": {
"type": "array",
"items": { "type": "string" }
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import { spawnSync } from "node:child_process";
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const fixtureRoot = await mkdtemp(
path.join(tmpdir(), "ca-frontend-frozen-lockfile-"),
);
try {
await cp("pnpm-lock.yaml", path.join(fixtureRoot, "pnpm-lock.yaml"));
const manifest = JSON.parse(await readFile("package.json", "utf8"));
manifest.dependencies.react = "0.0.0-invalid-fixture";
await writeFile(
path.join(fixtureRoot, "package.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
const result = spawnSync(
"corepack",
[
"pnpm",
"install",
"--frozen-lockfile",
"--lockfile-only",
"--ignore-scripts",
],
{
cwd: fixtureRoot,
encoding: "utf8",
},
);
if (result.status === 0) {
process.stderr.write("Tampered manifest unexpectedly passed frozen install.\n");
process.exitCode = 1;
} else {
process.stdout.write("Frozen lockfile mismatch fixture: rejected PASS\n");
}
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
+148
View File
@@ -0,0 +1,148 @@
import { mkdir, writeFile } from "node:fs/promises";
import {
diffDependencyInventories,
isValidSha512Integrity,
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
validateVulnerabilityReport,
verifySupplyChainCoherence,
} from "./lib/supply-chain.mjs";
const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
const baseDependency = {
name: "base",
version: "1.0.0",
direct: false,
scope: "production",
optional: false,
license: "MIT",
integrity,
dependencies: [],
};
const directDependency = {
...baseDependency,
name: "new-direct",
direct: true,
};
const before = { dependencies: [baseDependency] };
const after = { dependencies: [baseDependency, directDependency] };
const diff = diffDependencyInventories(before, after);
const selfReview = validateDependencyReview(diff, after, {
changes: [
{
changeId: "add:new-direct@1.0.0",
owner: "same-person",
reviewer: "same-person",
reason: "fixture",
rollback: "remove",
},
],
});
const deniedLicense = validateLicensePolicy(
{
dependencies: [{ ...baseDependency, license: "AGPL-3.0" }],
},
{
allowedLicenses: ["MIT"],
deniedLicensePatterns: ["AGPL"],
},
);
const vulnerable = validateVulnerabilityReport(
{
provider: "fixture",
scannedLockfileSha256: "lock",
findings: [
{
id: "CVE-FIXTURE",
packageName: "base",
version: "1.0.0",
severity: "critical",
},
],
},
{ blockAtSeverity: "high" },
{
exceptions: [
{
vulnerabilityId: "CVE-FIXTURE",
packageName: "base",
owner: "owner",
reviewer: "reviewer",
reason: "expired fixture",
expiresAt: "2000-01-01T00:00:00.000Z",
},
],
},
"lock",
new Date("2026-07-26T00:00:00.000Z"),
);
const mismatchedCoherence = verifySupplyChainCoherence(
{
components: [],
metadata: {
properties: [{ name: "ca:lockfileSha256", value: "wrong" }],
},
},
{ dependencies: [baseDependency], lockfileSha256: "lock" },
{
subject: [{ digest: { sha256: "wrong" } }],
predicate: { materials: { lockfileSha256: "wrong" } },
},
"dist",
);
const orderingStable =
supplyChainDigest({ dependencies: [baseDependency, directDependency] }) ===
supplyChainDigest({ dependencies: [directDependency, baseDependency] });
const approvedDigest = supplyChainDigest(before);
const tamperedBaselineRejected =
approvedDigest !==
supplyChainDigest({
dependencies: [{ ...baseDependency, version: "9.9.9-tampered" }],
});
const providerFailure = validateVulnerabilityReport(
{
provider: "",
scannedLockfileSha256: "wrong",
findings: [],
},
{ blockAtSeverity: "high" },
{ exceptions: [] },
"lock",
);
const results = [
{
id: "transitive-removal-is-real-diff",
passed:
diffDependencyInventories(after, before).removed[0] ===
"new-direct@1.0.0",
},
{
id: "tampered-integrity-rejected",
passed: !isValidSha512Integrity("sha512-dGFtcGVyZWQ="),
},
{ id: "high-risk-self-approval-rejected", passed: !selfReview.passed },
{ id: "denied-license-rejected", passed: !deniedLicense.passed },
{
id: "critical-vulnerability-expired-exception-rejected",
passed: !vulnerable.passed,
},
{ id: "sbom-provenance-mismatch-rejected", passed: !mismatchedCoherence.passed },
{ id: "dependency-ordering-deterministic", passed: orderingStable },
{ id: "baseline-digest-tamper-rejected", passed: tamperedBaselineRejected },
{
id: "vulnerability-provider-evidence-invalid",
passed: !providerFailure.passed,
},
];
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-fixtures.json",
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
);
if (results.some((result) => !result.passed)) {
process.stderr.write("Supply-chain negative fixture failed.\n");
process.exit(1);
}
process.stdout.write(`Supply-chain fixtures: ${results.length} PASS\n`);
@@ -0,0 +1,105 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
const fixtureDirectory = path.resolve(".tmp/supply-chain-provider-fixture");
await rm(fixtureDirectory, { recursive: true, force: true });
await mkdir(fixtureDirectory, { recursive: true });
const inventory = JSON.parse(
await readFile("artifacts/release/dependency-inventory.json", "utf8"),
);
const verification = JSON.parse(
await readFile(
"artifacts/security/supply-chain-verification.json",
"utf8",
),
);
const vulnerabilityPath = path.join(
fixtureDirectory,
"vulnerability-report.json",
);
const attestationPath = path.join(fixtureDirectory, "attestation.json");
await writeFile(
vulnerabilityPath,
`${JSON.stringify(
{
schemaVersion: 1,
provider: "fixture-scanner",
scannedLockfileSha256: inventory.lockfileSha256,
generatedAt: "2026-07-26T00:00:00.000Z",
findings: [],
},
null,
2,
)}\n`,
);
await writeFile(
attestationPath,
`${JSON.stringify(
{
schemaVersion: 1,
provider: "fixture-attestor",
signer: "fixture-workload-identity",
subject: {
name: "dist",
digest: { sha256: verification.distSha256 },
},
},
null,
2,
)}\n`,
);
const providerRun = spawnSync(
"node",
["scripts/generate-supply-chain.mjs"],
{
env: {
...process.env,
VULNERABILITY_REPORT_PATH: vulnerabilityPath,
PROVENANCE_ATTESTATION_PATH: attestationPath,
},
encoding: "utf8",
},
);
let promotionStatus = "MISSING";
if (providerRun.status === 0) {
promotionStatus = JSON.parse(
await readFile(
"artifacts/security/supply-chain-verification.json",
"utf8",
),
).promotionStatus;
}
const restore = spawnSync(
"node",
["scripts/generate-supply-chain.mjs"],
{ encoding: "utf8" },
);
await rm(fixtureDirectory, { recursive: true, force: true });
const passed =
providerRun.status === 0 &&
promotionStatus === "PASS" &&
restore.status === 0;
await writeFile(
"artifacts/security/supply-chain-provider-fixtures.json",
`${JSON.stringify(
{
schemaVersion: 1,
providerAccepted: providerRun.status === 0,
promotionStatus,
unverifiedDefaultRestored: restore.status === 0,
status: passed ? "PASS" : "FAIL",
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
`Supply-chain provider fixture failed: ${providerRun.stderr || restore.stderr}\n`,
);
process.exit(1);
}
process.stdout.write(
"Supply-chain provider fixture: verified PASS and unconfigured default restored\n",
);
+7 -1
View File
@@ -15,7 +15,13 @@ const buildId = process.env.VITE_BUILD_ID ?? "local-build";
const commitSha = process.env.VITE_COMMIT_SHA ?? "local";
const releaseId = process.env.RELEASE_ID ?? "local-release";
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
const builtAt = new Date().toISOString();
const buildTime = process.env.SOURCE_DATE_EPOCH
? new Date(Number(process.env.SOURCE_DATE_EPOCH) * 1_000)
: new Date();
if (!Number.isFinite(buildTime.getTime())) {
throw new Error("SOURCE_DATE_EPOCH must be epoch seconds");
}
const builtAt = buildTime.toISOString();
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
const viteManifestObject =
/** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ (
+425 -36
View File
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { gzipSync } from "node:zlib";
import {
@@ -9,47 +10,404 @@ import {
} from "node:fs/promises";
import path from "node:path";
import {
diffDependencyInventories,
flattenPnpmDependencyTree,
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
validateVulnerabilityReport,
verifySupplyChainCoherence,
} from "./lib/supply-chain.mjs";
/** @param {string} directory @returns {Promise<string[]>} */
async function filesWithin(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
));
return nested.flat().sort();
try {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
));
return nested.flat().sort();
} catch {
return [];
}
}
/** @param {string} file */
async function sha256File(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
/** @param {string[]} files */
async function digestFileSet(files) {
const rows = await Promise.all(
files.sort().map(async (file) => ({
path: file.replaceAll("\\", "/"),
sha256: await sha256File(file),
})),
);
return supplyChainDigest(rows);
}
/** @param {string} file @returns {Promise<Record<string, unknown> | null>} */
async function optionalJson(file) {
try {
return JSON.parse(await readFile(file, "utf8"));
} catch {
return null;
}
}
export async function buildDependencyInventory() {
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
const lockfileSha256 = createHash("sha256")
.update(lockfileText)
.digest("hex");
const listed = spawnSync(
"corepack",
["pnpm", "list", "--json", "--depth", "Infinity"],
{
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
},
);
if (listed.status !== 0) {
throw new Error(`pnpm dependency graph failed: ${listed.stderr}`);
}
const roots = JSON.parse(listed.stdout);
const root = roots[0];
const flattened = await flattenPnpmDependencyTree(
root,
packageJson.dependencies ?? {},
packageJson.devDependencies ?? {},
);
const lockRows = parsePnpmLockfilePackages(lockfileText);
const lockByIdentity = new Map(
lockRows.map((row) => [`${row.name}@${row.version}`, row]),
);
const failures = [];
const dependencies = flattened.map((dependency) => {
const identity = `${dependency.name}@${dependency.version}`;
const lockRow = lockByIdentity.get(identity);
if (!lockRow) failures.push(`dependency missing from lockfile: ${identity}`);
if (lockRow && !isValidSha512Integrity(lockRow.integrity)) {
failures.push(`dependency has invalid sha512 integrity: ${identity}`);
}
return {
...dependency,
integrity: lockRow?.integrity ?? "missing",
};
});
const inventoryIds = new Set(
dependencies.map((dependency) => `${dependency.name}@${dependency.version}`),
);
for (const lockRow of lockRows) {
const identity = `${lockRow.name}@${lockRow.version}`;
if (!inventoryIds.has(identity)) {
failures.push(`transitive lockfile dependency omitted: ${identity}`);
}
}
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
return {
schemaVersion: 2,
packageManager: packageJson.packageManager,
lockfileSha256,
dependencyCount: dependencies.length,
directDependencyCount: dependencies.filter((entry) => entry.direct).length,
dependencies,
};
}
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const lockfile = await readFile("pnpm-lock.yaml");
const outputFiles = await filesWithin("dist");
if (outputFiles.length === 0) {
throw new Error("dist is missing; run the production build first");
}
const outputs = await Promise.all(
outputFiles.map(async (outputFile) => {
const content = await readFile(outputFile);
const metadata = await stat(outputFile);
return {
path: outputFile,
path: outputFile.replaceAll("\\", "/"),
bytes: metadata.size,
gzipBytes: gzipSync(content).byteLength,
sha256: createHash("sha256").update(content).digest("hex"),
};
}),
);
const distDigest = supplyChainDigest(
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
path: outputPath,
bytes,
sha256,
})),
);
const inventory = await buildDependencyInventory();
const licensePolicy = JSON.parse(
await readFile("config/security/dependency-policy.json", "utf8"),
);
const licenseResult = validateLicensePolicy(inventory, licensePolicy);
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
const baseline = await optionalJson(
"config/security/dependency-baseline.json",
);
const baselineApproval = await optionalJson(
"config/security/dependency-baseline.approval.json",
);
const dependencyEvidence = JSON.parse(
await readFile(
"config/security/dependency-change-evidence.json",
"utf8",
),
);
const skipsBaseline = process.argv.includes("--no-baseline");
const baselineFailures = [];
let dependencyDiff =
/** @type {ReturnType<typeof diffDependencyInventories>} */ ({
added: [],
removed: [],
changed: [],
upgrades: [],
});
let reviewResult =
/** @type {ReturnType<typeof validateDependencyReview>} */ ({
passed: skipsBaseline,
highRisk: [],
failures: skipsBaseline ? [] : ["dependency baseline unavailable"],
});
if (baseline && baselineApproval) {
const actualBaselineDigest = supplyChainDigest(baseline);
if (
baselineApproval.schemaVersion !== 1 ||
baselineApproval.snapshotDigest !== actualBaselineDigest ||
typeof baselineApproval.owner !== "string" ||
!baselineApproval.owner
) {
baselineFailures.push("dependency baseline approval digest mismatch");
}
dependencyDiff = diffDependencyInventories(baseline, inventory);
reviewResult = validateDependencyReview(
dependencyDiff,
inventory,
dependencyEvidence,
);
} else if (!skipsBaseline) {
baselineFailures.push("dependency baseline and approval are required");
}
const vulnerabilityPolicy = JSON.parse(
await readFile("config/security/vulnerability-policy.json", "utf8"),
);
const vulnerabilityExceptions = JSON.parse(
await readFile("config/security/vulnerability-exceptions.json", "utf8"),
);
const vulnerabilityInput = process.env.VULNERABILITY_REPORT_PATH
? await optionalJson(process.env.VULNERABILITY_REPORT_PATH)
: null;
const vulnerabilityResult = vulnerabilityInput
? validateVulnerabilityReport(
vulnerabilityInput,
vulnerabilityPolicy,
vulnerabilityExceptions,
inventory.lockfileSha256,
)
: {
passed: false,
failures: ["external vulnerability provider report is missing"],
blocking: [],
};
const vulnerabilityReport = {
schemaVersion: 1,
provider: vulnerabilityInput?.provider ?? "UNCONFIGURED",
scannedLockfileSha256:
vulnerabilityInput?.scannedLockfileSha256 ?? inventory.lockfileSha256,
status: vulnerabilityInput
? vulnerabilityResult.passed
? "PASS"
: "FAIL"
: "FAIL_UNVERIFIED",
findings: vulnerabilityInput?.findings ?? [],
exceptionsApplied:
vulnerabilityInput && vulnerabilityResult.passed
? vulnerabilityExceptions.exceptions
: [],
failures: vulnerabilityResult.failures,
blocking: vulnerabilityResult.blocking,
};
const sourceFiles = (
await Promise.all(
[
"src",
"scripts",
"config",
"public",
"schemas",
"package.json",
"pnpm-lock.yaml",
"vite.config.js",
].map(async (target) => {
try {
const metadata = await stat(target);
return metadata.isDirectory() ? filesWithin(target) : [target];
} catch {
return [];
}
}),
)
).flat();
const sourceSetSha256 = await digestFileSet(sourceFiles);
const components = inventory.dependencies.map((dependency) => ({
type: "library",
"bom-ref": `pkg:npm/${encodeURIComponent(dependency.name)}@${dependency.version}`,
name: dependency.name,
version: dependency.version,
scope: dependency.optional ? "optional" : "required",
hashes: [
{
alg: "SHA-512",
content: dependency.integrity.slice("sha512-".length),
},
],
licenses:
dependency.license === "NOASSERTION"
? [{ expression: "NOASSERTION" }]
: [{ expression: dependency.license }],
properties: [
{ name: "ca:direct", value: String(dependency.direct) },
{ name: "ca:scope", value: dependency.scope },
],
}));
const serialSeed = supplyChainDigest({
lockfileSha256: inventory.lockfileSha256,
components: components.map((component) => component["bom-ref"]),
});
const sbom = {
bomFormat: "CycloneDX",
specVersion: "1.6",
serialNumber: `urn:uuid:${serialSeed.slice(0, 8)}-${serialSeed.slice(8, 12)}-${serialSeed.slice(12, 16)}-${serialSeed.slice(16, 20)}-${serialSeed.slice(20, 32)}`,
version: 1,
metadata: {
component: {
type: "application",
name: packageJson.name,
version: packageJson.version,
},
properties: [
{
name: "ca:lockfileSha256",
value: inventory.lockfileSha256,
},
],
},
components,
dependencies: inventory.dependencies.map((dependency) => ({
ref: `pkg:npm/${encodeURIComponent(dependency.name)}@${dependency.version}`,
dependsOn: dependency.dependencies.map((identity) => {
const separator = identity.lastIndexOf("@");
return `pkg:npm/${encodeURIComponent(identity.slice(0, separator))}@${identity.slice(separator + 1)}`;
}),
})),
};
const provenance = {
_type: "https://in-toto.io/Statement/v1",
subject: [{ name: "dist", digest: { sha256: distDigest } }],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType: "https://vite.dev/build/v1",
externalParameters: {
nodeVersion: process.version,
packageManager: packageJson.packageManager,
},
internalParameters: {
sourceSetSha256,
},
resolvedDependencies: [
{
uri: "pnpm-lock.yaml",
digest: { sha256: inventory.lockfileSha256 },
},
],
},
runDetails: {
builder: { id: "local:clean-architecture-frontend-template" },
metadata: { invocationId: "LOCAL_UNSIGNED" },
},
materials: {
lockfileSha256: inventory.lockfileSha256,
sourceSetSha256,
sbomSha256: supplyChainDigest(sbom),
},
},
};
const coherence = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
);
const attestationInput = process.env.PROVENANCE_ATTESTATION_PATH
? await optionalJson(process.env.PROVENANCE_ATTESTATION_PATH)
: null;
const attestationSubject =
/** @type {Record<string, unknown>} */ (
/** @type {Record<string, unknown>} */ (
attestationInput?.subject ?? {}
).digest ?? {}
);
const attestationPassed =
attestationSubject.sha256 === distDigest &&
typeof attestationInput?.provider === "string" &&
Boolean(attestationInput.provider) &&
typeof attestationInput?.signer === "string" &&
Boolean(attestationInput.signer);
const localFailures = [
...licenseResult.failures,
...baselineFailures,
...reviewResult.failures,
...coherence.failures,
];
if (vulnerabilityInput && !vulnerabilityResult.passed) {
localFailures.push(
...vulnerabilityResult.failures,
...vulnerabilityResult.blocking,
);
}
const localPassed = localFailures.length === 0;
const promotionPassed =
localPassed && vulnerabilityResult.passed && attestationPassed;
const verification = {
schemaVersion: 1,
localStatus: localPassed ? "PASS" : "FAIL",
promotionStatus: promotionPassed ? "PASS" : "FAIL_UNVERIFIED",
lockfileSha256: inventory.lockfileSha256,
sourceSetSha256,
distSha256: distDigest,
sbomSha256: supplyChainDigest(sbom),
dependencyDiff,
highRiskReview: reviewResult.highRisk,
vulnerabilityStatus: vulnerabilityReport.status,
provenanceAttestationStatus: attestationPassed
? "PASS"
: "FAIL_UNVERIFIED",
failures: localFailures,
};
const inventory = Object.entries(dependencies)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, version]) => ({ name, version, direct: true }));
await mkdir("artifacts/performance", { recursive: true });
await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/performance/bundle.json",
`${JSON.stringify(
@@ -59,7 +417,8 @@ await writeFile(
context: {
nodeVersion: process.version,
packageManager: packageJson.packageManager,
runnerImage: process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
runnerImage:
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
},
outputs,
},
@@ -67,36 +426,66 @@ await writeFile(
2,
)}\n`,
);
await writeFile(
"artifacts/release/dependency-inventory.json",
`${JSON.stringify(
{
schemaVersion: 1,
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"),
dependencies: inventory,
},
null,
2,
)}\n`,
`${JSON.stringify(inventory, null, 2)}\n`,
);
await writeFile(
"artifacts/release/sbom.cdx.json",
`${JSON.stringify(sbom, null, 2)}\n`,
);
await writeFile(
"artifacts/release/provenance.json",
`${JSON.stringify(provenance, null, 2)}\n`,
);
await writeFile(
"artifacts/release/checksums.txt",
`${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`,
);
await writeFile(
"artifacts/security/dependency-diff.json",
`${JSON.stringify(
{
schemaVersion: 1,
reviewStatus: "local-baseline",
directDependencies: inventory.length,
highRiskUnreviewed: [],
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"),
schemaVersion: 2,
baselineDigest: baseline ? supplyChainDigest(baseline) : null,
currentDigest: supplyChainDigest(inventory),
...dependencyDiff,
highRisk: reviewResult.highRisk,
reviewFailures: reviewResult.failures,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/security/license-report.json",
`${JSON.stringify(
{
schemaVersion: 1,
status: licenseResult.passed ? "PASS" : "FAIL",
dependencyCount: inventory.dependencyCount,
results: licenseResult.results,
failures: licenseResult.failures,
},
null,
2,
)}\n`,
);
await writeFile(
"artifacts/security/vulnerability-report.json",
`${JSON.stringify(vulnerabilityReport, null, 2)}\n`,
);
await writeFile(
"artifacts/security/supply-chain-verification.json",
`${JSON.stringify(verification, null, 2)}\n`,
);
if (!localPassed) {
process.stderr.write(
`Local supply-chain verification failed:\n- ${localFailures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Supply chain: LOCAL PASS (${inventory.dependencyCount} dependencies); promotion=${verification.promotionStatus}\n`,
);
+547
View File
@@ -0,0 +1,547 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
/** @param {unknown} value @returns {unknown} */
export function canonicalizeSupplyChainValue(value) {
if (Array.isArray(value)) {
return value
.map(canonicalizeSupplyChainValue)
.sort((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right)),
);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]),
);
}
return value;
}
/** @param {unknown} value */
export function supplyChainDigest(value) {
return createHash("sha256")
.update(JSON.stringify(canonicalizeSupplyChainValue(value)))
.digest("hex");
}
/** @param {string} lockfile */
export function parsePnpmLockfilePackages(lockfile) {
const entries =
/** @type {Array<{name: string, version: string, integrity: string}>} */ (
[]
);
let inPackages = false;
/** @type {{name: string, version: string, integrity: string} | null} */
let current = null;
for (const line of lockfile.split(/\r?\n/)) {
if (line === "packages:") {
inPackages = true;
continue;
}
if (line === "snapshots:") {
if (current) entries.push(current);
break;
}
if (!inPackages) continue;
const packageMatch = line.match(/^ {2}(\S.*):$/);
if (packageMatch) {
if (current) entries.push(current);
const key = packageMatch[1].replace(/^['"]|['"]$/g, "");
const separator = key.lastIndexOf("@");
current = {
name: key.slice(0, separator),
version: key.slice(separator + 1),
integrity: "",
};
continue;
}
const integrityMatch = line.match(/\bintegrity:\s*([^,}\s]+)/);
if (current && integrityMatch) {
current.integrity = integrityMatch[1];
}
}
return entries.sort((left, right) =>
`${left.name}@${left.version}`.localeCompare(
`${right.name}@${right.version}`,
),
);
}
/** @param {string} integrity */
export function isValidSha512Integrity(integrity) {
if (!integrity.startsWith("sha512-")) return false;
try {
return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64;
} catch {
return false;
}
}
/**
* @param {unknown} raw
* @returns {string}
*/
export function normalizeLicense(raw) {
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (
raw &&
typeof raw === "object" &&
"type" in raw &&
typeof raw.type === "string"
) {
return raw.type;
}
if (Array.isArray(raw)) {
const licenses = raw.map(normalizeLicense).filter(
(license) => license !== "NOASSERTION",
);
return licenses.length > 0 ? licenses.join(" OR ") : "NOASSERTION";
}
return "NOASSERTION";
}
/**
* @param {Record<string, unknown>} root
* @param {Readonly<Record<string, string>>} directProduction
* @param {Readonly<Record<string, string>>} directDevelopment
*/
export async function flattenPnpmDependencyTree(
root,
directProduction,
directDevelopment,
) {
const records =
/** @type {Map<string, {
* name: string,
* version: string,
* direct: boolean,
* scope: "production" | "development",
* optional: boolean,
* packagePath: string,
* dependencies: Set<string>
* }>} */ (new Map());
const directIds = new Set();
for (const [name, rawDependency] of Object.entries(
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
)) {
if (
Object.hasOwn(directProduction, name) &&
rawDependency &&
typeof rawDependency === "object" &&
!Array.isArray(rawDependency)
) {
directIds.add(
`${name}@${String(
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
)}`,
);
}
}
for (const [name, rawDependency] of Object.entries(
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
)) {
if (
Object.hasOwn(directDevelopment, name) &&
rawDependency &&
typeof rawDependency === "object" &&
!Array.isArray(rawDependency)
) {
directIds.add(
`${name}@${String(
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
)}`,
);
}
}
/**
* @param {Record<string, unknown>} node
* @param {"production" | "development"} scope
* @param {boolean} optionalPath
*/
function visit(node, scope, optionalPath) {
for (const [groupName, group] of Object.entries({
dependencies: node.dependencies,
devDependencies: node.devDependencies,
optionalDependencies: node.optionalDependencies,
})) {
if (!group || typeof group !== "object" || Array.isArray(group)) continue;
for (const [name, rawDependency] of Object.entries(group)) {
if (
!rawDependency ||
typeof rawDependency !== "object" ||
Array.isArray(rawDependency)
) {
continue;
}
const dependency =
/** @type {Record<string, unknown>} */ (rawDependency);
const version = String(dependency.version ?? "");
const packagePath = String(dependency.path ?? "");
const identity = `${name}@${version}`;
const childScope =
scope === "production" && groupName !== "devDependencies"
? "production"
: "development";
const childOptional =
optionalPath || groupName === "optionalDependencies";
const previous = records.get(identity);
const dependencies = previous?.dependencies ?? new Set();
for (const childGroup of [
dependency.dependencies,
dependency.optionalDependencies,
]) {
if (
!childGroup ||
typeof childGroup !== "object" ||
Array.isArray(childGroup)
) {
continue;
}
for (const [childName, rawChild] of Object.entries(childGroup)) {
if (
rawChild &&
typeof rawChild === "object" &&
!Array.isArray(rawChild)
) {
dependencies.add(
`${childName}@${String(rawChild.version ?? "")}`,
);
}
}
}
records.set(identity, {
name,
version,
direct: directIds.has(identity),
scope:
previous?.scope === "production" || childScope === "production"
? "production"
: "development",
optional: previous ? previous.optional && childOptional : childOptional,
packagePath: previous?.packagePath || packagePath,
dependencies,
});
visit(dependency, childScope, childOptional);
}
}
}
const productionRoot = {
dependencies: Object.fromEntries(
Object.entries(
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
).filter(([name]) => Object.hasOwn(directProduction, name)),
),
};
const developmentRoot = {
devDependencies: Object.fromEntries(
Object.entries(
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
).filter(([name]) => Object.hasOwn(directDevelopment, name)),
),
};
visit(productionRoot, "production", false);
visit(developmentRoot, "development", false);
const result = [];
for (const record of records.values()) {
let license = "NOASSERTION";
let optional = record.optional;
if (record.packagePath) {
try {
const manifest = JSON.parse(
await readFile(`${record.packagePath}/package.json`, "utf8"),
);
license = normalizeLicense(manifest.license ?? manifest.licenses);
} catch {
// Platform-specific optional packages may not be materialized locally.
optional = true;
}
}
result.push({
name: record.name,
version: record.version,
direct: record.direct,
scope: record.scope,
optional,
license,
dependencies: [...record.dependencies].sort(),
});
}
return result.sort((left, right) =>
`${left.name}@${left.version}`.localeCompare(
`${right.name}@${right.version}`,
),
);
}
/**
* @param {Readonly<Record<string, unknown>>} before
* @param {Readonly<Record<string, unknown>>} after
*/
export function diffDependencyInventories(before, after) {
const beforeRows =
/** @type {Array<Record<string, unknown>>} */ (before.dependencies ?? []);
const afterRows =
/** @type {Array<Record<string, unknown>>} */ (after.dependencies ?? []);
const beforeMap = new Map(
beforeRows.map((row) => [`${row.name}@${row.version}`, row]),
);
const afterMap = new Map(
afterRows.map((row) => [`${row.name}@${row.version}`, row]),
);
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key));
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key));
const changed = [];
for (const key of [...beforeMap.keys()].filter((item) => afterMap.has(item))) {
if (
supplyChainDigest(beforeMap.get(key)) !==
supplyChainDigest(afterMap.get(key))
) {
changed.push(key);
}
}
const upgrades = [];
for (const removedKey of removed) {
const previous = beforeMap.get(removedKey);
const replacement = added.find(
(addedKey) => afterMap.get(addedKey)?.name === previous?.name,
);
if (replacement) {
upgrades.push({
name: previous?.name,
from: previous?.version,
to: afterMap.get(replacement)?.version,
});
}
}
return Object.freeze({
added: Object.freeze(added.sort()),
removed: Object.freeze(removed.sort()),
changed: Object.freeze(changed.sort()),
upgrades: Object.freeze(
upgrades.sort((left, right) =>
String(left.name).localeCompare(String(right.name)),
),
),
});
}
/**
* @param {Readonly<Record<string, unknown>>} inventory
* @param {Readonly<Record<string, unknown>>} policy
*/
export function validateLicensePolicy(inventory, policy) {
const allowed = new Set(
/** @type {string[]} */ (policy.allowedLicenses ?? []),
);
const denied = /** @type {string[]} */ (policy.deniedLicensePatterns ?? []);
const failures = [];
const results = [];
for (const dependency of /** @type {Array<Record<string, unknown>>} */ (
inventory.dependencies ?? []
)) {
const license = String(dependency.license ?? "NOASSERTION");
const explicitlyDenied = denied.some((pattern) =>
new RegExp(pattern, "i").test(license),
);
const unknownAccepted =
license === "NOASSERTION" && dependency.optional === true;
const passed =
!explicitlyDenied && (allowed.has(license) || unknownAccepted);
results.push({
package: `${dependency.name}@${dependency.version}`,
license,
passed,
reason: unknownAccepted ? "platform-optional-not-materialized" : null,
});
if (!passed) {
failures.push(
`${dependency.name}@${dependency.version} has disallowed license ${license}`,
);
}
}
return Object.freeze({
passed: failures.length === 0,
failures: Object.freeze(failures),
results: Object.freeze(results),
});
}
/**
* @param {ReturnType<typeof diffDependencyInventories>} diff
* @param {Readonly<Record<string, unknown>>} inventory
* @param {Readonly<Record<string, unknown>>} evidenceFile
*/
export function validateDependencyReview(diff, inventory, evidenceFile) {
const rows =
/** @type {Array<Record<string, unknown>>} */ (inventory.dependencies ?? []);
const byIdentity = new Map(
rows.map((row) => [`${row.name}@${row.version}`, row]),
);
const evidence = new Map(
/** @type {Array<Record<string, unknown>>} */ (
evidenceFile.changes ?? []
).map((entry) => [entry.changeId, entry]),
);
const highRisk = diff.added.filter((identity) => {
const row = byIdentity.get(identity);
return row?.direct === true && row.scope === "production";
});
const failures = [];
for (const identity of highRisk) {
const changeId = `add:${identity}`;
const entry = evidence.get(changeId);
if (!entry) {
failures.push(`high-risk dependency missing review: ${changeId}`);
continue;
}
for (const field of ["owner", "reviewer", "reason", "rollback"]) {
if (typeof entry[field] !== "string" || !entry[field].trim()) {
failures.push(`${changeId} missing ${field}`);
}
}
if (entry.owner === entry.reviewer) {
failures.push(`${changeId} may not be self-approved`);
}
}
return Object.freeze({
passed: failures.length === 0,
highRisk: Object.freeze(highRisk),
failures: Object.freeze(failures),
});
}
const severityRank = new Map([
["unknown", 0],
["low", 1],
["moderate", 2],
["high", 3],
["critical", 4],
]);
/**
* @param {Readonly<Record<string, unknown>>} report
* @param {Readonly<Record<string, unknown>>} policy
* @param {Readonly<Record<string, unknown>>} exceptionFile
* @param {string} lockfileSha256
* @param {Date} [now]
*/
export function validateVulnerabilityReport(
report,
policy,
exceptionFile,
lockfileSha256,
now = new Date(),
) {
const failures = [];
if (report.scannedLockfileSha256 !== lockfileSha256) {
failures.push("vulnerability report lockfile digest mismatch");
}
if (typeof report.provider !== "string" || !report.provider.trim()) {
failures.push("vulnerability report provider missing");
}
const threshold = severityRank.get(String(policy.blockAtSeverity)) ?? 3;
const exceptions =
/** @type {Array<Record<string, unknown>>} */ (
exceptionFile.exceptions ?? []
);
const blocking = [];
for (const finding of /** @type {Array<Record<string, unknown>>} */ (
report.findings ?? []
)) {
const severity = String(finding.severity ?? "unknown").toLowerCase();
if ((severityRank.get(severity) ?? 0) < threshold) continue;
const exception = exceptions.find(
(entry) =>
entry.vulnerabilityId === finding.id &&
entry.packageName === finding.packageName,
);
const expiry =
typeof exception?.expiresAt === "string"
? Date.parse(exception.expiresAt)
: Number.NaN;
const validException =
exception &&
typeof exception.owner === "string" &&
exception.owner.trim() &&
typeof exception.reviewer === "string" &&
exception.reviewer.trim() &&
exception.owner !== exception.reviewer &&
typeof exception.reason === "string" &&
exception.reason.trim() &&
Number.isFinite(expiry) &&
expiry > now.getTime();
if (!validException) {
blocking.push(
`${finding.id}:${finding.packageName}@${finding.version}:${severity}`,
);
}
}
return Object.freeze({
passed: failures.length === 0 && blocking.length === 0,
failures: Object.freeze(failures),
blocking: Object.freeze(blocking),
});
}
/**
* @param {Readonly<Record<string, unknown>>} sbom
* @param {Readonly<Record<string, unknown>>} inventory
* @param {Readonly<Record<string, unknown>>} provenance
* @param {string} distDigest
*/
export function verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
) {
const failures = [];
const componentCount = Array.isArray(sbom.components)
? sbom.components.length
: -1;
const dependencyCount = Array.isArray(inventory.dependencies)
? inventory.dependencies.length
: -2;
if (componentCount !== dependencyCount) {
failures.push("SBOM component count does not match inventory");
}
const metadata =
/** @type {Record<string, unknown>} */ (sbom.metadata ?? {});
const properties =
/** @type {Array<{name?: string, value?: string}>} */ (
metadata.properties ?? []
);
if (properties.find(
/** @param {{name?: string, value?: string}} property */
(property) =>
property.name === "ca:lockfileSha256" &&
property.value === inventory.lockfileSha256,
) === undefined) {
failures.push("SBOM lockfile digest does not match inventory");
}
const subject =
/** @type {Array<Record<string, unknown>>} */ (provenance.subject ?? [])[0];
const subjectDigest =
/** @type {Record<string, unknown>} */ (subject?.digest ?? {});
if (subjectDigest.sha256 !== distDigest) {
failures.push("provenance subject does not match built dist digest");
}
const predicate =
/** @type {Record<string, unknown>} */ (provenance.predicate ?? {});
const materials =
/** @type {Record<string, unknown>} */ (predicate.materials ?? {});
if (materials.lockfileSha256 !== inventory.lockfileSha256) {
failures.push("provenance lockfile material does not match inventory");
}
return Object.freeze({
passed: failures.length === 0,
failures: Object.freeze(failures),
});
}
+149 -44
View File
@@ -1,10 +1,37 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
const scanRoots = ["src", "dist"];
const findings = /** @type {Array<{ruleId: string, file: string}>} */ ([]);
/** @param {string} name @param {string} fallback */
function argumentValue(name, fallback) {
const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1]
? process.argv[index + 1]
: fallback;
}
const policyPath = argumentValue(
"--policy",
"config/security/secret-scan-policy.json",
);
const artifactPath = argumentValue(
"--artifact",
"artifacts/security/scan.sarif",
);
const policy = JSON.parse(await readFile(policyPath, "utf8"));
const findings =
/** @type {Array<{
* ruleId: string,
* file: string,
* line: number,
* fingerprint: string
* }>} */ ([]);
const policyFailures = [];
const patterns = [
{ id: "private-key", expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g },
{
id: "private-key",
expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g,
},
{ id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
{
@@ -14,35 +41,101 @@ const patterns = [
},
];
/** @param {string} directory @returns {Promise<string[]>} */
async function filesWithin(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
));
return nested.flat();
/** @param {string} target @returns {Promise<string[]>} */
async function filesWithin(target) {
try {
const metadata = await stat(target);
if (metadata.isFile()) return [target];
const entries = await readdir(target, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const child = path.join(target, entry.name);
return entry.isDirectory() ? filesWithin(child) : [child];
}),
));
return nested.flat();
} catch {
return [];
}
}
for (const root of scanRoots) {
for (const scanFile of await filesWithin(root)) {
if (/\.(png|jpg|jpeg|gif|woff2?|zip)$/i.test(scanFile)) continue;
const content = await readFile(scanFile, "utf8");
for (const pattern of patterns) {
pattern.expression.lastIndex = 0;
if (pattern.expression.test(content)) {
findings.push({ ruleId: pattern.id, file: scanFile });
}
const excluded = new Set(
/** @type {string[]} */ (policy.excludedPaths ?? []).map((entry) =>
entry.replaceAll("\\", "/"),
),
);
const allowlist =
/** @type {Array<{
* path: string,
* ruleId: string,
* owner: string,
* reason: string,
* expiresAt: string
* }>} */ (policy.allowlist ?? []);
for (const entry of allowlist) {
const expiry = Date.parse(entry.expiresAt);
if (
!entry.path.startsWith("tests/") ||
!entry.owner?.trim() ||
!entry.reason?.trim() ||
!Number.isFinite(expiry) ||
expiry <= Date.now()
) {
policyFailures.push(
`invalid or expired secret allowlist entry: ${entry.path}:${entry.ruleId}`,
);
}
}
const roots = [
...(/** @type {string[]} */ (policy.trackedRoots ?? [])),
...(/** @type {string[]} */ (policy.generatedRoots ?? [])),
];
const scanFiles = (
await Promise.all(roots.map((root) => filesWithin(root)))
).flat();
for (const scanFile of [...new Set(scanFiles)].sort()) {
const normalized = scanFile.replaceAll("\\", "/");
if (
[...excluded].some(
(entry) => normalized === entry || normalized.startsWith(`${entry}/`),
) ||
/\.(?:png|jpe?g|gif|webp|woff2?|zip|gz|sarif)$/i.test(normalized)
) {
continue;
}
let content;
try {
content = await readFile(scanFile, "utf8");
} catch {
continue;
}
for (const pattern of patterns) {
pattern.expression.lastIndex = 0;
for (const match of content.matchAll(pattern.expression)) {
const isAllowed = allowlist.some(
(entry) =>
entry.path === normalized &&
entry.ruleId === pattern.id &&
Date.parse(entry.expiresAt) > Date.now(),
);
if (isAllowed) continue;
const prefix = content.slice(0, match.index);
findings.push({
ruleId: pattern.id,
file: normalized,
line: prefix.split(/\r?\n/).length,
fingerprint: createHash("sha256")
.update(`${pattern.id}:${normalized}:${String(match.index)}`)
.digest("hex"),
});
}
}
}
const sarif = {
version: "2.1.0",
$schema:
"https://json.schemastore.org/sarif-2.1.0.json",
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
runs: [
{
tool: {
@@ -54,29 +147,41 @@ const sarif = {
})),
},
},
results: findings.map((finding) => ({
ruleId: finding.ruleId,
message: { text: "Potential secret material must be removed." },
locations: [
{
physicalLocation: {
artifactLocation: { uri: finding.file },
},
results: [
...findings.map((finding) => ({
ruleId: finding.ruleId,
message: {
text: "Potential secret material must be removed.",
},
],
})),
partialFingerprints: {
primaryLocationLineHash: finding.fingerprint,
},
locations: [
{
physicalLocation: {
artifactLocation: { uri: finding.file },
region: { startLine: finding.line },
},
},
],
})),
...policyFailures.map((failure) => ({
ruleId: "invalid-allowlist",
message: { text: failure },
})),
],
},
],
};
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/scan.sarif",
`${JSON.stringify(sarif, null, 2)}\n`,
);
if (findings.length > 0) {
process.stderr.write(`Security scan found ${findings.length} blocking result(s).\n`);
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(sarif, null, 2)}\n`);
if (findings.length > 0 || policyFailures.length > 0) {
process.stderr.write(
`Security scan found ${findings.length + policyFailures.length} blocking result(s).\n`,
);
process.exit(1);
}
process.stdout.write("Source and built-asset secret scan: PASS\n");
process.stdout.write(
`Tracked source, config, built asset and artifact secret scan: PASS (${scanFiles.length} files)\n`,
);
+1
View File
@@ -18,6 +18,7 @@ const featureOwnedPaths = [
featureSource,
featureTests,
"tests/e2e/reference-form.spec.js",
"tests/e2e/reference-route.spec.js",
"tests/mocks",
];
const copyTargets = [
+47
View File
@@ -0,0 +1,47 @@
import { spawnSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { supplyChainDigest } from "./lib/supply-chain.mjs";
const owner = process.env.DEPENDENCY_BASELINE_OWNER;
const reason = process.env.DEPENDENCY_BASELINE_REASON;
if (!owner?.trim() || !reason?.trim()) {
process.stderr.write(
"DEPENDENCY_BASELINE_OWNER and DEPENDENCY_BASELINE_REASON are required.\n",
);
process.exit(2);
}
const commands = /** @type {Array<[string, string[]]>} */ ([
["corepack", ["pnpm", "build"]],
["node", ["scripts/generate-supply-chain.mjs", "--no-baseline"]],
]);
for (const [command, args] of commands) {
const result = spawnSync(command, args, { stdio: "inherit" });
if (result.status !== 0) process.exit(result.status ?? 1);
}
const inventory = JSON.parse(
await readFile("artifacts/release/dependency-inventory.json", "utf8"),
);
await writeFile(
"config/security/dependency-baseline.json",
`${JSON.stringify(inventory, null, 2)}\n`,
);
await writeFile(
"config/security/dependency-baseline.approval.json",
`${JSON.stringify(
{
schemaVersion: 1,
snapshotDigest: supplyChainDigest(inventory),
owner,
reason,
approvedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
process.stdout.write(
`Dependency baseline approved: ${inventory.dependencyCount} packages\n`,
);
+76
View File
@@ -0,0 +1,76 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { supplyChainDigest } from "./lib/supply-chain.mjs";
/** @param {string} directory @returns {Promise<string[]>} */
async function filesWithin(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
));
return nested.flat().sort();
}
async function distDigest() {
const rows = await Promise.all(
(await filesWithin("dist")).map(async (file) => ({
path: path.relative("dist", file).replaceAll("\\", "/"),
bytes: (await readFile(file)).byteLength,
content: supplyChainDigest(await readFile(file)),
})),
);
return supplyChainDigest(rows);
}
function build(environment = process.env) {
return spawnSync("corepack", ["pnpm", "build"], {
env: environment,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
}
const deterministicEnvironment = {
...process.env,
SOURCE_DATE_EPOCH: "946684800",
};
const firstBuild = build(deterministicEnvironment);
const firstDigest = firstBuild.status === 0 ? await distDigest() : "BUILD_FAILED";
const secondBuild = build(deterministicEnvironment);
const secondDigest =
secondBuild.status === 0 ? await distDigest() : "BUILD_FAILED";
const restoreBuild = build();
const passed =
firstBuild.status === 0 &&
secondBuild.status === 0 &&
restoreBuild.status === 0 &&
firstDigest === secondDigest;
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/reproducible-build.json",
`${JSON.stringify(
{
schemaVersion: 1,
sourceDateEpoch: deterministicEnvironment.SOURCE_DATE_EPOCH,
firstDigest,
secondDigest,
restored: restoreBuild.status === 0,
status: passed ? "PASS" : "FAIL",
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
`Reproducible build failed: first=${firstDigest} second=${secondDigest}\n`,
);
process.exit(1);
}
process.stdout.write(`Reproducible build: PASS (${firstDigest})\n`);
+121
View File
@@ -0,0 +1,121 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
verifySupplyChainCoherence,
} from "./lib/supply-chain.mjs";
/** @param {string} directory @returns {Promise<string[]>} */
async function filesWithin(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesWithin(target) : [target];
}),
));
return nested.flat().sort();
}
const inventory = JSON.parse(
await readFile("artifacts/release/dependency-inventory.json", "utf8"),
);
const sbom = JSON.parse(
await readFile("artifacts/release/sbom.cdx.json", "utf8"),
);
const provenance = JSON.parse(
await readFile("artifacts/release/provenance.json", "utf8"),
);
const verification = JSON.parse(
await readFile(
"artifacts/security/supply-chain-verification.json",
"utf8",
),
);
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
const lockfileSha256 = createHash("sha256")
.update(lockfileText)
.digest("hex");
const outputs = await Promise.all(
(await filesWithin("dist")).map(async (file) => {
const content = await readFile(file);
return {
path: file.replaceAll("\\", "/"),
bytes: (await stat(file)).size,
sha256: createHash("sha256").update(content).digest("hex"),
};
}),
);
const distDigest = supplyChainDigest(outputs);
const coherence = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
);
const failures = [...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 =
/** @type {Array<Record<string, unknown>>} */ (
inventory.dependencies ?? []
);
const inventoryByIdentity = new Map(
inventoryRows.map((entry) => [
`${entry.name}@${entry.version}`,
entry,
]),
);
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 writeFile(
"artifacts/security/supply-chain-coherence.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(
`Supply-chain artifact coherence failed:\n- ${failures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Supply-chain artifact coherence: PASS (${inventoryRows.length} dependencies)\n`,
);
+30
View File
@@ -0,0 +1,30 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
const verification = JSON.parse(
await readFile(
"artifacts/security/supply-chain-verification.json",
"utf8",
),
);
const passed = verification.promotionStatus === "PASS";
const report = {
schemaVersion: 1,
status: passed ? "PASS" : "FAIL_UNVERIFIED",
vulnerabilityStatus: verification.vulnerabilityStatus,
provenanceAttestationStatus:
verification.provenanceAttestationStatus,
lockfileSha256: verification.lockfileSha256,
distSha256: verification.distSha256,
};
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/promotion-verification.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(
"Supply-chain promotion is FAIL_UNVERIFIED: external vulnerability and signed provenance evidence are required.\n",
);
process.exit(1);
}
process.stdout.write("Supply-chain promotion evidence: PASS\n");
-35
View File
@@ -1,6 +1,4 @@
import { expect, test } from "../support/browser/strict-browser-test.js";
import { successEnvelope } from "../mocks/contracts/envelopes.js";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
test("boots the public app shell", async ({ page }) => {
await page.goto("/");
@@ -26,39 +24,6 @@ test("navigates to a registry-backed example without a page reload", async ({
).toBeFocused();
});
test("opens the protected integration route through the local demo seam", async ({
page,
}) => {
const protectedRoute = Object.values(ROUTE_REGISTRY).find(
(definition) => definition.access === "integration-defined",
);
if (!protectedRoute) throw new Error("An integration route is required");
await page.route(
"http://localhost:8080/api/reference-resources?*",
(route) =>
route.fulfill({
json: successEnvelope([
{
id: "browser-reference",
name: "Browser reference",
createdAt: "2026-07-26T00:00:00.000Z",
},
]),
}),
);
await page.goto(protectedRoute.path);
await expect(
page.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
await page.getByRole("button", { name: "로그인 시작" }).click();
await expect(
page.getByRole("heading", { name: protectedRoute.title }),
).toBeVisible();
await expect(page.getByText("인증됨")).toBeVisible();
});
test("provides an escape-dismissible mobile navigation", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from "../support/browser/strict-browser-test.js";
import { successEnvelope } from "../mocks/contracts/envelopes.js";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
test("opens the protected integration route through the local demo seam", async ({
page,
}) => {
const protectedRoute = Object.values(ROUTE_REGISTRY).find(
(definition) => definition.access === "integration-defined",
);
if (!protectedRoute) throw new Error("An integration route is required");
await page.route(
"http://localhost:8080/api/reference-resources?*",
(route) =>
route.fulfill({
json: successEnvelope([
{
id: "browser-reference",
name: "Browser reference",
createdAt: "2026-07-26T00:00:00.000Z",
},
]),
}),
);
await page.goto(protectedRoute.path);
await expect(
page.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
await page.getByRole("button", { name: "로그인 시작" }).click();
await expect(
page.getByRole("heading", { name: protectedRoute.title }),
).toBeVisible();
await expect(page.getByText("인증됨")).toBeVisible();
});
@@ -0,0 +1 @@
export const client_secret = "fixture-only-secret-value";
@@ -0,0 +1,9 @@
{
"schemaVersion": 1,
"trackedRoots": [
"tests/fixtures/security/secret-detection/forbidden"
],
"generatedRoots": [],
"excludedPaths": [],
"allowlist": []
}
@@ -0,0 +1,3 @@
{
"client_secret": "synthetic-forbidden-secret"
}
@@ -0,0 +1 @@
globalThis.password = "synthetic-built-secret";
@@ -0,0 +1 @@
export const leaked = "AKIAABCDEFGHIJKLMNOP";
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
diffDependencyInventories,
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
} from "../../scripts/lib/supply-chain.mjs";
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
const dependency = {
name: "fixture",
version: "1.0.0",
direct: true,
scope: "production",
optional: false,
license: "MIT",
integrity,
dependencies: [],
};
describe("supply-chain policy", () => {
it("parses every top-level lockfile package and validates SRI", () => {
const parsed = parsePnpmLockfilePackages(`
packages:
'@scope/one@1.0.0':
resolution: {integrity: ${integrity}}
two@2.0.0:
resolution: {integrity: ${integrity}}
snapshots:
`);
expect(parsed).toEqual([
{ name: "@scope/one", version: "1.0.0", integrity },
{ name: "two", version: "2.0.0", integrity },
]);
expect(parsed.every((entry) => isValidSha512Integrity(entry.integrity))).toBe(
true,
);
});
it("keeps inventory digests stable when dependency ordering changes", () => {
const other = { ...dependency, name: "other" };
expect(supplyChainDigest([dependency, other])).toBe(
supplyChainDigest([other, dependency]),
);
});
it("calculates actual additions and requires independent high-risk review", () => {
const before = { dependencies: [] };
const after = { dependencies: [dependency] };
const diff = diffDependencyInventories(before, after);
expect(diff.added).toEqual(["fixture@1.0.0"]);
expect(
validateDependencyReview(diff, after, {
changes: [
{
changeId: "add:fixture@1.0.0",
owner: "one",
reviewer: "one",
reason: "fixture",
rollback: "remove",
},
],
}).passed,
).toBe(false);
});
it("allows explicit policy licenses and rejects denied licenses", () => {
expect(
validateLicensePolicy(
{ dependencies: [dependency] },
{ allowedLicenses: ["MIT"], deniedLicensePatterns: ["AGPL"] },
).passed,
).toBe(true);
expect(
validateLicensePolicy(
{
dependencies: [{ ...dependency, license: "AGPL-3.0" }],
},
{ allowedLicenses: ["MIT"], deniedLicensePatterns: ["AGPL"] },
).passed,
).toBe(false);
});
});