refactor: adapter 구현중..

This commit is contained in:
DongHyeonka
2026-08-13 16:02:21 +09:00
parent 30ceac23c1
commit 4dc033cf33
72 changed files with 13370 additions and 1549 deletions
+32 -1
View File
@@ -161,6 +161,35 @@ jobs:
artifacts/security/supply-chain-coherence.json \
artifacts/security/supply-chain-verification.json \
artifacts/security/vulnerability-report.json \
config/security/dependency-baseline.approval.json \
config/security/dependency-baseline.json \
config/security/dependency-change-evidence.json \
config/security/dependency-policy.json \
config/security/secret-scan-policy.json \
config/security/vulnerability-exceptions.json \
config/security/vulnerability-policy.json \
schemas/artifacts/build-manifest.schema.json \
schemas/artifacts/dependency-inventory.schema.json \
schemas/artifacts/supply-chain-verification.schema.json \
scripts/contracts/release-artifacts.ts \
scripts/create-release-candidate.ts \
scripts/generate-supply-chain.ts \
scripts/lib/build-manifest-outputs.ts \
scripts/lib/json-schema.ts \
scripts/lib/local-policy-evidence.ts \
scripts/lib/local-release-evidence.ts \
scripts/lib/release-candidate.ts \
scripts/lib/release-input-evidence.ts \
scripts/lib/release-runtime-coherence.ts \
scripts/lib/repository-file-inventory.ts \
scripts/lib/secret-scan-evaluator.ts \
scripts/lib/secret-scan-policy.ts \
scripts/lib/secret-scan.ts \
scripts/lib/supply-chain.ts \
scripts/lib/validated-json-artifact.ts \
src/contracts/release-artifacts.ts \
src/features/installed-contract-contributions.ts \
src/features/installed-feature-contracts.ts \
artifacts/release/release-candidate.json
node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT"
- name: Upload release candidate
@@ -327,8 +356,10 @@ jobs:
PROMOTION_CLEANUP_TOKEN: ${{ steps.finalize.outputs.cleanup_token }}
PROMOTION_RUNNER_TEMP_DEV: ${{ steps.finalize.outputs.runner_temp_dev }}
PROMOTION_RUNNER_TEMP_INO: ${{ steps.finalize.outputs.runner_temp_ino }}
PROMOTION_STAGING_DEV: ${{ steps.finalize.outputs.staging_dev }}
PROMOTION_STAGING_INO: ${{ steps.finalize.outputs.staging_ino }}
run: |
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then
node scripts/cleanup-verified-promotion.ts
fi
+141 -22
View File
@@ -517,10 +517,100 @@
"executableSchemaId": "field-web-vitals"
},
{
"id": "json-generic-json-object",
"id": "json-automated-a11y",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "generic-json-object"
"executableSchemaId": "automated-a11y"
},
{
"id": "json-manual-a11y",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "manual-a11y"
},
{
"id": "json-architecture-dependency-report",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "architecture-dependency-report"
},
{
"id": "json-design-system-contract",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "design-system-contract"
},
{
"id": "json-i18n-contract",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "i18n-contract"
},
{
"id": "json-diagnostics-contract",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "diagnostics-contract"
},
{
"id": "json-realtime-boundaries",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "realtime-boundaries"
},
{
"id": "json-optional-recipes",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "optional-recipes"
},
{
"id": "json-optional-recipe-fixtures",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "optional-recipe-fixtures"
},
{
"id": "json-registry-compatibility-fixtures",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "registry-compatibility-fixtures"
},
{
"id": "json-reproducible-build",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "reproducible-build"
},
{
"id": "json-supply-chain-fixtures",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "supply-chain-fixtures"
},
{
"id": "json-supply-chain-provider-fixtures",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "supply-chain-provider-fixtures"
},
{
"id": "json-compatibility-fixtures",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "compatibility-fixtures"
},
{
"id": "json-documentation-review",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "documentation-review"
},
{
"id": "json-hosting-headers",
"kind": "json",
"maxBytes": 67108864,
"executableSchemaId": "hosting-headers"
},
{
"id": "json-ci-contract-report",
@@ -909,7 +999,7 @@
{
"id": "artifact-artifacts-tests-a11y-json",
"path": "artifacts/tests/a11y.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-automated-a11y",
"production": "command-generated",
"producerCommandIds": [
"test-a11y"
@@ -972,7 +1062,7 @@
{
"id": "artifact-artifacts-tests-a11y-manual-report-json",
"path": "artifacts/tests/a11y-manual/report.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-manual-a11y",
"production": "command-generated",
"producerCommandIds": [
"review-a11y-manual"
@@ -987,7 +1077,7 @@
{
"id": "artifact-artifacts-quality-dependency-report-json",
"path": "artifacts/quality/dependency-report.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-architecture-dependency-report",
"production": "command-generated",
"producerCommandIds": [
"check-architecture"
@@ -996,7 +1086,7 @@
{
"id": "artifact-artifacts-quality-design-system-json",
"path": "artifacts/quality/design-system.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-design-system-contract",
"production": "command-generated",
"producerCommandIds": [
"check-design-system"
@@ -1005,7 +1095,7 @@
{
"id": "artifact-artifacts-quality-design-system-fixture-json",
"path": "artifacts/quality/design-system-fixture.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-design-system-contract",
"production": "command-generated",
"producerCommandIds": [
"check-design-system-fixture"
@@ -1014,7 +1104,7 @@
{
"id": "artifact-artifacts-quality-i18n-json",
"path": "artifacts/quality/i18n.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-i18n-contract",
"production": "command-generated",
"producerCommandIds": [
"check-i18n"
@@ -1023,7 +1113,7 @@
{
"id": "artifact-artifacts-quality-i18n-fixture-json",
"path": "artifacts/quality/i18n-fixture.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-i18n-contract",
"production": "command-generated",
"producerCommandIds": [
"check-i18n-fixture"
@@ -1032,7 +1122,7 @@
{
"id": "artifact-artifacts-quality-diagnostics-json",
"path": "artifacts/quality/diagnostics.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-diagnostics-contract",
"production": "command-generated",
"producerCommandIds": [
"check-diagnostics"
@@ -1041,7 +1131,7 @@
{
"id": "artifact-artifacts-quality-diagnostics-fixture-json",
"path": "artifacts/quality/diagnostics-fixture.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-diagnostics-contract",
"production": "command-generated",
"producerCommandIds": [
"check-diagnostics-fixture"
@@ -1050,7 +1140,7 @@
{
"id": "artifact-artifacts-quality-realtime-boundaries-json",
"path": "artifacts/quality/realtime-boundaries.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-realtime-boundaries",
"production": "command-generated",
"producerCommandIds": [
"check-realtime-boundaries"
@@ -1059,7 +1149,7 @@
{
"id": "artifact-artifacts-quality-optional-recipes-json",
"path": "artifacts/quality/optional-recipes.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-optional-recipes",
"production": "command-generated",
"producerCommandIds": [
"check-optional-recipes-source",
@@ -1069,7 +1159,7 @@
{
"id": "artifact-artifacts-quality-optional-recipe-fixtures-json",
"path": "artifacts/quality/optional-recipe-fixtures.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-optional-recipe-fixtures",
"production": "command-generated",
"producerCommandIds": [
"check-optional-recipe-fixtures"
@@ -1087,7 +1177,7 @@
{
"id": "artifact-artifacts-quality-registry-compatibility-fixtures-json",
"path": "artifacts/quality/registry-compatibility-fixtures.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-registry-compatibility-fixtures",
"production": "command-generated",
"producerCommandIds": [
"check-registries-compatibility-fixtures"
@@ -1213,7 +1303,7 @@
{
"id": "artifact-artifacts-release-reproducible-build-json",
"path": "artifacts/release/reproducible-build.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-reproducible-build",
"production": "command-generated",
"producerCommandIds": [
"verify-reproducible-build"
@@ -1267,7 +1357,7 @@
{
"id": "artifact-artifacts-security-supply-chain-fixtures-json",
"path": "artifacts/security/supply-chain-fixtures.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-supply-chain-fixtures",
"production": "command-generated",
"producerCommandIds": [
"check-supply-chain-fixtures"
@@ -1276,7 +1366,7 @@
{
"id": "artifact-artifacts-security-supply-chain-provider-fixtures-json",
"path": "artifacts/security/supply-chain-provider-fixtures.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-supply-chain-provider-fixtures",
"production": "command-generated",
"producerCommandIds": [
"check-supply-chain-provider-fixtures"
@@ -1291,7 +1381,7 @@
{
"id": "artifact-artifacts-release-compatibility-json",
"path": "artifacts/release/compatibility.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-compatibility-fixtures",
"production": "command-generated",
"producerCommandIds": [
"verify-compatibility"
@@ -1345,7 +1435,7 @@
{
"id": "artifact-artifacts-quality-documentation-review-json",
"path": "artifacts/quality/documentation-review.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-documentation-review",
"production": "command-generated",
"producerCommandIds": [
"verify-documentation"
@@ -1375,7 +1465,7 @@
{
"id": "artifact-artifacts-release-hosting-headers-json",
"path": "artifacts/release/hosting-headers.json",
"schemaId": "json-generic-json-object",
"schemaId": "json-hosting-headers",
"production": "command-generated",
"producerCommandIds": [
"verify-hosting-headers"
@@ -2185,6 +2275,35 @@
"artifacts/security/supply-chain-coherence.json",
"artifacts/security/supply-chain-verification.json",
"artifacts/security/vulnerability-report.json",
"config/security/dependency-baseline.approval.json",
"config/security/dependency-baseline.json",
"config/security/dependency-change-evidence.json",
"config/security/dependency-policy.json",
"config/security/secret-scan-policy.json",
"config/security/vulnerability-exceptions.json",
"config/security/vulnerability-policy.json",
"schemas/artifacts/build-manifest.schema.json",
"schemas/artifacts/dependency-inventory.schema.json",
"schemas/artifacts/supply-chain-verification.schema.json",
"scripts/contracts/release-artifacts.ts",
"scripts/create-release-candidate.ts",
"scripts/generate-supply-chain.ts",
"scripts/lib/build-manifest-outputs.ts",
"scripts/lib/json-schema.ts",
"scripts/lib/local-policy-evidence.ts",
"scripts/lib/local-release-evidence.ts",
"scripts/lib/release-candidate.ts",
"scripts/lib/release-input-evidence.ts",
"scripts/lib/release-runtime-coherence.ts",
"scripts/lib/repository-file-inventory.ts",
"scripts/lib/secret-scan-evaluator.ts",
"scripts/lib/secret-scan-policy.ts",
"scripts/lib/secret-scan.ts",
"scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts",
"src/contracts/release-artifacts.ts",
"src/features/installed-contract-contributions.ts",
"src/features/installed-feature-contracts.ts",
"artifacts/release/release-candidate.json"
],
"archiveOutputName": "archive_sha256",
@@ -2383,7 +2502,7 @@
"vulnerability_provider",
"provenance_provider"
],
"condition": "always",
"condition": "needs-success",
"timeoutMinutes": 45,
"gateIds": [],
"browserGateIds": [],
+166 -20
View File
@@ -10,6 +10,39 @@ checking, the typed contract checker, and this workflow drift check. The Gitea
adapter runs each gate as an independent matrix check with full fan-out and no
soft-fail wiring.
Contract loading validates every registered command entry against the
authoritative root package-script graph and lifecycle/install policy before the
runner enters its execution callback. Foreign cwd/workspace script dispatch,
argument-sensitive dispatchers, and ineffective lifecycle suppression therefore
fail preflight even when the later `check:ci` command would never run.
For npm, that root-only boundary also parses options following an explicit
`run`/`run-script` target or implicit `test`/`start`/`stop`/`restart` target:
`--workspace`, `-w`, `--workspaces`, and `--prefix` are foreign manifest scope
and are rejected before execution. The first literal `--` ends npm option
parsing, so later tokens are ordinary script arguments. pnpm and Yarn differ:
their options following the selected script name are forwarded to that script;
their cwd/workspace selectors are rejected only where the manager consumes
them before script selection.
The npm graph includes existing root-manifest `pre<script>` and `post<script>`
hooks around every explicit `run`/`run-script` dependency and implicit
`start`/`stop`/`restart`/`test` dependency. Hooks are omitted only when ordered
npm options establish an unambiguous effective `--ignore-scripts` value before
the first literal `--`; bare and explicitly true forms omit hooks, while false,
negative, contradictory, malformed, and post-delimiter forms keep traversal or
fail closed. Case-insensitive `npm_config_workspace`, `npm_config_workspaces`,
`npm_config_prefix`, `npm_config_userconfig`, and `npm_config_globalconfig`
assignments are rejected whenever the tokenized command invokes npm. Direct
assignments, static paths whose basename is `env`, exact `command`/`exec`
prefix chains, prior exports, and `set -a` assignments share one prefix grammar.
Only modeled non-scope `env -i`, `env -u`, `env --unset`, and `env --` forms are
allowed; cwd-changing or unknown options fail closed. Dynamic assignment names,
unmodeled environment mutation, npm `--userconfig`/`--globalconfig`, and
unquoted pathname expansion before the npm argument delimiter are rejected.
The gate runner checks the same inherited environment names before loading the
contract or entering the execution callback, so they cannot reach a child gate
process.
The dependency graph is:
```text
@@ -52,19 +85,98 @@ digest가 일치할 때만 report를 업로드한다. Promotion은 같은 archiv
별도 경로로 내려받고 SHA/member 검증을 마친 뒤 격리된 root에 추출하여 local
evidence를 read-only로 다시 계산하고 Ed25519 signature/digest를 확인한다.
Promotion job에는 build/rebuild command가 없으며 검증한 archive 자체를 변경 없이
그대로 승격한다.
그대로 승격한다. Local evidence 관계 검증에 필요한 policy와 verifier source도
archive member로 고정되며, checkout 밖의 격리된 cwd에서도 archive path와 기대
digest만으로 candidate-internal check를 재계산하고 archived secret-scan
policy/rule/SARIF/zero-finding/digest 관계를 검증한다. Archive에 없는 checkout
source를 다시 scan했다고 주장하지 않는다.
Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다.
workflow의 provider job은 Linux runner에 실행 권한이 있는
`/usr/bin/bwrap`를 필수로 요구하며, trusted `process.execPath`를 sandbox 안의
`/tmp/node`에 read-only bind한다. Provider command는 bubblewrap 안에서
`/bin/sh -eu -c`로 비대화식 실행되고 30분 안에 종료되어야 한다. Sandbox는
workspace를 read-only로 bind하고 `.git`을 가리며, 별도의 `untrusted`
raw-evidence 하위 디렉터리만 writable로 노출한다. 따라서 command는 전달된
candidate/environment 값을 읽고 지정된 raw report 하나만 기록해야 하며,
workspace 수정, host home/toolcache 접근, sealed evidence 직접 기록에 의존하면
안 된다. Supervisor는 provider 종류에 해당하는 credential prefix와 제한된
환경만 전달하고, sandbox 또는 출력 경계를 만들 수 없으면 fail closed한다.
Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. Linux
runner에 실행 가능한 `/usr/bin/bwrap`, `/usr/bin/prlimit`,
`/usr/bin/systemd-run`, `/usr/bin/systemctl`, `bwrap --size` 지원, active user
bus, systemd user manager 254 이상, unified cgroup v2와 delegated
memory/pids/CPU controller가 모두 필요하다. 실행 파일, user manager/version,
trust/archive/path 같은 host-side preflight 실패는 raw report 생성 전에 차단된다.
`bwrap --size` 수용 여부와 실제 delegated controller/limit 값은 owned raw inode를
만든 뒤 scope 안에서만 확정할 수 있으며, 이 단계의 실패는 해당 inode를
identity-bound cleanup하고 fail closed한다.
각 provider는 고유한 collected user scope에서 실행된다. Supervisor는 실행 전에
실제 cgroup membership과 `memory.max=1073741824`, `memory.swap.max=0`,
`pids.max=64`, `cpu.max="100000 100000"`을 확인한다. 내부 process에는 core 0,
file-size 8,388,607 bytes, open FD 64, CPU 1,200초 상한도 적용된다.
같은 UID 전체에 합산되는 `RLIMIT_NPROC`로 provider별 32개를 보장한다고 주장하지
않으며 aggregate PID authority는 cgroup `TasksMax=64`다.
Bubblewrap는 network namespace를 분리해 완전 offline으로 실행하고 workspace,
verified candidate, `.git` mask, `/tmp`, `/etc`, `/proc`, `/dev`를 read-only로
유지한다. Archive 검증·추출과 sandbox/trust preflight가 끝난 뒤 supervisor가
생성하고 inode를 고정한 정확한 raw report 파일 하나만 read-write bind된다.
주변 `untrusted` directory 전체는 writable이 아니다. 실행 전 또는 provider
실패 시, 그리고 성공적으로 sealed evidence를 게시한 뒤에도 supervisor가 소유한
inode만 atomic quarantine을 거쳐 제거하므로 빈 stale report 없이 재시도할 수 있다.
Provider command와 provider-prefixed environment는 bounded length-prefixed bwrap vector로
`systemd-run` stdin에 전달되어 supervisor/systemd/bwrap wrapper argv나 unit
metadata에 노출되지 않는다. 단, 최종 provider executable의 일반 argv는 같은
UID의 process inspection에 보일 수 있으므로 command 문자열과 인자에 token,
password, private-key material을 넣으면 안 된다. Credential은 반드시 해당 종류의
`VULNERABILITY_PROVIDER_*` 또는 `PROVENANCE_PROVIDER_*` environment로만 전달하고
`*_COMMAND`에는 넣지 않는다. 정상 provider 종료까지 같은 stdin을 parent-liveness
pipe로 열어 두며 supervisor hard death의 EOF를 받은 in-scope wrapper는 provider
process group 전체를 종료하고 dev/inode가 일치하는 raw report만 정리한다.
별도의 trusted guardian child는 provider scope 밖에서 filesystem transaction 전체를
소유한다. Client는 spawn 전에 canonical raw/evidence directory를
`O_DIRECTORY|O_NOFOLLOW`로 열고 identity를 확인한 뒤 provider kind와 nonce에서
canonical raw/final 및 nonce-private raw-staging/sealed-temp exact leaf를 확정한다.
Client가 두 private file을 `O_RDWR|O_CREAT|O_EXCL|O_NOFOLLOW` mode `0600`으로
미리 할당하고 dev/inode를 기록한다. Directory descriptor는 guardian fd 3/4,
private file descriptor는 fd 5/6으로 상속되며 guardian argv에는 Node와 helper
경로만 있다. 최초 canonical v2 frame에는 provider kind, absolute deadline,
32-byte nonce만 전달한다.
Guardian bootstrap은 procfs link를 읽기 전에 fd 5/6을 fstat한다. Procfs pathname은
canonical directory의 exact direct-child grammar를 만족하고 descriptor-relative
lstat이 이미 확보한 fd identity/type/mode/size/link count와 일치할 때만 해당
identity의 cleanup alias가 된다. Guard frame 검증 뒤 raw staging을 fixed raw leaf에
no-overwrite hard link하고 두 alias의 link count 2를 확인한 다음 private alias를
제거하고 raw directory를 sync한다. Canonical raw가 같은 identity와 link count 1로
남은 뒤에만 READY를 응답한다. READY 전 종료 시 client는 pre-spawn raw identity로
raw staging/canonical을, sealed identity로 temp/final을 각각 확인해 일치하는 alias만
정리한다. 현재 canonical pathname을 새 ownership identity로 승격하지 않으므로 외부
file과 concurrent same-kind winner를 보존하면서 같은 workspace를 즉시 재시도할 수
있다. Supervisor는 READY identity와 canonical target도 정확히 확인한다.
검증된 JSON bytes는 pinned temp inode에만 기록하고 `0400` 적용과 file sync를 마친다.
Guardian은 authenticated publish의 size/hash/identity를 재검증하고 같은 directory에서
no-replace `link(temp, final)`, temp unlink, directory sync를 수행한 뒤 PUBLISHED를
응답한다. `GITHUB_OUTPUT` append 이후 commit은 raw inode를 제거하고
`commitPending`으로만 전이하며, 깨끗한 control EOF까지 확인해야 PASS가 된다. 그 전의
EOF, deadline, 잘못된 frame/nonce, commit 뒤 추가 byte 또는 guardian 조기 종료는
identity가 일치하는 raw/temp/final을 모두 정리하고 fail closed한다. Provider wall
timeout 30분에 post-processing 10분을 더한 guardian lease 상한은 40분이다.
Client가 private allocation 뒤 guardian spawn 전에 hard stop되면 empty mode-`0600`
nonce-private leaf만 남을 수 있다. 기록되지 않은 pathname은 ownership 근거가 아니므로
자동 sweeping하지 않는다. 이 private leaf는 fixed raw/final name을 점유하지 않아
same-kind retry를 막지 않는다.
`GITHUB_OUTPUT`은 runner가 소유한 regular file이라는 실행기 계약을 전제로 한다.
Sealing/output I/O의 OS-level cancellation을 보장하지 않는다. Provider scope가 이미
수집된 뒤 guardian이 종료되면 scope-active latch는 늦은 kill을 시작하지 않고 lifecycle
error만 기록한다. 이후 publish/commit의 awaited failure가 identity가 고정된
raw/temp/final fallback을 모두 정리하고 fail closed한다.
Provider stdout/stderr는 credential을 포함할 수 있는 untrusted bytes이므로 CI log로
재전송하거나 보관하지 않고, byte 수만 합산해 1 MiB 상한을 적용한다. Guardian의
stderr/control fd가 닫혀 진단 출력이 `EPIPE`/`EBADF`가 되어도 cleanup 뒤 nonzero
종료는 생략되지 않는다. Provider wall-clock 상한은 30분이다. Wall
timeout, aggregate output 초과, parent-liveness loss, 실행 중 guardian loss는 provider process group을
명시적으로 SIGKILL한다. 일반 command 실패와 FD/CPU RLIMIT 종료는 실제 exit/signal로
systemd completion을 거치며, 모든 경로에서 wrapper 종료와 systemd unit/cgroup
collection을 확인한다. Adapter는 사전에 배치된 offline data와 supervisor candidate binding만
읽어 정확한 report inode에 기록해야 한다. Scope/cgroup limit drift, residual unit,
workspace·host home/toolcache·sealed evidence 접근 의존성은 모두 blocking failure다.
Workflow가 실행하는 action은 `scripts/contracts/ci-gates.ts`의 단일 typed,
runtime-frozen registry에서만 resolve된다. `uses:`에는 repository 별칭, tag,
@@ -192,6 +304,9 @@ Repository variables required by higher tiers:
`PROVENANCE_PUBLIC_KEY_PATH`, and `PROVENANCE_KEY_ID` for separately managed
trusted Ed25519 verification material
두 provider role은 서로 다른 key ID뿐 아니라 canonical DER-SPKI public-key bytes도
사용해야 한다. 동일 key를 서로 다른 ID로 재등록한 구성도 finalizer가 거절한다.
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.
@@ -215,20 +330,51 @@ consumer도 artifact service나 transfer action을 신뢰 경계 밖으로 보
manifest와 signed provider evidence에 바인딩된 digest를 다운로드 후 다시
검증해야 한다. 현재 producer-side adjacency 자체는 consumer-side digest
revalidation을 대신하지 않는다.
Promotion job에는 job-level `if`가 없다. 기본 `needs` 성공 의미론으로 immutable
build, vulnerability provider, provenance provider 세 job이 모두 성공해야 하며,
`always()``cancelled()`로 cancellation을 덮어쓰지 않는다. Bare `always()`
step cleanup에만 사용된다. 다만 cancellation 시 cleanup 실행 여부는 workflow
정적 타입이나 단위 테스트로 증명하지 않았으며 runner/native smoke에서 확인해야
하는 신뢰 경계다.
Finalizer output은 `RUNNER_TEMP` 아래 random private directory이며 exact-five
upload는 `${{ steps.finalize.outputs.staging_root }}`만 사용한다. 바로 다음
`always()` cleanup은 finalizer의 token과 runner-temp device/inode를 모두
요구한다. stable `.release/promoted-staging` directory를 만들거나 재사용하지
않는다. exact five는 captured archive/report 두 개와 process 안에서 생성한
provider/promotion verification v3 두 개이며 promotion record는 provider record,
local assessment, report hashes와 run/source/candidate/nonces/key identities/
trust-policy hash를 함께 bind한다. 이 descriptor-relative 정리는 ancestor 교체와 symlink leaf를
fail-closed로 처리하지만 upload action의 same-UID pathname reopen 또는 atomic
upload는 `${{ steps.finalize.outputs.staging_root }}` 아래 다음 다섯 canonical
pathname만 사용한다: `release-candidate.tar.gz`, `vulnerability-report.json`,
`provenance-attestation.json`, `provider-verification.json`,
`promotion-verification.json`. 바로 다음
`always()` cleanup은 staging path/token, runner-temp device/inode와 staging-leaf
device/inode 여섯 output을 모두 요구한다. cleanup은 pin한 leaf descriptor에서
exact-five name만 unlink하고 non-recursive `rmdir`만 사용하므로 교체된 directory나
canary tree를 recursive 삭제하지 않는다. stable `.release/promoted-staging`
directory를 만들거나 재사용하지 않는다. exact five는 captured archive 한 개,
captured report 두 개와 process 안에서 생성한 provider/promotion verification v3
두 개이며 promotion
record는 provider record, local assessment, report hashes와 run/source/candidate/
nonces/key identities/trust-policy hash 및 signed `secretScanAttestation`을 함께
bind한다. 이 attestation은 PASS와 local-assessment/source-set/policy/SARIF/
scan-input digest를 포함한다. Supervisor/finalizer는 captured archive에서 기대
tuple을 유도해 exact equality를 확인하지만, 실제로 같은 source-set 전체를
독립 스캔하고 forged empty SARIF에 서명하지 않을 책임은 trusted vulnerability
provider에 있다. Staging은 restrictive
umask와 무관하게 directory `0700`, file `0400`을 강제하고, 모든 write 뒤 live
time으로 exact-five signature/freshness를 다시 확인한 뒤에만 output을 공개한다.
Descriptor-relative 정리는 ancestor/leaf 교체와 symlink를 fail-closed로 처리하지만
upload action의 same-UID pathname reopen 또는 atomic
`renameat2` handoff를 보장하지 않는다. staging Gitea smoke/native adapter 확인
전에는 그 경계를 닫았다고 보고하지 않는다. 실제 smoke는 exact-five
upload-download와 success, validation failure, upload failure, cancellation 각각의
cleanup을 관찰해야 한다. 현재 repository에는 native uploader나 `renameat2`
보장이 없다.
또한 portable Node의 `mkdir`와 최초 pathname `lstat`는 atomic하지 않다. 구현은
mkdir 직후 metadata를 저장하고 이후 `O_DIRECTORY|O_NOFOLLOW` descriptor의
device/inode와 비교한 뒤에만 permission을 바꾸지만, 최초 lstat보다 앞서 성공한
malicious same-UID 교체는 native/privilege 경계로 남는다. 따라서 `RUNNER_TEMP`
private `0700` ancestor와 exclusive single-tenant runner가 필수다.
실패 cleanup도 created device/inode와 opened descriptor가 일치한 뒤에만 활성화된다.
불일치 descriptor는 close만 수행하며 현재 visible replacement pathname은 unlink나
`rmdir`하지 않는다. 공격자가 original directory를 다른 이름이나 parent 밖으로
이동한 경우 portable Node parent scan으로 안전하게 회수할 수 없으므로, 공격자를
배제한 trusted runner/native cleanup 또는 격리된 test fixture가 잔여 directory를
후처리해야 한다.
Branch protection must mark each `FE-GATE-* / <name>` check required for its
declared tier. This repository cannot configure server-side protection by
+175 -33
View File
@@ -2,7 +2,17 @@
## Local blocking controls
- `pnpm install --frozen-lockfile` and a real manifest/lock mismatch fixture
- `pnpm install --frozen-lockfile --ignore-scripts` and a real manifest/lock
mismatch fixture; contract loading applies the root-only graph and lifecycle
policy to every registered command before the gate runner can spawn one, and
rejects nested installs without effective `--ignore-scripts`; npm script
traversal includes existing pre/post hooks unless an ordered bare or explicit
true `--ignore-scripts` suppresses them. Workspace/prefix and indirect
user/global config authority are rejected in npm options, direct or dynamic
assignments, exact `command`/`exec`/`env` prefix chains, cross-segment shell
state, and the inherited runner environment. Unknown or cwd-changing `env`
options and unquoted pre-delimiter pathname expansion fail closed, while the
explicitly modeled non-scope `env` options remain usable
- 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
@@ -39,7 +49,8 @@ Ed25519 signatures verified with separately configured trusted public keys and
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`.
`Ed25519`. The two roles must use different key IDs and different canonical
DER-SPKI key bytes; giving the same key two IDs is rejected.
`immutable_build` archives the raw `pnpm-lock.yaml`, `dist` (including hidden
`.vite` files), the build manifest, module inventory, release verification,
@@ -58,15 +69,21 @@ with its strict schema, and binds its dist and lockfile digests before upload.
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, 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. Promotion uploads the already verified archive
itself with the two provider reports and verification records; it does not
create a replacement archive from extracted files. Scanner or signing outages
are not converted to an empty PASS.
`promotionStatus: FAIL_UNVERIFIED`. The finalizer and downstream
`verify:promotion` exact-five validator then exit non-zero. Promotion derives
the candidate file set and digests only from the captured tar bytes, then
read-only revalidates archived executable schemas, archived policy/verifier
source bytes, raw lockfile, module inventory, build outputs, release coherence,
SBOM, provenance and supply-chain coherence. Candidate-internal checks are
recomputed; for the checkout-dependent secret scan, promotion independently
checks the archived policy, exact rule set, strict SARIF, zero findings and all
manifest/assessment digest bindings. It does not claim to rescan source bytes
that are not candidate members. It never rebuilds or rewrites candidate
evidence and never falls back to the checkout tree.
Promotion uploads the already verified archive itself with the two provider
reports and generated verification records; it does not create a replacement
archive from extracted files. Scanner or signing outages are not converted to
an empty PASS.
The generated workflow is also a supply-chain control. `config/ci/gates.json`
is its sole typed authority. Run `corepack pnpm generate:ci-workflow` after a
@@ -94,17 +111,107 @@ A real end-to-end provider smoke on the staging Gitea instance remains
mandatory before any generated job becomes a required check.
External provider supervision is fail-closed and requires a Linux runner with
an executable `/usr/bin/bwrap`. Bubblewrap mounts the repository workspace
read-only, hides `.git`, and read-only binds the trusted `process.execPath`
inside the sandbox at `/tmp/node`. It exposes only the sibling `untrusted` raw-evidence
directory as writable. Provider commands run non-interactively through
`/bin/sh -eu -c`, receive a minimized environment plus only their own
provider-prefixed credentials, and have a 30-minute limit. They must consume
the supplied candidate paths and digests, write exactly the configured raw
report, and must not depend on workspace mutation, host home/toolcache access,
or direct access to the sealed evidence path. Missing sandbox support, stale or
misplaced outputs, command failure/timeout, and post-command candidate drift
all stop publication.
executable `/usr/bin/bwrap`, `/usr/bin/prlimit`, `/usr/bin/systemd-run`, and
`/usr/bin/systemctl`, bubblewrap support for `--size`, an active user bus, a
systemd user manager version 254 or newer, unified cgroup v2, and delegated
memory, pids, and CPU controllers. Executable access, user-manager/version,
trust, archive, and path failures are rejected before raw creation. Bubblewrap
`--size` acceptance and effective delegated controller values can only be
verified after the owned raw inode exists inside a new scope; failures there
remove that inode by identity and fail closed. Each invocation runs in a unique collected user scope and verifies
its effective cgroup membership and limits before bubblewrap starts: memory is
exactly 1 GiB, swap is zero, `TasksMax` is 64, and CPU quota is 100% per 100 ms.
The inner process also has zero core size, a 8,388,607-byte file-size limit,
64 open files and at most 1,200 CPU seconds. `TasksMax=64` is the authoritative
aggregate PID boundary; no per-provider `RLIMIT_NPROC=32` claim is made because
that limit is counted across the runner's same-UID process population.
Bubblewrap uses a private network namespace (`--unshare-net`), mounts the
workspace and verified candidate read-only, hides `.git`, and read-only binds
the trusted `process.execPath` at `/tmp/node`. The supervisor creates and pins
the exact configured raw-report inode only after sandbox/trust/archive
preflight; that inode is the only provider evidence path mounted read-write.
The provider cannot write the surrounding `untrusted` directory, workspace,
candidate, host home/toolcache, sealed evidence path, or general temporary
filesystem. Pre-execution and provider failures remove only the supervisor-
owned raw inode so the same job can retry without a stale empty report.
The complete bwrap argument/environment vector, including the provider command
and provider-prefixed environment, is carried in a bounded length-prefixed frame over
`systemd-run` stdin rather
than placed in the supervisor, systemd, or bwrap wrapper argv. This prevents
credentials from entering unit metadata and wrapper command lines. The final
provider executable and its ordinary arguments remain visible to same-UID
process inspection, so commands must never contain tokens or secrets. The same
stdin remains open as a parent-liveness channel until normal provider exit; EOF
caused by supervisor death makes the in-scope wrapper kill the provider process
group and remove only the dev/inode-matched raw report. Supply
credentials only through the provider-kind prefix
(`VULNERABILITY_PROVIDER_*` or `PROVENANCE_PROVIDER_*`, excluding `*_COMMAND`).
A separate trusted guardian starts outside the provider scope and owns the
filesystem transaction. Before spawn, the client opens and identity-checks the
canonical raw and evidence directories with `O_DIRECTORY|O_NOFOLLOW`, derives
the exact canonical and nonce-private leaves, and exclusively allocates empty
mode-`0600` raw-staging and sealed-temp files. It records both dev/inode pairs
before spawn and inherits the directory descriptors as guardian fd 3/fd 4 and
the private file descriptors as fd 5/fd 6. Its argv contains only the trusted
Node and helper paths. A bounded canonical v2 request carries only provider
kind, an absolute deadline, and a random 32-byte control nonce.
At bootstrap the guardian fstats fd 5/fd 6 before reading their procfs links.
Each procfs target is accepted only as a direct-child alias whose exact grammar,
descriptor-relative lstat, type, mode, size, link count, and dev/inode match the
already-recorded descriptor identity. The guardian publishes raw staging to the
fixed raw leaf with a no-overwrite hard link, verifies both aliases at link
count two, removes the private raw alias, syncs the raw directory, and verifies
the canonical raw alias at link count one before authenticated READY. If startup
ends before READY is accepted, the client cleans raw staging/canonical only when
they match its pre-spawn raw identity and sealed temp/final only when they match
its pre-spawn sealed identity. It never derives cleanup ownership by opening a
current canonical pathname, so an external file or a concurrent same-kind
winner is preserved. Provider execution starts only after the supervisor
confirms that the returned identities and canonical targets match exactly.
After evidence validation, the supervisor writes schema-validated bytes to the
pinned temp inode, changes it to `0400`, fsyncs it, and sends authenticated
size/hash/identity metadata. The guardian verifies the held descriptor and
pathname, publishes without replacement using same-directory `link`, removes
the temp name, fsyncs the directory, and returns authenticated PUBLISHED. Only
after successful `GITHUB_OUTPUT` append does the supervisor send commit. Commit
removes the raw inode and enters `commitPending`; clean control EOF is the sole
success terminal and preserves the sealed final. EOF without that terminal,
deadline expiry, malformed/trailing control data, a wrong nonce, or premature
guardian exit cleans every matching raw/temp/final identity and fails closed.
Guardian loss while the provider scope is active also triggers whole-scope kill
and collection.
`GITHUB_OUTPUT` is assumed to be a runner-owned regular file. This protocol
does not claim OS-level cancellation of sealing or output I/O. If the guardian
exits after scope collection, the scope-active latch records the lifecycle error
without starting a late kill. Publication or terminal commit observes the
nonzero exit and the client removes every identity-pinned raw/temp/final
fallback before failing closed. The lease is bounded by the 30-minute provider
wall limit plus a fixed ten-minute post-processing allowance.
A client hard stop after private allocation but before guardian spawn can leave
only empty mode-`0600` nonce-private leaves. Automatic sweeping is intentionally
omitted because an unrecorded pathname does not prove ownership; these private
leaves cannot occupy the fixed raw or final names and do not block a retry.
Provider stdout and stderr are untrusted secret-bearing bytes. The supervisor
does not retain or forward them to CI logs; it counts them only to enforce one
1 MiB aggregate limit. Guardian diagnostics are best effort, so closed stderr
or control descriptors cannot bypass cleanup or the required nonzero exit. The
provider wall-clock limit is 30 minutes. Wall timeout, output overflow, parent-liveness loss,
and active-scope guardian loss explicitly SIGKILL the whole provider process group. Ordinary command and
RLIMIT failures complete through systemd with their concrete exit/signal; every
path still waits for wrapper closure and requires the systemd unit/cgroup to be
collected before returning. Provider adapters must therefore operate entirely from pre-populated
offline data, consume the supplied candidate bindings, and write exactly the
pinned report inode. Missing prerequisites, cgroup drift, stale or misplaced
outputs, post-command candidate drift, and residual scope cleanup all stop
publication.
Provider documents are strict schema v2. Their Ed25519 signature covers the
supervisor-supplied evidence type, validity window, run ID/attempt, independent
@@ -115,17 +222,31 @@ never lets a report define its own expected nonce. A report from another
attempt, source, archive, nonce, or key fingerprint is fail-closed even when it
has been correctly re-signed.
The immutable archive contains a strict producer-local assessment. Promotion
revalidates it from captured archive members without reopening checkout policy
or source paths. The finalizer captures the archive, both reports, and both
public keys once, generates both verification v3 records in memory, and writes
exactly five mode-`0400` files beneath a random mode-`0700` directory in
`RUNNER_TEMP`. The exact five are the captured archive, captured vulnerability
report, captured provenance attestation, generated provider-verification v3,
and generated promotion-verification v3. The promotion record binds the exact
provider-record hash, local-assessment hash, both report hashes, run/source/
candidate identities, both nonces, both key IDs/fingerprints, and canonical
trust-policy hash. It never creates or reuses `.release/promoted-staging`.
The immutable archive contains a strict producer-local assessment plus the
policy and verifier source bytes needed to validate its archived relationships.
Promotion recomputes candidate-internal checks and validates the captured
secret-scan policy/rules/SARIF/digest relationships from an isolated extraction
root; it does not reopen checkout policy or source paths or claim to rescan
unarchived checkout source. The finalizer captures the archive,
both reports, and both public keys once, generates both verification v3 records
in memory, and writes exactly five mode-`0400` files beneath a random
mode-`0700` directory in `RUNNER_TEMP`, independently of a restrictive runner
umask. The exact five are the captured archive, captured vulnerability report,
captured provenance attestation, generated provider-verification v3, and
generated promotion-verification v3. Before returning, the finalizer validates
those exact bytes again with live-time provider signature/freshness checks. The
promotion record binds the exact provider-record hash, local-assessment hash,
both report hashes, run/source/candidate identities, both nonces, both key
IDs/fingerprints, canonical trust-policy hash, and the vulnerability provider's
signed `secretScanAttestation`. That strict attestation says `PASS` and binds
the captured local-assessment, source-set, secret-scan policy, SARIF, and actual
scan-input digests. The supervisor derives the expected tuple from the captured
archive and exact equality is rechecked at upload and finalization. The trusted
vulnerability provider remains responsible for independently scanning that
source set and refusing to sign a forged empty SARIF or incomplete scan input;
the signature proves the provider made the claim, not that an untrusted
provider performed the scan honestly. It never creates or reuses
`.release/promoted-staging`.
The final promotion verification/staging step must be immediately adjacent to
the promoted-release upload, and that upload must not use `always()`. This
@@ -137,6 +258,14 @@ service and transfer actions also remain outside the candidate's cryptographic
identity: every downstream consumer must revalidate the downloaded archive,
manifest member digests and signed provider evidence. Producer-side adjacency
does not provide consumer-side digest revalidation.
The promotion job has no job-level `if`: ordinary `needs` success semantics
require immutable build and both provider jobs to succeed, and cancellation is
not overridden with `always()` or `cancelled()`. Cleanup alone uses bare `always()`
and is guarded by all six finalizer outputs: staging path, token, parent
device/inode, and staging-leaf device/inode. Cleanup opens the pinned leaf,
requires the exact five names, unlinks only those known files through the
descriptor, and uses a non-recursive `rmdir`; an exchanged directory or canary
is never recursively removed.
The immediately following upload action still reopens pathnames. The
descriptor-relative staging and cleanup code does not claim an atomic
`renameat2` handoff or close a malicious same-UID Gitea upload adapter; the
@@ -144,6 +273,19 @@ staging Gitea smoke/native platform adapter remains the required closure for
that boundary. That smoke must exercise exact-five upload and download plus
cleanup on success, validation failure, upload failure, and cancellation. No
native uploader or `renameat2` guarantee exists in this repository today.
Portable Node also cannot make `mkdir` plus the first pathname `lstat` atomic.
The implementation compares the immediate post-`mkdir` identity with the
subsequent `O_DIRECTORY|O_NOFOLLOW` descriptor before changing permissions, but
a malicious same-UID actor that wins before that first `lstat` remains part of
the native/privilege boundary. The private `0700` runner-temp ancestor and
single-tenant runner requirement are therefore security controls, not merely
hardening.
Failure cleanup is armed only after that created device/inode matches the opened
descriptor. If the opened descriptor is a replacement, it is closed without
unlinking or removing anything; the visible replacement is likewise untouched.
Portable Node cannot safely rediscover an attacker-moved original directory by
scanning the parent, so that residual must be removed by the isolated test
fixture or trusted runner/native cleanup after the attacker is excluded.
Approved vulnerability exceptions require vulnerability/package identity,
owner, a different reviewer, reason and expiry. Expired or self-approved
@@ -74,9 +74,9 @@
- Modify: `config/ci/gates.json`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
- [ ] Inventory every artifact still mapped to `generic-json-object` and export/reuse the producer's strict schema, including cross-field status/failure/count invariants. Do not treat a non-empty JSON object as semantic evidence.
- [ ] Replace substring-only JUnit/HTML acceptance with bounded well-formed document validation. Reject DTD/entities, malformed nesting, duplicate/invalid roots, and trailing non-whitespace content.
- [ ] Add invalid-but-pattern-matching fixtures for all structured kinds and a table proving every configured artifact resolves to a semantic validator.
- [x] Inventory every artifact still mapped to `generic-json-object` and export/reuse the producer's strict schema, including cross-field status/failure/count invariants. Do not treat a non-empty JSON object as semantic evidence.
- [x] Replace substring-only JUnit/HTML acceptance with bounded well-formed document validation. Reject DTD/entities, malformed nesting, duplicate/invalid roots, and trailing non-whitespace content.
- [x] Add invalid-but-pattern-matching fixtures for all structured kinds and a table proving every configured artifact resolves to a semantic validator.
- [ ] Run focused artifact tests, `corepack pnpm check:ci`, types, lint, and diff checks; commit separately so this evidence-quality closeout is independently reviewable.
### Task 4: One authoritative architecture graph
@@ -0,0 +1,598 @@
# Promotion Security Review Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the captured candidate archive and a strict exact-five bundle validator the only promotion authority while closing evidence, lifecycle, staging, output, freshness, and workflow gaps found by security review.
**Architecture:** Candidate identity and local verification are derived exclusively from the inode-captured tar stream. The finalizer revalidates archived subordinate evidence, provider signatures, freshness, role-separated trust, and then creates and validates an exact-five bundle before returning descriptor- and inode-bound cleanup metadata. Provider execution, CLI output publication, and workflow gating expose small injectable boundaries so failure and cleanup behavior can be tested directly.
**Tech Stack:** Node.js 24, TypeScript, Zod, Vitest, GNU tar, bubblewrap, Gitea Actions workflow generation.
## Global Constraints
- Work sequentially on the current clean `develop` HEAD and produce one review-fix commit.
- Every production change follows focused RED, observed expected failure, minimal GREEN, and regression verification.
- Candidate verification performs no checkout reads; test subprocesses from outside the checkout with contradictory canaries.
- Preserve real GNU tar and bubblewrap coverage; do not claim native uploader or atomic `renameat2`/`unlinkat` semantics.
- Gitea 1.26.4 and act_runner 1.0.0 exact-five upload/download/cancel behavior remains an explicitly documented external smoke boundary.
---
### Task 1: Canonical Captured Archive and Archived Local Authority
**Files:**
- Modify: `scripts/lib/release-candidate.ts`
- Modify: `scripts/lib/local-release-evidence.ts`
- Modify: `scripts/lib/ci-candidate-archive.ts`
- Delete: `scripts/lib/promotion-verifier.ts`
- Delete: `scripts/verify-provider-evidence.ts`
- Delete: `scripts/verify-supply-chain-promotion.ts`
- Modify: `tests/unit/security-followup.test.ts`
- Modify: `tests/unit/supply-chain.test.ts`
- Modify: `tests/integration/security-followup-archive.test.ts`
**Interfaces:**
- Consumes: captured tar bytes plus expected SHA-256.
- Produces: `withVerifiedCapturedCandidate()` callback data derived only from the extracted, exact-member, digest-verified tar; `verifyArchivedLocalEvidence()` independently recomputes all feasible archived checks.
- [x] Add failing tests for invalid tar, archive/tree mismatch, contradictory archived subordinate FAIL, exact archived policy bytes, and checkout-independent execution.
- [x] Run focused tests and record the expected RED diagnostics in the durable task report.
- [x] Archive the exact policy/verifier inputs required for independent release, supply-chain, dependency, license, vulnerability, and secret-scan checks.
- [x] Re-run producer checks against the extracted archive and require their result to agree with the assessment and member identities.
- [x] Remove the obsolete standalone PASS issuers and route all fixture checking through real captured tar/finalizer validation.
- [ ] Run focused archive, supply-chain, and integration tests to GREEN.
### Task 2: Exact-Five Validator and Role-Separated Trust
**Files:**
- Create: `scripts/lib/exact-promotion-bundle.ts`
- Create: `scripts/verify-exact-promotion-bundle.ts`
- Modify: `scripts/lib/provider-evidence.ts`
- Modify: `scripts/lib/promotion-stager.ts`
- Modify: `scripts/contracts/promotion-artifacts.ts`
- Modify: `tests/unit/security-followup.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: exactly five captured byte buffers and two trusted Ed25519 identities.
- Produces: `verifyExactPromotionBundle()` that requires literal verifier identity/version, provider and subordinate PASS states, exact hashes, and equal run/source/candidate/provider/trust fields.
- [x] Add failing tests for provider FAIL/absence, arbitrary provider hash, swapped roles, shared-field mismatch, archive/report digest mismatch, and identical role keys.
- [x] Run focused tests and record RED.
- [x] Implement strict exact-five parsing/cross-record validation and expose a downstream CLI command.
- [x] Reject equal DER-SPKI fingerprints and equal role key identity before evaluation/finalization.
- [x] Invoke exact-five validation inside the finalizer before publication; the full real-build fixture rerun remains sandbox-blocked below.
### Task 3: Provider Lifecycle and Freshness
**Files:**
- Modify: `scripts/lib/provider-supervisor.ts`
- Create: `scripts/lib/provider-process-runner.ts`
- Modify: `scripts/run-and-validate-provider.ts`
- Modify: `tests/unit/security-followup.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: a spawned bubblewrap child, injected timeout/clock, captured report.
- Produces: a runner that SIGKILLs on timeout but rejects only after `close`, and supervision that samples freshness after provider/report capture.
- [x] Add failing stubborn-descendant/short-timeout and sequence-clock expiry tests.
- [x] Run focused tests and record RED.
- [x] Extract the process runner, wait for close after timeout, and preserve the timeout diagnostic.
- [x] Issue provider timestamps immediately before execution, validate with a fresh clock after capture, and reject crossing expiry.
- [x] Run focused lifecycle tests, including real stubborn descendants, to GREEN; the shared real-build/bubblewrap fixture remains sandbox-blocked below.
### Task 4: Inode-Pinned Staging and Output-Failure Cleanup
**Files:**
- Modify: `scripts/lib/promotion-stager.ts`
- Create: `scripts/lib/stage-verified-promotion-cli.ts`
- Modify: `scripts/stage-verified-promotion.ts`
- Modify: `scripts/cleanup-verified-promotion.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Produces: `FinalizedPromotion.stagingIdentity` and a testable CLI function whose append failure invokes cleanup from the in-memory result.
- [x] Add failing tests for leaf replacement during writes, final visibility mismatch, partial-failure cleanup, GITHUB_OUTPUT open/write failure, and expiry during staging.
- [x] Run focused tests and record RED.
- [x] Open the created leaf with `O_DIRECTORY|O_NOFOLLOW`, write through `/proc/self/fd/<leafFd>`, pin dev/ino, require visible identity equality, and propagate identity through cleanup.
- [x] Force directory/file modes with `fchmod(0700/0400)` independent of a restrictive owner-preserving umask.
- [x] Extract CLI dependencies; on any post-finalization output failure call direct cleanup before rethrowing.
- [x] Revalidate evidence freshness before sealing and immediately before publication; isolated lifecycle/mode/output tests are GREEN and the shared real-build fixture remains sandbox-blocked below.
### Task 5: Workflow and Install Policy
**Files:**
- Modify: `package.json`
- Modify: `scripts/check-ci-contract.ts`
- Modify: `scripts/contracts/ci-gates.ts`
- Modify: `scripts/generate-ci-workflow.ts`
- Modify: `config/ci/gates.json`
- Modify: `.gitea/workflows/quality-gates.yml`
- Modify: `tests/unit/ci-workflow-generation.test.ts`
- Modify: `tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap`
**Interfaces:**
- Produces: promotion job `if: ${{ always() && needs.immutable_build.result == 'success' && needs.vulnerability_provider.result == 'success' && needs.provenance_provider.result == 'success' }}` and install-bearing script graph enforcement.
- [x] Add failing contract/generator tests for the job condition, upload without `always()`, missing cleanup outputs, and nested install scripts lacking `--ignore-scripts`.
- [x] Run focused tests and record RED.
- [x] Add `--ignore-scripts` to `verify:lockfile` and recursively reject each reachable install invocation without it.
- [x] Extend the typed job condition and render the explicit cancellation-resistant exact-needs predicate.
- [x] Regenerate workflow/snapshot and run workflow contract/byte tests to GREEN.
### Task 6: Fixtures, Documentation, Full Verification, and Commit
**Files:**
- Modify: `scripts/check-supply-chain-provider-fixtures.ts`
- Modify: `docs/security/supply-chain.md`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `.superpowers/sdd/2026-08-01-release-evidence-remediation/task-4-report.md` (ignored durable report)
- [x] Replace plaintext candidate fixtures with a real tar and canonical captured-archive/exact-five validation.
- [x] Rewrite operator docs around the sole captured-archive/exact-five authority and retain the Gitea/runner external-smoke residual.
- [ ] Run focused fixtures, archive integration, workflow snapshot/bytes, full unit, types, lint, `check:ci`, and diff checks; escalate only a sandbox-caused EPERM.
- [x] Append all RED/GREEN and verification evidence/constraints to the durable report.
- [ ] Invoke verification-before-completion, review the complete diff, commit once, and report commit/range/status.
---
## Review-Fix Wave D: Sealed Bytes, Replay Context, Scan Trust, and Cancellation
**Constraint:** Work only in the existing uncommitted tree. Do not write `.git`, stage, or commit. Each task follows a focused RED→GREEN cycle and records sandbox `EPERM` separately from product failures.
### Task D1: Seal the actual staged inode bytes
**Files:** `scripts/lib/promotion-stager.ts`, `tests/unit/security-followup.test.ts`, `tests/unit/ci-artifact-contract.test.ts`
**Interface:** The staging writer captures each canonical file through the already-open leaf FD using `O_NOFOLLOW`; it requires a regular single-link inode, mode `0400`, stable dev/ino/size, and the declared SHA-256. `verifyExactPromotionBundle` receives only these captured staged buffers immediately before return.
- [x] Add RED tests for unlink/recreate and chmod/mutation after a file write.
- [x] Implement bounded descriptor-relative capture and exact-five seal validation.
- [x] Run focused staging tests to GREEN.
### Task D2: Bind downstream verification to external expected identity
**Files:** `scripts/lib/exact-promotion-bundle.ts`, `scripts/verify-exact-promotion-bundle.ts`, `tests/unit/ci-artifact-contract.test.ts`
**Interface:** `verifyExactPromotionBundle` requires `expected.run.id`, `expected.run.attempt`, `expected.sourceRevision`, and `expected.archiveSha256`; optional bundle/dist/lock/source-set digests are compared when supplied. The CLI obtains these values from dedicated environment variables and never derives them from the bundle.
- [x] Add a RED signed other-run replay test.
- [x] Implement external expected-context comparison in library and CLI.
- [x] Run exact-bundle tests to GREEN where the sandbox permits.
### Task D3: Pin mkdir-to-open identity
**Files:** `scripts/lib/promotion-stager.ts`, `tests/unit/security-followup.test.ts`, `docs/security/supply-chain.md`, `docs/operations/ci-quality-gates.md`
**Interface:** A post-mkdir/pre-open test hook can replace the leaf. The implementation compares mkdir-returned pathname metadata with the `O_DIRECTORY|O_NOFOLLOW` handle `fstat` before any write; it never uses pathname chmod.
- [x] Add a RED pre-open replacement test.
- [x] Compare created and opened metadata and reject replacement.
- [x] Document the residual portable Node same-UID pre-lstat/native-privilege boundary.
### Task D4: Conservatively parse install invocations
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** A bounded shell/token parser recognizes `pnpm install|i`, `npm install|ci|i`, and `yarn install` after supported manager-global options with split or `=` values. Any reachable package-manager invocation that cannot be classified is rejected.
- [x] Add the eight required global-option/alias RED cases plus malformed fail-closed cases.
- [x] Implement tokenization and manager-specific invocation classification.
- [x] Run install-policy tests to GREEN.
### Task D5: Signed secret-scan attestation
**Files:** `scripts/lib/provider-evidence.ts`, `scripts/lib/provider-supervisor.ts`, `scripts/lib/provider-upload-validator.ts`, `scripts/lib/promotion-stager.ts`, `scripts/lib/exact-promotion-bundle.ts`, relevant unit/integration tests and docs.
**Interface:** Vulnerability evidence v2 contains a strict `secretScanAttestation` with `status: PASS`, local-assessment, source-set, policy, SARIF, and scan-input digests. The supervisor derives the expected tuple from captured archive members, exports it to the provider, and upload/final verification requires exact equality under the Ed25519 signature.
- [x] Add RED forged-empty-SARIF and attestation-mismatch tests.
- [x] Derive one captured-archive scan context and bind it through supervisor, signed schema, finalizer records, and exact validation.
- [x] Run provider/security tests to GREEN where the sandbox permits.
### Task D6: Cancellation-safe workflow and exact upload paths
**Files:** `scripts/contracts/ci-gates.ts`, `scripts/generate-ci-workflow.ts`, `config/ci/gates.json`, generated workflow/snapshot, workflow tests, and operations/security docs.
**Interface:** Promotion uses a typed dependency-success/no-job-if variant, so cancellation cannot be overridden by job-level `always()`. Step cleanup retains bare `always()` for ordinary failures. Upload documentation names the five canonical paths under `staging_root` and states cancellation cleanup remains a runner/native smoke boundary.
- [x] Add RED generator/contract assertions for no promotion job `if` and retained cleanup `always()`.
- [x] Regenerate workflow and snapshot after the typed condition change.
- [x] Correct operator/security wording and run workflow/CI checks to GREEN.
### Task D7: Verification
- [x] Run focused suites after each GREEN, then affected/full unit tests, all TypeScript targets, ESLint, `check:ci`, generated-byte check, and both diff checks.
- [x] Append exact PASS totals and sandbox-blocked commands to the ignored durable report.
- [x] Report modified files and remaining native/Gitea/unsandboxed verification boundaries; do not attempt git staging or commit.
## Wave E: Unified parser and downstream boundary review
### Task E1: One tokenized manager parser
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** One parse result reports manager invocations, package-script dependencies, unsupported controls, and effective lifecycle suppression. Both graph traversal and install policy consume it. Single `&`, unknown manager grammar, and malformed options fail closed. The last valid `--ignore-scripts` assignment controls the effective value; false, contradictory, valueless, and malformed assignments are unsafe. Lifecycle-capable mutation builtins are never implicit repository scripts and require effective suppression.
- [x] Add RED tables for single-ampersand segmentation, false/override/malformed suppression, global-option run/implicit dependencies, yarn/corepack reachability, and builtin/script-name collisions.
- [x] Replace the regex traversal and separate install scan with one parser result.
- [x] Run the parser-focused and full workflow-generation suites.
### Task E2: Evaluator-owned secret-scan equality
**Files:** `scripts/lib/provider-evidence.ts`, `tests/unit/security-followup.test.ts`, finalizer tests.
**Interface:** `evaluatePromotionEvidence` itself compares the parsed vulnerability report's signed `secretScanAttestation` with `expected.secretScanAttestation`. A mismatch makes vulnerability and overall promotion status `FAIL_UNVERIFIED`, including the production finalizer path.
- [x] Add a RED evaluator mismatch test.
- [x] Implement exact equality before vulnerability PASS assignment.
- [x] Run security/provider-focused tests.
### Task E3: Downstream CLI exact-five contract
**Files:** `tests/unit/ci-artifact-contract.test.ts`, `tests/unit/security-followup.test.ts`, `scripts/verify-exact-promotion-bundle.ts` if required.
**Interface:** A real finalizer-produced canonical exact-five directory passes the downstream CLI when all required external expected values and trust keys are supplied. Every required expected variable missing or mismatched exits non-zero. Optional digests remain exact when present.
- [x] Add RED happy-path and required-env negative coverage using real finalizer output where sandbox execution permits.
- [x] Make only the minimal CLI/library changes needed for GREEN.
- [x] Separate child-process sandbox blockers from library assertions.
### Task E4: Verification
- [x] Run focused parser/security/CLI suites, TypeScript, ESLint, `check:ci`, and `git diff --check`.
- [x] Run full unit if feasible and report nested-process `EPERM` separately.
- [x] Do not stage or commit.
## Wave F: Manager parser boundary hardening
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`, stage, or commit. Add behavior tests before production changes and keep unsupported manager grammar fail-closed.
### Task F1: Workspace dispatch and authoritative script lookup
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** The parser receives the authoritative root `scripts` record. Explicit `run` resolves a root script; pnpm/yarn implicit dispatch resolves only a known root script. Yarn `workspace` and `workspaces` dispatch are unsupported because the root graph does not load workspace package scripts. Builtin aliases are canonicalized before root-script lookup.
- [x] Add RED policy and graph tables for the three Yarn workspace dispatchers, pnpm `ln`, and unknown manager subcommands.
- [x] Remove workspace dispatchers from safe builtins, pass known root scripts into the parser, and canonicalize `pnpm ln` to lifecycle `link` before implicit lookup.
- [x] Run the focused dependency/lifecycle cases to GREEN.
### Task F2: Shell comments and lifecycle option state
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`
**Interface:** Unquoted `#` in a manager-bearing command is unsupported control syntax; quoted `#` remains ordinary token content. Lifecycle options are parsed in order into a canonical suppression state covering `--ignore-scripts`, `--no-ignore-scripts`, and `--config.ignore-scripts`; conflicting, malformed, unknown, or ineffective states fail closed. Other option-like lifecycle arguments require an explicit manager allowlist.
- [x] Add RED comment, negative suppression, supported positive, and unknown lifecycle-option tables.
- [x] Implement comment-aware tokenization and one ordered lifecycle argument parser.
- [x] Preserve the checked-in `--frozen-lockfile --ignore-scripts` path and run focused tests to GREEN.
### Task F3: Verification
- [x] Validate every checked-in package script through graph/install consumers without false positives.
- [x] Run the full workflow-generation file and related security tests.
- [x] Run all TypeScript targets, ESLint, `check:ci`, and `git diff --check`; report nested-process `EPERM` separately.
- [x] Update the durable report; do not stage or commit.
## Wave G: Verified cleanup and complete gate/parser preflight
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`, stage, or commit. Every production change follows a focused failing behavior test.
### Task G1: Verified-FD-only failure cleanup
**Files:** `scripts/lib/promotion-stager.ts`, `tests/unit/security-followup.test.ts`, security/operations documentation.
**Interface:** `openedIdentityVerified` becomes true only after the opened directory descriptor matches the post-`mkdir` device/inode. Failure cleanup may unlink canonical files or `rmdir` only through that verified descriptor and a still-matching visible identity. A mismatched opened descriptor and any visible replacement are close-only; a moved original directory remains for fixture/operator cleanup because portable Node cannot safely recover it.
- [x] Change the pre-open replacement regression to require both the replacement canary and displaced original directory to survive the failure.
- [x] Run the focused test to RED against parent-directory identity scanning.
- [x] Remove unverified inode discovery/recovery and gate descriptor cleanup on explicit identity verification.
- [x] Run staging race and cleanup tests to GREEN and document the native residual.
### Task G2: Contract-wide lifecycle preflight
**Files:** `scripts/contracts/ci-gates.ts`, `scripts/run-ci-gate.ts`, optional focused runner helper, `tests/unit/ci-workflow-generation.test.ts`.
**Interface:** `loadCiGateContract` runs `validateInstallScriptPolicy` over every unique contract command script after script existence and graph checks. The runner enters its execution callback only after this loader succeeds, enabling a no-execute regression without relying on a nested child process.
- [x] Add RED loader tables for contradictory npm suppression, pnpm config false, and `pnpm ln`, plus a production runner-boundary no-execute spy.
- [x] Enforce contract-command install policy and route runner execution through the preflight boundary.
- [x] Run loader/runner preflight tests to GREEN.
### Task G3: Foreign manifest scope by command class
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Manager-global options that change cwd, manifest or workspace scope are recorded during parsing. Explicit and implicit package-script dispatch with any such option is unsupported under the root-only graph. Lifecycle commands remain classifiable and are accepted only when their own ordered suppression/option grammar is safe.
- [x] Add RED policy+graph tables for pnpm filter/dir/`-C`, npm workspace/prefix, and Yarn cwd dispatch.
- [x] Add positive externally scoped lifecycle cases with verified suppression.
- [x] Track scope options and reject only package-script dispatch; run focused tests to GREEN.
### Task G4: Argument-sensitive builtin grammar and verification
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`, durable report.
**Interface:** Broad command-name-only safe builtins are replaced by exact per-manager read-only invocations. Init/explore/Yarn npm namespaces are unsupported. Audit is accepted only as an exact bare read-only command; `fix` and all unknown arguments are rejected.
- [x] Add RED policy+graph coverage for npm init/explore/audit-fix and Yarn npm publish, plus a bare-audit positive.
- [x] Replace permissive builtin lookup with exact argument grammar.
- [x] Audit every current package script for graph/policy false positives.
- [x] Run staging/parser/no-execute/workflow/security suites, all TypeScript targets, ESLint, `check:ci`, and `git diff --check`; record sandbox `EPERM` separately and do not stage or commit.
## Wave H: npm post-script scope-option boundary
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`, stage, or commit. Reproduce every reviewer command in a failing test before changing the parser.
### Task H1: Explicit and implicit npm dispatch arguments
**Files:** `scripts/lib/package-script-graph.ts`, `tests/unit/ci-workflow-generation.test.ts`.
**Interface:** After an explicit `npm run`/`run-script` dependency or an implicit npm lifecycle script, manager options before the first literal `--` are parsed using an exact harmless allowlist. Workspace/prefix selectors (`--workspace`, `-w`, `--workspaces`, `--prefix`, including supported attached/equal forms) and unknown manager options fail closed. Tokens after the first literal `--` are script arguments and cannot change the authoritative manifest scope.
- [x] Add RED policy-and-graph coverage for all seven reviewer inputs, short/equal forms, unknown pre-delimiter options, the literal `--` boundary, and ordinary current-tree dispatch.
- [x] Implement one npm post-script argument parser shared by explicit and implicit dispatch.
- [x] Run focused parser tests to GREEN.
### Task H2: Contract loader and runner boundary
**Files:** `tests/unit/ci-workflow-generation.test.ts`, contract preflight only if the RED test exposes a separate integration defect.
**Interface:** Every reviewer input is rejected by contract loading while the referenced root scripts exist and are otherwise safe. `withCiGatePreflight` must not enter its callback for any rejected command.
- [x] Add a table-driven loader/no-callback regression for the same seven reviewer inputs.
- [x] Run focused preflight tests to GREEN.
### Task H3: Verification
- [x] Re-audit current package scripts through graph and policy consumers.
- [x] Run workflow/security suites, all TypeScript targets, ESLint, `check:ci`, and `git diff --check`.
- [x] Record results in the durable report and do not stage or commit.
**Verification evidence:** The focused npm parser/preflight selection passed
31/31. The workflow file passed 208/210; its two remaining tests reached the
known nested-spawn sandbox boundary and reported `EPERM`. Security, supply-chain,
and local-promotion tests passed 64/64. All six TypeScript targets, ESLint,
`check:ci`, and `git diff --check` passed. Auditing the checked-in package found
zero policy failures across 109 scripts and zero graph failures across 108
entries (excluding the intentionally direct runner entry `ci:gate`). A broader
artifact-contract run passed 102 assertions and blocked 23 fixture cases at the
same nested `git ls-files` `EPERM` boundary. No `.git` write was performed.
Local pnpm 11.17 execution showed post-script `--filter`/`--dir` tokens arriving
in the root script's argv, and official Yarn run documentation defines all
parameters after the script name as script arguments. Those pre-existing
negative expectations were therefore corrected to positive regressions; only
npm receives the new post-script manager-option grammar.
## Wave I: npm hook closure and environment scope
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`,
stage, or commit. Add focused behavior tests and observe RED before each
production change.
### Task I1: npm pre/main/post dependency closure
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Explicit npm `run`/`run-script` and implicit
`start`/`stop`/`restart`/`test` return existing root-manifest lifecycle hooks in
`pre`, main, `post` order. Hooks are omitted only when ordered manager/tail
suppression is unambiguously effective before the first literal `--`; bare,
false, negative, contradictory, malformed, or post-delimiter suppression keeps
hook traversal active or fails closed.
- [x] Add RED policy/graph tables for nested, test, and restart pre/post hooks.
- [x] Add RED suppression positives and false/negative/contradictory/delimiter negatives.
- [x] Make npm tail parsing update the invocation suppression state and expand dependencies.
- [x] Run hook/parser tests to GREEN.
### Task I2: Tokenized npm scope environment
**Files:** `scripts/lib/package-script-graph.ts`, `scripts/contracts/ci-gates.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Case-insensitive assignments to `npm_config_workspace`,
`npm_config_workspaces`, or `npm_config_prefix` fail closed when their shell
segment executes npm. Direct assignment, `env`, `/usr/bin/env`, and an exported
assignment inherited by a later npm segment are covered without raw-substring
false positives for quoted text. `withCiGatePreflight` also rejects the same
sensitive keys inherited through `process.env` before entering its callback.
- [x] Add RED policy/graph coverage for all reviewer assignment forms and quoted/current-tree positives.
- [x] Add RED loader/no-callback coverage for command assignments and inherited process environment.
- [x] Implement token/segment assignment state and the preflight environment boundary.
- [x] Run environment/parser/preflight tests to GREEN.
### Task I3: Verification
- [x] Audit every current script through graph and policy consumers.
- [x] Run focused parser/preflight, workflow/security, all TypeScript targets,
ESLint, `check:ci`, and `git diff --check`.
- [x] Update durable operations/security documentation and record sandbox-only
nested spawn failures separately; do not stage or commit.
**Verification evidence:** Hook closure began RED 9/9 and GREEN 9/9;
ordered suppression began with 6 expected failures and finished GREEN 22/22;
wrapper/export/inherited environment coverage began with 7 expected failures
and finished GREEN 28/28. A final all-command-class environment RED 3/3
closed scoped lifecycle and builtin invocations. The combined Wave I focused
selection passed 59/59. The complete workflow file passed 269/271; its only
two failures were the existing nested child-spawn `EPERM` fixtures. Security,
supply-chain, and local-promotion tests passed 64/64. All six TypeScript
targets, ESLint, `check:ci`, and `git diff --check` passed. The checked-in tree
had zero policy failures across 109 scripts, zero graph failures across 108
entries after excluding the intentional direct runner entry `ci:gate`, and no
sensitive inherited npm scope environment. No `.git` write was performed.
## Wave J: coherent npm environment state and hook semantics
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`,
stage, or commit. Add each reviewer form as a failing regression before changing
the parser.
### Task J1: Stateful shell npm-scope environment analysis
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Shell segments carry a conservative npm-scope environment state
to later reachable npm invocations. The parser recognizes any static path whose
basename is `env`, optionally behind `command`, and rejects case-insensitive
scope assignments in direct or env-wrapper contexts. Static assignment/export
and `set -a` transitions are modeled across segments. Dynamic assignment names
and environment mutations that cannot be modeled accurately (`set +a`,
`unset`, `export -n`, `eval`, dot/source) make later npm dispatch unsupported.
Quoted harmless text and non-scope static assignments remain accepted; analysis
uses token and segment structure rather than raw substring matching.
- [x] Add RED policy/graph tables for every reviewer state transition, env path,
command wrapper, dynamic assignment name, and unsupported mutation.
- [x] Implement a shared tokenized shell-environment state machine and immediate
npm invocation environment inspection.
- [x] Add harmless quoted/static positive regressions and run focused tests GREEN.
### Task J2: Actual npm lifecycle-hook suppression semantics
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** Existing pre/main/post hooks are traversed for `run`,
`run-script`, `start`, `stop`, `restart`, and `test`. Bare `--ignore-scripts`
means true and omits hooks just like explicit true. False, negative,
contradictory, malformed, and post-delimiter forms retain hook traversal or fail
closed according to the existing ordered grammar.
- [x] Add hook safety/order coverage for run-script, start, and stop.
- [x] Move bare suppression forms to positive regressions and retain all false,
negative, contradictory, and delimiter negatives.
- [x] Remove the explicitly-valued distinction and run focused tests GREEN.
### Task J3: Contract boundary and verification
- [x] Run every environment reviewer command through policy, graph, contract
loading, and `withCiGatePreflight`, asserting the callback is never entered.
- [x] Retain the inherited process-environment regression and audit the current
package tree for policy/graph false positives.
- [x] Run workflow/security suites, all TypeScript targets, ESLint, `check:ci`,
and `git diff --check`; record sandbox-only failures and do not stage or commit.
**Verification evidence:** The initial Wave J selection produced 38 expected
failures across loader/policy/graph environment cases and bare hook suppression,
then passed 84/84 after implementation. A separate unsupported dynamic env-wrapper
expansion regression went RED 2/2 and GREEN 2/2; the final combined selection
passed 86/86. The full workflow file passed 327/329, with only the existing two
nested child-spawn `EPERM` fixtures failing at the sandbox boundary. Security,
supply-chain, local-promotion, and promotion-readiness tests passed 71/71. All
six TypeScript targets, ESLint, `check:ci`, and `git diff --check` passed. The
checked-in tree had zero policy failures across 109 scripts, zero graph failures
across 108 entries after excluding the intentional direct runner entry `ci:gate`,
and no sensitive inherited npm scope environment. No `.git` write was performed.
## Wave K: common shell-prefix grammar
**Constraint:** Continue in the existing uncommitted tree. Do not write `.git`,
stage, or commit. Every wrapper/prefix reviewer command must be RED in all four
public enforcement paths before production changes.
### Task K1: Shared prefix parser and state-builtin targeting
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** A single token-based prefix helper consumes leading static
assignments, then exact `command`/`exec` wrapper chains and their supported
separator syntax. It reports the effective command token/index, whether parsing
is uncertain, and the leading assignments. Both immediate npm env inspection
and persistent `export`/`set` state updates use this result. `command --` is
accepted; unknown `command` options and unmodeled `exec` options before npm/env
fail closed. Static paths retain basename-`env` behavior.
- [x] Add common policy/graph RED cases for `exec env`, `exec /bin/env`,
`command exec env`, `command -- env`, assignment-prefixed `export`, and
assignment-prefixed `set -a`.
- [x] Reuse the same reviewer table through `loadCiGateContract` and
`withCiGatePreflight`, asserting rejection and no callback entry.
- [x] Implement the shared prefix parser, route immediate env inspection and
state-builtin updates through it, and run the reviewer selection GREEN.
- [x] Preserve split assignment/export ordering, dynamic LHS, quoted text,
harmless `MESSAGE=...`, and supported command-wrapper positives.
### Task K2: Hook selection and final verification
**Files:** `tests/unit/ci-workflow-generation.test.ts`, durable report.
- [x] Ensure the final focused selection explicitly includes the bare npm hook
suppression table as well as prefix/environment policy and runner tests.
- [x] Run the full workflow file and security/supply/local-promotion suites;
classify only the known nested-spawn sandbox failures separately.
- [x] Audit all current scripts through policy and graph, then run all TypeScript
targets, ESLint, `check:ci`, and `git diff --check`; do not stage or commit.
**Verification evidence:** The nine shared shell-prefix reviewer commands began
RED in both enforcement tables, producing 18 expected failures across
loader/runner and policy/graph, then passed 18/18 after the common parser was
connected. The prefix negatives plus harmless positives passed 39/39. The final
focused selection explicitly combined prefix cases, dynamic environment cases,
effective/bare npm hook suppression, and harmless positives and passed 106/106.
The complete workflow file passed 353/355; its only two failures were the known
nested child-spawn `EPERM` fixtures. Security, supply-chain, local-promotion, and
promotion-readiness tests passed 71/71. The current package tree had zero policy
failures across 109 scripts, zero graph failures across 108 entries after
excluding `ci:gate`, and no sensitive inherited npm scope environment. All six
TypeScript targets, ESLint, `check:ci`, and `git diff --check` passed. The shared
workspace was preserved and no `.git` write was performed.
## Wave L: structural manager-prefix gap rejection
**Constraint:** Continue in the shared uncommitted tree. Do not write `.git`,
stage, or commit. Generalize the existing parser; do not add wrapper names to an
allowlist.
### Task L1: Reject unmodeled tokens before package managers
**Files:** `scripts/lib/package-script-graph.ts`,
`tests/unit/ci-workflow-generation.test.ts`.
**Interface:** The common shell-prefix result identifies the first effective
command after modeled assignments and `command`/`exec` wrappers. When manager
scanning later finds a package manager, every token between that effective
command position and the manager position must belong to a grammar explicitly
consumed by immediate env or corepack parsing. Otherwise the invocation is
unsupported. This structural rule covers `nice`, absolute-path `nice`, `nohup`,
and future unknown wrappers without naming them.
- [x] Add policy/graph RED coverage for the four env-wrapper reviewer commands
and direct unknown-wrapper manager commands (`nice npm`, `time pnpm`).
- [x] Reuse the env-wrapper reviewer commands through contract loading and
`withCiGatePreflight`, asserting the callback remains false.
- [x] Implement one structural gap check in manager parsing and run RED cases
GREEN without adding wrapper names.
- [x] Retain modeled assignment, `command`/`exec`/env/corepack, current-tree,
quoted echo, and harmless assignment positives.
### Task L2: Verification
- [x] Run a focused selection containing structural negatives and all modeled
prefix/environment positives.
- [x] Run the workflow and security/supply/local-promotion suites, current-tree
policy/graph/environment audit, all TypeScript targets, ESLint, `check:ci`, and
`git diff --check`; record sandbox-only failures and do not stage or commit.
**Verification evidence:** The six unmodeled-prefix reviewer commands began RED
in both enforcement tables, producing 12 expected loader/runner and policy/graph
failures, then passed 12/12 after one structural prefix-gap check was added. The
unmodeled negatives plus harmless/modeled positives passed 36/36. The final
focused Wave HL environment/prefix and effective/bare hook selection passed
121/121. The full workflow file passed 368/370, with only the two known nested
child-spawn `EPERM` fixtures failing at the sandbox boundary. Security,
supply-chain, local-promotion, and promotion-readiness tests passed 71/71. The
current package tree had zero policy failures across 109 scripts, zero graph
failures across 108 entries after excluding `ci:gate`, and no sensitive inherited
npm scope environment. All six TypeScript targets, ESLint, `check:ci`, and
`git diff --check` passed. No wrapper-name allowlist was added, the workspace was
preserved, and no `.git` write was performed.
@@ -0,0 +1,499 @@
# Provider Evidence Guardian Transaction Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make guardian startup cleanup derive authority only from identities allocated before spawn while preserving no-replace raw/sealed publication and immediate safe retry.
**Architecture:** Before spawn, the client pins the canonical raw/evidence directories and exclusively allocates nonce-private raw-staging/sealed-temp inodes whose handles and identities it retains. The guardian inherits directory fd 3/fd 4 and private-file fd 5/fd 6, binds strictly validated aliases to those inherited identities, and transfers raw authority with a no-replace hard link before authenticated READY. The client and guardian clean only pre-recorded identities; neither promotes a pathname-discovered inode to ownership.
**Tech Stack:** Node.js 24 TypeScript, Vitest, Linux file identities and procfs, systemd user scopes, bubblewrap, cgroup v2.
## Global Constraints
- Tasks 1-7 are the historical round-four/five record. Round-six Task 8 runs only in `/tmp/guardian-race-fix-y05lvLi1/repo` on top of `d781692`; never modify the original workspace, `/tmp/task3-integration-mU4L7J2u`, or the security-finalizer repository.
- Use RED-GREEN-REFACTOR for every production behavior change.
- Guardian argv contains only `process.execPath` and the trusted guardian script; its environment is empty, fd 3/fd 4 are the identity-pinned raw/evidence directories, and fd 5/fd 6 are the identity-pinned private raw/sealed allocations.
- Every request/ack is canonical length-prefixed JSON with exact ordered fields, strict UTF-8, no NUL, total bounds, a 32-byte nonce, and constant-time authentication.
- Canonical raw and sealed paths are derived from guardian `cwd` and provider kind; paths and identities are not accepted in the guard request.
- Provider wall timeout is at most 30 minutes and post-processing allowance is exactly 10 minutes; the guardian maximum lease is 40 minutes.
- Publication is no-replace and directory-durable. Abort/death/deadline cleans every raw/temp/final path that still names a pinned owned inode.
- Preserve all cleanup failures with the primary failure using `AggregateError`.
- Do not add PID-exhaustion loops or claim `RLIMIT_NPROC` enforcement.
- Do not run or report live systemd/bwrap tests as passing while the approval limit prevents execution.
- Never forward raw provider stdout/stderr bytes to supervisor or CI logs.
---
### Task 1: Versioned Transaction Protocol
**Files:**
- Modify: `scripts/lib/provider-guardian-protocol.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
**Interfaces:**
- Produces:
```ts
type ProviderGuardianGuard = Readonly<{
kind: "vulnerability" | "provenance";
nonce: Buffer;
deadlineEpochMs: number;
}>;
type ProviderGuardianReady = Readonly<{
nonce: Buffer;
rawDev: number;
rawIno: number;
sealedTempLeaf: string;
sealedDev: number;
sealedIno: number;
}>;
type ProviderGuardianPublish = Readonly<{
nonce: Buffer;
sealedDev: number;
sealedIno: number;
size: number;
sha256: string;
}>;
function encodeProviderGuardianGuard(input: ProviderGuardianGuard): Buffer;
function decodeProviderGuardianReady(payload: Buffer, nonce: Buffer): ProviderGuardianReady;
function encodeProviderGuardianPublish(input: ProviderGuardianPublish): Buffer;
function decodeProviderGuardianPublished(payload: Buffer, nonce: Buffer): void;
function encodeProviderGuardianCommit(nonce: Buffer): Buffer;
```
- [ ] **Step 1: Write failing exact-protocol tests**
Assert that guard contains no path or identity, READY returns authenticated identities, publish binds exact identity/size/SHA-256, PUBLISHED authenticates the same nonce, and duplicate/reordered/trailing/oversized/invalid UTF-8/NUL/short-nonce frames fail.
```ts
expect(JSON.parse(encodeProviderGuardianGuard(guard).subarray(4).toString())).toEqual({
type: "guard", version: 2, kind: "vulnerability",
nonce: nonce.toString("hex"), deadlineEpochMs,
});
expect(() => decodeProviderGuardianReady(duplicateNoncePayload, nonce)).toThrow(/canonical|fields/u);
```
- [ ] **Step 2: Run focused RED**
Run: `node_modules/.bin/vitest run tests/unit/task3-selective-integration.test.ts --reporter=default --maxWorkers=1`
Expected: FAIL because the v2 guard/READY/publish/PUBLISHED APIs do not exist and the old guard still accepts identities.
- [ ] **Step 3: Implement the minimal v2 codecs**
Use one bounded prefix/strict decode utility, exact ordered key arrays, canonical re-encoding, lowercase 64-hex nonces/SHA-256, safe positive integers, and `timingSafeEqual` for every acknowledgement/authentication comparison.
- [ ] **Step 4: Run focused GREEN**
Run the Step 2 command and require the protocol tests to pass.
### Task 2: Guardian-Owned Raw and Sealed Transaction
**Files:**
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Modify: `scripts/lib/provider-raw-cleanup.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
**Interfaces:**
- Consumes: Task 1 codecs.
- Produces: real process state machine `guard -> READY -> publish -> PUBLISHED -> commitPending -> EOF success`.
- [ ] **Step 1: Write failing real-process creation tests**
Cover no-frame and partial-frame EOF with no files, authenticated READY-created raw/temp identities and modes, full-frame parent EOF cleanup before commit, deadline cleanup, and a near-timeout successful transaction.
```ts
child.stdin.end(partialFrame);
await completion;
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(await lstat(rawPath)).toMatchObject({ mode: expect.any(Number) });
```
- [ ] **Step 2: Verify creation RED**
Run the focused test and require failure because the existing guardian expects supervisor-created identity and emits line-based READY.
- [ ] **Step 3: Implement exclusive creation and READY**
Derive canonical leaves, create raw and random sealed sibling temp with `O_EXCL|O_NOFOLLOW`, set raw/temp `0600`, fstat identities, close raw, keep temp handle, and emit bounded READY. On every error, attempt all owned cleanup before nonzero exit.
- [ ] **Step 4: Write failing publish/state tests**
Write validated bytes to the pinned temp, request publish, require PUBLISHED and final mode/hash/identity, then verify commit waits for EOF. Send one later trailing byte after commit and require final cleanup/nonzero exit. Kill the parent after PUBLISHED and require raw/temp/final absence.
- [ ] **Step 5: Implement no-replace durable publish and serialized terminal cleanup**
Verify held descriptor/path identity, `nlink=1`, `0400`, size, and SHA-256. Use `link(temp, final)`, `unlink(temp)`, final lstat identity, and parent-directory fsync. Serialize frame and EOF handling so a publish/death race cannot bypass cleanup. Commit removes raw and sets `commitPending`; only clean EOF exits zero.
- [ ] **Step 6: Run real-process GREEN**
Run focused tests and require zero raw/temp/final/process residuals in every failure case.
### Task 3: Authenticated Client Lease and Fallback Cleanup
**Files:**
- Modify: `scripts/lib/provider-guardian-client.ts`
- Modify: `scripts/lib/validated-json-artifact.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
- Modify: `tests/unit/validated-json-artifact.test.ts`
**Interfaces:**
- Produces:
```ts
type ProviderGuardianLease = Readonly<{
pid: number;
rawPath: string;
rawIdentity: Readonly<{ dev: number; ino: number }>;
sealedPath: string;
sealedTempPath: string;
sealedIdentity: Readonly<{ dev: number; ino: number }>;
prematureExit: Promise<Error>;
publish(bytes: Buffer): Promise<void>;
commit(): Promise<void>;
abort(): Promise<void>;
}>;
function serializeValidatedJsonArtifact(input: ValidatedJsonArtifactInput): Buffer;
```
- [ ] **Step 1: Write failing client transaction tests**
Require exact guardian argv and empty environment, READY identity capture, pinned temp write/fsync/mode, PUBLISHED wait, exactly-one terminal action, post-READY guardian SIGKILL cleanup of raw/temp/final, and cleanup error aggregation.
- [ ] **Step 2: Verify client RED**
Run focused and validated-writer tests. Expect missing publish/identity/serializer APIs.
- [ ] **Step 3: Implement serialization and lease**
Extract the existing schema-parse/pretty-JSON/newline serialization without changing `writeValidatedJsonArtifact`. Open the returned temp with `O_NOFOLLOW`, fstat identity, truncate/write/chmod `0400`/fsync/fstat/close, send authenticated publish metadata, and wait for PUBLISHED. Fallback cleanup attempts raw, temp, and final using READY identities and aggregates failures.
- [ ] **Step 4: Run client GREEN**
Run the Step 2 tests and require exact bytes, identities, cleanup, and no residual child.
### Task 4: Supervisor Transaction and Scope-Active Latch
**Files:**
- Modify: `scripts/run-and-validate-provider.ts`
- Modify: `tests/unit/task3-selective-integration.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: Task 3 lease and serializer.
- Produces: guardian-owned raw/provider execution, awaited publication, output append, commit/EOF, and scope-confined kill ownership.
- [ ] **Step 1: Write failing supervisor ordering/latch tests**
Require no `createProviderOutput`, lease start before provider, lease raw identity passed to scope, serialized bytes published before output append, commit after output append, and postprocess allowance included in lease. Add a pure scope-latch unit boundary or static contract proving the guardian callback can call `killProviderUnit` only while `scopeActive` is true.
- [ ] **Step 2: Verify supervisor RED**
Run focused tests and expect the old create/write/cleanup ordering assertions to fail.
- [ ] **Step 3: Integrate the lease transaction**
Start guardian in `executeProvider`, use READY raw path/identity for provider bind and capture, publish serialized validated evidence through the lease, append output, then commit. Remove supervisor raw creation and normal sealed writer publication. Keep only identity-bound lease fallback cleanup.
Set `PROVIDER_POSTPROCESS_TIMEOUT_MS = 600_000` and request `providerWallTimeoutMs + PROVIDER_POSTPROCESS_TIMEOUT_MS`.
- [ ] **Step 4: Implement scope-active guardian exit ownership**
Race an awaited scope-completion promise against termination. The guardian callback records its error and invokes termination only while `scopeActive`; the same function sets the latch false exactly once when kill/collection or normal collection completes. The callback never throws or creates an unobserved kill promise after the latch closes.
- [ ] **Step 5: Run supervisor GREEN**
Run focused tests and type/lint checks. Live systemd tests remain unexecuted and are not reported as passing.
### Task 5: Regression Fixtures and Documentation
**Files:**
- Modify: `tests/unit/task3-selective-integration.test.ts`
- Modify: `tests/unit/ci-artifact-contract.test.ts`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `docs/security/supply-chain.md`
- Modify: `docs/superpowers/specs/2026-08-02-provider-raw-guardian-design.md`
- [ ] **Step 1: Complete real-process regressions**
Cover no/partial frame, parent kill near READY, post-READY guardian kill, PUBLISHED parent death, publish/commit race, later-chunk commit trailing data, deadline/near-timeout, same-workspace retry, and zero guardian/raw/temp/final residuals.
- [ ] **Step 2: Specify live regressions**
Add active-scope guardian kill, post-scope/precommit guardian kill, supervisor hard death after PUBLISHED with same-workspace retry, and detached descendant attempts for both an external marker and raw append. Every case requires zero cgroup/process/file residuals. Do not execute these tests under the current approval limit.
- [ ] **Step 3: Correct operations and security docs**
Document guardian-owned creation/publication, READY/PUBLISHED identities, ten-minute postprocess lease, commitPending/EOF success, no-replace link publication, scopeActive kill ownership, regular-file `GITHUB_OUTPUT`, and explicit live-test limitation.
- [ ] **Step 4: Fresh verification**
Run focused real-process tests, validated artifact tests, direct Node/test/recipe TypeScript configs, full lint, artifact schemas, CI contract, generated workflow byte check, and `git diff --check`. Record broad-suite sandbox `EPERM` separately and never convert unexecuted live tests into PASS.
- [ ] **Step 5: Review and commit round four**
Confirm only the isolated worktree changed, no protocol secret/path enters argv, cleanup checks both sealed names by identity, and only the temp `node_modules` symlink remains untracked. Create a separate round-four implementation commit above the design/plan commit.
### Task 6: Round-Five Pre-READY Recovery Authority
**Files:**
- Modify: `scripts/lib/provider-guardian-protocol.ts`
- Modify: `scripts/lib/provider-guardian-client.ts`
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Test: `tests/unit/provider-guardian-transaction.test.ts`
**Interfaces:**
- Produces: `providerGuardianSealedTempLeaf(kind, nonce): string`, inherited raw/evidence directory fds 3/4, and descriptor-relative startup/lease cleanup.
- [x] **Step 1: Write failing pre-READY hard-death tests**
Start the real client without awaiting READY, observe its direct guardian child,
kill the guardian when either deterministic transaction leaf first appears, and
require startup rejection, zero raw/temp/final residuals, and a successful
same-workspace `startProviderGuardian(...).abort()` retry. Also require the temp
leaf computed before spawn to equal READY exactly and inherited fd 3/fd 4 to
remain directories during the lease.
- [x] **Step 2: Run focused RED**
Run: `node_modules/.bin/vitest run tests/unit/provider-guardian-transaction.test.ts --reporter=default --maxWorkers=1`
Expected: FAIL because the client has neither pre-spawn directory handles nor a
deterministic temp leaf and cannot clean a guardian killed before READY.
- [x] **Step 3: Implement pinned descriptor recovery**
Open and verify the canonical raw/evidence directories with
`O_DIRECTORY|O_NOFOLLOW`; derive the temp leaf from provider kind and the first
16 nonce bytes; spawn with those handles at fd 3/fd 4. Use only
`/proc/self/fd/<fd>/<leaf>` for guardian creation, publication, sync, and cleanup.
On startup failure, open each exact leaf through the still-live client
descriptor, fstat a regular single-link inode, close the discovery handle, and
run identity-bound quarantine/unlink. Aggregate primary, cleanup, and directory
close errors. Retain both handles until commit/abort terminates.
- [x] **Step 4: Run focused GREEN**
Run the Step 2 command and require the pre-READY kill/retry and all round-four
transaction tests to pass.
### Task 7: Round-Five Log Privacy and Terminal Fail-Closed Behavior
**Files:**
- Modify: `scripts/run-and-validate-provider.ts`
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Test: `tests/unit/provider-guardian-transaction.test.ts`
- Test: `tests/unit/ci-artifact-contract.test.ts`
**Interfaces:**
- Consumes: Task 6 descriptor-pinned transaction.
- Produces: bounded discard of provider output and nonzero guardian termination even when diagnostic fds are closed.
- [x] **Step 1: Write failing privacy and closed-stderr tests**
Run a successful provider that both receives and prints a unique
`VULNERABILITY_PROVIDER_*` credential, then assert the credential is absent from
supervisor stdout/stderr while the sealed signed evidence succeeds. Replace the
FD-limit provider's stderr marker expectations with evidence/side-channel state.
Spawn a real guardian with stderr's read side destroyed, establish owned files,
then abort or send invalid input and require zero files plus a nonzero exit.
- [x] **Step 2: Run targeted RED**
Run the focused guardian and selected CI artifact tests. Expect credential
disclosure and the existing raw provider stderr marker assertions to fail the
new contract; the EPIPE case can exit without the required nonzero terminal.
The executable non-live RED observed six expected failures: missing deterministic
leaf/fd inheritance/output limiter, retained pre-READY raw, and closed-stderr
exit 0. The live credential-printing fixture is authored but remains NOT RUN.
- [x] **Step 3: Implement minimal privacy and terminal fixes**
Continue counting provider stdout/stderr bytes against the aggregate output
limit but discard captured bytes instead of retaining or forwarding them. Make
guardian fd-close and stderr diagnostics best effort, run cleanup first, and
place `process.exit(exitCode)` or self-`SIGKILL` in an unconditional final
branch that cannot be skipped by `EPIPE`/`EBADF`.
- [x] **Step 4: Run targeted GREEN and regression verification**
Run focused guardian tests, selected non-live privacy tests, Node/test
TypeScript, affected ESLint, docs readiness, and `git diff --check`. Do not run
live systemd/bwrap tests under the approval limit.
- [x] **Step 5: Commit round five implementation**
Commit production, tests, and operational/security documentation separately
above this round-five design/plan commit. Record live systemd/bwrap as NOT RUN.
Round-five verification record:
- Focused real-process/unit GREEN: 4 files, 52 tests passed.
- Direct Node and test TypeScript projects: PASS.
- Affected ESLint with zero warnings: PASS.
- Documentation readiness: `PASS_SCOPED`.
- `git diff --check`: PASS.
- Live systemd/bwrap credential, FD-limit, cgroup, and hard-death fixtures:
**NOT RUN** because the active approval limit forbids those executions.
### Task 8: Round-Six Pre-READY Inode Ownership
**Files:**
- Modify: `scripts/lib/provider-guardian-protocol.ts`
- Modify: `scripts/lib/provider-guardian-client.ts`
- Modify: `scripts/lib/provider-raw-guardian.ts`
- Test: `tests/unit/provider-guardian-transaction.test.ts`
- Modify: `docs/security/supply-chain.md`
- Modify: `docs/operations/ci-quality-gates.md`
- Modify: `docs/superpowers/specs/2026-08-02-provider-raw-guardian-design.md`
**Interfaces:**
- Produces:
```ts
function providerGuardianRawStagingLeaf(
kind: ProviderGuardianKind,
nonce: Buffer,
): string;
type RecoveryAuthority = Readonly<{
rawDirectoryHandle: FileHandle;
evidenceDirectoryHandle: FileHandle;
rawStagingHandle: FileHandle;
sealedTempHandle: FileHandle;
rawIdentity: Readonly<{ dev: number; ino: number }>;
sealedIdentity: Readonly<{ dev: number; ino: number }>;
rawStagingPinnedPath: string;
rawPinnedPath: string;
sealedTempPinnedPath: string;
sealedPinnedPath: string;
}>;
```
- [ ] **Step 1: Write the deterministic external-canary RED**
Create a temporary guardian fixture that writes a spawn marker and remains
alive without producing READY. Start the real client, wait for that marker (so
`assertRecoveryLeavesMissing` has completed), create a fixed-raw canary, kill
the direct guardian, and require startup rejection without canary deletion or
mutation.
```ts
const canaryBytes = Buffer.from("external-canary\n");
const canaryHandle = await open(rawPath, constants.O_CREAT | constants.O_EXCL |
constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
await canaryHandle.writeFile(canaryBytes);
const canaryIdentity = await canaryHandle.stat();
await canaryHandle.close();
process.kill(guardianPid, "SIGKILL");
await expect(starting).rejects.toThrow(/provider guardian/u);
expect(await readFile(rawPath)).toEqual(canaryBytes);
expect(await lstat(rawPath)).toMatchObject({
dev: canaryIdentity.dev,
ino: canaryIdentity.ino,
});
```
- [ ] **Step 2: Run the canary RED and confirm the ownership bug**
Run:
`node_modules/.bin/vitest run tests/unit/provider-guardian-transaction.test.ts -t "preserves an external raw canary" --reporter=default --maxWorkers=1`
Expected: FAIL with `ENOENT` when reading the canary because
`discoverAndCleanupOwnedLeaf` opens the current raw pathname and promotes the
external inode to cleanup authority.
- [ ] **Step 3: Add private-leaf derivation and client allocations**
Derive raw staging and sealed temp from the same first 16 nonce bytes:
```ts
return `.${baseLeaf(kind)}.guardian-${nonce.subarray(0, 16).toString("hex")}.raw.tmp`;
```
Through the pinned directory paths, create raw staging and sealed temp with
`O_RDWR|O_CREAT|O_EXCL|O_NOFOLLOW`, mode `0600`; require regular file, link count
one, mode `0600`, and size zero; store identities before spawn. Spawn with fd
3-fd 6. If allocation, validation, or spawn fails, identity-clean every private
alias and close every opened handle while preserving primary and cleanup/close
errors in one `AggregateError`.
- [ ] **Step 4: Add concurrency, bootstrap, and link-before-READY RED tests**
Add real-process tests that require:
```ts
// no/partial frame: bootstrap-owned private aliases are removed
child.stdin!.end(partialFrame);
await expect(readdir(rawDirectory)).resolves.toEqual([]);
// same kind: exactly one READY lease, loser never removes winner raw
const results = await Promise.allSettled([startProviderGuardian(input), startProviderGuardian(input)]);
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
// canonical raw link exists but READY has not been accepted
process.kill(guardianPid, "SIGKILL");
await expect(starting).rejects.toThrow(/provider guardian/u);
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
```
The link-before-READY test watches only the fixed raw basename, obtains the
direct child pid before the event, and kills on that exact link event so private
allocation events cannot satisfy the synchronization point. Each test performs
a same-workspace retry and requires no owned private/canonical residue.
- [ ] **Step 5: Implement guardian bootstrap identity binding**
At process bootstrap, fstat fd 5/fd 6 and read `/proc/self/fd/5|6`. Accept an
alias only if `dirname(readlink)` is the canonical expected directory, basename
is a direct child matching the exact raw-staging or sealed-temp lowercase-hex
grammar, both names encode the same kind/nonce prefix, and descriptor-relative
lstat equals the inherited fd identity/type/mode/size/link count. Store the fd
identity before reading any pathname; the pathname only becomes an alias for
that identity.
On valid guard, require exact `providerGuardianRawStagingLeaf(kind, nonce)` and
`providerGuardianSealedTempLeaf(kind, nonce)` matches. Use
`link(rawStaging, rawCanonical)` without replacement, check both aliases equal
the inherited raw identity with link count two, unlink raw staging, fsync fd 3,
and check raw canonical remains the same identity with link count one before
READY. Use the inherited sealed identity for READY and publication.
- [ ] **Step 6: Replace discovery cleanup and close all private fds**
Delete `discoverAndCleanupOwnedLeaf`. Client pre-READY and fallback cleanup
attempts raw staging/canonical with only `recovery.rawIdentity`, then sealed
temp/final with only `recovery.sealedIdentity`. Guardian no/partial-frame and
terminal cleanup uses only its bootstrap fd identities and bound aliases.
On success and every failure branch, attempt all cleanup first, close guardian
fd 5/fd 6 duplicates and client fd 3-fd 6 handles exactly once, and append every
close failure to the existing aggregate. Never open a current leaf to obtain a
new cleanup identity.
- [ ] **Step 7: Run focused GREEN and regressions**
Run:
```bash
node_modules/.bin/vitest run tests/unit/provider-guardian-transaction.test.ts --reporter=default --maxWorkers=1
node_modules/.bin/vitest run tests/unit/provider-output-limiter.test.ts tests/unit/ci-artifact-contract.test.ts --reporter=default --maxWorkers=1
node_modules/.bin/tsc --project tsconfig.node.json
node_modules/.bin/tsc --project tsconfig.test.json
node_modules/.bin/eslint scripts/lib/provider-guardian-protocol.ts scripts/lib/provider-guardian-client.ts scripts/lib/provider-raw-guardian.ts tests/unit/provider-guardian-transaction.test.ts --max-warnings=0
node scripts/verify-documentation-readiness.ts
git diff --check
```
Require focused tests, Node/test TypeScript, affected ESLint, documentation
readiness, and whitespace verification to pass. Live systemd/bwrap fixtures
remain **NOT RUN** under the current approval limit.
- [ ] **Step 8: Review and commit round six**
Confirm the original workspace, `/tmp/task3-integration-mU4L7J2u`, and the
security-finalizer repository are unchanged; only the temporary `node_modules`
symlink is untracked. Commit production/tests/docs together above design commit
`0b1a1db` and report the isolated path, commit SHA, RED evidence, and fresh GREEN
evidence.
@@ -0,0 +1,222 @@
# Provider Evidence Guardian Transaction Design
## Goal
Make one guardian process own the provider evidence filesystem transaction from
raw creation through sealed publication. A supervisor or provider hard death
must leave neither canonical raw evidence nor a guardian-owned sealed temp/final
inode, and the same workspace must be immediately retryable. Only the complete
authenticated `publish -> PUBLISHED -> commit -> EOF` sequence preserves the
canonical sealed artifact.
## Chosen Ownership Boundary
The guardian owns filesystem identity and publication. The supervisor retains
archive, trust, schema, signature, and evidence validation. This avoids two
unsafe alternatives:
- Keeping raw-only guardianship would leave the sealed rename-to-supervisor-death
cleanup gap.
- Moving evidence validation into the guardian would duplicate security policy
and make the helper unnecessarily privileged and complex.
The client opens the canonical `provider-evidence/untrusted` and
`provider-evidence` directories with `O_DIRECTORY|O_NOFOLLOW` before spawning
the guardian. Those identity-pinned directory descriptors are inherited as fd 3
and fd 4; they are never encoded in argv or the environment. The canonical raw
and final leaves are fixed by provider kind. Before spawn, the client exclusively
creates a nonce-private raw staging inode and the nonce-private sealed temp
inode, records both identities, and inherits their open descriptors as fd 5 and
fd 6. The guardian validates each inherited descriptor against its
descriptor-relative pathname, then publishes raw staging to the fixed raw leaf
with a no-replace hard link. Startup recovery authority is therefore an inode
identity acquired before spawn, never an identity discovered later from an
expected pathname. Both processes perform transaction I/O through
`/proc/self/fd/<fd>/<leaf>` so pathname substitution cannot redirect creation or
recovery into another directory. No raw path, sealed path, identity, provider
command, or credential is an argv value. The initial request contains only the
version, kind, random control nonce, and absolute deadline.
## Transaction Invariants
1. Before a complete valid guard frame, the guardian has not published a
canonical filesystem object. The client may have allocated only zero-byte,
mode `0600`, nonce-private raw staging and sealed temp inodes whose identities
it already holds. EOF with no frame or a partial frame removes both allocations.
2. Before spawning, the client validates that both pinned descriptors name the
expected canonical directories; computes fixed raw/final leaves and
nonce-private raw-staging/sealed-temp leaves; and creates the private leaves
with `O_CREAT|O_EXCL|O_NOFOLLOW`, mode `0600`, size zero, and link count one.
It retains both handles and inherits them as fd 5/fd 6 in addition to directory
fd 3/fd 4.
3. At bootstrap, the guardian fstats fd 5/fd 6, reads only their
`/proc/self/fd/5|6` link targets, and accepts each basename only when it is a
direct child of the canonical fd 3/fd 4 directory and matches the exact
provider-kind/32-lowercase-hex private-leaf grammar. It then requires
descriptor-relative lstat of that basename to match the already-fstat fd
identity, type, mode, size, and link count. This binds a deletion alias to an
inherited identity; it never promotes a pathname-discovered identity to
ownership. The two basenames must encode the same kind and nonce prefix.
4. After guard validation, the guardian verifies that the received kind/nonce
derives those exact bootstrapped private leaves. It verifies fd 5/fd 6 remain
regular zero-byte single-link `0600` files and exactly match the derived
private pathnames. It
uses `link(raw staging, canonical raw)` without replacement, verifies both
names have the inherited raw identity and link count two, unlinks the private
raw name, fsyncs the raw directory, and verifies the canonical raw link count
is one. READY is emitted only after this authority transfer succeeds.
5. READY is authenticated by the request nonce and returns raw dev/inode plus
sealed temp leaf/dev/inode. The supervisor starts the provider only after it
validates this exact bounded response with constant-time nonce equality.
6. The supervisor writes only schema-validated sealed bytes to the temp inode.
It opens with `O_NOFOLLOW`, checks dev/inode before and after writing, applies
mode `0400`, writes the complete bounded bytes, fsyncs, and closes.
7. Publish metadata contains the nonce, sealed dev/inode, byte length, and
SHA-256. The guardian checks the held descriptor and temp pathname identity,
regular-file type, link count, exact mode/size/hash, and canonical final-path
absence.
8. Publication uses atomic no-replace `link(temp, final)`, then unlinks temp and
fsyncs the parent directory. If death occurs between link and unlink, both
names refer to the same pinned inode and both are cleanup candidates.
9. PUBLISHED is authenticated and is emitted only after final pathname identity
and directory durability are verified.
10. Commit is legal only after PUBLISHED. It removes the pinned raw inode and
enters `commitPending`; it does not exit. EOF with no pending bytes is the
sole success terminal and preserves only the sealed final inode.
11. Any data after commit, including a separate later chunk, is a protocol error.
EOF/abort/deadline/protocol failure before the success terminal cleans raw,
temp, and final only when each path still names the guardian-owned identity.
12. If the guardian dies before READY is accepted, the client attempts cleanup
of raw staging, canonical raw, sealed temp, and sealed final aliases using
only the two identities recorded before spawn. A current pathname is never
opened and promoted to an owned identity. A competing canary or same-kind
transaction therefore survives every startup failure.
13. Cleanup attempts every owned target and reports cleanup failures together
with the primary failure using `AggregateError` at the supervisor boundary.
Client fd 3-fd 6 handles and guardian fd 5/fd 6 duplicates are closed on
every success and failure branch; close errors join the same aggregate rather
than skipping remaining cleanup.
Client-side exclusive private allocation is the startup ownership token. The
guardian accepts that token only after inherited-fd, descriptor-relative
pathname, type, mode, size, and link-count checks. Every cleanup identity is
recorded at allocation or authenticated READY; pathname discovery never creates
authority. Creation, validation, link, unlink, chmod, fstat, close, publish,
sync, and cleanup failures all fail closed.
## Bounded Authenticated Protocol
Every control or acknowledgement message is a four-byte big-endian length plus
canonical JSON with an exact ordered field set, strict UTF-8, no NUL, and a
total payload bound. Unknown, duplicate, reordered, oversized, truncated, or
trailing fields are rejected.
The state sequence is:
```text
guard -> READY(raw identity, sealed temp identity)
-> publish(size, sha256, sealed identity)
-> PUBLISHED(sealed identity)
-> commit
-> EOF success
```
All messages carry the same 32-byte random nonce. READY and PUBLISHED are
validated with `timingSafeEqual`; publish and commit are authenticated the same
way. Commit merely changes state, so a byte delivered in a later chunk before
EOF remains observable and causes fail-closed cleanup.
The maximum initial lease is the provider wall timeout plus a fixed ten-minute
post-processing allowance. The provider timeout remains bounded at 30 minutes,
so the guardian maximum is 40 minutes. Near-provider-timeout tests must show
that valid publication still has post-processing time, while an expired lease
cleans all owned objects.
## Supervisor and Scope Exit Ownership
The lease exposes raw/temp/final identities, `publish(bytes)`, `commit()`,
`abort()`, and a non-rejecting premature-exit promise. The client knows all
possible leaves and both startup identities before spawn and retains its pinned
directory and private-file handles until the lease terminates. Before READY it
cleans only aliases that still match those recorded identities. After READY it
checks the guardian response against the same identities and fallback-cleans
raw, temp, and final by identity if the guardian dies.
Provider waiting owns an explicit `scopeActive` latch. A guardian exit starts
whole-scope kill and collection only while that latch is true. Once the scope
completion path has collected the unit, the callback records a lifecycle error
but cannot start an unawaited kill. Publication and terminal commit observe the
guardian exit through their normal awaited failure path and clean sealed state.
Provider stdout and stderr are untrusted secret-bearing byte streams. The
supervisor counts and bounds them for resource enforcement but never forwards
their raw bytes into supervisor/CI stdout or stderr, on either success or
failure. Functional provider assertions use signed evidence or a non-log side
channel. Sealing/output I/O is allowed to settle; the design does not claim
OS-level cancellation. `GITHUB_OUTPUT` is a runner-owned regular file. After
output append succeeds, commit makes the guardian remove raw and EOF completes
the transaction.
Guardian diagnostics are best-effort only. A closed stderr or control descriptor
must not turn a fail-closed branch into a resolved operation or exit zero:
diagnostic and fd-close failures are absorbed after cleanup, and a nonzero exit
or requested fatal signal is issued unconditionally.
## Failure and Recovery
- No/partial guard EOF: no canonical raw or sealed object is published. The
guardian removes both nonce-private allocations through aliases that bootstrap
already bound to inherited fd identities, without needing kind/nonce from a
complete control frame.
- A competing canonical raw canary or another same-kind attempt causes
no-replace link failure. The loser removes only its private identities and
never removes the winner or canary.
- Guardian death after linking raw but before READY: the client uses its
pre-recorded raw identity to clean both private and canonical aliases and its
pre-recorded sealed identity for temp/final aliases, then retries the same
workspace immediately.
- Parent death after creation but before READY: stdout/control pipe failure or
EOF makes the still-running guardian clean both owned objects.
- Guardian death after READY: the supervisor knows raw and sealed identities and
cleans raw, temp, and final fallbacks.
- Supervisor death after PUBLISHED: guardian EOF cleans raw and the published
final inode, including the link/unlink intermediate state.
- Publish or commit race: serialized guardian state completes the current file
operation, then applies EOF/protocol failure cleanup; success requires clean
EOF after commitPending.
- Cleanup failure: remaining targets are still attempted and every error is
preserved; PASS is impossible.
There is one bounded crash window before spawn: if the client itself is killed
after private allocation but before the guardian is created, zero-byte `0600`
nonce-private leaves can remain. They contain no provider or credential bytes
and cannot occupy the fixed canonical raw/final names, so they do not block an
immediate same-kind retry. Automatic pathname sweeping is intentionally omitted
because an unproven stale pathname is not deletion authority.
After each observable managed-process failure, tests require canonical raw,
private staging/temp, canonical final, guardian, and provider cgroup residual
counts to be zero before retrying the same workspace successfully. The
documented pre-spawn client hard-death window is the sole residual exception.
## Verification
Real-process tests cover no/partial frames, a competing raw canary, same-kind
concurrency, guardian `SIGKILL` after raw link but before READY followed by
same-workspace retry, parent death around READY, valid
READY identities, EOF/deadline cleanup, publish/PUBLISHED, post-scope guardian
death, supervisor death after publication, commit trailing bytes in a later
chunk, closed-stderr fail-closed termination, near-timeout publication, and no
residual guardian/files. A provider that successfully prints a supplied
credential is verified not to expose it through supervisor stdout/stderr. Live fixtures
also specify active-scope guardian kill, detached-child external marker/raw
append suppression, cgroup collection, and same-workspace retry. Live
systemd/bwrap execution remains explicitly unverified when the approval limit
prevents running it.
The external-canary regression waits for a test guardian spawn marker before
creating the fixed raw file, proving that the initial absence check has already
completed. The fixed raw bytes and dev/inode must remain unchanged after startup
rejection. The pre-READY link regression watches only the fixed raw basename,
kills the exact direct child on that link event, and requires identity-bound
cleanup plus an immediate same-workspace retry.
+2 -3
View File
@@ -74,14 +74,13 @@
"test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts",
"check:coverage:fixture": "node scripts/check-risk-coverage.ts --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 && corepack pnpm test:recipes",
"verify:lockfile": "corepack pnpm install --frozen-lockfile",
"verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts",
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts",
"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-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",
"verify:promotion": "node scripts/verify-exact-promotion-bundle.ts",
"update:dependency-baseline": "node scripts/update-dependency-baseline.ts",
"check:supply-chain:fixtures": "node scripts/check-supply-chain-fixtures.ts",
"check:supply-chain:provider-fixtures": "node scripts/check-supply-chain-provider-fixtures.ts",
+9 -5
View File
@@ -1,10 +1,13 @@
import { parseAsync } from "@babel/core";
import { spawnSync } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createRequire } from "node:module";
import { architectureDependencyReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type PathRule = Readonly<{ path?: string; pathNot?: string }>;
type ArchitectureRule = Readonly<{
name: string;
@@ -156,10 +159,11 @@ dependencyReport.staticImportGraph = {
typeScriptOnlySourcePolicy: typeScriptOnlyPolicy,
};
await writeFile(
qualityArtifact,
`${JSON.stringify(dependencyReport, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: qualityArtifact,
schema: architectureDependencyReportArtifactSchema,
value: dependencyReport,
});
let architectureFailed = false;
+14 -10
View File
@@ -1,10 +1,13 @@
import { readFile, writeFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.ts";
import {
bundleOutputInventoryArtifactSchema,
bundlePerformanceArtifactSchema,
} from "./contracts/release-artifacts.ts";
import { classifyViteJavascript } from "./lib/classify-vite-bundle.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type BundleOutput = { path: string; gzipBytes: number };
type BundleReport = { outputs: BundleOutput[]; [key: string]: unknown };
type ViteManifest = Record<
string,
{ file: string; isEntry?: boolean; imports?: string[] }
@@ -14,9 +17,9 @@ type BundleBudgets = {
lazyChunkGzipBytes: number;
};
const report = JSON.parse(
await readFile("artifacts/performance/bundle.json", "utf8"),
) as BundleReport;
const report = bundleOutputInventoryArtifactSchema.parse(
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8")) as unknown,
);
const viteManifest = JSON.parse(
await readFile("dist/.vite/manifest.json", "utf8"),
) as ViteManifest;
@@ -87,10 +90,11 @@ const completedReport = {
passed,
};
await writeFile(
"artifacts/performance/bundle.json",
`${JSON.stringify(completedReport, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/performance/bundle.json",
schema: bundlePerformanceArtifactSchema,
value: completedReport,
});
if (!passed) {
process.stderr.write(
`Bundle budget or manifest integrity failed: ${[
+10 -2
View File
@@ -14,7 +14,10 @@ import { generateCiWorkflow, renderCiWorkflow } from "./generate-ci-workflow.ts"
import {
ciContractReportSchema,
} from "./lib/ci-contract-report.ts";
import { validatePackageScriptGraph } from "./lib/package-script-graph.ts";
import {
validateInstallScriptPolicy,
validatePackageScriptGraph,
} from "./lib/package-script-graph.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const removalFixtureMode = process.argv.includes("--reduced-removal-fixture");
@@ -35,7 +38,6 @@ if (!/^\d+\.\d+\.\d+$/u.test(nodeVersion)) {
for (const script of [
"build:release-candidate",
"verify:local-evidence",
"verify:provider-evidence",
"verify:promotion",
"generate:ci-workflow",
"check:ci-workflow",
@@ -54,6 +56,12 @@ if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"]
failures.push("verify:promotion must not build or rebuild candidate bytes");
}
failures.push(...validatePackageScriptGraph(packageScripts, "check:ci"));
failures.push(
...validateInstallScriptPolicy(
packageScripts,
[...new Set(contract.commands.map(({ script }) => script))],
),
);
const immutable = index.gates.get("FE-GATE-015");
const immutableCommands = immutable?.commandIds.map((id) => index.commands.get(id)?.script);
+19 -20
View File
@@ -1,6 +1,8 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.ts";
import { compatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type CompatibilitySchema = Readonly<{
required?: readonly string[];
@@ -32,25 +34,22 @@ for (const [family, cases] of Object.entries(fixtures.families)) {
}
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/compatibility.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
rules: [
"additive changes preserve required fields",
"breaking changes require version bump and migration, discard, fallback, or rollback",
"config and API major versions must match",
"incompatible persisted cache is discarded by default",
"rollback uses a coherent compatibility tuple",
],
results,
},
null,
2,
)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/release/compatibility.json",
schema: compatibilityFixturesArtifactSchema,
value: {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
rules: [
"additive changes preserve required fields",
"breaking changes require version bump and migration, discard, fallback, or rollback",
"config and API major versions must match",
"incompatible persisted cache is discarded by default",
"rollback uses a coherent compatibility tuple",
],
results,
},
});
if (results.some((result) => !result.passed)) {
process.stderr.write("Compatibility fixture classification failed.\n");
+11 -7
View File
@@ -1,4 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import { designSystemReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import path from "node:path";
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
@@ -118,7 +121,7 @@ for (const file of sources) {
}
}
const report = {
const report = designSystemReportArtifactSchema.parse({
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
checkedTokenCount:
@@ -127,14 +130,15 @@ const report = {
REQUIRED_COMPONENT_TOKENS.length,
failures,
passed: failures.length === 0,
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
await writeValidatedJsonArtifact({
path: fixtureMode
? "artifacts/quality/design-system-fixture.json"
: "artifacts/quality/design-system.json",
`${JSON.stringify(report, null, 2)}\n`,
);
schema: designSystemReportArtifactSchema,
value: report,
});
if (failures.length > 0) {
process.stderr.write(`Design system contract failed:\n${failures.join("\n")}\n`);
+11 -7
View File
@@ -1,4 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import { diagnosticsReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import path from "node:path";
import { DIAGNOSTIC_EVENT_REGISTRY } from "../src/contracts/diagnostics.ts";
@@ -89,7 +92,7 @@ for (const file of sources) {
}
}
const report = {
const report = diagnosticsReportArtifactSchema.parse({
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
telemetryEventCount: Object.keys(TELEMETRY_REGISTRY).length,
@@ -97,14 +100,15 @@ const report = {
checkedFiles: sources.length,
failures,
passed: failures.length === 0,
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
await writeValidatedJsonArtifact({
path: fixtureMode
? "artifacts/quality/diagnostics-fixture.json"
: "artifacts/quality/diagnostics.json",
`${JSON.stringify(report, null, 2)}\n`,
);
schema: diagnosticsReportArtifactSchema,
value: report,
});
if (failures.length > 0) {
process.stderr.write(
`Diagnostics contract failed:\n${failures.join("\n")}\n`,
+11 -7
View File
@@ -1,4 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import { i18nReportArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import path from "node:path";
import { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts";
@@ -77,7 +80,7 @@ for (const file of sources) {
}
}
const report = {
const report = i18nReportArtifactSchema.parse({
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
localeCount: Object.keys(MESSAGE_CATALOGS).length + 1,
@@ -85,14 +88,15 @@ const report = {
checkedFiles: sources.length,
failures,
passed: failures.length === 0,
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
await writeValidatedJsonArtifact({
path: fixtureMode
? "artifacts/quality/i18n-fixture.json"
: "artifacts/quality/i18n.json",
`${JSON.stringify(report, null, 2)}\n`,
);
schema: i18nReportArtifactSchema,
value: report,
});
if (failures.length > 0) {
process.stderr.write(`I18n contract failed:\n${failures.join("\n")}\n`);
+9 -6
View File
@@ -1,11 +1,13 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { measureOptionalRecipeBundle } from "./lib/optional-recipe-bundle.ts";
import { optionalRecipeFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
import {
scanOptionalRecipeSources,
scanProductionBundle,
validateRecipeCatalog,
} from "./lib/optional-recipes.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type ReferenceRuntimeRecipe = Readonly<{
id: string;
@@ -176,17 +178,18 @@ const results = [
),
},
];
const report = {
const report = optionalRecipeFixturesArtifactSchema.parse({
schemaVersion: 1,
results,
bundleBudgetFixtures,
passed: results.every(({ passed }) => passed),
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/optional-recipe-fixtures.json",
`${JSON.stringify(report, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/quality/optional-recipe-fixtures.json",
schema: optionalRecipeFixturesArtifactSchema,
value: report,
});
if (!report.passed) {
process.stderr.write(
`Optional recipe negative fixtures failed: ${results
+10 -4
View File
@@ -1,5 +1,6 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { mkdir, readFile, stat } from "node:fs/promises";
import { optionalRecipesArtifactSchema } from "./contracts/release-artifacts.ts";
import {
measureOptionalRecipeBundle,
type OptionalRecipeBundleMeasurement,
@@ -10,6 +11,7 @@ import {
validateRecipeCatalog,
} from "./lib/optional-recipes.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type GateViolation = Readonly<{
ruleId: string;
@@ -141,7 +143,7 @@ const violations: GateViolation[] = [
...referenceRuntimeBundleConfigurationViolations,
...referenceRuntimeBundleMeasurementViolations,
];
const report = {
const report = optionalRecipesArtifactSchema.parse({
schemaVersion: 1,
decisionId: "VD-10",
selectedCapabilities: [],
@@ -169,9 +171,13 @@ const report = {
: "NOT_BUILT",
violations,
passed: violations.length === 0,
};
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
await writeValidatedJsonArtifact({
path: artifactPath,
schema: optionalRecipesArtifactSchema,
value: report,
});
if (violations.length > 0) {
process.stderr.write(
+9 -6
View File
@@ -1,14 +1,16 @@
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { realtimeBoundariesArtifactSchema } from "./contracts/release-artifacts.ts";
import { scanRealtimeBoundaries } from "./lib/realtime-boundaries.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const sourceRoot = argument("--source-root") ?? "src";
const artifact =
argument("--artifact") ??
"artifacts/quality/realtime-boundaries.json";
const violations = await scanRealtimeBoundaries(sourceRoot);
const report = Object.freeze({
const report = realtimeBoundariesArtifactSchema.parse({
schemaVersion: 1,
sourceRoot,
violations,
@@ -16,10 +18,11 @@ const report = Object.freeze({
});
await mkdir(path.dirname(artifact), { recursive: true });
await writeFile(
artifact,
`${JSON.stringify(report, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: artifact,
schema: realtimeBoundariesArtifactSchema,
value: report,
});
if (violations.length > 0) {
for (const violation of violations) {
@@ -1,10 +1,12 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import { registryCompatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
import {
diffRegistrySnapshots,
validateBreakingEvidence,
verifyRegistryBaselineApproval,
} from "./lib/registry-compatibility.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const fixtures = JSON.parse(
await readFile(
@@ -50,10 +52,11 @@ results.push({
});
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/registry-compatibility-fixtures.json",
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/quality/registry-compatibility-fixtures.json",
schema: registryCompatibilityFixturesArtifactSchema,
value: { schemaVersion: 1, results },
});
if (results.some((result) => !result.passed)) {
process.stderr.write("Registry compatibility fixture failed.\n");
process.exit(1);
+8 -5
View File
@@ -1,5 +1,6 @@
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import { supplyChainFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
import {
diffDependencyInventories,
isValidSha512Integrity,
@@ -9,6 +10,7 @@ import {
validateVulnerabilityReport,
verifySupplyChainCoherence,
} from "./lib/supply-chain.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
const baseDependency = {
@@ -137,10 +139,11 @@ const results = [
},
];
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-fixtures.json",
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/security/supply-chain-fixtures.json",
schema: supplyChainFixturesArtifactSchema,
value: { schemaVersion: 1, results },
});
if (results.some((result) => !result.passed)) {
process.stderr.write("Supply-chain negative fixture failed.\n");
process.exit(1);
+307 -260
View File
@@ -1,248 +1,161 @@
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import {
createHash,
generateKeyPairSync,
sign,
type KeyObject,
} from "node:crypto";
import {
cp,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
localEvidenceAssessmentArtifactSchema,
supplyChainProviderFixturesArtifactSchema,
} from "./contracts/release-artifacts.ts";
import { PROMOTED_FILE_NAMES } from "./contracts/promotion-artifacts.ts";
import {
captureCiCandidateArchive,
} from "./lib/ci-candidate-archive.ts";
import {
cleanupFinalizedPromotion,
finalizeVerifiedPromotion,
} from "./lib/promotion-stager.ts";
import { verifyExactPromotionBundle } from "./lib/exact-promotion-bundle.ts";
import {
providerEvidenceSignaturePayload,
providerPublicKeyFingerprint,
} from "./lib/provider-evidence.ts";
import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts";
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
import {
createReleaseCandidateManifest,
LOCAL_EVIDENCE_ASSESSMENT_PATH,
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
releaseCandidateManifestSchema,
} from "./lib/release-candidate.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const NOW = Date.parse("2026-08-02T01:00:00.000Z");
const FIXTURE_SOURCE = Object.freeze({
revision: "a".repeat(40),
sourceSetSha256: "b".repeat(64),
});
const FIXTURE_LOCAL_IDENTITY = Object.freeze({
sourceRevision: FIXTURE_SOURCE.revision,
sourceSetSha256: FIXTURE_SOURCE.sourceSetSha256,
assessmentSha256: "c".repeat(64),
});
const fixtureRoot = await mkdtemp(
path.join(tmpdir(), "supply-chain-provider-fixture-"),
);
const repositoryRoot = process.cwd();
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "provider-exact-five-fixture-"));
try {
const repositoryRoot = process.cwd();
const actualCandidate = releaseCandidateManifestSchema.parse(
await cp(repositoryRoot, fixtureRoot, {
recursive: true,
filter: (source) => {
const relative = path.relative(repositoryRoot, source);
if (!relative) return true;
const first = relative.split(path.sep)[0];
return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? "");
},
});
await cp(path.join(repositoryRoot, "artifacts"), path.join(fixtureRoot, "artifacts"), {
recursive: true,
});
await rm(path.join(fixtureRoot, "artifacts/release"), { recursive: true, force: true });
await symlink(path.join(repositoryRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
cwd: repositoryRoot,
encoding: "utf8",
});
if (git.status !== 0) throw new Error(`provider fixture git identity failed: ${git.stderr}`);
const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u);
if (!revision || !sourceDateEpoch) throw new Error("provider fixture git identity is incomplete");
const build = spawnSync("corepack", ["pnpm", "build:release-candidate"], {
cwd: fixtureRoot,
encoding: "utf8",
timeout: 120_000,
maxBuffer: 32 * 1024 * 1024,
env: {
...process.env,
CI: "true",
VITE_BUILD_ID: "provider-exact-five-fixture",
VITE_COMMIT_SHA: revision,
RELEASE_ID: "provider-exact-five-fixture",
SOURCE_DATE_EPOCH: sourceDateEpoch,
CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`,
},
});
if (build.status !== 0) throw new Error(`${build.stdout}\n${build.stderr}`);
const manifest = releaseCandidateManifestSchema.parse(
JSON.parse(
await readFile(
path.join(repositoryRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
"utf8",
),
await readFile(path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
) as unknown,
);
const actualAssessment = localEvidenceAssessmentArtifactSchema.parse(
const assessment = localEvidenceAssessmentArtifactSchema.parse(
JSON.parse(
await readFile(path.join(repositoryRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"),
await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"),
) as unknown,
);
const actualProviderEnvironment = absoluteProviderEnvironment(
fixtureRoot,
await writeProviderEnvironment(
fixtureRoot,
"actual",
actualCandidate,
{
revision: actualAssessment.source.revision,
sourceSetSha256: actualAssessment.source.sourceSetSha256,
},
),
const archivePath = path.join(fixtureRoot, "candidate.tar.gz");
const tar = spawnSync(
"/usr/bin/tar",
[
"--sort=name",
"--mtime=@0",
"--owner=0",
"--group=0",
"--numeric-owner",
"-czf",
archivePath,
"dist",
...RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
],
{ cwd: fixtureRoot, encoding: "utf8" },
);
const actualDefaultVerifier = await verifyPromotionInputs({
artifactType: "provider-verification",
environment: actualProviderEnvironment,
nowEpochMs: () => NOW,
});
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,
FIXTURE_SOURCE,
);
const wrongEnvironment = await writeProviderEnvironment(
fixtureRoot,
"wrong",
candidate,
FIXTURE_SOURCE,
{ distSha256: "3".repeat(64) },
);
const acceptLocalEvidence = async () => ({
status: "PASS" as const,
identity: FIXTURE_LOCAL_IDENTITY,
failures: [] as const,
});
const fixtures = {
absent: await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: fixtureRoot,
environment: {},
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
}),
validImmutable: await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: fixtureRoot,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
}),
wrongDigest: await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: fixtureRoot,
environment: wrongEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
}),
postAttestationMutation: null as Awaited<
ReturnType<typeof verifyPromotionInputs>
> | null,
};
await writeFile(path.join(fixtureRoot, "dist/app.js"), "mutated\n");
fixtures.postAttestationMutation = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: fixtureRoot,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
const passed =
actualDefaultVerifier.status === "PASS" &&
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,
actualDefaultVerifier: {
status: actualDefaultVerifier.status,
failures: actualDefaultVerifier.failures,
},
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: actual default verifier and valid immutable fixture PASS\n",
);
}
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
function absoluteProviderEnvironment(
repositoryRoot: string,
environment: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv {
const absolute = { ...environment };
for (const key of [
"CANDIDATE_ARCHIVE_PATH",
"VULNERABILITY_REPORT_PATH",
"PROVENANCE_ATTESTATION_PATH",
"VULNERABILITY_PUBLIC_KEY_PATH",
"PROVENANCE_PUBLIC_KEY_PATH",
] as const) {
const value = absolute[key];
if (value) absolute[key] = path.join(repositoryRoot, value);
}
return absolute;
}
async function writeProviderEnvironment(
repositoryRoot: string,
name: string,
candidate: Awaited<ReturnType<typeof createReleaseCandidateManifest>>,
source: Readonly<{ revision: string; sourceSetSha256: string }>,
overrides: Readonly<{ distSha256?: string }> = {},
): Promise<NodeJS.ProcessEnv> {
if (tar.status !== 0) throw new Error(`provider fixture real tar failed: ${tar.stderr}`);
const archiveBytes = await readFile(archivePath);
const archiveSha256 = sha256(archiveBytes);
const now = Date.parse("2026-08-02T01:00:00.000Z");
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const directory = `provider/${name}`;
const archiveBytes = `fixture archive ${name}\n`;
const archiveSha256 = createHash("sha256").update(archiveBytes).digest("hex");
const candidateIdentity = {
archiveSha256,
bundleSha256: candidate.bundleSha256,
distSha256: overrides.distSha256 ?? candidate.distSha256,
lockfileSha256: candidate.lockfileSha256,
};
const sourceIdentity = {
revision: source.revision,
sourceSetSha256: source.sourceSetSha256,
};
await mkdir(path.join(repositoryRoot, directory), { recursive: true });
const vulnerabilityKeyId = "fixture-vulnerability-key";
const provenanceKeyId = "fixture-provenance-key";
const vulnerabilityNonce = "1".repeat(64);
const provenanceNonce = "2".repeat(64);
const context = {
run: { id: "fixture-run", attempt: 1 },
source: {
revision: assessment.source.revision,
sourceSetSha256: assessment.source.sourceSetSha256,
},
candidate: {
archiveSha256,
bundleSha256: manifest.bundleSha256,
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
},
} as const;
const vulnerability = signedEvidence(
{
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "fixture-vulnerability-provider",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { id: "fixture-run", attempt: 1, invocationNonce: "1".repeat(64) },
source: sourceIdentity,
candidate: candidateIdentity,
issuedAt: new Date(now).toISOString(),
expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(),
run: { ...context.run, invocationNonce: vulnerabilityNonce },
source: context.source,
candidate: context.candidate,
secretScanAttestation: {
status: "PASS",
localEvidenceAssessmentSha256: sha256(
await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
),
sourceSetSha256: assessment.source.sourceSetSha256,
policySha256: assessment.secretScan.policySha256,
sarifSha256: assessment.secretScan.sarifSha256,
scanInputSha256: assessment.secretScan.scanInputSha256,
},
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeyId,
vulnerabilityKeys.publicKey,
vulnerabilityKeys.privateKey,
);
@@ -252,65 +165,175 @@ async function writeProviderEnvironment(
evidenceType: "provenance-attestation",
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { id: "fixture-run", attempt: 1, invocationNonce: "2".repeat(64) },
source: sourceIdentity,
candidate: candidateIdentity,
subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } },
issuedAt: new Date(now).toISOString(),
expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(),
run: { ...context.run, invocationNonce: provenanceNonce },
source: context.source,
candidate: context.candidate,
subject: { name: "dist", digest: { sha256: manifest.distSha256 } },
},
"fixture-provenance-key",
provenanceKeyId,
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
await Promise.all([
writeFile(
path.join(repositoryRoot, directory, "candidate.tar.gz"),
archiveBytes,
write(fixtureRoot, "provider/vulnerability.json", `${JSON.stringify(vulnerability)}\n`),
write(fixtureRoot, "provider/provenance.json", `${JSON.stringify(provenance)}\n`),
write(
fixtureRoot,
"provider/vulnerability.pem",
vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }),
),
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(),
write(
fixtureRoot,
"provider/provenance.pem",
provenanceKeys.publicKey.export({ type: "spki", format: "pem" }),
),
]);
return {
CANDIDATE_ARCHIVE_PATH: `${directory}/candidate.tar.gz`,
CANDIDATE_ARCHIVE_SHA256: archiveSha256,
CI_RUN_ID: "fixture-run",
CI_RUN_ATTEMPT: "1",
EXPECTED_SOURCE_REVISION: source.revision,
VULNERABILITY_INVOCATION_NONCE: "1".repeat(64),
PROVENANCE_INVOCATION_NONCE: "2".repeat(64),
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",
};
const runnerTempRoot = path.join(fixtureRoot, "runner-temp");
await mkdir(runnerTempRoot, { mode: 0o700 });
const finalized = await finalizeVerifiedPromotion(
{
repositoryRoot: fixtureRoot,
archivePath,
expectedArchiveSha256: archiveSha256,
vulnerabilityReportPath: "provider/vulnerability.json",
provenanceAttestationPath: "provider/provenance.json",
vulnerabilityPublicKeyPath: "provider/vulnerability.pem",
vulnerabilityKeyId,
provenancePublicKeyPath: "provider/provenance.pem",
provenanceKeyId,
expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision },
vulnerabilityInvocationNonce: vulnerabilityNonce,
provenanceInvocationNonce: provenanceNonce,
runnerTempRoot,
},
{
nowEpochMs: () => now,
randomBytes: (bytes) => Buffer.alloc(bytes, 0x4a),
afterCapture: async () => {
await writeFile(path.join(fixtureRoot, "dist/index.html"), "contradictory external tree\n");
},
},
);
const validImmutable =
finalized.files.length === 5 &&
(await readdir(finalized.stagingRoot)).length === 5;
const exactFiles = Object.fromEntries(
await Promise.all(
PROMOTED_FILE_NAMES.map(async (name) => [
name,
await readFile(path.join(finalized.stagingRoot, name)),
] as const),
),
);
const bundleTrust = {
vulnerabilityTrust: {
keyId: vulnerabilityKeyId,
publicKey: vulnerabilityKeys.publicKey,
publicKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
},
provenanceTrust: {
keyId: provenanceKeyId,
publicKey: provenanceKeys.publicKey,
publicKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey),
},
expected: {
run: context.run,
sourceRevision: context.source.revision,
sourceSetSha256: context.source.sourceSetSha256,
archiveSha256: context.candidate.archiveSha256,
bundleSha256: context.candidate.bundleSha256,
distSha256: context.candidate.distSha256,
lockfileSha256: context.candidate.lockfileSha256,
},
nowEpochMs: () => now,
} as const;
const absentFiles = { ...exactFiles } as Partial<typeof exactFiles>;
delete absentFiles["provider-verification.json"];
const absent = await captureRejection(async () => {
await verifyExactPromotionBundle(absentFiles, bundleTrust);
});
const invalidPath = path.join(fixtureRoot, "invalid.tar.gz");
const invalidBytes = Buffer.from("arbitrary non-tar bytes\n");
await writeFile(invalidPath, invalidBytes);
const invalidTar = await captureRejection(async () => {
await finalizeVerifiedPromotion(
{
repositoryRoot: fixtureRoot,
archivePath: invalidPath,
expectedArchiveSha256: sha256(invalidBytes),
vulnerabilityReportPath: "provider/vulnerability.json",
provenanceAttestationPath: "provider/provenance.json",
vulnerabilityPublicKeyPath: "provider/vulnerability.pem",
vulnerabilityKeyId,
provenancePublicKeyPath: "provider/provenance.pem",
provenanceKeyId,
expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision },
vulnerabilityInvocationNonce: vulnerabilityNonce,
provenanceInvocationNonce: provenanceNonce,
runnerTempRoot,
},
{ nowEpochMs: () => now },
);
});
const wrongDigest = await captureRejection(async () => {
await captureCiCandidateArchive({ archivePath, expectedSha256: "0".repeat(64) });
});
const passed =
validImmutable && absent.rejected && invalidTar.rejected && wrongDigest.rejected;
await cleanupFinalizedPromotion({
runnerTempRoot,
stagingRoot: finalized.stagingRoot,
cleanupToken: finalized.cleanupToken,
runnerTempIdentity: finalized.runnerTempIdentity,
stagingIdentity: finalized.stagingIdentity,
});
await mkdir(path.join(repositoryRoot, "artifacts/security"), { recursive: true });
await writeValidatedJsonArtifact({
path: path.join(repositoryRoot, "artifacts/security/supply-chain-provider-fixtures.json"),
schema: supplyChainProviderFixturesArtifactSchema,
value: {
schemaVersion: 1,
actualDefaultVerifier: {
status: validImmutable ? "PASS" : "FAIL_UNVERIFIED",
failures: validImmutable ? [] : ["canonical exact-five finalizer failed"],
},
fixtures: {
absent: {
status: absent.rejected ? "FAIL_UNVERIFIED" : "PASS",
failures: [absent.failure],
},
validImmutable: { status: validImmutable ? "PASS" : "FAIL_UNVERIFIED", failures: [] },
wrongDigest: {
status: wrongDigest.rejected ? "FAIL_UNVERIFIED" : "PASS",
failures: [wrongDigest.failure],
},
invalidTar: {
status: invalidTar.rejected ? "FAIL_UNVERIFIED" : "PASS",
failures: [invalidTar.failure],
},
},
externalTreeCanary: {
status: validImmutable ? "PASS" : "FAIL_UNVERIFIED",
failures: validImmutable ? [] : ["external tree canary influenced captured archive"],
},
passingFixtureCount: validImmutable ? 1 : 0,
status: passed ? "PASS" : "FAIL",
},
});
if (!passed) throw new Error("canonical exact-five provider fixtures failed closed incorrectly");
process.stdout.write("Supply-chain provider real-tar signed exact-five fixtures: PASS\n");
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
function signedEvidence(
value: Record<string, unknown>,
keyId: string,
publicKey: ReturnType<typeof generateKeyPairSync>["publicKey"],
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
publicKey: KeyObject,
privateKey: KeyObject,
) {
return {
...value,
@@ -326,3 +349,27 @@ function signedEvidence(
},
};
}
async function write(root: string, relative: string, value: string | Buffer): Promise<void> {
const absolute = path.join(root, relative);
await mkdir(path.dirname(absolute), { recursive: true });
await writeFile(absolute, value);
}
async function captureRejection(
operation: () => Promise<void>,
): Promise<Readonly<{ rejected: boolean; failure: string }>> {
try {
await operation();
return Object.freeze({ rejected: false, failure: "fixture unexpectedly passed" });
} catch (error) {
return Object.freeze({
rejected: true,
failure: error instanceof Error ? error.message : String(error),
});
}
}
function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
+4
View File
@@ -21,5 +21,9 @@ await cleanupFinalizedPromotion({
dev: requiredIdentity("PROMOTION_RUNNER_TEMP_DEV"),
ino: requiredIdentity("PROMOTION_RUNNER_TEMP_INO"),
},
stagingIdentity: {
dev: requiredIdentity("PROMOTION_STAGING_DEV"),
ino: requiredIdentity("PROMOTION_STAGING_INO"),
},
});
process.stdout.write("Promotion staging cleanup: PASS\n");
+123 -17
View File
@@ -9,7 +9,11 @@ import {
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
} from "../lib/release-candidate.ts";
import { validatePackageScriptGraph } from "../lib/package-script-graph.ts";
import {
validateInstallScriptPolicy,
validateNpmScopeEnvironment,
validatePackageScriptGraph,
} from "../lib/package-script-graph.ts";
import { PROMOTED_UPLOAD_PATHS } from "./promotion-artifacts.ts";
const ciActionRegistrationSchema = z
@@ -201,7 +205,22 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [
kind: z.literal("json"),
maxBytes: z.number().int().min(1).max(268_435_456),
executableSchemaId: z.enum([
"generic-json-object",
"automated-a11y",
"manual-a11y",
"architecture-dependency-report",
"design-system-contract",
"i18n-contract",
"diagnostics-contract",
"realtime-boundaries",
"optional-recipes",
"optional-recipe-fixtures",
"registry-compatibility-fixtures",
"reproducible-build",
"supply-chain-fixtures",
"supply-chain-provider-fixtures",
"compatibility-fixtures",
"documentation-review",
"hosting-headers",
"coverage-summary-v8",
"risk-coverage-v3",
"build-manifest",
@@ -318,12 +337,6 @@ const uploadStep = z
const downloadStep = z
.object({ kind: z.literal("download"), transferId: id, path: workflowPath })
.strict();
const validateCandidateArchiveStep = z
.object({
kind: z.literal("validate-candidate-archive"),
archivePath: workflowPath,
})
.strict();
const extractStep = z
.object({
kind: z.literal("extract"),
@@ -360,7 +373,6 @@ const jobStepSchema = z.discriminatedUnion("kind", [
archiveCandidateStep,
uploadStep,
downloadStep,
validateCandidateArchiveStep,
extractStep,
providerStep,
validateProviderStep,
@@ -374,7 +386,7 @@ const jobSchema = z
displayName: workflowValue,
kind: z.enum(["gate-matrix", "gate-single", "immutable", "provider", "promotion"]),
needs: z.array(id).max(32),
condition: z.enum(["always", "merge", "release", "production", "field", "documentation"]),
condition: z.enum(["always", "needs-success", "merge", "release", "production", "field", "documentation"]),
timeoutMinutes: z.number().int().positive(),
gateIds: z.array(id).max(64),
browserGateIds: z.array(id).max(64),
@@ -455,7 +467,43 @@ function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}
export function parseCiGateContract(value: unknown): CiGateContract {
function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] {
const failures: string[] = [];
const commandReferenceCount = contract.gates.reduce(
(total, gate) => total + gate.commandIds.length,
0,
);
if (contract.gates.length !== 26) {
failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`);
}
if (contract.commands.length !== 81 || commandReferenceCount !== 93) {
failures.push(
`command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
);
}
if (contract.artifacts.length !== 105) {
failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`);
}
if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
}
const expectedRetentionClasses = [
{ id: "merge-cycle", policy: "at least through pull-request readiness decision" },
{ id: "release-coherence", policy: "at least until the next release is promoted" },
{ id: "prod-drill", policy: "at least until the next production promotion decision" },
{ id: "field", policy: "through the 28-day window and aggregation" },
{ id: "documentation", policy: "through documentation readiness review" },
];
if (JSON.stringify(contract.retention.classes) !== JSON.stringify(expectedRetentionClasses)) {
failures.push("retention registry must contain exactly the five canonical retention classes");
}
return failures;
}
export function parseCiGateContract(
value: unknown,
options: LoadCiGateContractOptions = {},
): CiGateContract {
const result = ciGateContractSchema.safeParse(value);
if (!result.success) {
const diagnostic = result.error.issues
@@ -463,6 +511,12 @@ export function parseCiGateContract(value: unknown): CiGateContract {
.join("\n");
throw new TypeError(`CI gate contract invalid:\n${diagnostic}`);
}
if ((options.mode ?? "canonical") === "canonical") {
const failures = canonicalAuthorityBaselineFailures(result.data);
if (failures.length > 0) {
throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`);
}
}
return result.data;
}
@@ -470,12 +524,12 @@ export async function loadCiGateContract(
root = process.cwd(),
options: LoadCiGateContractOptions = {},
): Promise<CiGateContract> {
const mode = options.mode ?? "canonical";
const [rawContract, rawPackage] = await Promise.all([
readFile(path.join(root, "config/ci/gates.json"), "utf8"),
readFile(path.join(root, "package.json"), "utf8"),
]);
const contract = parseCiGateContract(JSON.parse(rawContract));
const mode = options.mode ?? "canonical";
const contract = parseCiGateContract(JSON.parse(rawContract), { mode });
if (
mode === "canonical" &&
canonicalGateShapeSha256(contract.gates) !== CANONICAL_GATE_SHAPE_SHA256
@@ -510,13 +564,57 @@ export async function loadCiGateContract(
throw new TypeError(`canonical check:ci dependency drift: ${script}`);
}
}
const graphFailures = validatePackageScriptGraph(packageDocument.scripts, "check:ci");
const contractEntryScripts = [
...new Set(contract.commands.map((command) => command.script)),
];
const graphFailures = [
...validatePackageScriptGraph(packageDocument.scripts, "check:ci"),
...contractEntryScripts.flatMap((script) =>
validatePackageScriptGraph(packageDocument.scripts, script)
),
];
if (graphFailures.length > 0) {
throw new TypeError(`CI package script graph invalid:\n${graphFailures.join("\n")}`);
throw new TypeError(
`CI package script graph invalid:\n${[...new Set(graphFailures)].join("\n")}`,
);
}
const installPolicyFailures = validateInstallScriptPolicy(
packageDocument.scripts,
contractEntryScripts,
);
if (installPolicyFailures.length > 0) {
throw new TypeError(
`CI package script install policy invalid:\n${installPolicyFailures.join("\n")}`,
);
}
return contract;
}
export async function withCiGatePreflight<Result>(
root: string,
gateId: string | undefined,
execute: (context: Readonly<{
contract: CiGateContract;
contractIndex: CiGateContractIndex;
gateId: string;
gate: CiGate;
}>) => Promise<Result> | Result,
): Promise<Result> {
const npmScopeEnvironmentFailures = validateNpmScopeEnvironment(process.env);
if (npmScopeEnvironmentFailures.length > 0) {
throw new TypeError(
`CI runner npm scope environment invalid:\n${npmScopeEnvironmentFailures.join("\n")}`,
);
}
const contract = await loadCiGateContract(root);
const contractIndex = indexCiGateContract(contract);
const gate = gateId ? contractIndex.gates.get(gateId) : undefined;
if (!gateId || !gate) {
throw new TypeError("CI gate id must be FE-GATE-001..FE-GATE-026");
}
return execute({ contract, contractIndex, gateId, gate });
}
export function indexCiGateContract(contract: CiGateContract): CiGateContractIndex {
return Object.freeze({
commands: new Map(contract.commands.map((entry) => [entry.id, entry])),
@@ -625,6 +723,14 @@ function validateContractSemantics(
issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`);
}
}
const referencedRetentionClasses = new Set(
contract.gates.map(({ retentionClassId }) => retentionClassId),
);
for (const retentionClass of contract.retention.classes) {
if (!referencedRetentionClasses.has(retentionClass.id)) {
issue(`orphan retention class: ${retentionClass.id}`);
}
}
const referencedCommands = new Set(contract.gates.flatMap(({ commandIds }) => commandIds));
for (const command of contract.commands) {
if (!referencedCommands.has(command.id)) issue(`orphan command: ${command.id}`);
@@ -751,7 +857,7 @@ function validateContractSemantics(
immutable_build: ["immutable", ["release_gate"], "release"],
vulnerability_provider: ["provider", ["immutable_build"], "always"],
provenance_provider: ["provider", ["immutable_build"], "always"],
promotion: ["promotion", ["immutable_build", "vulnerability_provider", "provenance_provider"], "always"],
promotion: ["promotion", ["immutable_build", "vulnerability_provider", "provenance_provider"], "needs-success"],
production_gate: ["gate-matrix", ["promotion"], "production"],
field_gate: ["gate-single", ["production_gate"], "field"],
documentation_gate: ["gate-single", [], "documentation"],
@@ -1026,7 +1132,7 @@ function validateJobStepKinds(
"gate-matrix": new Set(["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"]),
"gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]),
immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]),
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "run-provider", "validate-provider-evidence", "upload"]),
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"]),
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "verify-promotion", "upload", "cleanup-promotion"]),
};
for (const step of job.steps) {
+853 -11
View File
@@ -2,6 +2,8 @@ import { z } from "zod";
export * from "../../src/contracts/release-artifacts.ts";
import { MANUAL_A11Y_ROUTE_IDS } from "../lib/manual-a11y-evidence.ts";
const nonEmptyString = z.string().min(1);
const timestamp = z.iso.datetime();
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
@@ -85,6 +87,13 @@ export const localEvidenceAssessmentArtifactSchema = z
candidate: z
.object({ distSha256: sha256, lockfileSha256: sha256, sbomSha256: sha256 })
.strict(),
secretScan: z
.object({
policySha256: sha256,
sarifSha256: sha256,
scanInputSha256: sha256,
})
.strict(),
policyInputs: z.array(assessmentInputRowSchema).min(1).max(256),
evidenceInputs: z.array(assessmentInputRowSchema).min(1).max(4_096),
checks: z
@@ -308,20 +317,162 @@ const outputDigestSchema = z
})
.strict();
export const bundlePerformanceArtifactSchema = z
const bundleOutputInventoryShape = {
schemaVersion: z.literal(1),
generatedAt: timestamp,
context: z
.object({
nodeVersion: nonEmptyString,
packageManager: nonEmptyString,
runnerImage: nonEmptyString,
})
.strict(),
outputs: z.array(outputDigestSchema).min(1),
} as const;
function addUniqueBundleOutputIssues(
artifact: Readonly<{ outputs: readonly Readonly<{ path: string }>[] }>,
context: z.RefinementCtx,
): void {
const paths = artifact.outputs.map(({ path }) => path);
if (new Set(paths).size !== paths.length) {
context.addIssue({
code: "custom",
path: ["outputs"],
message: "output paths must be unique",
});
}
}
export const bundleOutputInventoryArtifactSchema = z
.object(bundleOutputInventoryShape)
.strict()
.superRefine(addUniqueBundleOutputIssues);
const bundleMeasurementSchema = z
.object({ path: nonEmptyString, gzipBytes: z.int().nonnegative() })
.strict();
const bundleClassificationSchema = z
.object({
schemaVersion: z.literal(1),
generatedAt: timestamp,
context: z
.object({
nodeVersion: nonEmptyString,
packageManager: nonEmptyString,
runnerImage: nonEmptyString,
})
.strict(),
outputs: z.array(outputDigestSchema).min(1),
initialFiles: z.array(nonEmptyString),
lazyFiles: z.array(nonEmptyString),
missingImports: z.array(nonEmptyString),
})
.strict();
const bundleThresholdsSchema = z
.object({
initialJsGzipBytes: z.int().positive(),
lazyChunkGzipBytes: z.int().positive(),
})
.strict();
const bundleBudgetResultSchema = z
.object({
initialPassed: z.boolean(),
lazyResults: z.array(
bundleMeasurementSchema.extend({
threshold: z.int().positive(),
passed: z.boolean(),
}),
),
passed: z.boolean(),
})
.strict();
export const bundlePerformanceArtifactSchema = z
.object({
...bundleOutputInventoryShape,
measurements: z
.object({
initialJsGzipBytes: z.int().nonnegative(),
lazyChunks: z.array(bundleMeasurementSchema),
})
.strict(),
classification: bundleClassificationSchema,
missingOutputs: z.array(nonEmptyString),
thresholds: bundleThresholdsSchema,
results: bundleBudgetResultSchema,
fixtures: z.tuple([
z.object({ name: z.literal("initial-js-over-budget"), passed: z.boolean() }).strict(),
z.object({ name: z.literal("lazy-chunk-over-budget"), passed: z.boolean() }).strict(),
]),
passed: z.boolean(),
})
.strict()
.superRefine((artifact, context) => {
addUniqueBundleOutputIssues(artifact, context);
const issue = (path: PropertyKey[], message: string) =>
context.addIssue({ code: "custom", path, message });
const uniqueSorted = (values: readonly string[]) =>
new Set(values).size === values.length &&
JSON.stringify(values) === JSON.stringify([...values].sort());
for (const [field, values] of [
["initialFiles", artifact.classification.initialFiles],
["lazyFiles", artifact.classification.lazyFiles],
["missingImports", artifact.classification.missingImports],
["missingOutputs", artifact.missingOutputs],
] as const) {
if (!uniqueSorted(values)) {
issue(
field === "missingOutputs" ? [field] : ["classification", field],
"paths must be unique and sorted",
);
}
}
const initial = new Set(artifact.classification.initialFiles);
if (artifact.classification.lazyFiles.some((file) => initial.has(file))) {
issue(["classification"], "initial and lazy files must be disjoint");
}
const outputs = new Map(
artifact.outputs.map((output) => [output.path.replace(/^dist\//u, ""), output]),
);
const expectedMissing = [
...artifact.classification.initialFiles,
...artifact.classification.lazyFiles,
].filter((file) => !outputs.has(file)).sort();
if (JSON.stringify(artifact.missingOutputs) !== JSON.stringify(expectedMissing)) {
issue(["missingOutputs"], "must equal classified JavaScript outputs not found in inventory");
}
const expectedInitialBytes = artifact.classification.initialFiles.reduce(
(total, file) => total + (outputs.get(file)?.gzipBytes ?? 0),
0,
);
if (artifact.measurements.initialJsGzipBytes !== expectedInitialBytes) {
issue(["measurements", "initialJsGzipBytes"], "must equal classified initial output bytes");
}
const expectedLazyChunks = artifact.classification.lazyFiles.map((file) => ({
path: file,
gzipBytes: outputs.get(file)?.gzipBytes ?? 0,
}));
if (JSON.stringify(artifact.measurements.lazyChunks) !== JSON.stringify(expectedLazyChunks)) {
issue(["measurements", "lazyChunks"], "must equal classified lazy output bytes");
}
const expectedInitialPassed =
artifact.measurements.initialJsGzipBytes <= artifact.thresholds.initialJsGzipBytes;
if (artifact.results.initialPassed !== expectedInitialPassed) {
issue(["results", "initialPassed"], "must agree with initial threshold");
}
const expectedLazyResults = artifact.measurements.lazyChunks.map((chunk) => ({
...chunk,
threshold: artifact.thresholds.lazyChunkGzipBytes,
passed: chunk.gzipBytes <= artifact.thresholds.lazyChunkGzipBytes,
}));
if (JSON.stringify(artifact.results.lazyResults) !== JSON.stringify(expectedLazyResults)) {
issue(["results", "lazyResults"], "must agree with lazy measurements and threshold");
}
const expectedBudgetPassed =
expectedInitialPassed && expectedLazyResults.every(({ passed }) => passed);
if (artifact.results.passed !== expectedBudgetPassed) {
issue(["results", "passed"], "must agree with budget results");
}
const expectedPassed =
expectedBudgetPassed &&
artifact.fixtures.every(({ passed }) => passed) &&
artifact.classification.missingImports.length === 0 &&
artifact.missingOutputs.length === 0;
if (artifact.passed !== expectedPassed) {
issue(["passed"], "must agree with budgets, fixtures, and manifest integrity");
}
});
const cyclonedxComponentSchema = z
.object({
@@ -571,3 +722,694 @@ export const runbookRecordArtifactSchema = z
passed: z.boolean(),
})
.strict();
const failureList = z.array(nonEmptyString).max(4_096);
const sourceOrFixtureMode = z.enum(["source", "negative-fixture"]);
const namedBooleanResultSchema = z
.object({ id: nonEmptyString, passed: z.boolean() })
.strict();
function addPassedFailureInvariant(
artifact: Readonly<{ passed: boolean; failures: readonly string[] }>,
context: z.RefinementCtx,
): void {
if (artifact.passed !== (artifact.failures.length === 0)) {
context.addIssue({
code: "custom",
path: ["passed"],
message: "passed must agree with failures",
});
}
}
function addUniqueStringIssues(
values: readonly string[],
path: PropertyKey[],
context: z.RefinementCtx,
): void {
if (new Set(values).size !== values.length) {
context.addIssue({ code: "custom", path, message: "must not contain duplicates" });
}
}
export const automatedA11yArtifactSchema = z
.object({
schemaVersion: z.literal(1),
generatedAt: timestamp,
scope: z.array(nonEmptyString).min(1).max(128),
threshold: z.object({ critical: z.literal(0), serious: z.literal(0) }).strict(),
automatedStatus: z.literal("passed"),
manualReview: z.literal("see artifacts/tests/a11y-manual/report.json"),
})
.strict()
.superRefine((artifact, context) => {
addUniqueStringIssues(artifact.scope, ["scope"], context);
if (JSON.stringify(artifact.scope) !== JSON.stringify(MANUAL_A11Y_ROUTE_IDS)) {
context.addIssue({ code: "custom", path: ["scope"], message: "must match the installed route registry" });
}
});
const manualA11yResultSchema = z
.object({
routeId: nonEmptyString,
path: nonEmptyString,
reviewer: z.string().nullable(),
reviewedAt: z.string().nullable(),
releaseId: z.string().nullable(),
failures: failureList,
passed: z.boolean(),
})
.strict()
.superRefine((result, context) => {
if (result.path !== `artifacts/tests/a11y-manual/${result.routeId}.md`) {
context.addIssue({ code: "custom", path: ["path"], message: "path must match routeId" });
}
const hasIdentity = Boolean(
result.reviewer &&
result.releaseId &&
result.reviewedAt &&
Number.isFinite(Date.parse(result.reviewedAt)),
);
if (result.passed !== (result.failures.length === 0 && hasIdentity)) {
context.addIssue({
code: "custom",
path: ["passed"],
message: "passed must agree with failures and review identity",
});
}
});
export const manualA11yReportArtifactSchema = z
.object({
schemaVersion: z.literal(1),
generatedAt: timestamp,
scope: z.array(nonEmptyString).min(1).max(128),
results: z.array(manualA11yResultSchema).min(1).max(128),
coherentRelease: z.boolean(),
passed: z.boolean(),
})
.strict()
.superRefine((artifact, context) => {
const routeIds = artifact.results.map(({ routeId }) => routeId);
addUniqueStringIssues(artifact.scope, ["scope"], context);
addUniqueStringIssues(routeIds, ["results"], context);
if (JSON.stringify(artifact.scope) !== JSON.stringify(MANUAL_A11Y_ROUTE_IDS)) {
context.addIssue({ code: "custom", path: ["scope"], message: "must match the installed route registry" });
}
if (JSON.stringify(routeIds) !== JSON.stringify(artifact.scope)) {
context.addIssue({ code: "custom", path: ["results"], message: "result routeIds must match scope" });
}
const releaseIds = artifact.results.map(({ releaseId }) => releaseId);
const coherentRelease =
releaseIds.every((releaseId): releaseId is string => Boolean(releaseId)) &&
new Set(releaseIds).size === 1;
if (artifact.coherentRelease !== coherentRelease) {
context.addIssue({ code: "custom", path: ["coherentRelease"], message: "must represent one non-empty releaseId" });
}
if (
artifact.passed !==
(coherentRelease && artifact.results.every(({ passed }) => passed))
) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with route results" });
}
});
const architectureDependencySchema = z
.object({
source: nonEmptyString,
target: nonEmptyString,
specifier: nonEmptyString,
kind: z.enum(["local", "external"]),
})
.strict();
const architectureUnresolvedSchema = z
.object({ source: nonEmptyString, specifier: nonEmptyString, reason: nonEmptyString })
.strict();
const architectureViolationSchema = z
.object({
rule: nonEmptyString,
severity: nonEmptyString,
source: nonEmptyString,
target: nonEmptyString,
cycle: z.array(nonEmptyString).optional(),
})
.strict();
const staticImportGraphSchema = z
.object({
analyzer: z.literal("babel-parser-node-resolver"),
modules: z.array(nonEmptyString),
dependencies: z.array(architectureDependencySchema),
unresolved: z.array(architectureUnresolvedSchema),
parseFailures: z.array(
z.object({ source: nonEmptyString, reason: nonEmptyString }).strict(),
),
cycles: z.array(z.array(nonEmptyString).min(1)),
violations: z.array(architectureViolationSchema),
summary: z
.object({
modules: z.int().nonnegative(),
typescriptModules: z.int().nonnegative(),
dependencies: z.int().nonnegative(),
localDependencies: z.int().nonnegative(),
unresolved: z.int().nonnegative(),
parseFailures: z.int().nonnegative(),
cycles: z.int().nonnegative(),
errors: z.int().nonnegative(),
typeScriptOnlyPolicyPassed: z.boolean(),
nonTypeScriptExecutableSources: z.int().nonnegative(),
})
.strict(),
fixtureChecks: z
.object({ passed: z.boolean(), checks: z.array(nonEmptyString), failures: failureList })
.strict(),
typeScriptOnlySourcePolicy: z
.object({
checkedRoots: z.array(nonEmptyString).min(1),
exceptionsAllowed: z.literal(false),
violations: z.array(nonEmptyString),
passed: z.boolean(),
})
.strict(),
})
.strict()
.superRefine((graph, context) => {
const counts = [
["modules", graph.modules.length],
["dependencies", graph.dependencies.length],
["localDependencies", graph.dependencies.filter(({ kind }) => kind === "local").length],
["unresolved", graph.unresolved.length],
["parseFailures", graph.parseFailures.length],
["cycles", graph.cycles.length],
["errors", graph.violations.filter(({ severity }) => severity === "error").length],
["nonTypeScriptExecutableSources", graph.typeScriptOnlySourcePolicy.violations.length],
] as const;
for (const [field, expected] of counts) {
if (graph.summary[field] !== expected) {
context.addIssue({ code: "custom", path: ["summary", field], message: "count does not match evidence rows" });
}
}
if (graph.summary.typescriptModules > graph.summary.modules) {
context.addIssue({ code: "custom", path: ["summary", "typescriptModules"], message: "cannot exceed modules" });
}
if (graph.fixtureChecks.passed !== (graph.fixtureChecks.failures.length === 0)) {
context.addIssue({ code: "custom", path: ["fixtureChecks", "passed"], message: "must agree with failures" });
}
if (
graph.typeScriptOnlySourcePolicy.passed !==
(graph.typeScriptOnlySourcePolicy.violations.length === 0) ||
graph.summary.typeScriptOnlyPolicyPassed !== graph.typeScriptOnlySourcePolicy.passed
) {
context.addIssue({ code: "custom", path: ["typeScriptOnlySourcePolicy", "passed"], message: "must agree with violations and summary" });
}
});
const dependencyCruiserSummarySchema = z
.object({
violations: z.array(jsonObject),
error: z.int().nonnegative(),
warn: z.int().nonnegative(),
info: z.int().nonnegative(),
ignore: z.int().nonnegative(),
totalCruised: z.int().nonnegative(),
totalDependenciesCruised: z.int().nonnegative(),
})
.catchall(z.json());
export const architectureDependencyReportArtifactSchema = z.union([
z
.object({
modules: z.array(jsonObject),
summary: dependencyCruiserSummarySchema,
staticImportGraph: staticImportGraphSchema,
})
.strict(),
z
.object({
summary: z.object({ errors: z.literal(1) }).strict(),
dependencyCruiserOutput: z.string(),
staticImportGraph: staticImportGraphSchema,
})
.strict(),
]);
export const designSystemReportArtifactSchema = z
.object({
schemaVersion: z.literal(1),
mode: sourceOrFixtureMode,
checkedTokenCount: z.int().positive(),
failures: failureList,
passed: z.boolean(),
})
.strict()
.superRefine(addPassedFailureInvariant);
export const i18nReportArtifactSchema = z
.object({
schemaVersion: z.literal(1),
mode: sourceOrFixtureMode,
localeCount: z.int().positive(),
messageKeyCount: z.int().positive(),
checkedFiles: z.int().nonnegative(),
failures: failureList,
passed: z.boolean(),
})
.strict()
.superRefine(addPassedFailureInvariant);
export const diagnosticsReportArtifactSchema = z
.object({
schemaVersion: z.literal(1),
mode: sourceOrFixtureMode,
telemetryEventCount: z.int().positive(),
diagnosticEventCount: z.int().positive(),
checkedFiles: z.int().nonnegative(),
failures: failureList,
passed: z.boolean(),
})
.strict()
.superRefine(addPassedFailureInvariant);
export const realtimeBoundariesArtifactSchema = z
.object({
schemaVersion: z.literal(1),
sourceRoot: nonEmptyString,
violations: z.array(
z
.object({
ruleId: z.enum([
"NATIVE_REALTIME_API_OUTSIDE_ADAPTER",
"PRESENTATION_INTERVAL_OWNER",
"UNSELECTED_REALTIME_RUNTIME_COMPOSED",
]),
file: nonEmptyString,
line: z.int().positive(),
})
.strict(),
),
passed: z.boolean(),
})
.strict()
.superRefine((artifact, context) => {
if (artifact.passed !== (artifact.violations.length === 0)) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with violations" });
}
const keys = artifact.violations.map(({ ruleId, file, line }) => `${file}\0${line}\0${ruleId}`);
addUniqueStringIssues(keys, ["violations"], context);
});
const optionalRecipeBundleOutputSchema = z
.object({
fileName: nonEmptyString,
bytes: z.int().nonnegative(),
gzipBytes: z.int().nonnegative(),
sha256,
})
.strict();
const optionalRecipeBundleMeasurementSchema = z
.object({
recipeId: nonEmptyString,
sourceRoots: z.array(nonEmptyString).min(1),
sourceFileCount: z.int().positive(),
toolchain: z
.object({
bundler: z.literal("vite"),
viteVersion: nonEmptyString,
mode: z.literal("production"),
target: z.literal("es2022"),
format: z.literal("es"),
minifier: z.literal("esbuild"),
treeshake: z.literal(false),
compression: z.literal("node-zlib-gzip"),
})
.strict(),
outputs: z.array(optionalRecipeBundleOutputSchema).min(1),
bytes: z.int().nonnegative(),
gzipBytes: z.int().nonnegative(),
bundleBudgetGzipBytes: z.int().positive(),
remainingGzipBytes: z.int(),
sha256,
passed: z.boolean(),
})
.strict()
.superRefine((measurement, context) => {
if (measurement.bytes !== measurement.outputs.reduce((total, output) => total + output.bytes, 0)) {
context.addIssue({ code: "custom", path: ["bytes"], message: "must equal output bytes" });
}
if (measurement.gzipBytes !== measurement.outputs.reduce((total, output) => total + output.gzipBytes, 0)) {
context.addIssue({ code: "custom", path: ["gzipBytes"], message: "must equal output gzip bytes" });
}
if (measurement.remainingGzipBytes !== measurement.bundleBudgetGzipBytes - measurement.gzipBytes) {
context.addIssue({ code: "custom", path: ["remainingGzipBytes"], message: "must equal budget minus gzip bytes" });
}
if (measurement.passed !== (measurement.gzipBytes <= measurement.bundleBudgetGzipBytes)) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with bundle budget" });
}
});
const optionalRecipeReferenceRuntimeSchema = z
.object({
status: z.literal("AVAILABLE_NOT_COMPOSED"),
coveredCapabilities: z.array(nonEmptyString).min(1),
sourceRoots: z.array(nonEmptyString).min(1),
conformanceScripts: z.array(nonEmptyString).min(1),
productionComposition: z.literal(false),
})
.strict();
const optionalRecipeViolationSchema = z
.object({ ruleId: nonEmptyString, path: nonEmptyString, detail: nonEmptyString.optional() })
.strict();
export const optionalRecipesArtifactSchema = z
.object({
schemaVersion: z.literal(1),
decisionId: z.literal("VD-10"),
selectedCapabilities: z.array(nonEmptyString).max(0),
referenceRuntimes: z.array(
z.object({ id: nonEmptyString, referenceRuntime: optionalRecipeReferenceRuntimeSchema }).strict(),
).min(1),
recipeCount: z.int().nonnegative(),
productionRuntimeDependencies: z.array(nonEmptyString).nullable(),
referenceRuntimeBundleBudgets: z.array(optionalRecipeBundleMeasurementSchema).min(1),
bundleStatus: z.enum(["PASS", "FAIL", "NOT_BUILT"]),
violations: z.array(optionalRecipeViolationSchema),
passed: z.boolean(),
})
.strict()
.superRefine((artifact, context) => {
if (artifact.recipeCount < artifact.referenceRuntimes.length) {
context.addIssue({ code: "custom", path: ["recipeCount"], message: "cannot be smaller than reference runtimes" });
}
if (artifact.passed !== (artifact.violations.length === 0)) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with violations" });
}
const runtimeIds = artifact.referenceRuntimes.map(({ id }) => id).sort();
const budgetIds = artifact.referenceRuntimeBundleBudgets.map(({ recipeId }) => recipeId).sort();
addUniqueStringIssues(runtimeIds, ["referenceRuntimes"], context);
addUniqueStringIssues(budgetIds, ["referenceRuntimeBundleBudgets"], context);
if (JSON.stringify(runtimeIds) !== JSON.stringify(budgetIds)) {
context.addIssue({ code: "custom", path: ["referenceRuntimeBundleBudgets"], message: "must cover every reference runtime" });
}
});
const OPTIONAL_RECIPE_FIXTURE_IDS = [
"cleanup-omission",
"unselected-runtime-dependency",
"server-state-policy",
"vendor-direct-import",
"credential-leak",
"server-state-source-duplication",
"production-imports-recipe",
"reference-runtime-not-composed",
"reference-runtime-not-bundled",
"reference-runtime-module-not-bundled",
"reference-runtime-bundle-over-budget",
] as const;
const OPTIONAL_RECIPE_BUDGET_FIXTURE_IDS = [
"file-transfer",
"offline-indexeddb",
"realtime",
"service-worker-pwa",
] as const;
export const optionalRecipeFixturesArtifactSchema = z
.object({
schemaVersion: z.literal(1),
results: z.array(
namedBooleanResultSchema.extend({ id: z.enum(OPTIONAL_RECIPE_FIXTURE_IDS) }),
).length(OPTIONAL_RECIPE_FIXTURE_IDS.length),
bundleBudgetFixtures: z.array(
z
.object({
recipeId: z.enum(OPTIONAL_RECIPE_BUDGET_FIXTURE_IDS),
gzipBytes: z.int().nonnegative(),
fixtureBudgetGzipBytes: z.int().positive(),
rejected: z.boolean(),
})
.strict(),
).length(OPTIONAL_RECIPE_BUDGET_FIXTURE_IDS.length),
passed: z.boolean(),
})
.strict()
.superRefine((artifact, context) => {
addUniqueStringIssues(artifact.results.map(({ id }) => id), ["results"], context);
addUniqueStringIssues(artifact.bundleBudgetFixtures.map(({ recipeId }) => recipeId), ["bundleBudgetFixtures"], context);
if (artifact.passed !== artifact.results.every(({ passed }) => passed)) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with fixture results" });
}
artifact.bundleBudgetFixtures.forEach((fixture, index) => {
if (fixture.rejected !== (fixture.gzipBytes > fixture.fixtureBudgetGzipBytes)) {
context.addIssue({ code: "custom", path: ["bundleBudgetFixtures", index, "rejected"], message: "must agree with fixture budget" });
}
});
});
const registryCompatibilityValueSchema = z.union([
z.enum(["none", "additive", "behavior-change", "breaking"]),
z.boolean(),
]);
const REGISTRY_COMPATIBILITY_FIXTURE_IDS = [
"ordering-only",
"row-addition",
"behavior-change",
"row-removal",
"field-type-narrowing",
"route-path-change",
"registry-contract-narrowing",
"breaking-evidence-required",
"tampered-baseline-digest",
] as const;
export const registryCompatibilityFixturesArtifactSchema = z
.object({
schemaVersion: z.literal(1),
results: z.array(
z
.object({
id: z.enum(REGISTRY_COMPATIBILITY_FIXTURE_IDS),
expected: registryCompatibilityValueSchema,
actual: registryCompatibilityValueSchema,
passed: z.boolean(),
})
.strict(),
).length(REGISTRY_COMPATIBILITY_FIXTURE_IDS.length),
})
.strict()
.superRefine((artifact, context) => {
addUniqueStringIssues(artifact.results.map(({ id }) => id), ["results"], context);
artifact.results.forEach((result, index) => {
if (result.passed !== (result.actual === result.expected)) {
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with expected and actual" });
}
});
});
const buildDigestSchema = z.union([sha256, z.literal("BUILD_FAILED")]);
export const reproducibleBuildArtifactSchema = z
.object({
schemaVersion: z.literal(1),
sourceDateEpoch: z.string().regex(/^\d+$/u),
buildId: nonEmptyString,
commitSha: nonEmptyString,
releaseId: nonEmptyString,
runnerImage: nonEmptyString,
firstDigest: buildDigestSchema,
secondDigest: buildDigestSchema,
restored: z.boolean(),
status: z.enum(["PASS", "FAIL"]),
})
.strict()
.superRefine((artifact, context) => {
const passed =
artifact.restored &&
artifact.firstDigest !== "BUILD_FAILED" &&
artifact.firstDigest === artifact.secondDigest;
if ((artifact.status === "PASS") !== passed) {
context.addIssue({ code: "custom", path: ["status"], message: "must agree with build digests and restoration" });
}
});
const SUPPLY_CHAIN_FIXTURE_IDS = [
"transitive-removal-is-real-diff",
"tampered-integrity-rejected",
"high-risk-self-approval-rejected",
"denied-license-rejected",
"critical-vulnerability-expired-exception-rejected",
"sbom-provenance-mismatch-rejected",
"dependency-ordering-deterministic",
"baseline-digest-tamper-rejected",
"vulnerability-provider-evidence-invalid",
] as const;
export const supplyChainFixturesArtifactSchema = z
.object({
schemaVersion: z.literal(1),
results: z.array(
namedBooleanResultSchema.extend({ id: z.enum(SUPPLY_CHAIN_FIXTURE_IDS) }),
).length(SUPPLY_CHAIN_FIXTURE_IDS.length),
})
.strict()
.superRefine((artifact, context) =>
addUniqueStringIssues(artifact.results.map(({ id }) => id), ["results"], context)
);
const providerFixtureResultSchema = z
.object({ status: z.enum(["PASS", "FAIL_UNVERIFIED"]), failures: failureList })
.strict()
.superRefine((result, context) => {
if ((result.status === "PASS") !== (result.failures.length === 0)) {
context.addIssue({ code: "custom", path: ["status"], message: "must agree with failures" });
}
});
export const supplyChainProviderFixturesArtifactSchema = z
.object({
schemaVersion: z.literal(1),
actualDefaultVerifier: providerFixtureResultSchema,
fixtures: z
.object({
absent: providerFixtureResultSchema,
validImmutable: providerFixtureResultSchema,
wrongDigest: providerFixtureResultSchema,
invalidTar: providerFixtureResultSchema,
})
.strict(),
externalTreeCanary: providerFixtureResultSchema,
passingFixtureCount: z.int().nonnegative(),
status: z.enum(["PASS", "FAIL"]),
})
.strict()
.superRefine((artifact, context) => {
const expectedPass =
artifact.actualDefaultVerifier.status === "PASS" &&
artifact.fixtures.validImmutable.status === "PASS" &&
artifact.externalTreeCanary.status === "PASS" &&
[artifact.fixtures.absent, artifact.fixtures.wrongDigest, artifact.fixtures.invalidTar]
.every(({ status }) => status === "FAIL_UNVERIFIED");
if (artifact.passingFixtureCount !== (artifact.fixtures.validImmutable.status === "PASS" ? 1 : 0)) {
context.addIssue({ code: "custom", path: ["passingFixtureCount"], message: "must count the passing immutable fixture" });
}
if ((artifact.status === "PASS") !== expectedPass) {
context.addIssue({ code: "custom", path: ["status"], message: "must agree with required fixture outcomes" });
}
});
const compatibilityClassificationSchema = z.enum(["additive", "breaking"]);
export const compatibilityFixturesArtifactSchema = z
.object({
schemaVersion: z.literal(1),
generatedAt: timestamp,
rules: z.array(nonEmptyString).length(5),
results: z.array(
z
.object({
family: z.enum(["api", "config", "storage", "release"]),
expected: compatibilityClassificationSchema,
actual: compatibilityClassificationSchema,
passed: z.boolean(),
})
.strict(),
).length(8),
})
.strict()
.superRefine((artifact, context) => {
const keys = artifact.results.map(({ family, expected }) => `${family}\0${expected}`);
addUniqueStringIssues(keys, ["results"], context);
artifact.results.forEach((result, index) => {
if (result.passed !== (result.actual === result.expected)) {
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with expected and actual" });
}
});
});
export const documentationReviewArtifactSchema = z
.object({
schemaVersion: z.literal(1),
generatedAt: timestamp,
status: z.literal("PASS_SCOPED"),
reviewer: z.literal("wiki-diagram-reviewer"),
standard: z.literal("rules/diagram-standards.md v2"),
evidenceReport: z
.object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 })
.strict(),
reportDigestValid: z.boolean(),
results: z.array(
z
.object({
diagram: z.enum(["overview", "staticDelivery"]),
sourcePath: nonEmptyString,
sha256,
sourceReferenced: z.boolean(),
digestReferenced: z.boolean(),
reviewer: z.literal("wiki-diagram-reviewer"),
score: z.number().min(0).max(100),
scorePass: z.boolean(),
passed: z.boolean(),
})
.strict(),
).length(2),
passed: z.boolean(),
})
.strict()
.superRefine((artifact, context) => {
addUniqueStringIssues(artifact.results.map(({ diagram }) => diagram), ["results"], context);
artifact.results.forEach((result, index) => {
const passed = result.sourceReferenced && result.digestReferenced && result.scorePass;
if (result.passed !== passed) {
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" });
}
});
if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" });
}
});
export const hostingHeadersArtifactSchema = z
.object({
schemaVersion: z.literal(1),
generatedAt: timestamp,
mode: z.enum(["live", "invalid-live", "fixture"]),
baseUrl: z.string().nullable(),
providerVerificationRequired: z.boolean(),
results: z.array(
z
.object({
surface: nonEmptyString,
header: nonEmptyString,
expected: z.json(),
observed: z.json().optional(),
reason: nonEmptyString.optional(),
passed: z.boolean(),
})
.strict(),
).min(1),
passed: z.boolean(),
})
.strict()
.superRefine((artifact, context) => {
const keys = artifact.results.map(({ surface, header }) => `${surface}\0${header}`);
addUniqueStringIssues(keys, ["results"], context);
const requiredKeys = [
...["index", "runtimeConfig", "releaseManifest"].flatMap((surface) =>
[
"cache-control",
"content-type",
"content-security-policy",
"strict-transport-security",
"x-frame-options",
"referrer-policy",
"x-content-type-options",
"permissions-policy",
].map((header) => `${surface}\0${header}`)
),
"hashedAsset\0cache-control",
"hashedAsset\0content-type",
"sourceMap\0public",
"serviceWorker\0enabled",
];
for (const requiredKey of requiredKeys) {
if (!keys.includes(requiredKey)) {
context.addIssue({ code: "custom", path: ["results"], message: `missing required probe: ${requiredKey}` });
}
}
if (artifact.providerVerificationRequired !== (artifact.mode !== "live")) {
context.addIssue({ code: "custom", path: ["providerVerificationRequired"], message: "must agree with hosting mode" });
}
if ((artifact.mode === "live") !== (artifact.baseUrl !== null)) {
context.addIssue({ code: "custom", path: ["baseUrl"], message: "must be present only for live mode" });
}
if (artifact.passed !== artifact.results.every(({ passed }) => passed)) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with probe results" });
}
});
+6 -6
View File
@@ -44,6 +44,7 @@ export type CiWorkflowFileSystem = Readonly<{
readFile(target: string): Promise<Buffer>;
open(target: string, flags: number, mode: number): Promise<{
writeFile(content: string, encoding: "utf8"): Promise<unknown>;
chmod(mode: number): Promise<unknown>;
sync(): Promise<unknown>;
close(): Promise<unknown>;
}>;
@@ -226,11 +227,6 @@ function renderStep(
` name: ${yamlScalar(requiredTransferName(transfers, step.transferId))}`,
` path: ${yamlScalar(step.path)}`,
];
case "validate-candidate-archive":
return [
" - name: Validate immutable candidate before extraction",
` run: node scripts/verify-ci-candidate-archive.ts --archive ${shellDoubleQuoted(step.archivePath)}`,
];
case "extract":
return [
" - name: Verify and extract the candidate through one inode-bound operation",
@@ -263,8 +259,10 @@ function renderStep(
` PROMOTION_CLEANUP_TOKEN: \${{ steps.${step.finalizerStepId}.outputs.cleanup_token }}`,
` PROMOTION_RUNNER_TEMP_DEV: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_dev }}`,
` PROMOTION_RUNNER_TEMP_INO: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_ino }}`,
` PROMOTION_STAGING_DEV: \${{ steps.${step.finalizerStepId}.outputs.staging_dev }}`,
` PROMOTION_STAGING_INO: \${{ steps.${step.finalizerStepId}.outputs.staging_ino }}`,
" run: |",
' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then',
' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then',
" node scripts/cleanup-verified-promotion.ts",
" fi",
];
@@ -296,6 +294,7 @@ function requiredStepActionUses(stepKind: string): string {
function renderCondition(condition: CiWorkflowJob["condition"]): string | null {
const expressions: Record<CiWorkflowJob["condition"], string | null> = {
always: null,
"needs-success": null,
merge: "${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}",
release: "${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}",
production: "${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}",
@@ -412,6 +411,7 @@ export function createCiWorkflowGenerator(
let failure: unknown;
try {
await handle.writeFile(expected.toString("utf8"), "utf8");
await handle.chmod(0o644);
await handle.sync();
} catch (error) {
failure = error;
+2 -2
View File
@@ -7,7 +7,7 @@ import {
} from "node:fs/promises";
import {
bundlePerformanceArtifactSchema,
bundleOutputInventoryArtifactSchema,
buildManifestArtifactSchema,
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
@@ -327,7 +327,7 @@ await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/security", { recursive: true });
await writeValidatedJsonArtifact({
path: "artifacts/performance/bundle.json",
schema: bundlePerformanceArtifactSchema,
schema: bundleOutputInventoryArtifactSchema,
value: bundleReport,
});
await writeValidatedJsonArtifact({
+298 -11
View File
@@ -10,21 +10,37 @@ import type {
} from "../contracts/ci-gates.ts";
import { ciContractReportSchema } from "./ci-contract-report.ts";
import {
architectureDependencyReportArtifactSchema,
automatedA11yArtifactSchema,
buildManifestArtifactSchema,
bundlePerformanceArtifactSchema,
compatibilityFixturesArtifactSchema,
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
designSystemReportArtifactSchema,
diagnosticsReportArtifactSchema,
documentationReviewArtifactSchema,
fieldWebVitalsArtifactSchema,
hostingHeadersArtifactSchema,
i18nReportArtifactSchema,
jsonSchemaDocumentArtifactSchema,
labPerformanceArtifactSchema,
licenseReportArtifactSchema,
manualA11yReportArtifactSchema,
moduleInventoryArtifactSchema,
optionalRecipeFixturesArtifactSchema,
optionalRecipesArtifactSchema,
provenanceArtifactSchema,
realtimeBoundariesArtifactSchema,
registryGovernanceRunArtifactSchema,
registryCompatibilityFixturesArtifactSchema,
registrySnapshotArtifactSchema,
releaseVerificationArtifactSchema,
reproducibleBuildArtifactSchema,
runbookRecordArtifactSchema,
sbomArtifactSchema,
supplyChainFixturesArtifactSchema,
supplyChainProviderFixturesArtifactSchema,
supplyChainVerificationArtifactSchema,
vulnerabilityReportArtifactSchema,
} from "../contracts/release-artifacts.ts";
@@ -40,10 +56,6 @@ import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"
import { secretScanSarifSchema } from "./secret-scan-evaluator.ts";
import { testEvidenceReportSchema } from "./test-evidence-artifact.ts";
const jsonObjectSchema = z.record(z.string(), z.json()).refine(
(value) => Object.keys(value).length > 0,
"generic JSON artifact must be a non-empty object",
);
const coverageCounterSchema = z
.object({
total: z.number().int().nonnegative(),
@@ -184,7 +196,22 @@ type ExecutableJsonSchemaId = Extract<
>["executableSchemaId"];
const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> = Object.freeze({
"generic-json-object": jsonObjectSchema,
"automated-a11y": automatedA11yArtifactSchema,
"manual-a11y": manualA11yReportArtifactSchema,
"architecture-dependency-report": architectureDependencyReportArtifactSchema,
"design-system-contract": designSystemReportArtifactSchema,
"i18n-contract": i18nReportArtifactSchema,
"diagnostics-contract": diagnosticsReportArtifactSchema,
"realtime-boundaries": realtimeBoundariesArtifactSchema,
"optional-recipes": optionalRecipesArtifactSchema,
"optional-recipe-fixtures": optionalRecipeFixturesArtifactSchema,
"registry-compatibility-fixtures": registryCompatibilityFixturesArtifactSchema,
"reproducible-build": reproducibleBuildArtifactSchema,
"supply-chain-fixtures": supplyChainFixturesArtifactSchema,
"supply-chain-provider-fixtures": supplyChainProviderFixturesArtifactSchema,
"compatibility-fixtures": compatibilityFixturesArtifactSchema,
"documentation-review": documentationReviewArtifactSchema,
"hosting-headers": hostingHeadersArtifactSchema,
"coverage-summary-v8": coverageSummarySchema,
"risk-coverage-v3": riskCoverageArtifactSchema,
"build-manifest": buildManifestArtifactSchema,
@@ -213,6 +240,12 @@ const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> =
"ci-contract-report": ciContractReportSchema,
});
export function hasCiArtifactSemanticValidator(
schema: CiGateArtifactSchema,
): boolean {
return schema.kind !== "json" || schema.executableSchemaId in executableJsonSchemas;
}
type ReadHandle = Readonly<{
stat(): Promise<Stats>;
read(
@@ -253,9 +286,7 @@ export async function validateCiArtifact(
if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`);
return;
case "html":
if (!/^\s*(?:<!doctype\s+html\s*>\s*)?<html\b[^>]*>[\s\S]*<\/html\s*>\s*$/iu.test(text)) {
throw new TypeError(`invalid HTML artifact: ${relative}`);
}
assertWellFormedHtml(text, relative);
return;
case "junit":
assertWellFormedJUnitXml(text, relative);
@@ -355,51 +386,85 @@ async function readHandleBounded(
return captured.subarray(0, offset);
}
const MAX_DOCUMENT_DEPTH = 256;
const MAX_DOCUMENT_UNITS = 100_000;
function assertWellFormedJUnitXml(source: string, relative: string): void {
const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`);
if (/<!DOCTYPE\b|<!ENTITY\b/iu.test(source)) throw invalid();
if (!hasOnlyXmlCharacters(source)) throw invalid();
const stack: string[] = [];
let root: string | undefined;
let rootClosed = false;
let declarationSeen = false;
let units = 0;
let cursor = 0;
while (cursor < source.length) {
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if ((text.includes("]]>") || !hasValidXmlEntities(text)) && text.length > 0) throw invalid();
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<![CDATA[", open)) {
const close = source.indexOf("]]>", open + 9);
if (stack.length === 0 || close < 0) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<?", open)) {
const close = source.indexOf("?>", open + 2);
if (root || close < 0) throw invalid();
const processingInstruction = source.slice(open, close + 2);
const match = /^<\?([A-Za-z_][\w:.-]*)(?:\s+[\s\S]*?)?\?>$/u.exec(
processingInstruction,
);
if (!match) throw invalid();
if (match[1]!.toLowerCase() === "xml") {
if (
declarationSeen ||
source.slice(0, open).trim() ||
!/^<\?xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?\s*\?>$/u.test(
processingInstruction,
)
) {
throw invalid();
}
declarationSeen = true;
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 2;
continue;
}
const close = source.indexOf(">", open + 1);
if (source.startsWith("<!", open)) throw invalid();
const close = markupEnd(source, open + 1);
if (close < 0) throw invalid();
const tag = source.slice(open, close + 1);
const closing = /^<\/([A-Za-z_][\w:.-]*)\s*>$/u.exec(tag);
if (closing) {
if (stack.pop() !== closing[1]) throw invalid();
if (stack.length === 0) rootClosed = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
continue;
}
const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid();
root ??= opening[1];
if (opening[3] !== "/") stack.push(opening[1]!);
if (opening[3] !== "/") {
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
stack.push(opening[1]!);
}
else if (stack.length === 0) rootClosed = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
}
if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) {
@@ -412,14 +477,236 @@ function hasValidXmlAttributes(source: string): boolean {
const names = new Set<string>();
while (remaining.length > 0) {
if (!remaining.trim()) return true;
const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"[^"<]*"|'[^'<]*')/u.exec(remaining);
const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/u.exec(remaining);
if (!match || names.has(match[1]!)) return false;
if (!hasValidXmlEntities(match[2] ?? match[3] ?? "")) return false;
names.add(match[1]!);
remaining = remaining.slice(match[0].length);
}
return true;
}
function hasOnlyXmlCharacters(source: string): boolean {
for (const character of source) {
const codePoint = character.codePointAt(0)!;
if (
codePoint !== 0x09 &&
codePoint !== 0x0a &&
codePoint !== 0x0d &&
(codePoint < 0x20 ||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
(codePoint > 0xfffd && codePoint < 0x10000) ||
codePoint > 0x10ffff)
) {
return false;
}
}
return true;
}
function hasValidXmlEntities(source: string): boolean {
let cursor = 0;
while (cursor < source.length) {
const ampersand = source.indexOf("&", cursor);
if (ampersand < 0) return true;
const semicolon = source.indexOf(";", ampersand + 1);
if (semicolon < 0) return false;
const entity = source.slice(ampersand + 1, semicolon);
if (!["amp", "lt", "gt", "apos", "quot"].includes(entity)) {
const decimal = /^#([0-9]+)$/u.exec(entity);
const hexadecimal = /^#x([a-fA-F0-9]+)$/u.exec(entity);
if (!decimal && !hexadecimal) return false;
const codePoint = Number.parseInt((decimal ?? hexadecimal)![1]!, decimal ? 10 : 16);
if (
!Number.isSafeInteger(codePoint) ||
(codePoint !== 0x09 &&
codePoint !== 0x0a &&
codePoint !== 0x0d &&
(codePoint < 0x20 ||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
(codePoint > 0xfffd && codePoint < 0x10000) ||
codePoint > 0x10ffff))
) {
return false;
}
}
cursor = semicolon + 1;
}
return true;
}
const HTML_VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
const HTML_RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
function assertWellFormedHtml(source: string, relative: string): void {
const invalid = () => new TypeError(`invalid HTML artifact: ${relative}`);
if (/<!ENTITY\b|<!DOCTYPE\s+html\s+[^>]*\[/iu.test(source)) throw invalid();
const stack: string[] = [];
let cursor = 0;
let units = 0;
let doctypeSeen = false;
let rootSeen = false;
let rootClosed = false;
let playwrightPayloadSeen = false;
while (cursor < source.length) {
const rawElement = stack.at(-1);
if (rawElement && HTML_RAW_TEXT_ELEMENTS.has(rawElement)) {
const closingStart = source.toLowerCase().indexOf(`</${rawElement}`, cursor);
if (closingStart < 0) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = closingStart;
}
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
const declarationEnd = source.indexOf(">", open + 2);
if (source.slice(open, open + 9).toLowerCase() === "<!doctype") {
if (
declarationEnd < 0 ||
doctypeSeen ||
rootSeen ||
source.slice(open, declarationEnd + 1).toLowerCase() !== "<!doctype html>"
) {
throw invalid();
}
doctypeSeen = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = declarationEnd + 1;
continue;
}
if (source.startsWith("<!", open) || source.startsWith("<?", open)) throw invalid();
const close = markupEnd(source, open + 1);
if (close < 0) throw invalid();
const tag = source.slice(open, close + 1);
const closing = /^<\/([A-Za-z][A-Za-z0-9:-]*)\s*>$/u.exec(tag);
if (closing) {
const name = closing[1]!.toLowerCase();
if (stack.pop() !== name) throw invalid();
if (stack.length === 0) {
if (name === "html") rootClosed = true;
else if (name === "template" && playwrightPayloadSeen) {
// Playwright emits its base64 report template after </html>; HTML5
// reparents this token into the document body. It is the sole
// permitted generated-report sidecar and does not create a new root.
} else {
throw invalid();
}
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
continue;
}
const opening = /^<([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
if (!opening) throw invalid();
const name = opening[1]!.toLowerCase();
const attributes = parseHtmlAttributes(opening[2] ?? "");
if (!attributes || name.includes(":")) throw invalid();
if (!rootSeen) {
if (name !== "html" || opening[3] === "/") throw invalid();
rootSeen = true;
} else if (name === "html") {
throw invalid();
}
if (rootClosed && stack.length === 0) {
if (
playwrightPayloadSeen ||
name !== "template" ||
attributes.get("id") !== "playwrightReportBase64" ||
opening[3] === "/"
) {
throw invalid();
}
playwrightPayloadSeen = true;
}
if (!HTML_VOID_ELEMENTS.has(name) && opening[3] !== "/") {
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
stack.push(name);
} else if (stack.length === 0 && name === "html") {
rootClosed = true;
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
}
if (stack.length > 0 || !rootSeen || !rootClosed) throw invalid();
}
function markupEnd(source: string, start: number): number {
let quote: "\"" | "'" | undefined;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (character === quote) quote = undefined;
} else if (character === "\"" || character === "'") {
quote = character;
} else if (character === ">") {
return index;
}
}
return -1;
}
function parseHtmlAttributes(source: string): ReadonlyMap<string, string> | null {
const attributes = new Map<string, string>();
let cursor = 0;
while (cursor < source.length) {
const whitespace = /^\s+/u.exec(source.slice(cursor));
if (!whitespace) return source.slice(cursor).trim() ? null : attributes;
cursor += whitespace[0].length;
if (cursor >= source.length) return attributes;
const nameMatch = /^[A-Za-z_:][A-Za-z0-9:._-]*/u.exec(source.slice(cursor));
if (!nameMatch) return null;
const name = nameMatch[0].toLowerCase();
if (attributes.has(name) || name.includes(":")) return null;
cursor += nameMatch[0].length;
const spacing = /^\s*/u.exec(source.slice(cursor))![0];
cursor += spacing.length;
let value = "";
if (source[cursor] === "=") {
cursor += 1;
cursor += /^\s*/u.exec(source.slice(cursor))![0].length;
const quote = source[cursor];
if (quote === "\"" || quote === "'") {
const end = source.indexOf(quote, cursor + 1);
if (end < 0) return null;
value = source.slice(cursor + 1, end);
if (value.includes("<")) return null;
cursor = end + 1;
} else {
const unquoted = /^[^\s"'`=<>]+/u.exec(source.slice(cursor));
if (!unquoted) return null;
value = unquoted[0];
cursor += value.length;
}
}
attributes.set(name, value);
}
return attributes;
}
function assertSameIdentity(before: Stats, after: Stats, relative: string): void {
if (
!Number.isSafeInteger(before.dev) ||
+353
View File
@@ -0,0 +1,353 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import {
PROMOTED_FILE_NAMES,
type PromotedFileName,
} from "../contracts/promotion-artifacts.ts";
import {
PROMOTION_VERIFIER_ID,
PROMOTION_VERIFIER_VERSION,
assertDistinctProviderTrust,
evaluatePromotionEvidence,
providerVerificationArtifactSchema,
provenanceProviderAttestationSchema,
vulnerabilityProviderReportSchema,
trustPolicySha256,
type ProviderTrust,
} from "./provider-evidence.ts";
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
import { LOCAL_EVIDENCE_ASSESSMENT_PATH } from "./release-candidate.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
export type ExactPromotionBundle = Readonly<
Partial<Record<PromotedFileName, Buffer>>
>;
export type ExactPromotionExpectedContext = Readonly<{
run: Readonly<{ id: string; attempt: number }>;
sourceRevision: string;
archiveSha256: string;
sourceSetSha256?: string;
bundleSha256?: string;
distSha256?: string;
lockfileSha256?: string;
}>;
export async function verifyExactPromotionBundle(
files: ExactPromotionBundle,
options: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
expected: ExactPromotionExpectedContext;
nowEpochMs?: () => number;
}>,
): Promise<Readonly<{ status: "PASS" }>> {
assertDistinctProviderTrust(options);
assertExternalExpectedContext(options.expected);
const names = Object.keys(files).sort(asciiCompare);
const expectedNames = [...PROMOTED_FILE_NAMES].sort(asciiCompare);
if (JSON.stringify(names) !== JSON.stringify(expectedNames)) {
throw new Error("promotion bundle must contain the exact five canonical files");
}
for (const name of PROMOTED_FILE_NAMES) {
if (!Buffer.isBuffer(files[name])) {
throw new TypeError(`promotion bundle file is missing or not captured bytes: ${name}`);
}
}
const archiveBytes = files["release-candidate.tar.gz"]!;
const vulnerabilityBytes = files["vulnerability-report.json"]!;
const provenanceBytes = files["provenance-attestation.json"]!;
const providerBytes = files["provider-verification.json"]!;
const promotionBytes = files["promotion-verification.json"]!;
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(
vulnerabilityBytes,
"vulnerability report",
));
const provenance = provenanceProviderAttestationSchema.parse(parseJson(
provenanceBytes,
"provenance attestation",
));
const provider = providerVerificationArtifactSchema.parse(parseJson(
providerBytes,
"provider verification",
));
const promotion = providerVerificationArtifactSchema.parse(parseJson(
promotionBytes,
"promotion verification",
));
if (
provider.artifactType !== "provider-verification" ||
promotion.artifactType !== "promotion-verification"
) {
throw new Error("promotion verification artifact role mismatch");
}
for (const [label, record] of [
["provider", provider],
["promotion", promotion],
] as const) {
if (
record.verifier.id !== PROMOTION_VERIFIER_ID ||
record.verifier.version !== PROMOTION_VERIFIER_VERSION
) {
throw new Error(`${label} verification literal verifier identity mismatch`);
}
if (record.status !== "PASS" || record.failures.length !== 0) {
throw new Error(`${label} verification must be PASS without failures`);
}
}
if (
provider.vulnerabilityStatus !== "PASS" ||
provider.provenanceAttestationStatus !== "PASS"
) {
throw new Error("provider verification subordinate statuses must both be PASS");
}
if (promotion.localEvidenceStatus !== "PASS") {
throw new Error("promotion local evidence subordinate status must be PASS");
}
assertEqual("shared verifiedAt", provider.verifiedAt, promotion.verifiedAt);
assertEqual("shared run", provider.run, promotion.run);
assertEqual("shared source", provider.source, promotion.source);
assertEqual("shared candidate", provider.candidate, promotion.candidate);
assertEqual(
"shared provider evidence",
provider.providerEvidence,
promotion.providerEvidence,
);
assertEqual(
"shared trust policy",
provider.trustPolicySha256,
promotion.trustPolicySha256,
);
assertEqual("external expected run", provider.run, options.expected.run);
assertEqual(
"external expected source revision",
provider.source.revision,
options.expected.sourceRevision,
);
assertEqual(
"external expected archive digest",
provider.candidate.archiveSha256,
options.expected.archiveSha256,
);
for (const [label, actual, expected] of [
["source set", provider.source.sourceSetSha256, options.expected.sourceSetSha256],
["bundle", provider.candidate.bundleSha256, options.expected.bundleSha256],
["dist", provider.candidate.distSha256, options.expected.distSha256],
["lockfile", provider.candidate.lockfileSha256, options.expected.lockfileSha256],
] as const) {
if (expected !== undefined) {
assertEqual(`external expected ${label} digest`, actual, expected);
}
}
const anchoredTrustPolicySha256 = trustPolicySha256(options);
if (provider.trustPolicySha256 !== anchoredTrustPolicySha256) {
throw new Error("verification trust policy does not match anchored provider keys");
}
if (promotion.providerVerificationSha256 !== sha256(providerBytes)) {
throw new Error("promotion provider verification byte hash mismatch");
}
if (
provider.candidate.archiveSha256 !== sha256(archiveBytes) ||
provider.providerEvidence.vulnerabilityReportSha256 !== sha256(vulnerabilityBytes) ||
provider.providerEvidence.provenanceAttestationSha256 !== sha256(provenanceBytes)
) {
if (provider.candidate.archiveSha256 !== sha256(archiveBytes)) {
throw new Error("candidate archive actual digest mismatch");
}
if (
provider.providerEvidence.vulnerabilityReportSha256 !==
sha256(vulnerabilityBytes)
) {
throw new Error("vulnerability report actual digest mismatch");
}
throw new Error("provenance attestation actual digest mismatch");
}
for (const [label, evidence, nonce, keyId, fingerprint] of [
[
"vulnerability",
vulnerability,
provider.providerEvidence.vulnerabilityInvocationNonce,
provider.providerEvidence.vulnerabilityKeyId,
provider.providerEvidence.vulnerabilityKeyFingerprint,
],
[
"provenance",
provenance,
provider.providerEvidence.provenanceInvocationNonce,
provider.providerEvidence.provenanceKeyId,
provider.providerEvidence.provenanceKeyFingerprint,
],
] as const) {
assertEqual(`${label} run`, { id: evidence.run.id, attempt: evidence.run.attempt }, provider.run);
assertEqual(`${label} source`, evidence.source, provider.source);
assertEqual(`${label} candidate`, evidence.candidate, provider.candidate);
if (
evidence.run.invocationNonce !== nonce ||
evidence.signature.keyId !== keyId ||
evidence.signature.publicKeyFingerprint !== fingerprint
) {
throw new Error(`${label} provider evidence nonce or trust role mismatch`);
}
}
if (provenance.subject.digest.sha256 !== provider.candidate.distSha256) {
throw new Error("provenance subject dist digest mismatch");
}
if (vulnerability.findings.length !== 0) {
throw new Error("vulnerability report is not PASS");
}
assertEqual(
"signed secret scan attestation",
vulnerability.secretScanAttestation,
provider.providerEvidence.secretScanAttestation,
);
let assessmentSha256: string | null = null;
const localIdentityHolder: {
current: null | Readonly<{
sourceRevision: string;
sourceSetSha256: string;
assessmentSha256: string;
secretScan: Readonly<{
policySha256: string;
sarifSha256: string;
scanInputSha256: string;
}>;
}>;
} = { current: null };
await verifyCapturedCiCandidateArchive(
archiveBytes,
provider.candidate.archiveSha256,
{
verifyExtracted: async (extractionRoot, manifest) => {
assertEqual("archive candidate", {
bundleSha256: manifest.bundleSha256,
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
}, {
bundleSha256: provider.candidate.bundleSha256,
distSha256: provider.candidate.distSha256,
lockfileSha256: provider.candidate.lockfileSha256,
});
assessmentSha256 = sha256(
await readFile(path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
);
const local = await verifyArchivedLocalEvidence({
extractionRoot,
expectedManifest: manifest,
});
if (local.status !== "PASS" || !local.identity) {
throw new Error(
`exact-five archived local verification is not PASS: ${local.failures.join(", ")}`,
);
}
localIdentityHolder.current = local.identity;
},
},
);
if (assessmentSha256 !== promotion.localEvidenceAssessmentSha256) {
throw new Error("promotion local evidence assessment actual digest mismatch");
}
if (
!localIdentityHolder.current ||
localIdentityHolder.current.sourceRevision !== provider.source.revision ||
localIdentityHolder.current.sourceSetSha256 !== provider.source.sourceSetSha256 ||
localIdentityHolder.current.assessmentSha256 !== promotion.localEvidenceAssessmentSha256
) {
throw new Error("exact-five archived local identity mismatch");
}
assertEqual("archived secret scan attestation", {
status: "PASS",
localEvidenceAssessmentSha256: localIdentityHolder.current.assessmentSha256,
sourceSetSha256: localIdentityHolder.current.sourceSetSha256,
policySha256: localIdentityHolder.current.secretScan.policySha256,
sarifSha256: localIdentityHolder.current.secretScan.sarifSha256,
scanInputSha256: localIdentityHolder.current.secretScan.scanInputSha256,
}, vulnerability.secretScanAttestation);
const reevaluated = evaluatePromotionEvidence({
expected: {
run: provider.run,
source: provider.source,
candidate: provider.candidate,
vulnerabilityInvocationNonce:
provider.providerEvidence.vulnerabilityInvocationNonce,
provenanceInvocationNonce:
provider.providerEvidence.provenanceInvocationNonce,
secretScanAttestation: provider.providerEvidence.secretScanAttestation,
},
localStatus: "PASS",
vulnerabilityReport: vulnerability,
provenanceAttestation: provenance,
vulnerabilityTrust: options.vulnerabilityTrust,
provenanceTrust: options.provenanceTrust,
nowEpochMs: options.nowEpochMs,
});
if (
reevaluated.status !== "PASS" ||
reevaluated.vulnerabilityStatus !== "PASS" ||
reevaluated.provenanceAttestationStatus !== "PASS"
) {
throw new Error(
`exact-five provider signature/freshness revalidation is not PASS: ${reevaluated.failures.join(", ")}`,
);
}
return Object.freeze({ status: "PASS" as const });
}
function assertExternalExpectedContext(
expected: ExactPromotionExpectedContext,
): void {
if (
!expected ||
typeof expected.run?.id !== "string" ||
expected.run.id.length === 0 ||
!Number.isSafeInteger(expected.run.attempt) ||
expected.run.attempt < 1 ||
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(expected.sourceRevision) ||
!isSha256(expected.archiveSha256)
) {
throw new TypeError("external expected promotion context is invalid or incomplete");
}
for (const digest of [
expected.sourceSetSha256,
expected.bundleSha256,
expected.distSha256,
expected.lockfileSha256,
]) {
if (digest !== undefined && !isSha256(digest)) {
throw new TypeError("external optional expected promotion digest is invalid");
}
}
}
function isSha256(value: unknown): value is string {
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
}
function parseJson(bytes: Buffer, label: string): unknown {
try {
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
} catch {
throw new TypeError(`${label} is not strict UTF-8 JSON`);
}
}
function assertEqual(label: string, left: unknown, right: unknown): void {
if (JSON.stringify(left) !== JSON.stringify(right)) {
throw new Error(`${label} mismatch`);
}
}
function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
+200 -35
View File
@@ -31,6 +31,8 @@ import {
import { assertMatchesJsonSchema } from "./json-schema.ts";
import {
LOCAL_EVIDENCE_ASSESSMENT_PATH,
LOCAL_EVIDENCE_POLICY_INPUT_PATHS,
LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
collectDistOutputs,
@@ -54,9 +56,12 @@ import {
parseRepositoryFileInventoryPolicy,
} from "./repository-file-inventory.ts";
import {
parseSecretScanPolicy,
secretScanSarifSchema,
evaluateRepositorySecretScan,
verifyStoredSecretScan,
} from "./secret-scan-evaluator.ts";
import { secretScanRules } from "./secret-scan.ts";
import {
isValidSha512Integrity,
parsePnpmLockfilePackages,
@@ -301,37 +306,10 @@ export async function verifyLocalSupplyChainEvidence(
export const LOCAL_EVIDENCE_VERIFIER_ID =
"clean-architecture-frontend-template/local-evidence-verifier";
export const LOCAL_EVIDENCE_VERIFIER_VERSION = "1";
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
"scripts/contracts/release-artifacts.ts",
"scripts/create-release-candidate.ts",
"scripts/generate-supply-chain.ts",
"scripts/lib/build-manifest-outputs.ts",
"scripts/lib/json-schema.ts",
"scripts/lib/local-policy-evidence.ts",
"scripts/lib/local-release-evidence.ts",
"scripts/lib/release-candidate.ts",
"scripts/lib/release-input-evidence.ts",
"scripts/lib/release-runtime-coherence.ts",
"scripts/lib/repository-file-inventory.ts",
"scripts/lib/secret-scan-evaluator.ts",
"scripts/lib/secret-scan-policy.ts",
"scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts",
"src/contracts/release-artifacts.ts",
] as const);
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
"config/security/dependency-baseline.approval.json",
"config/security/dependency-baseline.json",
"config/security/dependency-change-evidence.json",
"config/security/dependency-policy.json",
"config/security/secret-scan-policy.json",
"config/security/vulnerability-exceptions.json",
"config/security/vulnerability-policy.json",
"schemas/artifacts/build-manifest.schema.json",
"schemas/artifacts/dependency-inventory.schema.json",
"schemas/artifacts/supply-chain-verification.schema.json",
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
] as const);
export {
LOCAL_EVIDENCE_POLICY_INPUT_PATHS,
LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
} from "./release-candidate.ts";
export async function createLocalEvidenceAssessment(
repositoryRoot = process.cwd(),
@@ -360,7 +338,7 @@ export async function createLocalEvidenceAssessment(
files: evidenceInputs,
};
const evaluated = await evaluateProducerLocalChecks(root, candidate);
const [build, release, provenance, supply, sbomDocument, policyInputs] = await Promise.all([
const [build, release, provenance, supply, sbomDocument, policyInputs, secretScan] = await Promise.all([
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
buildManifestArtifactSchema.parse(value),
),
@@ -381,6 +359,7 @@ export async function createLocalEvidenceAssessment(
digestInput(root, policyPath),
),
),
evaluateRepositorySecretScan({ repositoryRoot: root }),
]);
const identityFailures: string[] = [];
if (build.commitSha !== release.commitSha) {
@@ -421,6 +400,15 @@ export async function createLocalEvidenceAssessment(
if (verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length) {
throw new Error("local assessment verifier source set is incomplete");
}
const secretPolicy = policyInputs.find(
({ path: policyPath }) => policyPath === "config/security/secret-scan-policy.json",
);
const secretSarif = evidenceInputs.find(
({ path: evidencePath }) => evidencePath === "artifacts/security/scan.sarif",
);
if (!secretPolicy || !secretSarif) {
throw new Error("local assessment secret scan inputs are incomplete");
}
return localEvidenceAssessmentArtifactSchema.parse({
schemaVersion: 1,
artifactType: "local-evidence-assessment",
@@ -440,6 +428,11 @@ export async function createLocalEvidenceAssessment(
lockfileSha256: candidate.lockfileSha256,
sbomSha256: sbom.sha256,
},
secretScan: {
policySha256: secretPolicy.sha256,
sarifSha256: secretSarif.sha256,
scanInputSha256: secretScan.scanInputSha256,
},
policyInputs,
evidenceInputs,
checks,
@@ -458,6 +451,7 @@ type LocalCheckName =
async function evaluateProducerLocalChecks(
root: string,
candidate: ReleaseCandidateManifest,
options: Readonly<{ archived?: boolean }> = {},
): Promise<Readonly<{
checks: Readonly<Record<LocalCheckName, "PASS" | "FAIL">>;
failures: readonly string[];
@@ -490,13 +484,16 @@ async function evaluateProducerLocalChecks(
};
await evaluate("release", async () => {
const [build, release, stored] = await Promise.all([
const [build, release, runtime, stored] = await Promise.all([
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
buildManifestArtifactSchema.parse(value),
),
readJson(root, "dist/release-manifest.json").then((value) =>
releaseManifestArtifactSchema.parse(value),
),
readJson(root, "dist/config.json").then((value) =>
runtimeConfigArtifactSchema.parse(value),
),
readJson(root, "artifacts/release/verification.json").then((value) =>
releaseVerificationArtifactSchema.parse(value),
),
@@ -522,30 +519,104 @@ async function evaluateProducerLocalChecks(
) {
diagnostics.push("stored release verification is not a coherent PASS");
}
if (!runtime.BUILD_ID || !runtime.RELEASE_ID) {
diagnostics.push("runtime release identity is missing");
} else {
const apiContractVersion =
release.schemaVersion === 1 && "API_CONTRACT_VERSION" in runtime
? runtime.API_CONTRACT_VERSION
: undefined;
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,
});
diagnostics.push(...coherence.mismatches.map((item) => `runtime:${item}`));
}
diagnostics.push(...(await verifyBuildManifestOutputs(build, { repositoryRoot: root })));
return diagnostics;
});
await evaluate("supplyChain", async () => {
const [supply, coherence] = await Promise.all([
const [inventory, sbom, provenance, supply, coherence, lockfileBytes] = await Promise.all([
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
dependencyInventoryArtifactSchema.parse(value),
),
readJson(root, "artifacts/release/sbom.cdx.json").then((value) =>
sbomArtifactSchema.parse(value),
),
readJson(root, "artifacts/release/provenance.json").then((value) =>
provenanceArtifactSchema.parse(value),
),
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
supplyChainVerificationArtifactSchema.parse(value),
),
readJson(root, "artifacts/security/supply-chain-coherence.json").then((value) =>
supplyChainCoherenceReportSchema.parse(value),
),
readFile(path.join(root, "pnpm-lock.yaml")),
]);
const diagnostics: string[] = [];
const lockfileSha256 = createHash("sha256").update(lockfileBytes).digest("hex");
const outputs = await collectDistOutputs(root);
const currentDistSha256 = distSha256(outputs);
const sbomSha256 = supplyChainDigest(sbom);
const independentlyCoherent = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
currentDistSha256,
);
if (
supply.localStatus !== "PASS" ||
supply.failures.length > 0 ||
supply.distSha256 !== candidate.distSha256 ||
supply.lockfileSha256 !== candidate.lockfileSha256 ||
supply.sbomSha256 !== sbomSha256 ||
supply.sourceSetSha256 !== provenance.predicate.materials.sourceSetSha256 ||
inventory.lockfileSha256 !== lockfileSha256 ||
candidate.lockfileSha256 !== lockfileSha256 ||
candidate.distSha256 !== currentDistSha256 ||
coherence.status !== "PASS" ||
coherence.failures.length > 0 ||
coherence.distSha256 !== candidate.distSha256 ||
coherence.lockfileSha256 !== candidate.lockfileSha256
coherence.lockfileSha256 !== candidate.lockfileSha256 ||
coherence.sbomSha256 !== sbomSha256 ||
coherence.dependencyCount !== inventory.dependencies.length ||
independentlyCoherent.failures.length > 0
) {
diagnostics.push("stored supply-chain evidence is not a coherent PASS");
}
diagnostics.push(...verifyLocalSupplyChainDefaults(supply));
diagnostics.push(
...verifyStoredDistChecksums(
outputs,
await readFile(path.join(root, "artifacts/release/checksums.txt"), "utf8"),
),
);
const lockRows = parsePnpmLockfilePackages(lockfileBytes.toString("utf8"));
const inventoryByIdentity = new Map(
inventory.dependencies.map((entry) => [`${entry.name}@${entry.version}`, entry] as const),
);
if (lockRows.length !== inventory.dependencies.length) {
diagnostics.push("transitive dependency count differs from lockfile");
}
for (const lockRow of lockRows) {
const dependency = inventoryByIdentity.get(`${lockRow.name}@${lockRow.version}`);
if (
!dependency ||
dependency.integrity !== lockRow.integrity ||
!isValidSha512Integrity(lockRow.integrity)
) {
diagnostics.push(`lockfile inventory integrity mismatch: ${lockRow.name}@${lockRow.version}`);
}
}
return diagnostics;
});
await evaluate("dependencyPolicy", async () => {
@@ -596,6 +667,25 @@ async function evaluateProducerLocalChecks(
);
});
await evaluate("secretScan", async () => {
if (options.archived) {
const policy = parseSecretScanPolicy(
await readJson(root, "config/security/secret-scan-policy.json"),
);
const sarif = secretScanSarifSchema.parse(
await readJson(root, "artifacts/security/scan.sarif"),
);
const diagnostics: string[] = [];
if (
policy.trackedRoots.length === 0 ||
policy.generatedRoots.length === 0 ||
sarif.runs[0]!.results.length > 0 ||
JSON.stringify(sarif.runs[0]!.tool.driver.rules.map(({ id }) => id)) !==
JSON.stringify(secretScanRules().map(({ id }) => id))
) {
diagnostics.push("archived secret scan is not an independently valid PASS");
}
return diagnostics;
}
const evaluation = await evaluateRepositorySecretScan({ repositoryRoot: root });
return verifyStoredSecretScan(
evaluation,
@@ -640,6 +730,11 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
sourceRevision: string;
sourceSetSha256: string;
assessmentSha256: string;
secretScan: Readonly<{
policySha256: string;
sarifSha256: string;
scanInputSha256: string;
}>;
}>;
failures: readonly string[];
}>> {
@@ -696,6 +791,25 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
if (JSON.stringify(policyPaths) !== JSON.stringify(LOCAL_EVIDENCE_POLICY_INPUT_PATHS)) {
failures.push("local assessment policyInputs exact set mismatch");
}
for (const policyInput of assessment.policyInputs) {
try {
const bytes = await readFile(path.join(extractionRoot, policyInput.path));
const member = extractedManifest.files.find(
({ path: memberPath }) => memberPath === policyInput.path,
);
if (
!member ||
member.bytes !== policyInput.bytes ||
member.sha256 !== policyInput.sha256 ||
bytes.byteLength !== policyInput.bytes ||
createHash("sha256").update(bytes).digest("hex") !== policyInput.sha256
) {
failures.push(`archived policy input binding mismatch: ${policyInput.path}`);
}
} catch {
failures.push(`archived policy input is missing or invalid: ${policyInput.path}`);
}
}
const verifierSources = assessment.policyInputs.filter(({ path: policyPath }) =>
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
);
@@ -715,6 +829,12 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
const sbom = extractedManifest.files.find(
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
);
const secretPolicy = extractedManifest.files.find(
({ path: memberPath }) => memberPath === "config/security/secret-scan-policy.json",
);
const secretSarif = extractedManifest.files.find(
({ path: memberPath }) => memberPath === "artifacts/security/scan.sarif",
);
if (
assessment.candidate.distSha256 !== extractedManifest.distSha256 ||
assessment.candidate.lockfileSha256 !== extractedManifest.lockfileSha256 ||
@@ -723,10 +843,54 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
) {
failures.push("local assessment candidate digest binding mismatch");
}
if (
!secretPolicy ||
!secretSarif ||
assessment.secretScan.policySha256 !== secretPolicy.sha256 ||
assessment.secretScan.sarifSha256 !== secretSarif.sha256
) {
failures.push("local assessment secret scan artifact binding mismatch");
}
if (assessment.status !== "PASS" || Object.values(assessment.checks).includes("FAIL")) {
failures.push("local evidence assessment is not PASS");
}
try {
const [supply, coherence] = await Promise.all([
readJson(extractionRoot, "artifacts/security/supply-chain-verification.json").then(
(value) => supplyChainVerificationArtifactSchema.parse(value),
),
readJson(extractionRoot, "artifacts/security/supply-chain-coherence.json").then(
(value) => supplyChainCoherenceReportSchema.parse(value),
),
]);
if (
supply.localStatus !== "PASS" ||
supply.failures.length > 0 ||
coherence.status !== "PASS" ||
coherence.failures.length > 0
) {
failures.push("archived supply-chain subordinate evidence is not PASS");
}
} catch {
failures.push("archived supply-chain subordinate evidence is missing or invalid");
}
const independent = await evaluateProducerLocalChecks(
extractionRoot,
extractedManifest,
{ archived: true },
);
if (
independent.failures.length > 0 ||
JSON.stringify(independent.checks) !== JSON.stringify(assessment.checks)
) {
failures.push(
...independent.failures.map((failure) => `archived local check:${failure}`),
);
failures.push("archived local checks do not independently reproduce assessment PASS");
}
const identities = await readArchivedIdentities(extractionRoot, failures);
if (
identities.buildRevision !== assessment.source.revision ||
@@ -751,6 +915,7 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
sourceRevision: passingAssessment.source.revision,
sourceSetSha256: passingAssessment.source.sourceSetSha256,
assessmentSha256,
secretScan: passingAssessment.secretScan,
})
: null,
failures: uniqueFailures,
+956 -16
View File
@@ -1,5 +1,138 @@
const packageScriptInvocation = /\b(?:(?:corepack\s+)?pnpm(?:\s+--?[A-Za-z][A-Za-z-]*(?:=[^\s;&|]+)?)*(?:\s+run)?|npm\s+run)\s+([A-Za-z0-9:_-]+)/gu;
const pnpmNonScriptCommands = new Set(["dlx", "exec", "install"]);
type PackageManager = "pnpm" | "npm" | "yarn";
type ManagerParseResult = Readonly<{
dependencies: readonly string[];
unsafeLifecycle: boolean;
unsupportedManagerSyntax: boolean;
}>;
type SuppressionState = {
effective: boolean | undefined;
contradictory: boolean;
malformed: boolean;
};
type ShellNpmScopeEnvironmentState = {
autoExport: boolean;
forbidden: boolean;
uncertain: boolean;
};
type ShellCommandPrefix = Readonly<{
assignments: readonly Readonly<{
dynamicName: boolean;
name: string | null;
}>[];
commandIndex: number;
uncertain: boolean;
}>;
type EnvironmentCommandPrefix = Readonly<{
assignmentNames: readonly string[];
uncertain: boolean;
}>;
type TokenizedShellSegment = Readonly<{
tokens: readonly string[];
expansionTokens: readonly boolean[];
}>;
const managerNames = new Set<PackageManager>(["pnpm", "npm", "yarn"]);
const managerOptionsWithValue = new Set([
"-C", "--cache", "--cache-folder", "--config-dir", "--cwd", "--dir", "--filter",
"--global-dir", "--globalconfig", "--home", "--lockfile-dir", "--mutex", "--prefix",
"--registry", "--store-dir", "--userconfig", "--workspace", "--workspace-dir",
]);
const managerBooleanOptions = new Set([
"--color", "--global", "--no-color", "--offline", "--prefer-offline", "--silent",
"--use-stderr", "--verbose", "-g", "-s",
]);
const npmScriptDispatchBooleanOptions = new Set([
"--foreground-scripts", "--if-present", "--ignore-scripts",
]);
const npmScriptDispatchScopeOptions = new Set([
"--prefix", "--workspace", "--workspaces",
]);
const npmDispatchScopeEnvironmentNames = new Set([
"npm_config_globalconfig", "npm_config_prefix", "npm_config_userconfig",
"npm_config_workspace", "npm_config_workspaces",
]);
const npmIndirectConfigAuthorityOptions = new Set([
"--globalconfig", "--userconfig",
]);
const manifestScopeOptions: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
pnpm: new Set(["-C", "--dir", "--filter", "--workspace-dir"]),
npm: new Set(["--prefix", "--workspace"]),
yarn: new Set(["--cwd"]),
};
const lifecycleMutationCommands = new Set([
"add", "ci", "dedupe", "i", "install", "link", "pack", "prune", "publish",
"rebuild", "remove", "rm", "uninstall", "unlink", "up", "update", "upgrade",
]);
const managerBuiltinAliases: Readonly<
Record<PackageManager, ReadonlyMap<string, string>>
> = {
pnpm: new Map([["ln", "link"]]),
npm: new Map(),
yarn: new Map(),
};
const unsupportedBuiltinDispatchers: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set(["dlx", "exec"]),
npm: new Set(["exec"]),
yarn: new Set(["dlx", "exec", "workspace", "workspaces"]),
};
const lifecycleBooleanOptions: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set([
"--dry-run", "--force", "--frozen-lockfile", "--lockfile-only",
"--no-optional", "--prefer-frozen-lockfile", "--recursive",
"--workspace-root", "-D", "-P", "-r", "-w",
]),
npm: new Set([
"--audit", "--dry-run", "--force", "--foreground-scripts", "--fund",
"--package-lock-only",
]),
yarn: new Set([
"--check-cache", "--frozen-lockfile", "--ignore-engines",
"--ignore-optional", "--immutable", "--immutable-cache", "--inline-builds",
"--no-lockfile", "--non-interactive", "--pure-lockfile",
]),
};
const lifecycleOptionsWithValue: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set(["--child-concurrency", "--modules-dir", "--reporter"]),
npm: new Set(["--include", "--install-strategy", "--omit"]),
yarn: new Set(["--mode", "--modules-folder", "--production"]),
};
const knownBuiltinCommands: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
pnpm: new Set([
"audit", "config", "deploy", "dlx", "exec", "fetch", "help", "list", "ls",
"outdated", "root", "server", "setup", "store", "view", "why",
]),
npm: new Set([
"access", "audit", "bugs", "cache", "completion", "config", "diff", "docs",
"doctor", "exec", "explore", "fund", "help", "help-search", "hook", "init",
"list", "login", "logout", "ls", "org", "outdated", "owner", "ping", "pkg",
"prefix", "profile", "query", "repo", "root", "search", "star", "stars",
"team", "token", "unstar", "version", "view", "whoami",
]),
yarn: new Set([
"cache", "config", "constraints", "dedupe", "dlx", "exec", "help", "info",
"npm", "plugin", "set", "stage", "version", "why",
]),
};
const exactBareSafeBuiltinCommands: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set(["audit"]),
npm: new Set(["audit"]),
yarn: new Set(),
};
const npmImplicitScripts = new Set(["restart", "start", "stop", "test"]);
export function validatePackageScriptGraph(
scripts: Readonly<Record<string, string>>,
@@ -27,21 +160,17 @@ export function validatePackageScriptGraph(
if (/\bscripts\/run-ci-gate(?:\.[cm]?[jt]s)?\b/u.test(command)) {
failures.push(`${scriptName} must not invoke the CI gate runner`);
}
if (/\bci:gate\b/u.test(command)) {
failures.push(`${scriptName} must not invoke ci:gate`);
const parsed = parseManagerCommands(command, scripts);
if (parsed.unsupportedManagerSyntax) {
failures.push(`package script manager invocation is not safely parseable: ${scriptName}`);
}
packageScriptInvocation.lastIndex = 0;
const dependencies = Array.from(
command.matchAll(packageScriptInvocation),
(match) => match[1]!,
).filter((dependency) => !pnpmNonScriptCommands.has(dependency));
for (const dependency of dependencies) {
if (dependency !== "ci:gate") {
if (!(dependency in scripts)) {
failures.push(`package script missing: ${scriptName} -> ${dependency}`);
} else {
visit(dependency);
}
for (const dependency of parsed.dependencies) {
if (dependency === "ci:gate") {
failures.push(`${scriptName} must not invoke ci:gate`);
} else if (!(dependency in scripts)) {
failures.push(`package script missing: ${scriptName} -> ${dependency}`);
} else {
visit(dependency);
}
}
stack.pop();
@@ -52,3 +181,814 @@ export function validatePackageScriptGraph(
visit(entryScript);
return [...new Set(failures)];
}
export function validateInstallScriptPolicy(
scripts: Readonly<Record<string, string>>,
entryScripts: readonly string[],
): string[] {
const failures: string[] = [];
const visited = new Set<string>();
const visit = (scriptName: string): void => {
if (visited.has(scriptName)) return;
visited.add(scriptName);
const command = scripts[scriptName];
if (command === undefined) {
failures.push(`package script missing: ${scriptName}`);
return;
}
const parsed = parseManagerCommands(command, scripts);
if (parsed.unsafeLifecycle || parsed.unsupportedManagerSyntax) {
failures.push(
`install-bearing package script must use --ignore-scripts: ${scriptName}`,
);
}
for (const dependency of parsed.dependencies) {
if (dependency !== "ci:gate") visit(dependency);
}
};
for (const entryScript of entryScripts) visit(entryScript);
return [...new Set(failures)];
}
export function validateNpmScopeEnvironment(
environment: Readonly<Record<string, string | undefined>>,
): string[] {
return Object.keys(environment)
.filter((name) => npmDispatchScopeEnvironmentNames.has(name.toLowerCase()))
.map((name) => `npm scope environment is not allowed: ${name}`);
}
function parseManagerCommands(
command: string,
scripts: Readonly<Record<string, string>>,
): ManagerParseResult {
const tokenized = tokenizeShellSegments(command);
const dependencies: string[] = [];
let unsafeLifecycle = false;
let unsupportedManagerSyntax = false;
if (!tokenized) {
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle: false,
unsupportedManagerSyntax: containsManagerReference(command),
});
}
unsupportedManagerSyntax ||= tokenized.unsupportedControl && containsManagerReference(command);
const npmScopeEnvironmentState: ShellNpmScopeEnvironmentState = {
autoExport: false,
forbidden: false,
uncertain: false,
};
for (const segment of tokenized.segments) {
const { tokens, expansionTokens } = segment;
updateShellNpmScopeEnvironmentState(segment, npmScopeEnvironmentState);
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]!;
if (token === "corepack") {
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
unsupportedManagerSyntax = true;
break;
}
const wrapped = tokens[index + 1];
if (!wrapped || !isPackageManager(wrapped)) {
unsupportedManagerSyntax = true;
break;
}
const parsed = parseManagerInvocation(
wrapped,
tokens,
expansionTokens,
index + 2,
scripts,
hasUnsafeNpmScopeEnvironment(
npmScopeEnvironmentState,
tokens,
expansionTokens,
index,
),
);
dependencies.push(...parsed.dependencies);
unsafeLifecycle ||= parsed.unsafeLifecycle;
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
break;
}
if (isPackageManager(token)) {
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
unsupportedManagerSyntax = true;
break;
}
const parsed = parseManagerInvocation(
token,
tokens,
expansionTokens,
index + 1,
scripts,
hasUnsafeNpmScopeEnvironment(
npmScopeEnvironmentState,
tokens,
expansionTokens,
index,
),
);
dependencies.push(...parsed.dependencies);
unsafeLifecycle ||= parsed.unsafeLifecycle;
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
break;
}
if (containsManagerReference(token)) {
unsupportedManagerSyntax = true;
break;
}
}
}
return Object.freeze({
dependencies: Object.freeze([...new Set(dependencies)]),
unsafeLifecycle,
unsupportedManagerSyntax,
});
}
function parseManagerInvocation(
manager: PackageManager,
tokens: readonly string[],
expansionTokens: readonly boolean[],
start: number,
scripts: Readonly<Record<string, string>>,
hasNpmScopeEnvironment: boolean,
): ManagerParseResult {
let cursor = start;
let changesManifestScope = false;
let hasNpmConfigAuthority = false;
let consumedManagerSyntax = false;
const suppression: SuppressionState = {
effective: undefined,
contradictory: false,
malformed: false,
};
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
consumedManagerSyntax = true;
const option = tokens[cursor]!;
const parsedSuppression = consumeSuppressionOption(
manager,
tokens,
cursor,
suppression,
);
if (parsedSuppression.recognized) {
if (parsedSuppression.unsupported) return unsupportedResult();
cursor = parsedSuppression.nextIndex;
continue;
}
const equals = option.indexOf("=");
const name = equals < 0 ? option : option.slice(0, equals);
if (managerOptionsWithValue.has(name)) {
changesManifestScope ||= manifestScopeOptions[manager].has(name);
hasNpmConfigAuthority ||=
manager === "npm" && npmIndirectConfigAuthorityOptions.has(name);
if (equals >= 0) {
if (option.slice(equals + 1).length === 0) return unsupportedResult();
} else {
cursor += 1;
if (cursor >= tokens.length || tokens[cursor]!.startsWith("-")) {
return unsupportedResult();
}
}
} else if (managerBooleanOptions.has(name)) {
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) {
return unsupportedResult();
}
} else if (option !== "--") {
return unsupportedResult();
}
cursor += 1;
}
const subcommand = tokens[cursor];
if (!subcommand) return unsupportedResult();
if (manager === "npm" && (hasNpmScopeEnvironment || hasNpmConfigAuthority)) {
return unsupportedResult();
}
const argumentsAfterCommand = tokens.slice(cursor + 1);
if (subcommand === "run" || subcommand === "run-script") {
const dependency = argumentsAfterCommand[0];
if (!dependency || dependency.startsWith("-")) return unsupportedResult();
if (changesManifestScope) return unsupportedResult();
if (
manager === "npm" &&
(expansionTokens.slice(start, cursor + 2).some(Boolean) ||
!areNpmScriptDispatchArgumentsSupported(
argumentsAfterCommand.slice(1),
expansionTokens.slice(cursor + 2),
suppression,
))
) {
return unsupportedResult();
}
return manager === "npm"
? npmScriptDependencyResult(dependency, scripts, suppression)
: dependencyResult(dependency);
}
const canonicalSubcommand = managerBuiltinAliases[manager].get(subcommand) ?? subcommand;
if (unsupportedBuiltinDispatchers[manager].has(canonicalSubcommand)) {
return unsupportedResult();
}
if (lifecycleMutationCommands.has(canonicalSubcommand)) {
const lifecycleArgumentsSupported = parseLifecycleArguments(
manager,
argumentsAfterCommand,
suppression,
);
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle:
!lifecycleArgumentsSupported || !hasEffectiveLifecycleSuppression(suppression),
unsupportedManagerSyntax: !lifecycleArgumentsSupported,
});
}
if (knownBuiltinCommands[manager].has(canonicalSubcommand)) {
return exactBareSafeBuiltinCommands[manager].has(canonicalSubcommand) &&
!consumedManagerSyntax &&
argumentsAfterCommand.length === 0
? emptyResult()
: unsupportedResult();
}
const isKnownRootScript = Object.prototype.hasOwnProperty.call(scripts, subcommand);
const supportsImplicit = /^[A-Za-z0-9:_-]+$/u.test(subcommand) && (
((manager === "pnpm" || manager === "yarn") && isKnownRootScript) ||
(manager === "npm" && npmImplicitScripts.has(subcommand))
);
if (supportsImplicit) {
if (changesManifestScope) return unsupportedResult();
if (
manager === "npm" &&
(expansionTokens.slice(start, cursor + 1).some(Boolean) ||
!areNpmScriptDispatchArgumentsSupported(
argumentsAfterCommand,
expansionTokens.slice(cursor + 1),
suppression,
))
) {
return unsupportedResult();
}
return manager === "npm"
? npmScriptDependencyResult(subcommand, scripts, suppression)
: dependencyResult(subcommand);
}
return unsupportedResult();
}
function areNpmScriptDispatchArgumentsSupported(
tokens: readonly string[],
expansionTokens: readonly boolean[],
suppression: SuppressionState,
): boolean {
let index = 0;
while (index < tokens.length) {
const token = tokens[index]!;
if (token === "--") return true;
if (expansionTokens[index]) return false;
if (!token.startsWith("-")) {
index += 1;
continue;
}
const parsedSuppression = consumeSuppressionOption(
"npm",
tokens,
index,
suppression,
);
if (parsedSuppression.recognized) {
if (parsedSuppression.unsupported) return false;
index = parsedSuppression.nextIndex;
continue;
}
const equals = token.indexOf("=");
const name = equals < 0 ? token : token.slice(0, equals);
const isShortWorkspaceOption = token === "-w" || /^-w(?:=)?.+/u.test(token);
if (npmScriptDispatchScopeOptions.has(name) || isShortWorkspaceOption) {
return false;
}
if (!npmScriptDispatchBooleanOptions.has(name)) return false;
if (equals >= 0 && !/^(?:true|false)$/u.test(token.slice(equals + 1))) {
return false;
}
index += 1;
}
return true;
}
function hasUnsafeNpmScopeEnvironment(
state: Readonly<ShellNpmScopeEnvironmentState>,
tokens: readonly string[],
expansionTokens: readonly boolean[],
commandIndex: number,
): boolean {
return state.forbidden || state.uncertain ||
hasUnsafeImmediateNpmScopeEnvironment(tokens, expansionTokens, commandIndex);
}
function hasUnsafeManagerCommandPrefix(
tokens: readonly string[],
expansionTokens: readonly boolean[],
commandIndex: number,
): boolean {
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
if (prefix.uncertain) return true;
let cursor = prefix.commandIndex;
if (cursor === commandIndex) return false;
if (cursor > commandIndex ||
!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) {
return true;
}
return parseEnvironmentCommandPrefix(
tokens,
expansionTokens,
cursor,
commandIndex,
).uncertain;
}
function hasUnsafeImmediateNpmScopeEnvironment(
tokens: readonly string[],
expansionTokens: readonly boolean[],
commandIndex: number,
): boolean {
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
if (prefix.uncertain || prefix.assignments.some(
({ name }) => name !== null && isNpmScopeEnvironmentName(name),
)) return true;
let cursor = prefix.commandIndex;
if (cursor === commandIndex) return false;
if (!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) return false;
const environmentPrefix = parseEnvironmentCommandPrefix(
tokens,
expansionTokens,
cursor,
commandIndex,
);
return environmentPrefix.uncertain || environmentPrefix.assignmentNames.some(
(name) => isNpmScopeEnvironmentName(name),
);
}
function parseEnvironmentCommandPrefix(
tokens: readonly string[],
expansionTokens: readonly boolean[],
start: number,
commandIndex: number,
): EnvironmentCommandPrefix {
const assignmentNames: string[] = [];
let cursor = start + 1;
let uncertain = false;
while (cursor < commandIndex && tokens[cursor]!.startsWith("-")) {
const option = tokens[cursor]!;
if (expansionTokens[cursor]) uncertain = true;
if (option === "--") {
cursor += 1;
break;
}
if (option === "-i" || option === "--ignore-environment") {
cursor += 1;
continue;
}
if (option === "-u" || option === "--unset") {
cursor += 1;
if (cursor >= commandIndex || tokens[cursor]!.startsWith("-")) {
uncertain = true;
break;
}
uncertain ||= expansionTokens[cursor] ?? false;
cursor += 1;
continue;
}
if (/^--unset=.+/u.test(option)) {
cursor += 1;
continue;
}
uncertain = true;
cursor += 1;
}
while (cursor < commandIndex) {
const token = tokens[cursor]!;
if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false)) {
uncertain = true;
}
const assignmentName = parseEnvironmentAssignmentName(token);
if (assignmentName) assignmentNames.push(assignmentName);
else uncertain = true;
cursor += 1;
}
return Object.freeze({
assignmentNames: Object.freeze(assignmentNames),
uncertain,
});
}
function parseShellCommandPrefix(
tokens: readonly string[],
expansionTokens: readonly boolean[],
): ShellCommandPrefix {
const assignments: Array<{
dynamicName: boolean;
name: string | null;
}> = [];
let cursor = 0;
let uncertain = false;
while (cursor < tokens.length) {
const token = tokens[cursor]!;
const name = parseAssignmentName(token);
const dynamicName = hasDynamicAssignmentName(
token,
expansionTokens[cursor] ?? false,
);
if (!name && !dynamicName) break;
assignments.push({ dynamicName, name });
uncertain ||= dynamicName;
cursor += 1;
}
while (cursor < tokens.length) {
const wrapper = tokens[cursor];
if (expansionTokens[cursor]) {
uncertain = true;
break;
}
if (wrapper !== "command" && wrapper !== "exec") break;
cursor += 1;
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
const option = tokens[cursor]!;
if (option === "--") {
cursor += 1;
break;
}
if (wrapper === "command" && option === "-p") {
cursor += 1;
continue;
}
uncertain = true;
cursor += 1;
if (wrapper === "exec" && option === "-a" && cursor < tokens.length) {
cursor += 1;
}
}
}
if (expansionTokens[cursor]) uncertain = true;
return Object.freeze({
assignments: Object.freeze(assignments.map((assignment) => Object.freeze(assignment))),
commandIndex: cursor,
uncertain,
});
}
function updateShellNpmScopeEnvironmentState(
segment: TokenizedShellSegment,
state: ShellNpmScopeEnvironmentState,
): void {
const { tokens, expansionTokens } = segment;
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
state.uncertain ||= prefix.uncertain;
const command = tokens[prefix.commandIndex];
if (!command) {
for (const assignment of prefix.assignments) {
if (state.autoExport && assignment.name &&
isNpmScopeEnvironmentName(assignment.name)) {
state.forbidden = true;
}
}
return;
}
if (command === "eval" || command === "." || command === "source") {
state.uncertain = true;
return;
}
if (command === "unset" || command === "typeset" || command === "declare" ||
command === "local" || command === "readonly") {
state.uncertain = true;
return;
}
if (command === "set") {
if (tokens.slice(prefix.commandIndex + 1).includes("-a")) state.autoExport = true;
if (tokens.slice(prefix.commandIndex + 1).includes("+a")) {
state.autoExport = false;
state.uncertain = true;
}
return;
}
if (command === "export") {
let cursor = prefix.commandIndex + 1;
for (; cursor < tokens.length; cursor += 1) {
const token = tokens[cursor]!;
if (token === "--") continue;
if (token === "-n" || token.startsWith("-")) {
state.uncertain = true;
continue;
}
const assignmentName = parseAssignmentName(token);
const bareName = /^[A-Za-z_][A-Za-z0-9_]*$/u.test(token) ? token : null;
if (assignmentName || bareName) {
if (isNpmScopeEnvironmentName(assignmentName ?? bareName!)) {
state.forbidden = true;
}
} else if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false) ||
expansionTokens[cursor]) {
state.uncertain = true;
}
}
return;
}
}
function isEnvironmentCommand(token: string | undefined, hasExpansion: boolean): boolean {
if (!token || hasExpansion) return false;
return token.split("/").at(-1) === "env";
}
function parseEnvironmentAssignmentName(token: string): string | null {
const equals = token.indexOf("=");
return equals > 0 ? token.slice(0, equals) : null;
}
function isNpmScopeEnvironmentName(name: string): boolean {
return npmDispatchScopeEnvironmentNames.has(name.toLowerCase());
}
function hasDynamicAssignmentName(token: string, hasExpansion: boolean): boolean {
const equals = token.indexOf("=");
return hasExpansion && equals > 0 && parseAssignmentName(token) === null;
}
function parseAssignmentName(token: string): string | null {
return /^([A-Za-z_][A-Za-z0-9_]*)=/u.exec(token)?.[1] ?? null;
}
function parseLifecycleArguments(
manager: PackageManager,
tokens: readonly string[],
suppression: SuppressionState,
): boolean {
let cursor = 0;
while (cursor < tokens.length) {
const token = tokens[cursor]!;
if (!token.startsWith("-")) {
cursor += 1;
continue;
}
const parsedSuppression = consumeSuppressionOption(
manager,
tokens,
cursor,
suppression,
);
if (parsedSuppression.recognized) {
if (parsedSuppression.unsupported) return false;
cursor = parsedSuppression.nextIndex;
continue;
}
const parsedOption = consumeAllowedLifecycleOption(manager, tokens, cursor);
if (parsedOption === null) return false;
cursor = parsedOption;
}
return true;
}
function consumeSuppressionOption(
manager: PackageManager,
tokens: readonly string[],
index: number,
state: SuppressionState,
): Readonly<{ recognized: boolean; unsupported: boolean; nextIndex: number }> {
const token = tokens[index]!;
if (token === "--no-ignore-scripts") {
recordSuppression(state, false);
return { recognized: true, unsupported: false, nextIndex: index + 1 };
}
const equalsForms = ["--ignore-scripts=", "--config.ignore-scripts="] as const;
for (const prefix of equalsForms) {
if (!token.startsWith(prefix)) continue;
if (prefix.startsWith("--config.") && manager !== "pnpm") {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 1 };
}
const raw = token.slice(prefix.length);
if (raw !== "true" && raw !== "false") {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 1 };
}
recordSuppression(state, raw === "true");
return { recognized: true, unsupported: false, nextIndex: index + 1 };
}
if (token !== "--ignore-scripts" && token !== "--config.ignore-scripts") {
return { recognized: false, unsupported: false, nextIndex: index };
}
if (token === "--config.ignore-scripts" && manager !== "pnpm") {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 1 };
}
const next = tokens[index + 1];
if (next === "true" || next === "false") {
const supportsSplitValue = manager === "npm" || manager === "pnpm";
if (!supportsSplitValue) {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 2 };
}
recordSuppression(state, next === "true");
return { recognized: true, unsupported: false, nextIndex: index + 2 };
}
recordSuppression(state, true);
return { recognized: true, unsupported: false, nextIndex: index + 1 };
}
function recordSuppression(
state: SuppressionState,
value: boolean,
): void {
if (state.effective !== undefined && state.effective !== value) {
state.contradictory = true;
}
state.effective = value;
}
function hasEffectiveLifecycleSuppression(state: SuppressionState): boolean {
return state.effective === true && !state.contradictory && !state.malformed;
}
function consumeAllowedLifecycleOption(
manager: PackageManager,
tokens: readonly string[],
index: number,
): number | null {
const option = tokens[index]!;
const equals = option.indexOf("=");
const name = equals < 0 ? option : option.slice(0, equals);
const booleanOption =
managerBooleanOptions.has(name) || lifecycleBooleanOptions[manager].has(name);
if (booleanOption) {
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) return null;
return index + 1;
}
const valuedOption =
managerOptionsWithValue.has(name) || lifecycleOptionsWithValue[manager].has(name);
if (!valuedOption) return null;
if (equals >= 0) return option.slice(equals + 1).length > 0 ? index + 1 : null;
const value = tokens[index + 1];
if (!value || value.startsWith("-")) return null;
return index + 2;
}
function dependencyResult(dependency: string): ManagerParseResult {
return dependenciesResult([dependency]);
}
function npmScriptDependencyResult(
dependency: string,
scripts: Readonly<Record<string, string>>,
suppression: SuppressionState,
): ManagerParseResult {
if (hasEffectiveLifecycleSuppression(suppression)) {
return dependenciesResult([dependency]);
}
return dependenciesResult(
[`pre${dependency}`, dependency, `post${dependency}`]
.filter((scriptName) => scriptName === dependency || scriptName in scripts),
);
}
function dependenciesResult(dependencies: readonly string[]): ManagerParseResult {
return Object.freeze({
dependencies: Object.freeze([...dependencies]),
unsafeLifecycle: false,
unsupportedManagerSyntax: false,
});
}
function emptyResult(): ManagerParseResult {
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle: false,
unsupportedManagerSyntax: false,
});
}
function unsupportedResult(): ManagerParseResult {
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle: false,
unsupportedManagerSyntax: true,
});
}
function isPackageManager(value: string): value is PackageManager {
return managerNames.has(value as PackageManager);
}
function containsManagerReference(value: string): boolean {
return /(?:^|[^A-Za-z0-9_-])(?:corepack|pnpm|npm|yarn)(?:[^A-Za-z0-9_-]|$)/u
.test(value);
}
function tokenizeShellSegments(command: string): Readonly<{
segments: readonly TokenizedShellSegment[];
unsupportedControl: boolean;
}> | null {
const segments: Array<{ tokens: string[]; expansionTokens: boolean[] }> = [
{ tokens: [], expansionTokens: [] },
];
let token = "";
let tokenHasExpansion = false;
let quote: "'" | '"' | null = null;
let escaping = false;
let unsupportedControl = false;
const pushToken = (): void => {
if (token.length > 0) {
segments.at(-1)!.tokens.push(token);
segments.at(-1)!.expansionTokens.push(tokenHasExpansion);
}
token = "";
tokenHasExpansion = false;
};
const pushSegment = (): void => {
pushToken();
if (segments.at(-1)!.tokens.length > 0) {
segments.push({ tokens: [], expansionTokens: [] });
}
};
for (let index = 0; index < command.length; index += 1) {
const character = command[index]!;
if (escaping) {
token += character;
escaping = false;
continue;
}
if (character === "\\" && quote !== "'") {
escaping = true;
continue;
}
if (quote) {
if (character === quote) quote = null;
else {
if (quote === '"' && character === "$") tokenHasExpansion = true;
token += character;
}
continue;
}
if (character === "'" || character === '"') {
quote = character;
continue;
}
if (character === "`" || (character === "$" && command[index + 1] === "(")) {
unsupportedControl = true;
if (character === "$") tokenHasExpansion = true;
token += character;
continue;
}
if (character === "$" || character === "*" || character === "?" || character === "[") {
tokenHasExpansion = true;
}
if (character === "#") {
unsupportedControl = true;
pushToken();
while (
index + 1 < command.length &&
command[index + 1] !== "\n" &&
command[index + 1] !== "\r"
) {
index += 1;
}
continue;
}
if (character === "<" || character === ">" || character === "(" || character === ")") {
unsupportedControl = true;
token += character;
continue;
}
if (/\s/u.test(character)) {
pushToken();
if (character === "\n" || character === "\r") pushSegment();
continue;
}
if (character === ";" || character === "|" || character === "&") {
pushSegment();
if (command[index + 1] === character) index += 1;
continue;
}
token += character;
}
if (quote || escaping) return null;
pushToken();
return Object.freeze({
segments: Object.freeze(
segments
.filter((segment) => segment.tokens.length > 0)
.map((segment) => Object.freeze({
tokens: Object.freeze(segment.tokens),
expansionTokens: Object.freeze(segment.expansionTokens),
})),
),
unsupportedControl,
});
}
+304 -29
View File
@@ -8,7 +8,9 @@ import {
lstat,
mkdir,
open,
readdir,
rm,
rmdir,
stat,
} from "node:fs/promises";
import path from "node:path";
@@ -19,6 +21,7 @@ import {
} from "../contracts/promotion-artifacts.ts";
import {
evaluatePromotionEvidence,
assertDistinctProviderTrust,
providerPublicKeyFingerprint,
providerVerificationArtifactSchema,
PROMOTION_VERIFIER_ID,
@@ -28,6 +31,7 @@ import {
vulnerabilityProviderReportSchema,
type ProviderTrust,
} from "./provider-evidence.ts";
import { verifyExactPromotionBundle } from "./exact-promotion-bundle.ts";
import {
captureCiCandidateArchive,
withVerifiedCapturedCandidate,
@@ -35,7 +39,8 @@ import {
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
type StagedFile = Readonly<{
export type StagedFile = Readonly<{
name: PromotedFileName;
bytes: Buffer;
sha256: string;
@@ -45,6 +50,7 @@ export type FinalizedPromotion = Readonly<{
stagingRoot: string;
cleanupToken: string;
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
stagingIdentity: Readonly<{ dev: number; ino: number }>;
files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[];
}>;
@@ -69,6 +75,9 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
afterCapture?: () => Promise<void>;
beforePublish?: () => Promise<void>;
afterStagingWrite?: () => Promise<void>;
afterFileWrite?: (name: PromotedFileName) => Promise<void>;
beforeSeal?: () => Promise<void>;
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>;
}> = {}): Promise<FinalizedPromotion> {
const root = path.resolve(input.repositoryRoot);
const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
@@ -92,14 +101,14 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
input.provenanceKeyId,
provenanceKeyBytes,
);
assertDistinctProviderTrust({ vulnerabilityTrust, provenanceTrust });
const vulnerabilityReport = vulnerabilityProviderReportSchema.parse(
parseJson(vulnerabilityBytes),
);
const provenanceAttestation = provenanceProviderAttestationSchema.parse(
parseJson(provenanceBytes),
);
const now = (dependencies.nowEpochMs ?? Date.now)();
const verifiedAt = new Date(now).toISOString();
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
const generated = await withVerifiedCapturedCandidate({
captured: capturedArchive,
@@ -128,6 +137,14 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
},
secretScanAttestation: {
status: "PASS" as const,
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
sourceSetSha256: local.identity.sourceSetSha256,
policySha256: local.identity.secretScan.policySha256,
sarifSha256: local.identity.secretScan.sarifSha256,
scanInputSha256: local.identity.secretScan.scanInputSha256,
},
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
provenanceInvocationNonce: input.provenanceInvocationNonce,
} as const;
@@ -138,7 +155,7 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
provenanceAttestation,
vulnerabilityTrust,
provenanceTrust,
nowEpochMs: () => now,
nowEpochMs,
});
if (reevaluated.status !== "PASS") {
throw new Error(
@@ -154,8 +171,10 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
provenanceKeyId: provenanceTrust.keyId,
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
secretScanAttestation: expected.secretScanAttestation,
} as const;
const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
const verifiedAt = new Date(nowEpochMs()).toISOString();
const common = {
schemaVersion: 3 as const,
verifiedAt,
@@ -188,6 +207,15 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
return Object.freeze({
providerRecordBytes,
promotionRecordBytes: canonicalJsonBytes(promotionRecord),
exactExpected: Object.freeze({
run: expected.run,
sourceRevision: expected.source.revision,
sourceSetSha256: expected.source.sourceSetSha256,
archiveSha256: expected.candidate.archiveSha256,
bundleSha256: expected.candidate.bundleSha256,
distSha256: expected.candidate.distSha256,
lockfileSha256: expected.candidate.lockfileSha256,
}),
});
},
});
@@ -206,12 +234,35 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
throw new Error("promotion exact-five canonical file order drift");
}
await dependencies.beforePublish?.();
return publishPrivateStaging(
await verifyExactPromotionBundle(
Object.fromEntries(stagedFiles.map(({ name, bytes }) => [name, bytes])),
{
vulnerabilityTrust,
provenanceTrust,
expected: generated.exactExpected,
nowEpochMs,
},
);
return publishPrivatePromotionStaging(
input.runnerTempRoot,
input.expectedRun,
stagedFiles,
dependencies.randomBytes ?? cryptoRandomBytes,
dependencies.afterStagingWrite,
dependencies.afterFileWrite,
async (capturedFiles) => {
await verifyExactPromotionBundle(
capturedFiles,
{
vulnerabilityTrust,
provenanceTrust,
expected: generated.exactExpected,
nowEpochMs,
},
);
},
dependencies.afterMkdirBeforeOpen,
dependencies.beforeSeal,
);
}
@@ -222,6 +273,7 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
stagingRoot: string;
cleanupToken: string;
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
stagingIdentity: Readonly<{ dev: number; ino: number }>;
}>, dependencies: Readonly<{
beforeRemove?: () => Promise<void>;
}> = {}): Promise<void> {
@@ -234,6 +286,10 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
input.runnerTempIdentity.dev <= 0 ||
!Number.isSafeInteger(input.runnerTempIdentity.ino) ||
input.runnerTempIdentity.ino <= 0
|| !Number.isSafeInteger(input.stagingIdentity.dev)
|| input.stagingIdentity.dev <= 0
|| !Number.isSafeInteger(input.stagingIdentity.ino)
|| input.stagingIdentity.ino <= 0
) {
throw new TypeError("promotion cleanup root/token mismatch");
}
@@ -260,10 +316,33 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new TypeError("promotion cleanup leaf is unsafe");
}
await dependencies.beforeRemove?.();
const visibleParent = await lstat(parent);
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
await rm(descriptorExpected, { recursive: true, force: true });
assertStagingIdentity(metadata, input.stagingIdentity);
const stagingHandle = await open(
descriptorExpected,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
try {
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
const names = (await readdir(stagingDescriptorRoot)).sort(asciiCompare);
if (
JSON.stringify(names) !==
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
) {
throw new Error("promotion cleanup leaf does not contain the exact five files");
}
await dependencies.beforeRemove?.();
const visibleParent = await lstat(parent);
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
for (const name of PROMOTED_FILE_NAMES) {
await rm(path.join(stagingDescriptorRoot, name), { force: false });
}
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity);
await rmdir(descriptorExpected);
} finally {
await stagingHandle.close();
}
const afterParent = await lstat(parent);
assertRunnerTempIdentity(afterParent, input.runnerTempIdentity);
} finally {
@@ -271,13 +350,29 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
}
}
async function publishPrivateStaging(
export async function publishPrivatePromotionStaging(
runnerTempRoot: string,
run: Readonly<{ id: string; attempt: number }>,
files: readonly StagedFile[],
randomBytes: (bytes: number) => Buffer,
afterStagingWrite?: () => Promise<void>,
afterFileWrite?: (name: PromotedFileName) => Promise<void>,
sealStagedFiles?: (files: Readonly<Record<PromotedFileName, Buffer>>) => Promise<void>,
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>,
beforeSeal?: () => Promise<void>,
): Promise<FinalizedPromotion> {
if (
JSON.stringify(files.map(({ name }) => name)) !==
JSON.stringify(PROMOTED_FILE_NAMES) ||
files.some(
({ bytes, sha256: digest }) =>
!Buffer.isBuffer(bytes) ||
!/^[a-f0-9]{64}$/u.test(digest) ||
sha256(bytes) !== digest,
)
) {
throw new TypeError("private promotion staging requires the canonical exact-five bytes");
}
const parentPath = path.resolve(runnerTempRoot);
const before = await lstat(parentPath);
if (!before.isDirectory() || before.isSymbolicLink()) {
@@ -298,14 +393,119 @@ async function publishPrivateStaging(
const descriptorStaging = path.join(descriptorRoot, cleanupToken);
const visibleStaging = path.join(parentPath, cleanupToken);
let ownsStaging = false;
let stagingHandle: Awaited<ReturnType<typeof open>> | undefined;
let createdStagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
let stagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
let openedIdentityVerified = false;
const cleanup = async (primaryFailure?: unknown): Promise<void> => {
const cleanupFailures: unknown[] = [];
const attemptCleanup = async (operation: () => Promise<void>): Promise<void> => {
try {
await operation();
} catch (error) {
cleanupFailures.push(error);
}
};
if (ownsStaging && openedIdentityVerified && stagingHandle && stagingIdentity) {
const ownedIdentity = stagingIdentity;
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
const removals = await Promise.allSettled(
files.map(({ name }) => rm(path.join(stagingDescriptorRoot, name), { force: true })),
);
cleanupFailures.push(
...removals.flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
),
);
await attemptCleanup(async () => {
let visible;
try {
visible = await lstat(descriptorStaging);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return;
throw error;
}
if (
visible.isDirectory() &&
!visible.isSymbolicLink() &&
visible.dev === ownedIdentity.dev &&
visible.ino === ownedIdentity.ino
) {
await rmdir(descriptorStaging);
}
});
}
if (stagingHandle) {
const ownedHandle = stagingHandle;
await attemptCleanup(async () => ownedHandle.close());
}
await attemptCleanup(async () => parentHandle.close());
if (cleanupFailures.length > 0) {
throw new AggregateError(
primaryFailure === undefined
? cleanupFailures
: [primaryFailure, ...cleanupFailures],
primaryFailure instanceof Error
? `${primaryFailure.message}; promotion staging cleanup also failed`
: "promotion staging cleanup failed",
{ cause: cleanupFailures.at(-1) },
);
}
};
let finalizedPromotion: FinalizedPromotion;
try {
const procMetadata = await stat(descriptorRoot);
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
await mkdir(descriptorStaging, { mode: 0o700 });
ownsStaging = true;
const createdStaging = await lstat(descriptorStaging);
if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) {
throw new Error("created promotion staging leaf is unsafe");
}
createdStagingIdentity = Object.freeze({
dev: createdStaging.dev,
ino: createdStaging.ino,
});
await afterMkdirBeforeOpen?.(visibleStaging);
const openedHandle = await open(
descriptorStaging,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
let openedStaging;
try {
openedStaging = await openedHandle.stat();
if (!openedStaging.isDirectory()) {
throw new Error("promotion staging descriptor is not a directory");
}
if (
openedStaging.dev !== createdStagingIdentity.dev ||
openedStaging.ino !== createdStagingIdentity.ino
) {
throw new Error("promotion staging leaf identity changed between mkdir and open");
}
openedIdentityVerified = true;
} catch (error) {
try {
await openedHandle.close();
} catch (closeError) {
throw new AggregateError(
[error, closeError],
error instanceof Error
? `${error.message}; rejected staging descriptor close also failed`
: "rejected staging descriptor and close both failed",
{ cause: closeError },
);
}
throw error;
}
stagingHandle = openedHandle;
await stagingHandle.chmod(0o700);
stagingIdentity = Object.freeze({ dev: openedStaging.dev, ino: openedStaging.ino });
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
assertStagingIdentity(await stat(stagingDescriptorRoot), stagingIdentity);
for (const file of files) {
const handle = await open(
path.join(descriptorStaging, file.name),
path.join(stagingDescriptorRoot, file.name),
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
@@ -313,15 +513,20 @@ async function publishPrivateStaging(
0o400,
);
try {
await handle.chmod(0o400);
await handle.writeFile(file.bytes);
await handle.sync();
} finally {
await handle.close();
}
await afterFileWrite?.(file.name);
}
await syncDirectory(descriptorStaging);
await syncHandle(stagingHandle);
await syncHandle(parentHandle);
await afterStagingWrite?.();
await beforeSeal?.();
const capturedFiles = await captureStagedFiles(stagingHandle, files);
await sealStagedFiles?.(capturedFiles);
const after = await lstat(parentPath);
if (
after.dev !== before.dev ||
@@ -335,20 +540,102 @@ async function publishPrivateStaging(
if (!visible.isDirectory() || visible.isSymbolicLink()) {
throw new Error("promotion staging visibility identity mismatch");
}
assertStagingIdentity(visible, stagingIdentity);
ownsStaging = false;
return Object.freeze({
finalizedPromotion = Object.freeze({
stagingRoot: visibleStaging,
cleanupToken,
runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }),
stagingIdentity,
files: Object.freeze(
files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })),
),
});
} finally {
if (ownsStaging) {
await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined);
} catch (error) {
await cleanup(error);
throw error;
}
await cleanup();
return finalizedPromotion;
}
async function captureStagedFiles(
stagingHandle: Awaited<ReturnType<typeof open>>,
declaredFiles: readonly StagedFile[],
): Promise<Readonly<Record<PromotedFileName, Buffer>>> {
const descriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
const names = (await readdir(descriptorRoot)).sort(asciiCompare);
if (
JSON.stringify(names) !==
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
) {
throw new Error("staged promotion seal requires exactly the canonical five files");
}
const declared = new Map(declaredFiles.map((file) => [file.name, file] as const));
const captured = {} as Record<PromotedFileName, Buffer>;
for (const name of PROMOTED_FILE_NAMES) {
const expected = declared.get(name)!;
const handle = await open(
path.join(descriptorRoot, name),
constants.O_RDONLY | constants.O_NOFOLLOW,
);
try {
const before = await handle.stat();
const maxBytes = name === "release-candidate.tar.gz" ? 268_435_456 : 16_777_216;
if (
!before.isFile() ||
before.nlink !== 1 ||
(before.mode & 0o777) !== 0o400 ||
before.size <= 0 ||
before.size > maxBytes
) {
throw new Error(
`staged promotion file must be regular, single-link, bounded, and mode 0400: ${name}`,
);
}
const bytes = await handle.readFile();
const after = await handle.stat();
if (
after.dev !== before.dev ||
after.ino !== before.ino ||
after.size !== before.size ||
after.nlink !== 1 ||
(after.mode & 0o777) !== 0o400 ||
bytes.byteLength !== before.size
) {
throw new Error(`staged promotion file inode or size changed during seal: ${name}`);
}
if (sha256(bytes) !== expected.sha256) {
throw new Error(`staged promotion file digest mismatch during seal: ${name}`);
}
captured[name] = bytes;
} finally {
await handle.close();
}
await parentHandle.close();
}
return Object.freeze(captured);
}
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function assertStagingIdentity(
metadata: Readonly<{
dev: number;
ino: number;
isDirectory: () => boolean;
isSymbolicLink?: () => boolean;
}>,
expected: Readonly<{ dev: number; ino: number }>,
): void {
if (
metadata.dev !== expected.dev ||
metadata.ino !== expected.ino ||
!metadata.isDirectory() ||
metadata.isSymbolicLink?.()
) {
throw new Error("promotion staging leaf identity changed");
}
}
@@ -409,18 +696,6 @@ function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
async function syncDirectory(directory: string): Promise<void> {
const handle = await open(
directory,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
try {
await syncHandle(handle);
} finally {
await handle.close();
}
}
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
try {
await handle.sync();
-333
View File
@@ -1,333 +0,0 @@
import { createHash, createPublicKey } from "node:crypto";
import path from "node:path";
import {
PROMOTION_VERIFIER_ID,
PROMOTION_VERIFIER_VERSION,
evaluatePromotionEvidence,
providerPublicKeyFingerprint,
trustPolicySha256,
type ProviderVerificationArtifactType,
type ProviderTrust,
} from "./provider-evidence.ts";
import {
RELEASE_CANDIDATE_MANIFEST_PATH,
releaseCandidateManifestSchema,
verifyReleaseCandidate,
} from "./release-candidate.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
import { supplyChainDigest } from "./supply-chain.ts";
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
export type VerifyPromotionInputsOptions = Readonly<{
artifactType: ProviderVerificationArtifactType;
environment?: NodeJS.ProcessEnv;
repositoryRoot?: string;
providerEvidenceRoot?: string;
trustRoot?: string;
verifyLocalEvidence?: LocalEvidenceVerifier;
nowEpochMs?: () => number;
}>;
export async function verifyPromotionInputs(
options: VerifyPromotionInputsOptions,
) {
const environment = options.environment ?? process.env;
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
const trustRoot = path.resolve(options.trustRoot ?? repositoryRoot);
const providerEvidenceRoot = path.resolve(
options.providerEvidenceRoot ?? repositoryRoot,
);
const inputFailures: string[] = [];
const archive = await captureOptionalInput(
providerEvidenceRoot,
environment.CANDIDATE_ARCHIVE_PATH,
268_435_456,
"candidate archive",
inputFailures,
);
if (!environment.CANDIDATE_ARCHIVE_SHA256) {
inputFailures.push("candidate archive expected SHA-256 is missing");
} else if (
archive.sha256 &&
archive.sha256 !== environment.CANDIDATE_ARCHIVE_SHA256
) {
inputFailures.push("candidate archive SHA-256 does not match immutable output");
}
const vulnerabilityCapture = await captureOptionalInput(
providerEvidenceRoot,
environment.VULNERABILITY_REPORT_PATH,
16_777_216,
"vulnerability report",
inputFailures,
);
const provenanceCapture = await captureOptionalInput(
providerEvidenceRoot,
environment.PROVENANCE_ATTESTATION_PATH,
16_777_216,
"provenance attestation",
inputFailures,
);
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
)({ extractionRoot: repositoryRoot, expectedManifest: manifest });
const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes);
const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes);
const vulnerabilityTrust = await readProviderTrust(
trustRoot,
environment.VULNERABILITY_PUBLIC_KEY_PATH,
environment.VULNERABILITY_KEY_ID,
);
const provenanceTrust = await readProviderTrust(
trustRoot,
environment.PROVENANCE_PUBLIC_KEY_PATH,
environment.PROVENANCE_KEY_ID,
);
const runId = environment.CI_RUN_ID ?? "missing-run";
const runAttempt = Number(environment.CI_RUN_ATTEMPT);
if (!environment.CI_RUN_ID) inputFailures.push("provider expected run ID is missing");
if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) {
inputFailures.push("provider expected run attempt is missing or invalid");
}
if (!localEvidence.identity) {
inputFailures.push("archived local evidence identity is unavailable");
}
if (
environment.EXPECTED_SOURCE_REVISION &&
localEvidence.identity &&
environment.EXPECTED_SOURCE_REVISION !== localEvidence.identity.sourceRevision
) {
inputFailures.push(
`provider expected source revision mismatch: expected ${environment.EXPECTED_SOURCE_REVISION}, archived ${localEvidence.identity.sourceRevision}`,
);
}
const vulnerabilityInvocationNonce = requiredExpectedNonce(
environment.VULNERABILITY_INVOCATION_NONCE,
"vulnerability",
inputFailures,
);
const provenanceInvocationNonce = requiredExpectedNonce(
environment.PROVENANCE_INVOCATION_NONCE,
"provenance",
inputFailures,
);
const expected = {
run: { id: runId, attempt: Number.isInteger(runAttempt) ? runAttempt : 1 },
source: {
revision:
localEvidence.identity?.sourceRevision ??
environment.EXPECTED_SOURCE_REVISION ??
"0".repeat(40),
sourceSetSha256: localEvidence.identity?.sourceSetSha256 ?? "0".repeat(64),
},
candidate: {
archiveSha256: archive.sha256 ?? "0".repeat(64),
bundleSha256: manifest.bundleSha256,
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
},
vulnerabilityInvocationNonce,
provenanceInvocationNonce,
} as const;
const result = evaluatePromotionEvidence({
expected,
localStatus: localEvidence.status,
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust,
provenanceTrust,
nowEpochMs: options.nowEpochMs,
});
const failures = [
...inputFailures,
...candidate.failures,
...localEvidence.failures,
...result.failures,
];
const now = (options.nowEpochMs ?? Date.now)();
const common = {
schemaVersion: 3 as const,
artifactType: options.artifactType,
verifiedAt: new Date(now).toISOString(),
status:
failures.length === 0 && result.status === "PASS"
? ("PASS" as const)
: ("FAIL_UNVERIFIED" as const),
verifier: Object.freeze({
id: PROMOTION_VERIFIER_ID,
version: PROMOTION_VERIFIER_VERSION,
}),
run: expected.run,
source: expected.source,
candidate: expected.candidate,
providerEvidence: Object.freeze({
vulnerabilityReportSha256: vulnerabilityCapture.sha256 ?? "0".repeat(64),
provenanceAttestationSha256: provenanceCapture.sha256 ?? "0".repeat(64),
vulnerabilityInvocationNonce: expected.vulnerabilityInvocationNonce,
provenanceInvocationNonce: expected.provenanceInvocationNonce,
vulnerabilityKeyId:
vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
vulnerabilityKeyFingerprint:
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
provenanceKeyId:
provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
provenanceKeyFingerprint:
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
}),
trustPolicySha256: verificationTrustPolicySha256(
vulnerabilityTrust,
provenanceTrust,
environment,
),
failures: Object.freeze(failures),
};
return options.artifactType === "provider-verification"
? Object.freeze({
...common,
artifactType: "provider-verification" as const,
vulnerabilityStatus: result.vulnerabilityStatus,
provenanceAttestationStatus: result.provenanceAttestationStatus,
})
: Object.freeze({
...common,
artifactType: "promotion-verification" as const,
localEvidenceStatus: localEvidence.status,
localEvidenceAssessmentSha256:
localEvidence.identity?.assessmentSha256 ?? "0".repeat(64),
providerVerificationSha256:
environment.PROVIDER_VERIFICATION_SHA256 ?? "0".repeat(64),
});
}
function requiredExpectedNonce(
value: string | undefined,
label: "vulnerability" | "provenance",
failures: string[],
): string {
if (value && /^[a-f0-9]{64}$/u.test(value)) return value;
failures.push(`${label} expected invocation nonce is missing or invalid`);
return "0".repeat(64);
}
function verificationTrustPolicySha256(
vulnerabilityTrust: ProviderTrust | null,
provenanceTrust: ProviderTrust | null,
environment: NodeJS.ProcessEnv,
): string {
if (vulnerabilityTrust && provenanceTrust) {
return trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
}
return supplyChainDigest({
algorithm: "Ed25519",
vulnerability: {
keyId: vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
publicKeyFingerprint:
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
},
provenance: {
keyId: provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
publicKeyFingerprint:
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
},
issuedAtFutureSkewMs: 5 * 60 * 1_000,
maximumLifetimeMs: 2 * 60 * 60 * 1_000,
});
}
export async function readProviderTrust(
repositoryRoot: string,
publicKeyPath: string | undefined,
keyId: string | undefined,
): Promise<ProviderTrust | null> {
if (!publicKeyPath || !keyId?.trim()) return null;
try {
const publicKey = createPublicKey(
new TextDecoder("utf-8", { fatal: true }).decode(
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
),
);
return Object.freeze({
keyId,
publicKey,
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
});
} catch {
return null;
}
}
async function captureOptionalInput(
root: string,
configuredPath: string | undefined,
maxBytes: number,
label: string,
failures: string[],
): Promise<Readonly<{ bytes: Buffer | null; sha256: string | null }>> {
if (!configuredPath) {
failures.push(`${label} path is missing`);
return Object.freeze({ bytes: null, sha256: null });
}
try {
const bytes = await boundedConfiguredFile(root, configuredPath, maxBytes);
return Object.freeze({
bytes,
sha256: createHash("sha256").update(bytes).digest("hex"),
});
} catch (error) {
failures.push(
`${label} capture failed: ${error instanceof Error ? error.message : String(error)}`,
);
return Object.freeze({ bytes: null, sha256: null });
}
}
function parseCapturedJson(bytes: Buffer | null): unknown {
if (!bytes) return null;
try {
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
} catch {
return null;
}
}
async function requiredJson(
repositoryRoot: string,
file: string,
): Promise<Record<string, unknown>> {
const value: unknown = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(
await boundedConfiguredFile(repositoryRoot, file, 8_388_608),
),
);
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${file} must be a JSON object`);
}
return value as Record<string, unknown>;
}
async function boundedConfiguredFile(
configuredRoot: string,
configuredPath: string,
maxBytes: number,
): Promise<Buffer> {
const root = path.resolve(configuredRoot);
const absolute = path.resolve(root, configuredPath);
const relative = path.relative(root, absolute);
const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
return readBoundedRegularFile({
root: outside ? path.dirname(absolute) : root,
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
maxBytes,
});
}
+169
View File
@@ -0,0 +1,169 @@
export type ProviderKind = "vulnerability" | "provenance";
const MEMORY_MAX = 1_073_741_824;
const TASKS_MAX = 64;
const STOP_TIMEOUT_MS = 5_000;
const RUNTIME_GRACE_MS = 10_000;
const UNIT_NAME = /^ca-provider-(?:vulnerability|provenance)-[1-9][0-9]*-[0-9a-f]{24}\.scope$/u;
const UNIT_NONCE = /^[0-9a-f]{24}$/u;
const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
const ENFORCEMENT_GATE = [
'cgroup_path=""',
"while IFS=: read -r hierarchy controllers candidate; do",
' if [ "$hierarchy" = 0 ] && [ -z "$controllers" ]; then cgroup_path=$candidate; fi',
"done < /proc/self/cgroup",
'if [ -z "$cgroup_path" ]; then',
" printf '%s\\n' 'provider cgroup enforcement failed: unified cgroup v2 membership is required' >&2",
" exit 125",
"fi",
'case "$cgroup_path" in',
' */"$0") ;;',
" *)",
" printf '%s\\n' 'provider cgroup enforcement failed: unit membership is invalid' >&2",
" exit 125",
" ;;",
"esac",
"cgroup_root=/sys/fs/cgroup$cgroup_path",
"require_cgroup_value() {",
' actual=$(/bin/cat "$cgroup_root/$1") || {',
" printf 'provider cgroup enforcement failed: cannot read %s\\n' \"$1\" >&2",
" exit 125",
" }",
' if [ "$actual" != "$2" ]; then',
" printf 'provider cgroup enforcement failed: %s is %s, expected %s\\n' \"$1\" \"$actual\" \"$2\" >&2",
" exit 125",
" fi",
"}",
`require_cgroup_value memory.max ${MEMORY_MAX}`,
"require_cgroup_value memory.swap.max 0",
`require_cgroup_value pids.max ${TASKS_MAX}`,
"require_cgroup_value cpu.max '100000 100000'",
'exec "$@"',
].join("\n");
export function formatProviderCgroupUnitName(
kind: ProviderKind,
supervisorPid: number,
nonce: string,
): string {
if (!Number.isSafeInteger(supervisorPid) || supervisorPid <= 0 || !UNIT_NONCE.test(nonce)) {
throw new TypeError("provider cgroup unit identity is invalid");
}
const unit = `ca-provider-${kind}-${supervisorPid}-${nonce}.scope`;
assertUnit(unit);
return unit;
}
export function systemdRunProviderArguments(
unit: string,
timeoutMs: number,
cpuSeconds: number,
nodeExecutable: string,
wrapperScript: string,
reportPath: string,
reportDev: number,
reportIno: number,
): string[] {
assertUnit(unit);
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > Number.MAX_SAFE_INTEGER - RUNTIME_GRACE_MS) {
throw new TypeError("provider cgroup runtime is invalid");
}
if (!Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0) {
throw new TypeError("provider cgroup CPU limit is invalid");
}
if (
!nodeExecutable.startsWith("/") || !wrapperScript.startsWith("/") ||
!reportPath.startsWith("/") || reportPath.includes("\0") ||
!Number.isSafeInteger(reportDev) || reportDev <= 0 ||
!Number.isSafeInteger(reportIno) || reportIno <= 0
) {
throw new TypeError("provider scope wrapper path is invalid");
}
return [
"--user",
"--scope",
"--collect",
"--quiet",
"--expand-environment=no",
`--unit=${unit}`,
`--property=MemoryMax=${MEMORY_MAX}`,
"--property=MemorySwapMax=0",
`--property=TasksMax=${TASKS_MAX}`,
"--property=CPUQuota=100%",
"--property=CPUQuotaPeriodSec=100ms",
"--property=KillMode=control-group",
"--property=SendSIGKILL=yes",
`--property=TimeoutStopSec=${STOP_TIMEOUT_MS}ms`,
`--property=RuntimeMaxSec=${timeoutMs + RUNTIME_GRACE_MS}ms`,
"--",
"/bin/sh",
"-eu",
"-c",
ENFORCEMENT_GATE,
unit,
nodeExecutable,
wrapperScript,
String(cpuSeconds),
reportPath,
String(reportDev),
String(reportIno),
];
}
export type ProviderScopeFrame = Readonly<{
bwrapInput: Buffer;
reportPath: string;
reportDev: number;
reportIno: number;
}>;
export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
if (
!Buffer.isBuffer(input.bwrapInput) || input.bwrapInput.byteLength === 0 ||
!input.reportPath.startsWith("/") || input.reportPath.includes("\0") ||
!Number.isSafeInteger(input.reportDev) || input.reportDev <= 0 ||
!Number.isSafeInteger(input.reportIno) || input.reportIno <= 0
) {
throw new TypeError("provider scope frame is invalid");
}
const payload = Buffer.from(JSON.stringify({
bwrapInputBase64: input.bwrapInput.toString("base64"),
reportPath: input.reportPath,
reportDev: input.reportDev,
reportIno: input.reportIno,
}));
const frame = Buffer.allocUnsafe(4 + payload.byteLength);
frame.writeUInt32BE(payload.byteLength, 0);
payload.copy(frame, 4);
return frame;
}
export function encodeProviderBwrapInput(
arguments_: readonly string[],
environment: Readonly<Record<string, string | undefined>>,
): Buffer {
if (arguments_.some((argument) => argument.includes("\0"))) {
throw new TypeError("provider bwrap argument is invalid");
}
const entries = Object.entries(environment).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
);
if (entries.some(([name, value]) =>
!ENVIRONMENT_NAME.test(name) || value === undefined || value.includes("\0")
)) {
throw new TypeError("provider bwrap environment is invalid");
}
const input = ["--clearenv"];
for (const [name, value] of entries) input.push("--setenv", name, value ?? "");
input.push(...arguments_);
return Buffer.from(`${input.join("\0")}\0`);
}
export function systemctlKillProviderArguments(unit: string): string[] {
assertUnit(unit);
return ["--user", "kill", "--kill-whom=all", "--signal=SIGKILL", unit];
}
function assertUnit(unit: string): void {
if (!UNIT_NAME.test(unit)) throw new TypeError("provider cgroup unit name is invalid");
}
+46
View File
@@ -37,6 +37,16 @@ const candidateSchema = z
})
.strict();
const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict();
export const secretScanAttestationSchema = z
.object({
status: z.literal("PASS"),
localEvidenceAssessmentSha256: sha256,
sourceSetSha256: sha256,
policySha256: sha256,
sarifSha256: sha256,
scanInputSha256: sha256,
})
.strict();
const signatureSchema = z
.object({
algorithm: z.literal("Ed25519"),
@@ -60,6 +70,7 @@ export const vulnerabilityProviderReportSchema = z
.object({
...providerCommon,
evidenceType: z.literal("vulnerability-report"),
secretScanAttestation: secretScanAttestationSchema,
findings: z.array(z.record(z.string(), z.json())),
})
.strict();
@@ -95,6 +106,7 @@ const verificationCommon = {
vulnerabilityKeyFingerprint: fingerprint,
provenanceKeyId: nonEmptyString,
provenanceKeyFingerprint: fingerprint,
secretScanAttestation: secretScanAttestationSchema,
})
.strict(),
trustPolicySha256: sha256,
@@ -169,6 +181,7 @@ export type ExpectedPromotionContext = Readonly<{
}>;
vulnerabilityInvocationNonce: string;
provenanceInvocationNonce: string;
secretScanAttestation: z.infer<typeof secretScanAttestationSchema>;
}>;
export type PromotionEvidenceResult = Readonly<{
@@ -211,6 +224,12 @@ export function validateProviderEvidence(input: Readonly<{
now,
failures,
);
if (
JSON.stringify(parsed.data.secretScanAttestation) !==
JSON.stringify(input.expected.secretScanAttestation)
) {
failures.push("vulnerability report secret scan attestation mismatch");
}
if (parsed.data.findings.length > 0) {
failures.push("vulnerability report contains findings");
}
@@ -268,6 +287,7 @@ export function createTrustPolicy(input: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
}>) {
assertDistinctProviderTrust(input);
return Object.freeze({
algorithm: "Ed25519" as const,
vulnerability: Object.freeze({
@@ -283,6 +303,26 @@ export function createTrustPolicy(input: Readonly<{
});
}
export function assertDistinctProviderTrust(input: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
}>): void {
const vulnerabilityFingerprint = providerPublicKeyFingerprint(
input.vulnerabilityTrust.publicKey,
);
const provenanceFingerprint = providerPublicKeyFingerprint(
input.provenanceTrust.publicKey,
);
if (
input.vulnerabilityTrust.keyId === input.provenanceTrust.keyId ||
vulnerabilityFingerprint === provenanceFingerprint ||
input.vulnerabilityTrust.publicKeyFingerprint ===
input.provenanceTrust.publicKeyFingerprint
) {
throw new TypeError("provider trust roles require distinct key identities and DER-SPKI fingerprints");
}
}
export function trustPolicySha256(input: Readonly<{
vulnerabilityTrust: ProviderTrust;
provenanceTrust: ProviderTrust;
@@ -323,6 +363,12 @@ export function evaluatePromotionEvidence(input: Readonly<{
now,
failures,
);
if (
JSON.stringify(vulnerability.data.secretScanAttestation) !==
JSON.stringify(input.expected.secretScanAttestation)
) {
failures.push("vulnerability report secret scan attestation mismatch");
}
if (vulnerability.data.findings.length > 0) {
failures.push("vulnerability report contains findings");
}
+763
View File
@@ -0,0 +1,763 @@
import { spawn } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, type FileHandle } from "node:fs/promises";
import path from "node:path";
import {
decodeProviderGuardianPublished,
decodeProviderGuardianReady,
encodeProviderGuardianCommit,
encodeProviderGuardianGuard,
encodeProviderGuardianPublish,
MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES,
MAX_PROVIDER_GUARDIAN_LEASE_MS,
MAX_PROVIDER_SEALED_BYTES,
providerGuardianRawStagingLeaf,
providerGuardianSealedTempLeaf,
type ProviderGuardianKind,
} from "./provider-guardian-protocol.ts";
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
const RESPONSE_TIMEOUT_MS = 5_000;
const CLOSE_TIMEOUT_MS = 5_000;
const MAX_CONTROL_OUTPUT_BYTES = 4_096;
type OwnedIdentity = Readonly<{ dev: number; ino: number }>;
type RecoveryAuthority = Readonly<{
rawDirectoryHandle: FileHandle;
evidenceDirectoryHandle: FileHandle;
rawStagingHandle: FileHandle;
sealedTempHandle: FileHandle;
rawIdentity: OwnedIdentity;
sealedIdentity: OwnedIdentity;
rawStagingPinnedPath: string;
rawPinnedPath: string;
sealedTempPinnedPath: string;
sealedPinnedPath: string;
}>;
export type ProviderGuardianLease = Readonly<{
pid: number;
rawPath: string;
rawIdentity: OwnedIdentity;
sealedPath: string;
sealedTempPath: string;
sealedIdentity: OwnedIdentity;
prematureExit: Promise<Error>;
publish(bytes: Buffer): Promise<void>;
commit(): Promise<void>;
abort(): Promise<void>;
}>;
export type StartProviderGuardianInput = Readonly<{
kind: ProviderGuardianKind;
workspaceRoot: string;
leaseMs: number;
guardianScript: string;
}>;
export type ProviderScopeGuardianLatch = Readonly<{
activeFailure: Promise<Error>;
close(): Promise<void>;
failure(): Error | undefined;
}>;
export function createProviderScopeGuardianLatch(
guardianExit: Promise<Error>,
): ProviderScopeGuardianLatch {
let active = true;
let closing: Promise<void> | undefined;
let observedFailure: Error | undefined;
let signalActiveFailure!: (error: Error) => void;
const activeFailure = new Promise<Error>((resolve) => { signalActiveFailure = resolve; });
void guardianExit.then((error) => {
observedFailure = error;
if (active) signalActiveFailure(error);
});
return Object.freeze({
activeFailure,
close: () => {
closing ??= Promise.resolve().then(() => { active = false; });
return closing;
},
failure: () => observedFailure,
});
}
export function assertProviderGuardianLeasePaths(
lease: Readonly<{ rawPath: string; sealedPath: string }>,
expected: Readonly<{ rawPath: string; sealedPath: string }>,
): void {
if (lease.rawPath !== expected.rawPath) {
throw new Error("provider guardian returned a noncanonical raw path");
}
if (lease.sealedPath !== expected.sealedPath) {
throw new Error("provider guardian returned a noncanonical sealed path");
}
}
type GuardianResult = Readonly<{
code: number | null;
error?: Error;
signal: NodeJS.Signals | null;
}>;
export async function startProviderGuardian(
input: StartProviderGuardianInput,
): Promise<ProviderGuardianLease> {
if (
(input.kind !== "vulnerability" && input.kind !== "provenance") ||
!path.isAbsolute(input.workspaceRoot) || !path.isAbsolute(input.guardianScript) ||
!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0 ||
input.leaseMs > MAX_PROVIDER_GUARDIAN_LEASE_MS
) {
throw new TypeError("provider guardian client input is invalid");
}
const rawLeaf = input.kind === "vulnerability"
? "vulnerability-report.json"
: "provenance-attestation.json";
const evidenceRoot = path.resolve(input.workspaceRoot, "provider-evidence");
const rawDirectory = path.join(evidenceRoot, "untrusted");
const rawPath = path.join(evidenceRoot, "untrusted", rawLeaf);
const sealedPath = path.join(evidenceRoot, rawLeaf);
const nonce = randomBytes(32);
const rawStagingLeaf = providerGuardianRawStagingLeaf(input.kind, nonce);
const sealedTempLeaf = providerGuardianSealedTempLeaf(input.kind, nonce);
const sealedTempPath = path.join(evidenceRoot, sealedTempLeaf);
const recovery = await openRecoveryAuthority({
rawDirectory,
evidenceRoot,
rawLeaf,
rawStagingLeaf,
sealedLeaf: rawLeaf,
sealedTempLeaf,
});
let child: ReturnType<typeof spawn>;
try {
await assertRecoveryLeavesMissing(recovery);
child = spawn(process.execPath, [input.guardianScript], {
cwd: input.workspaceRoot,
env: {},
stdio: [
"pipe",
"pipe",
"pipe",
recovery.rawDirectoryHandle.fd,
recovery.evidenceDirectoryHandle.fd,
recovery.rawStagingHandle.fd,
recovery.sealedTempHandle.fd,
],
});
} catch (error) {
return await closeRecoveryAndThrow(recovery, error);
}
if (!child.pid || !child.stdin || !child.stdout || !child.stderr) {
child.kill("SIGKILL");
return await closeRecoveryAndThrow(
recovery,
new Error("provider guardian process pipes are unavailable"),
);
}
let state: "starting" | "guarding" | "publishing" | "published" |
"committing" | "aborting" | "terminated" = "starting";
let stderr = Buffer.alloc(0);
let inputError: Error | undefined;
child.stdin.once("error", (error) => { inputError = error; });
child.stderr.on("data", (chunk: Buffer | string) => {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (stderr.byteLength < MAX_CONTROL_OUTPUT_BYTES) {
stderr = Buffer.concat([stderr, bytes.subarray(0, MAX_CONTROL_OUTPUT_BYTES - stderr.byteLength)]);
}
});
const completion = guardianCompletion(child);
let signalPrematureExit!: (error: Error) => void;
const prematureExit = new Promise<Error>((resolve) => { signalPrematureExit = resolve; });
void completion.then((result) => {
if (state === "guarding" || state === "publishing" || state === "published") {
signalPrematureExit(guardianCloseError(result, stderr));
}
});
let ready: ReturnType<typeof decodeProviderGuardianReady>;
try {
const readyResponse = waitForFrame(child.stdout, completion, "READY");
child.stdin.write(encodeProviderGuardianGuard({
kind: input.kind,
nonce,
deadlineEpochMs: Date.now() + input.leaseMs,
}));
ready = decodeProviderGuardianReady(await readyResponse, nonce);
if (
ready.sealedTempLeaf !== sealedTempLeaf ||
ready.rawDev !== recovery.rawIdentity.dev ||
ready.rawIno !== recovery.rawIdentity.ino ||
ready.sealedDev !== recovery.sealedIdentity.dev ||
ready.sealedIno !== recovery.sealedIdentity.ino
) {
throw new TypeError("provider guardian READY identity is invalid for its allocation");
}
await assertPinnedLeafIdentity(recovery.rawPinnedPath, {
dev: ready.rawDev,
ino: ready.rawIno,
}, 0o600);
await assertPinnedLeafIdentity(recovery.sealedTempPinnedPath, {
dev: ready.sealedDev,
ino: ready.sealedIno,
}, 0o600);
await assertPinnedLeafMissing(recovery.rawStagingPinnedPath);
if (child.exitCode !== null || child.signalCode !== null) {
throw guardianCloseError(await completion, stderr);
}
state = "guarding";
} catch (error) {
state = "aborting";
child.stdin.end();
const failures = [toError(error)];
try {
await waitForClose(completion, child);
} catch (closeError) {
failures.push(toError(closeError));
}
try {
await cleanupStartupRecovery(recovery);
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
try {
await closeRecoveryAuthority(recovery);
} catch (closeError) {
failures.push(toError(closeError));
}
state = "terminated";
if (failures.length > 1) {
throw new AggregateError(failures, "provider guardian startup failed", { cause: error });
}
throw failures[0]!;
}
const rawIdentity = Object.freeze({ dev: ready.rawDev, ino: ready.rawIno });
const sealedIdentity = Object.freeze({ dev: ready.sealedDev, ino: ready.sealedIno });
const fallback = Object.freeze({
rawStagingPath: recovery.rawStagingPinnedPath,
rawPath: recovery.rawPinnedPath,
rawIdentity,
sealedPath: recovery.sealedPinnedPath,
sealedTempPath: recovery.sealedTempPinnedPath,
sealedIdentity,
});
const publish = async (bytes: Buffer): Promise<void> => {
if (state !== "guarding") throw new Error("provider guardian lease is not ready to publish");
if (!Buffer.isBuffer(bytes) || bytes.byteLength <= 0 || bytes.byteLength > MAX_PROVIDER_SEALED_BYTES) {
throw new TypeError("provider guardian sealed bytes are invalid");
}
state = "publishing";
try {
await writePinnedSealedBytes(recovery.sealedTempHandle, sealedIdentity, bytes);
const publishedResponse = waitForFrame(child.stdout!, completion, "PUBLISHED");
child.stdin!.write(encodeProviderGuardianPublish({
nonce,
sealedDev: sealedIdentity.dev,
sealedIno: sealedIdentity.ino,
size: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
}));
decodeProviderGuardianPublished(
await publishedResponse,
nonce,
sealedIdentity,
);
if (inputError) throw inputError;
state = "published";
} catch (error) {
state = "guarding";
throw error;
}
};
const commit = async (): Promise<void> => {
if (state !== "published") throw new Error("provider guardian lease is not ready to commit");
state = "committing";
child.stdin!.write(encodeProviderGuardianCommit(nonce));
child.stdin!.end();
let result: GuardianResult;
try {
result = await waitForClose(completion, child);
} catch (error) {
state = "terminated";
return await cleanupFallbackCloseAndThrow(fallback, recovery, error);
}
state = "terminated";
if (inputError) return await cleanupFallbackCloseAndThrow(fallback, recovery, inputError);
if (result.error || result.code !== 0 || result.signal !== null) {
return await cleanupFallbackCloseAndThrow(
fallback,
recovery,
guardianCloseError(result, stderr),
);
}
await closeRecoveryAuthority(recovery);
};
const abort = async (): Promise<void> => {
if (state !== "guarding" && state !== "published") {
throw new Error("provider guardian lease already terminated");
}
state = "aborting";
child.stdin!.end();
let closeError: unknown;
try {
await waitForClose(completion, child);
} catch (error) {
closeError = error;
}
state = "terminated";
if (closeError) return await cleanupFallbackCloseAndThrow(fallback, recovery, closeError);
await cleanupFallbackAndClose(fallback, recovery);
};
return Object.freeze({
pid: child.pid,
rawPath,
rawIdentity,
sealedPath,
sealedTempPath,
sealedIdentity,
prematureExit,
publish,
commit,
abort,
});
}
async function writePinnedSealedBytes(
handle: FileHandle,
identity: OwnedIdentity,
bytes: Buffer,
): Promise<void> {
assertPinnedMetadata(await handle.stat(), identity, 0o600, 0);
await handle.truncate(0);
await handle.writeFile(bytes);
await handle.chmod(0o400);
await handle.sync();
assertPinnedMetadata(await handle.stat(), identity, 0o400, bytes.byteLength);
}
function assertPinnedMetadata(
metadata: Awaited<ReturnType<Awaited<ReturnType<typeof open>>["stat"]>>,
identity: OwnedIdentity,
mode: number,
size: number,
): void {
if (
!metadata.isFile() || Number(metadata.dev) !== identity.dev ||
Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 ||
(Number(metadata.mode) & 0o777) !== mode || Number(metadata.size) !== size
) {
throw new TypeError("provider guardian sealed temp identity changed");
}
}
function guardianCompletion(child: ReturnType<typeof spawn>): Promise<GuardianResult> {
return new Promise((resolve) => {
child.once("error", (error) => resolve({ code: null, error, signal: null }));
child.once("close", (code, signal) => resolve({ code, signal }));
});
}
async function waitForFrame(
stdout: NodeJS.ReadableStream,
completion: Promise<GuardianResult>,
label: string,
): Promise<Buffer> {
let timer: NodeJS.Timeout | undefined;
let pending = Buffer.alloc(0);
const response = new Promise<Buffer>((resolve, reject) => {
const onData = (chunk: Buffer | string): void => {
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
if (pending.byteLength > MAX_CONTROL_OUTPUT_BYTES) {
reject(new Error(`provider guardian ${label} output exceeded its bound`));
return;
}
if (pending.byteLength < 4) return;
const payloadBytes = pending.readUInt32BE(0);
if (payloadBytes <= 0 || payloadBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
reject(new Error(`provider guardian ${label} frame length is invalid`));
return;
}
if (pending.byteLength < payloadBytes + 4) return;
if (pending.byteLength !== payloadBytes + 4) {
reject(new Error(`provider guardian ${label} output has trailing bytes`));
return;
}
resolve(pending.subarray(4));
};
stdout.on("data", onData);
});
try {
return await Promise.race([
response,
completion.then((result) => { throw guardianCloseError(result, Buffer.alloc(0)); }),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`provider guardian ${label} timed out`)),
RESPONSE_TIMEOUT_MS,
);
}),
]);
} finally {
if (timer) clearTimeout(timer);
stdout.removeAllListeners("data");
}
}
async function waitForClose(
completion: Promise<GuardianResult>,
child: ReturnType<typeof spawn>,
): Promise<GuardianResult> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
completion,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("provider guardian did not close within its bound"));
}, CLOSE_TIMEOUT_MS);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
type FallbackIdentity = Readonly<{
rawStagingPath: string;
rawPath: string;
rawIdentity: OwnedIdentity;
sealedPath: string;
sealedTempPath: string;
sealedIdentity: OwnedIdentity;
}>;
async function cleanupFallback(input: FallbackIdentity): Promise<void> {
const failures: Error[] = [];
for (const target of [
{ path: input.rawStagingPath, identity: input.rawIdentity },
{ path: input.rawPath, identity: input.rawIdentity },
{ path: input.sealedTempPath, identity: input.sealedIdentity },
{ path: input.sealedPath, identity: input.sealedIdentity },
]) {
try {
await cleanupOwnedProviderReport({
reportPath: target.path,
reportDev: target.identity.dev,
reportIno: target.identity.ino,
});
} catch (error) {
failures.push(toError(error));
}
}
if (failures.length > 0) {
throw new AggregateError(failures, "provider guardian fallback cleanup failed", {
cause: failures[0],
});
}
}
async function cleanupFallbackCloseAndThrow(
fallback: FallbackIdentity,
recovery: RecoveryAuthority,
primaryError: unknown,
): Promise<never> {
const failures = [toError(primaryError)];
try {
await cleanupFallback(fallback);
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
try {
await closeRecoveryAuthority(recovery);
} catch (closeError) {
failures.push(toError(closeError));
}
if (failures.length > 1) {
throw new AggregateError(failures, "provider guardian failure and recovery failed", {
cause: failures[0],
});
}
throw failures[0]!;
}
async function cleanupFallbackAndClose(
fallback: FallbackIdentity,
recovery: RecoveryAuthority,
): Promise<void> {
const failures: Error[] = [];
try {
await cleanupFallback(fallback);
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
try {
await closeRecoveryAuthority(recovery);
} catch (closeError) {
failures.push(toError(closeError));
}
if (failures.length > 0) {
throw new AggregateError(failures, "provider guardian abort recovery failed", {
cause: failures[0],
});
}
}
async function openRecoveryAuthority(input: Readonly<{
rawDirectory: string;
evidenceRoot: string;
rawLeaf: string;
rawStagingLeaf: string;
sealedLeaf: string;
sealedTempLeaf: string;
}>): Promise<RecoveryAuthority> {
let rawDirectoryHandle: FileHandle | undefined;
let evidenceDirectoryHandle: FileHandle | undefined;
let rawStagingHandle: FileHandle | undefined;
let sealedTempHandle: FileHandle | undefined;
let rawIdentity: OwnedIdentity | undefined;
let sealedIdentity: OwnedIdentity | undefined;
let rawStagingPinnedPath: string | undefined;
let rawPinnedPath: string | undefined;
let sealedTempPinnedPath: string | undefined;
let sealedPinnedPath: string | undefined;
try {
rawDirectoryHandle = await open(
input.rawDirectory,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
await assertPinnedDirectory(rawDirectoryHandle, input.rawDirectory, "raw");
evidenceDirectoryHandle = await open(
input.evidenceRoot,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
await assertPinnedDirectory(evidenceDirectoryHandle, input.evidenceRoot, "evidence");
rawStagingPinnedPath =
`/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawStagingLeaf}`;
rawPinnedPath = `/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawLeaf}`;
sealedTempPinnedPath =
`/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedTempLeaf}`;
sealedPinnedPath = `/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedLeaf}`;
rawStagingHandle = await open(
rawStagingPinnedPath,
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
const rawMetadata = await rawStagingHandle.stat();
rawIdentity = Object.freeze({ dev: rawMetadata.dev, ino: rawMetadata.ino });
assertAllocatedPrivateMetadata(rawMetadata, rawIdentity, "raw staging");
await assertPinnedLeafIdentity(rawStagingPinnedPath, rawIdentity, 0o600);
sealedTempHandle = await open(
sealedTempPinnedPath,
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
const sealedMetadata = await sealedTempHandle.stat();
sealedIdentity = Object.freeze({ dev: sealedMetadata.dev, ino: sealedMetadata.ino });
assertAllocatedPrivateMetadata(sealedMetadata, sealedIdentity, "sealed temp");
await assertPinnedLeafIdentity(sealedTempPinnedPath, sealedIdentity, 0o600);
return Object.freeze({
rawDirectoryHandle,
evidenceDirectoryHandle,
rawStagingHandle,
sealedTempHandle,
rawIdentity,
sealedIdentity,
rawStagingPinnedPath,
rawPinnedPath,
sealedTempPinnedPath,
sealedPinnedPath,
});
} catch (error) {
const failures = [toError(error)];
for (const target of [
{ path: rawStagingPinnedPath, identity: rawIdentity },
{ path: rawPinnedPath, identity: rawIdentity },
{ path: sealedTempPinnedPath, identity: sealedIdentity },
{ path: sealedPinnedPath, identity: sealedIdentity },
]) {
if (!target.path || !target.identity) continue;
try {
await cleanupOwnedProviderReport({
reportPath: target.path,
reportDev: target.identity.dev,
reportIno: target.identity.ino,
});
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
}
for (const handle of [
sealedTempHandle,
rawStagingHandle,
evidenceDirectoryHandle,
rawDirectoryHandle,
]) {
if (!handle) continue;
try { await handle.close(); } catch (closeError) { failures.push(toError(closeError)); }
}
if (failures.length > 1) {
throw new AggregateError(failures, "provider guardian recovery setup failed", {
cause: error,
});
}
throw failures[0]!;
}
}
function assertAllocatedPrivateMetadata(
metadata: Awaited<ReturnType<FileHandle["stat"]>>,
identity: OwnedIdentity,
label: string,
): void {
if (
!metadata.isFile() || Number(metadata.dev) !== identity.dev ||
Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 ||
(Number(metadata.mode) & 0o777) !== 0o600 || Number(metadata.size) !== 0
) {
throw new TypeError(`provider guardian ${label} allocation is invalid`);
}
}
async function assertPinnedDirectory(
handle: FileHandle,
canonicalPath: string,
label: string,
): Promise<void> {
const [descriptorMetadata, pathMetadata] = await Promise.all([
handle.stat(),
lstat(canonicalPath),
]);
if (
!descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() ||
pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev ||
descriptorMetadata.ino !== pathMetadata.ino
) {
throw new TypeError(`provider guardian ${label} recovery directory identity changed`);
}
}
async function assertRecoveryLeavesMissing(recovery: RecoveryAuthority): Promise<void> {
await assertPinnedLeafMissing(recovery.rawPinnedPath);
await assertPinnedLeafMissing(recovery.sealedPinnedPath);
assertAllocatedPrivateMetadata(
await recovery.rawStagingHandle.stat(),
recovery.rawIdentity,
"raw staging",
);
assertAllocatedPrivateMetadata(
await recovery.sealedTempHandle.stat(),
recovery.sealedIdentity,
"sealed temp",
);
await assertPinnedLeafIdentity(
recovery.rawStagingPinnedPath,
recovery.rawIdentity,
0o600,
);
await assertPinnedLeafIdentity(
recovery.sealedTempPinnedPath,
recovery.sealedIdentity,
0o600,
);
}
async function assertPinnedLeafMissing(target: string): Promise<void> {
try {
await lstat(target);
throw new Error("provider guardian transaction leaf already exists");
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
}
}
async function assertPinnedLeafIdentity(
target: string,
identity: OwnedIdentity,
mode: number,
): Promise<void> {
const metadata = await lstat(target);
if (
!metadata.isFile() || metadata.isSymbolicLink() || metadata.dev !== identity.dev ||
metadata.ino !== identity.ino || metadata.nlink !== 1 ||
(metadata.mode & 0o777) !== mode || metadata.size !== 0
) {
throw new TypeError("provider guardian READY identity changed");
}
}
async function cleanupStartupRecovery(recovery: RecoveryAuthority): Promise<void> {
await cleanupFallback({
rawStagingPath: recovery.rawStagingPinnedPath,
rawPath: recovery.rawPinnedPath,
rawIdentity: recovery.rawIdentity,
sealedTempPath: recovery.sealedTempPinnedPath,
sealedPath: recovery.sealedPinnedPath,
sealedIdentity: recovery.sealedIdentity,
});
}
async function closeRecoveryAuthority(recovery: RecoveryAuthority): Promise<void> {
const failures: Error[] = [];
for (const handle of [
recovery.rawStagingHandle,
recovery.sealedTempHandle,
recovery.rawDirectoryHandle,
recovery.evidenceDirectoryHandle,
]) {
try { await handle.close(); } catch (error) { failures.push(toError(error)); }
}
if (failures.length > 0) {
throw new AggregateError(failures, "provider guardian recovery directory close failed", {
cause: failures[0],
});
}
}
async function closeRecoveryAndThrow(
recovery: RecoveryAuthority,
primaryError: unknown,
): Promise<never> {
const failures = [toError(primaryError)];
try {
await cleanupStartupRecovery(recovery);
} catch (cleanupError) {
failures.push(toError(cleanupError));
}
try {
await closeRecoveryAuthority(recovery);
} catch (closeError) {
failures.push(toError(closeError));
}
if (failures.length > 1) {
throw new AggregateError(failures,
"provider guardian failure and recovery close failed", { cause: failures[0] });
}
throw failures[0]!;
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
function guardianCloseError(result: GuardianResult, stderr: Buffer): Error {
if (result.error) return result.error;
const detail = stderr.toString("utf8").trim();
return new Error(
`provider guardian failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}${detail ? `, output=${detail}` : ""}`,
);
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
+396
View File
@@ -0,0 +1,396 @@
import { timingSafeEqual } from "node:crypto";
export type ProviderGuardianKind = "vulnerability" | "provenance";
export const MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES = 4_092;
export const MAX_PROVIDER_GUARDIAN_LEASE_MS = 40 * 60 * 1_000;
export const MAX_PROVIDER_SEALED_BYTES = 8_388_608;
const V2_GUARD_KEYS = ["type", "version", "kind", "nonce", "deadlineEpochMs"] as const;
const READY_KEYS = [
"type", "version", "nonce", "rawDev", "rawIno",
"sealedTempLeaf", "sealedDev", "sealedIno",
] as const;
const PUBLISH_KEYS = [
"type", "version", "nonce", "sealedDev", "sealedIno", "size", "sha256",
] as const;
const PUBLISHED_KEYS = ["type", "version", "nonce", "sealedDev", "sealedIno"] as const;
const COMMIT_KEYS = ["type", "version", "nonce"] as const;
export type ProviderGuardianGuard = Readonly<{
kind: ProviderGuardianKind;
nonce: Buffer;
deadlineEpochMs: number;
}>;
export type ProviderGuardianReady = Readonly<{
nonce: Buffer;
rawDev: number;
rawIno: number;
sealedTempLeaf: string;
sealedDev: number;
sealedIno: number;
}>;
export type ProviderGuardianPublish = Readonly<{
nonce: Buffer;
sealedDev: number;
sealedIno: number;
size: number;
sha256: string;
}>;
export type ProviderGuardianPublished = Readonly<{
nonce: Buffer;
sealedDev: number;
sealedIno: number;
}>;
export function providerGuardianSealedTempLeaf(
kind: ProviderGuardianKind,
nonce: Buffer,
): string {
const rawLeaf = baseLeaf(kind);
assertV2Nonce(nonce);
return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.tmp`;
}
export function providerGuardianRawStagingLeaf(
kind: ProviderGuardianKind,
nonce: Buffer,
): string {
const rawLeaf = baseLeaf(kind);
assertV2Nonce(nonce);
return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.raw.tmp`;
}
export function encodeProviderGuardianGuard(input: ProviderGuardianGuard): Buffer {
assertV2Guard(input);
return prefixFrame(encodeV2GuardPayload(input));
}
export function decodeProviderGuardianGuard(
payload: Buffer,
options: Readonly<{ nowEpochMs: number; maxLeaseMs: number }>,
): ProviderGuardianGuard {
const value = parseRecord(payload, V2_GUARD_KEYS, "guard");
const guard: ProviderGuardianGuard = {
kind: value.kind as ProviderGuardianKind,
nonce: parseV2Nonce(value.nonce),
deadlineEpochMs: value.deadlineEpochMs as number,
};
if (value.type !== "guard" || value.version !== 2) {
throw new TypeError("provider guardian guard version is invalid");
}
assertV2Guard(guard);
if (
!Number.isSafeInteger(options.nowEpochMs) ||
!Number.isSafeInteger(options.maxLeaseMs) || options.maxLeaseMs <= 0 ||
guard.deadlineEpochMs <= options.nowEpochMs ||
guard.deadlineEpochMs > options.nowEpochMs + options.maxLeaseMs
) {
throw new TypeError("provider guardian guard deadline is invalid");
}
assertCanonical(payload, encodeV2GuardPayload(guard), "guard");
return guard;
}
export function encodeProviderGuardianReady(input: ProviderGuardianReady): Buffer {
assertReady(input);
return prefixFrame(encodeReadyPayload(input));
}
export function decodeProviderGuardianReady(
payload: Buffer,
expectedNonce: Buffer,
): ProviderGuardianReady {
assertV2Nonce(expectedNonce);
const value = parseRecord(payload, READY_KEYS, "READY");
const ready: ProviderGuardianReady = {
nonce: parseV2Nonce(value.nonce),
rawDev: value.rawDev as number,
rawIno: value.rawIno as number,
sealedTempLeaf: value.sealedTempLeaf as string,
sealedDev: value.sealedDev as number,
sealedIno: value.sealedIno as number,
};
if (value.type !== "ready" || value.version !== 2) {
throw new TypeError("provider guardian READY version is invalid");
}
assertReady(ready);
assertAuthenticatedNonce(ready.nonce, expectedNonce, "READY");
assertCanonical(payload, encodeReadyPayload(ready), "READY");
return ready;
}
export function encodeProviderGuardianPublish(input: ProviderGuardianPublish): Buffer {
assertPublish(input);
return prefixFrame(encodePublishPayload(input));
}
export function decodeProviderGuardianPublish(
payload: Buffer,
expectedNonce: Buffer,
): ProviderGuardianPublish {
assertV2Nonce(expectedNonce);
const value = parseRecord(payload, PUBLISH_KEYS, "publish");
const publish: ProviderGuardianPublish = {
nonce: parseV2Nonce(value.nonce),
sealedDev: value.sealedDev as number,
sealedIno: value.sealedIno as number,
size: value.size as number,
sha256: value.sha256 as string,
};
if (value.type !== "publish" || value.version !== 2) {
throw new TypeError("provider guardian publish version is invalid");
}
assertPublish(publish);
assertAuthenticatedNonce(publish.nonce, expectedNonce, "publish");
assertCanonical(payload, encodePublishPayload(publish), "publish");
return publish;
}
export function encodeProviderGuardianPublished(input: ProviderGuardianPublished): Buffer {
assertPublished(input);
return prefixFrame(encodePublishedPayload(input));
}
export function decodeProviderGuardianPublished(
payload: Buffer,
expectedNonce: Buffer,
expectedIdentity: Readonly<{ dev: number; ino: number }>,
): void {
assertV2Nonce(expectedNonce);
const value = parseRecord(payload, PUBLISHED_KEYS, "PUBLISHED");
const published: ProviderGuardianPublished = {
nonce: parseV2Nonce(value.nonce),
sealedDev: value.sealedDev as number,
sealedIno: value.sealedIno as number,
};
if (value.type !== "published" || value.version !== 2) {
throw new TypeError("provider guardian PUBLISHED version is invalid");
}
assertPublished(published);
assertAuthenticatedNonce(published.nonce, expectedNonce, "PUBLISHED");
if (published.sealedDev !== expectedIdentity.dev || published.sealedIno !== expectedIdentity.ino) {
throw new TypeError("provider guardian PUBLISHED identity is invalid");
}
assertCanonical(payload, encodePublishedPayload(published), "PUBLISHED");
}
function encodeV2GuardPayload(input: ProviderGuardianGuard): Buffer {
return Buffer.from(JSON.stringify({
type: "guard",
version: 2,
kind: input.kind,
nonce: input.nonce.toString("hex"),
deadlineEpochMs: input.deadlineEpochMs,
}));
}
export function encodeProviderGuardianCommit(nonce: Buffer): Buffer {
assertV2Nonce(nonce);
return prefixFrame(encodeCommitPayload(nonce));
}
export function decodeProviderGuardianCommit(payload: Buffer, expectedNonce: Buffer): void {
assertPayloadSize(payload);
assertV2Nonce(expectedNonce);
const decoded = decodeUtf8(payload);
let value: unknown;
try {
value = JSON.parse(decoded);
} catch {
throw new TypeError("provider guardian commit JSON is invalid");
}
if (!isRecord(value) || !hasExactKeys(value, COMMIT_KEYS)) {
throw new TypeError("provider guardian commit fields are invalid");
}
const nonce = parseV2Nonce(value.nonce);
if (
value.type !== "commit" || value.version !== 2 ||
nonce.byteLength !== expectedNonce.byteLength ||
!timingSafeEqual(nonce, expectedNonce)
) {
throw new TypeError("provider guardian commit authentication failed");
}
if (!payload.equals(encodeCommitPayload(nonce))) {
throw new TypeError("provider guardian commit is not canonical");
}
}
function encodeCommitPayload(nonce: Buffer): Buffer {
return Buffer.from(JSON.stringify({
type: "commit",
version: 2,
nonce: nonce.toString("hex"),
}));
}
function encodeReadyPayload(input: ProviderGuardianReady): Buffer {
return Buffer.from(JSON.stringify({
type: "ready",
version: 2,
nonce: input.nonce.toString("hex"),
rawDev: input.rawDev,
rawIno: input.rawIno,
sealedTempLeaf: input.sealedTempLeaf,
sealedDev: input.sealedDev,
sealedIno: input.sealedIno,
}));
}
function encodePublishPayload(input: ProviderGuardianPublish): Buffer {
return Buffer.from(JSON.stringify({
type: "publish",
version: 2,
nonce: input.nonce.toString("hex"),
sealedDev: input.sealedDev,
sealedIno: input.sealedIno,
size: input.size,
sha256: input.sha256,
}));
}
function encodePublishedPayload(input: ProviderGuardianPublished): Buffer {
return Buffer.from(JSON.stringify({
type: "published",
version: 2,
nonce: input.nonce.toString("hex"),
sealedDev: input.sealedDev,
sealedIno: input.sealedIno,
}));
}
function prefixFrame(payload: Buffer): Buffer {
if (payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
throw new TypeError("provider guardian frame size is invalid");
}
const frame = Buffer.allocUnsafe(payload.byteLength + 4);
frame.writeUInt32BE(payload.byteLength, 0);
payload.copy(frame, 4);
return frame;
}
function assertV2Guard(input: ProviderGuardianGuard): void {
if (
(input.kind !== "vulnerability" && input.kind !== "provenance") ||
!isV2Nonce(input.nonce) ||
!Number.isSafeInteger(input.deadlineEpochMs) || input.deadlineEpochMs <= 0
) {
throw new TypeError("provider guardian guard fields are invalid");
}
}
function baseLeaf(kind: ProviderGuardianKind): string {
if (kind === "vulnerability") return "vulnerability-report.json";
if (kind === "provenance") return "provenance-attestation.json";
throw new TypeError("provider guardian kind is invalid");
}
function assertReady(input: ProviderGuardianReady): void {
if (
!isV2Nonce(input.nonce) ||
!isIdentityPart(input.rawDev) || !isIdentityPart(input.rawIno) ||
typeof input.sealedTempLeaf !== "string" ||
!/^\.(?:vulnerability-report|provenance-attestation)\.json\.guardian-[0-9a-f]{32}\.tmp$/u
.test(input.sealedTempLeaf) ||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno)
) {
throw new TypeError("provider guardian READY fields are invalid");
}
}
function assertPublish(input: ProviderGuardianPublish): void {
if (
!isV2Nonce(input.nonce) ||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno) ||
!Number.isSafeInteger(input.size) || input.size <= 0 || input.size > MAX_PROVIDER_SEALED_BYTES ||
typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(input.sha256)
) {
throw new TypeError("provider guardian publish fields are invalid");
}
}
function assertPublished(input: ProviderGuardianPublished): void {
if (
!isV2Nonce(input.nonce) ||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno)
) {
throw new TypeError("provider guardian PUBLISHED fields are invalid");
}
}
function assertV2Nonce(nonce: Buffer): void {
if (!isV2Nonce(nonce)) throw new TypeError("provider guardian nonce is invalid");
}
function isV2Nonce(nonce: Buffer): boolean {
return Buffer.isBuffer(nonce) && nonce.byteLength === 32;
}
function parseV2Nonce(value: unknown): Buffer {
return typeof value === "string" && /^[0-9a-f]{64}$/u.test(value)
? Buffer.from(value, "hex")
: Buffer.alloc(0);
}
function isIdentityPart(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) > 0;
}
function assertAuthenticatedNonce(received: Buffer, expected: Buffer, label: string): void {
if (received.byteLength !== expected.byteLength || !timingSafeEqual(received, expected)) {
throw new TypeError(`provider guardian ${label} authentication failed`);
}
}
function parseRecord(
payload: Buffer,
expectedKeys: readonly string[],
label: string,
): Record<string, unknown> {
assertPayloadSize(payload);
let value: unknown;
try {
value = JSON.parse(decodeUtf8(payload));
} catch (error) {
if (error instanceof TypeError && /provider guardian/u.test(error.message)) throw error;
throw new TypeError(`provider guardian ${label} JSON is invalid`, { cause: error });
}
if (!isRecord(value) || !hasExactKeys(value, expectedKeys)) {
throw new TypeError(`provider guardian ${label} fields are invalid`);
}
return value;
}
function assertCanonical(payload: Buffer, canonical: Buffer, label: string): void {
if (!payload.equals(canonical)) {
throw new TypeError(`provider guardian ${label} frame is not canonical`);
}
}
function assertPayloadSize(payload: Buffer): void {
if (!Buffer.isBuffer(payload) || payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
throw new TypeError("provider guardian frame size is invalid");
}
}
function decodeUtf8(payload: Buffer): string {
let decoded: string;
try {
decoded = new TextDecoder("utf-8", { fatal: true }).decode(payload);
} catch {
throw new TypeError("provider guardian frame UTF-8 is invalid");
}
if (decoded.includes("\0")) throw new TypeError("provider guardian frame contains NUL");
return decoded;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
const keys = Object.keys(value);
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
}
+27
View File
@@ -0,0 +1,27 @@
export type ProviderOutputLimiter = Readonly<{
consume(chunk: Buffer | string): void;
bytes(): number;
}>;
export function createProviderOutputLimiter(
maxBytes: number,
onExceeded: () => void,
): ProviderOutputLimiter {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || typeof onExceeded !== "function") {
throw new TypeError("provider output limiter input is invalid");
}
let observedBytes = 0;
let exceeded = false;
return Object.freeze({
consume: (chunk: Buffer | string) => {
if (exceeded) return;
const bytes = Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(chunk);
observedBytes += bytes;
if (observedBytes > maxBytes) {
exceeded = true;
onExceeded();
}
},
bytes: () => observedBytes,
});
}
+103
View File
@@ -0,0 +1,103 @@
import { spawn, type ChildProcess } from "node:child_process";
export type ProviderProcessInput = Readonly<{
executable: string;
arguments: readonly string[];
environment: NodeJS.ProcessEnv;
timeoutMs: number;
}>;
type ProviderChild = Pick<ChildProcess, "kill" | "once" | "pid">;
export async function runProviderProcess(
input: ProviderProcessInput,
dependencies: Readonly<{
spawnChild?: (input: ProviderProcessInput) => ProviderChild;
setTimer?: (callback: () => void, milliseconds: number) => ReturnType<typeof setTimeout>;
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
killProcessGroup?: (child: ProviderChild) => void;
}> = {},
): Promise<void> {
if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs <= 0) {
throw new TypeError("provider process timeout must be a positive integer");
}
const child = (dependencies.spawnChild ?? defaultSpawn)(input);
const setTimer = dependencies.setTimer ?? setTimeout;
const clearTimer = dependencies.clearTimer ?? clearTimeout;
const killProcessGroup = dependencies.killProcessGroup ?? defaultKillProcessGroup;
await new Promise<void>((resolve, reject) => {
let settled = false;
let timedOut = false;
const killFailures: unknown[] = [];
const settle = (error?: Error): void => {
if (settled) return;
settled = true;
clearTimer(timer);
error ? reject(error) : resolve();
};
const timer = setTimer(() => {
timedOut = true;
try {
killProcessGroup(child);
} catch (groupError) {
killFailures.push(groupError);
try {
child.kill("SIGKILL");
} catch (fallbackError) {
killFailures.push(fallbackError);
}
}
}, input.timeoutMs);
child.once("error", (error: Error) => {
if (!timedOut) settle(error);
});
child.once("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timedOut) {
const timeoutError = new Error(
"sandboxed external provider command timed out after process close",
);
settle(
killFailures.length === 0
? timeoutError
: new AggregateError(
[timeoutError, ...killFailures],
"sandboxed external provider timed out and process-group kill failed before close",
{ cause: killFailures.at(-1) },
),
);
} else if (code === 0 && signal === null) {
settle();
} else {
settle(
new Error(
`sandboxed external provider failed: exit=${code ?? "none"}, signal=${signal ?? "none"}`,
),
);
}
});
});
}
function defaultSpawn(input: ProviderProcessInput): ProviderChild {
return spawn(input.executable, [...input.arguments], {
env: input.environment,
stdio: "inherit",
detached: true,
});
}
function defaultKillProcessGroup(child: ProviderChild): void {
if (child.pid && child.pid > 0) {
try {
process.kill(-child.pid, "SIGKILL");
return;
} catch (error) {
if (!hasErrorCode(error, "ESRCH")) throw error;
}
}
child.kill("SIGKILL");
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+40
View File
@@ -0,0 +1,40 @@
import { randomBytes } from "node:crypto";
import { lstat, rename, unlink } from "node:fs/promises";
export type ProviderRawIdentity = Readonly<{
reportPath: string;
reportDev: number;
reportIno: number;
}>;
export async function cleanupOwnedProviderReport(
identity: ProviderRawIdentity,
): Promise<boolean> {
try {
const metadata = await lstat(identity.reportPath);
if (!matchesReportIdentity(metadata, identity)) return false;
const quarantine = `${identity.reportPath}.parent-loss-${process.pid}-${randomBytes(16).toString("hex")}`;
await rename(identity.reportPath, quarantine);
const quarantinedMetadata = await lstat(quarantine);
if (!matchesReportIdentity(quarantinedMetadata, identity)) {
throw new Error("provider raw output identity changed during parent-loss cleanup");
}
await unlink(quarantine);
return true;
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false;
throw error;
}
}
function matchesReportIdentity(
metadata: Awaited<ReturnType<typeof lstat>>,
identity: ProviderRawIdentity,
): boolean {
return metadata.isFile() && !metadata.isSymbolicLink() &&
metadata.dev === identity.reportDev && metadata.ino === identity.reportIno;
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+641
View File
@@ -0,0 +1,641 @@
import { createHash } from "node:crypto";
import {
closeSync,
fstatSync,
fsyncSync,
lstatSync,
readlinkSync,
readSync,
type Stats,
writeSync,
createReadStream,
} from "node:fs";
import { link, lstat, unlink } from "node:fs/promises";
import path from "node:path";
import {
decodeProviderGuardianCommit,
decodeProviderGuardianGuard,
decodeProviderGuardianPublish,
encodeProviderGuardianPublished,
encodeProviderGuardianReady,
MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES,
MAX_PROVIDER_GUARDIAN_LEASE_MS,
providerGuardianRawStagingLeaf,
providerGuardianSealedTempLeaf,
type ProviderGuardianGuard,
type ProviderGuardianKind,
} from "./provider-guardian-protocol.ts";
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
type OwnedIdentity = Readonly<{ dev: number; ino: number }>;
type BootstrapAuthority = Readonly<{
kind: ProviderGuardianKind;
noncePrefix: string;
rawStagingLeaf: string;
rawStagingPath: string;
rawPath: string;
rawIdentity: OwnedIdentity;
sealedTempLeaf: string;
sealedTempPath: string;
sealedPath: string;
sealedIdentity: OwnedIdentity;
}>;
type BoundPrivateAuthority = Readonly<{
identity: OwnedIdentity;
leaf: string;
noncePrefix: string;
path: string;
stem: string;
}>;
type GuardianTransaction = Readonly<{ guard: ProviderGuardianGuard }>;
const RAW_DIRECTORY_FD = 3;
const EVIDENCE_DIRECTORY_FD = 4;
const RAW_STAGING_FD = 5;
const SEALED_TEMP_FD = 6;
const RAW_DIRECTORY_PATH = `/proc/self/fd/${RAW_DIRECTORY_FD}`;
const EVIDENCE_DIRECTORY_PATH = `/proc/self/fd/${EVIDENCE_DIRECTORY_FD}`;
const RAW_STAGING_FD_PATH = `/proc/self/fd/${RAW_STAGING_FD}`;
const SEALED_TEMP_FD_PATH = `/proc/self/fd/${SEALED_TEMP_FD}`;
const RAW_STAGING_PATTERN =
/^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.raw\.tmp$/u;
const SEALED_TEMP_PATTERN =
/^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.tmp$/u;
let privateFdsClosed = false;
const bootstrap = await initializeBootstrap();
let pending = Buffer.alloc(0);
let expectedBytes: number | undefined;
let state: "starting" | "guarding" | "published" | "commitPending" = "starting";
let transaction: GuardianTransaction | undefined;
let terminal = false;
let deadline: NodeJS.Timeout | undefined;
let operations = Promise.resolve();
const liveness = createReadStream("", { fd: 0, autoClose: false });
liveness.on("data", consumeChunk);
liveness.once("end", () => {
enqueue(async () => {
if (state === "commitPending" && pending.byteLength === 0 && expectedBytes === undefined) {
await succeedOnCommittedEof();
return;
}
if (state === "starting" && pending.byteLength > 0) {
await failClosed(126, "provider guardian frame is truncated");
return;
}
await failClosed(125, "provider guardian liveness EOF");
});
});
liveness.once("error", (error) => {
enqueue(async () => failClosed(125, `provider guardian liveness error: ${error.message}`));
});
async function initializeBootstrap(): Promise<BootstrapAuthority> {
const rawDirectory = path.resolve(process.cwd(), "provider-evidence/untrusted");
const evidenceDirectory = path.resolve(process.cwd(), "provider-evidence");
const failures: Error[] = [];
const rawDirectoryValid = captureInheritedDirectory(
RAW_DIRECTORY_FD,
rawDirectory,
"raw",
failures,
);
const evidenceDirectoryValid = captureInheritedDirectory(
EVIDENCE_DIRECTORY_FD,
evidenceDirectory,
"evidence",
failures,
);
const rawDescriptor = capturePrivateDescriptor(RAW_STAGING_FD, "raw staging", failures);
const sealedDescriptor = capturePrivateDescriptor(SEALED_TEMP_FD, "sealed temp", failures);
const rawAuthority = rawDescriptor && rawDirectoryValid
? capturePrivateAlias({
descriptorMetadata: rawDescriptor,
descriptorTarget: RAW_STAGING_FD_PATH,
expectedDirectory: rawDirectory,
descriptorDirectory: RAW_DIRECTORY_PATH,
grammar: RAW_STAGING_PATTERN,
label: "raw staging",
}, failures)
: undefined;
const sealedAuthority = sealedDescriptor && evidenceDirectoryValid
? capturePrivateAlias({
descriptorMetadata: sealedDescriptor,
descriptorTarget: SEALED_TEMP_FD_PATH,
expectedDirectory: evidenceDirectory,
descriptorDirectory: EVIDENCE_DIRECTORY_PATH,
grammar: SEALED_TEMP_PATTERN,
label: "sealed temp",
}, failures)
: undefined;
if (!rawAuthority || !sealedAuthority) {
return await failBootstrap(rawAuthority, sealedAuthority, failures);
}
try {
if (
rawAuthority.stem !== sealedAuthority.stem ||
rawAuthority.noncePrefix !== sealedAuthority.noncePrefix
) {
throw new TypeError("provider guardian inherited private aliases disagree");
}
const kind = providerKindFromStem(rawAuthority.stem);
const rawLeaf = kind === "vulnerability"
? "vulnerability-report.json"
: "provenance-attestation.json";
return Object.freeze({
kind,
noncePrefix: rawAuthority.noncePrefix,
rawStagingLeaf: rawAuthority.leaf,
rawStagingPath: rawAuthority.path,
rawPath: `${RAW_DIRECTORY_PATH}/${rawLeaf}`,
rawIdentity: rawAuthority.identity,
sealedTempLeaf: sealedAuthority.leaf,
sealedTempPath: sealedAuthority.path,
sealedPath: `${EVIDENCE_DIRECTORY_PATH}/${rawLeaf}`,
sealedIdentity: sealedAuthority.identity,
});
} catch (error) {
failures.push(toError(error));
return await failBootstrap(rawAuthority, sealedAuthority, failures);
}
}
function captureInheritedDirectory(
fd: number,
canonicalPath: string,
label: string,
failures: Error[],
): boolean {
try {
assertInheritedDirectory(fd, canonicalPath, label);
return true;
} catch (error) {
failures.push(toError(error));
return false;
}
}
function capturePrivateDescriptor(
fd: number,
label: string,
failures: Error[],
): Stats | undefined {
try {
return fstatSync(fd);
} catch (error) {
failures.push(new Error(`provider guardian inherited ${label} fd is invalid`, {
cause: error,
}));
return undefined;
}
}
function capturePrivateAlias(
input: Readonly<{
descriptorMetadata: Stats;
descriptorTarget: string;
expectedDirectory: string;
descriptorDirectory: string;
grammar: RegExp;
label: string;
}>,
failures: Error[],
): BoundPrivateAuthority | undefined {
try {
return bindPrivateAlias(input);
} catch (error) {
failures.push(toError(error));
return undefined;
}
}
function bindPrivateAlias(input: Readonly<{
descriptorMetadata: Stats;
descriptorTarget: string;
expectedDirectory: string;
descriptorDirectory: string;
grammar: RegExp;
label: string;
}>): BoundPrivateAuthority {
const descriptorTarget = readlinkSync(input.descriptorTarget);
if (path.dirname(descriptorTarget) !== input.expectedDirectory) {
throw new TypeError(
`provider guardian inherited ${input.label} alias is outside its directory`,
);
}
const leaf = path.basename(descriptorTarget);
const match = input.grammar.exec(leaf);
if (!match) {
throw new TypeError(`provider guardian inherited ${input.label} alias is invalid`);
}
const boundPath = `${input.descriptorDirectory}/${leaf}`;
const pathnameMetadata = lstatSync(boundPath);
assertPrivateMetadata(input.descriptorMetadata, pathnameMetadata, input.label);
return Object.freeze({
identity: Object.freeze({
dev: input.descriptorMetadata.dev,
ino: input.descriptorMetadata.ino,
}),
leaf,
noncePrefix: match[2]!,
path: boundPath,
stem: match[1]!,
});
}
async function failBootstrap(
rawAuthority: BoundPrivateAuthority | undefined,
sealedAuthority: BoundPrivateAuthority | undefined,
failures: Error[],
): Promise<never> {
for (const authority of [rawAuthority, sealedAuthority]) {
if (!authority) continue;
await cleanupOwnedPath(authority.path, authority.identity, failures);
}
closePrivateFds(failures);
writeAggregateDiagnostic("provider guardian bootstrap failed", failures);
process.exit(126);
}
function assertPrivateMetadata(
descriptorMetadata: Stats,
pathnameMetadata: Stats,
label: string,
): void {
if (
!descriptorMetadata.isFile() || !pathnameMetadata.isFile() ||
pathnameMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathnameMetadata.dev ||
descriptorMetadata.ino !== pathnameMetadata.ino || descriptorMetadata.nlink !== 1 ||
pathnameMetadata.nlink !== 1 || (descriptorMetadata.mode & 0o777) !== 0o600 ||
(pathnameMetadata.mode & 0o777) !== 0o600 || descriptorMetadata.size !== 0 ||
pathnameMetadata.size !== 0
) {
throw new TypeError(`provider guardian inherited ${label} identity is invalid`);
}
}
function providerKindFromStem(stem: string): ProviderGuardianKind {
if (stem === "vulnerability-report") return "vulnerability";
if (stem === "provenance-attestation") return "provenance";
throw new TypeError("provider guardian inherited private kind is invalid");
}
function consumeChunk(chunk: Buffer | string): void {
if (terminal) return;
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
if (expectedBytes === undefined && pending.byteLength >= 4) {
expectedBytes = pending.readUInt32BE(0);
if (expectedBytes <= 0 || expectedBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
enqueue(async () => failClosed(126, "provider guardian frame length is invalid"));
return;
}
}
if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) {
const payload = pending.subarray(4);
pending = Buffer.alloc(0);
expectedBytes = undefined;
enqueue(async () => handleFrame(payload));
} else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) {
enqueue(async () => failClosed(126, "provider guardian frame has trailing bytes"));
}
}
function enqueue(operation: () => Promise<void>): void {
operations = operations.then(operation).catch(async (error) => {
await failClosed(126, error instanceof Error ? error.message : String(error));
});
}
async function handleFrame(payload: Buffer): Promise<void> {
if (state === "starting") {
await establishTransaction(payload);
} else if (state === "guarding") {
await publishSealedArtifact(payload);
} else if (state === "published") {
await prepareCommit(payload);
} else {
await failClosed(126, "provider guardian received data after commit");
}
}
async function establishTransaction(payload: Buffer): Promise<void> {
const nowEpochMs = Date.now();
const guard = decodeProviderGuardianGuard(payload, {
nowEpochMs,
maxLeaseMs: MAX_PROVIDER_GUARDIAN_LEASE_MS,
});
if (
guard.kind !== bootstrap.kind ||
guard.nonce.subarray(0, 16).toString("hex") !== bootstrap.noncePrefix ||
providerGuardianRawStagingLeaf(guard.kind, guard.nonce) !== bootstrap.rawStagingLeaf ||
providerGuardianSealedTempLeaf(guard.kind, guard.nonce) !== bootstrap.sealedTempLeaf
) {
throw new TypeError("provider guardian guard does not match inherited private aliases");
}
assertBoundPrivateLeaf(
RAW_STAGING_FD,
bootstrap.rawStagingPath,
bootstrap.rawIdentity,
"raw staging",
);
assertBoundPrivateLeaf(
SEALED_TEMP_FD,
bootstrap.sealedTempPath,
bootstrap.sealedIdentity,
"sealed temp",
);
transaction = Object.freeze({ guard });
await link(bootstrap.rawStagingPath, bootstrap.rawPath);
assertOwnedPathMetadata(bootstrap.rawStagingPath, bootstrap.rawIdentity, 2, 0o600, 0,
"raw staging link");
assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 2, 0o600, 0,
"canonical raw link");
await unlink(bootstrap.rawStagingPath);
fsyncSync(RAW_DIRECTORY_FD);
assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 1, 0o600, 0,
"canonical raw");
assertBoundPrivateLeaf(
SEALED_TEMP_FD,
bootstrap.sealedTempPath,
bootstrap.sealedIdentity,
"sealed temp",
);
const remainingLeaseMs = guard.deadlineEpochMs - Date.now();
if (remainingLeaseMs <= 0) {
throw new TypeError("provider guardian guard deadline expired during startup");
}
deadline = setTimeout(() => {
enqueue(async () => failClosed(124, "provider guardian lease deadline expired", true));
}, remainingLeaseMs);
writeSync(1, encodeProviderGuardianReady({
nonce: guard.nonce,
rawDev: bootstrap.rawIdentity.dev,
rawIno: bootstrap.rawIdentity.ino,
sealedTempLeaf: bootstrap.sealedTempLeaf,
sealedDev: bootstrap.sealedIdentity.dev,
sealedIno: bootstrap.sealedIdentity.ino,
}));
state = "guarding";
}
function assertBoundPrivateLeaf(
fd: number,
target: string,
identity: OwnedIdentity,
label: string,
): void {
const descriptorMetadata = fstatSync(fd);
const pathnameMetadata = lstatSync(target);
assertOwnedMetadata(descriptorMetadata, identity, 1, 0o600, 0, label);
assertOwnedMetadata(pathnameMetadata, identity, 1, 0o600, 0, label);
if (pathnameMetadata.isSymbolicLink()) {
throw new TypeError(`provider guardian ${label} alias became symbolic`);
}
}
async function publishSealedArtifact(payload: Buffer): Promise<void> {
if (!transaction) {
throw new Error("provider guardian transaction identity is unavailable");
}
const publication = decodeProviderGuardianPublish(payload, transaction.guard.nonce);
if (
publication.sealedDev !== bootstrap.sealedIdentity.dev ||
publication.sealedIno !== bootstrap.sealedIdentity.ino
) {
throw new TypeError("provider guardian publish identity is invalid");
}
assertOwnedMetadata(
fstatSync(SEALED_TEMP_FD),
bootstrap.sealedIdentity,
1,
0o400,
publication.size,
"sealed publish descriptor",
);
const pathnameMetadata = await lstat(bootstrap.sealedTempPath);
assertOwnedMetadata(
pathnameMetadata,
bootstrap.sealedIdentity,
1,
0o400,
publication.size,
"sealed publish pathname",
);
if (pathnameMetadata.isSymbolicLink()) {
throw new TypeError("provider guardian sealed publish pathname became symbolic");
}
const actualSha256 = hashInheritedFile(SEALED_TEMP_FD, publication.size);
if (actualSha256 !== publication.sha256) {
throw new TypeError("provider guardian publish hash is invalid");
}
try {
await lstat(bootstrap.sealedPath);
throw new Error("provider guardian sealed output already exists");
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
}
await link(bootstrap.sealedTempPath, bootstrap.sealedPath);
await unlink(bootstrap.sealedTempPath);
fsyncSync(EVIDENCE_DIRECTORY_FD);
const finalMetadata = await lstat(bootstrap.sealedPath);
assertOwnedMetadata(
finalMetadata,
bootstrap.sealedIdentity,
1,
0o400,
publication.size,
"sealed final",
);
if (finalMetadata.isSymbolicLink()) {
throw new TypeError("provider guardian sealed final became symbolic");
}
state = "published";
writeSync(1, encodeProviderGuardianPublished({
nonce: transaction.guard.nonce,
sealedDev: bootstrap.sealedIdentity.dev,
sealedIno: bootstrap.sealedIdentity.ino,
}));
}
function assertOwnedPathMetadata(
target: string,
identity: OwnedIdentity,
expectedLinks: number,
expectedMode: number,
expectedSize: number,
label: string,
): void {
const metadata = lstatSync(target);
assertOwnedMetadata(metadata, identity, expectedLinks, expectedMode, expectedSize, label);
if (metadata.isSymbolicLink()) {
throw new TypeError(`provider guardian ${label} became symbolic`);
}
}
function assertOwnedMetadata(
metadata: Stats,
identity: OwnedIdentity,
expectedLinks: number,
expectedMode: number,
expectedSize: number,
label: string,
): void {
if (
!metadata.isFile() || metadata.dev !== identity.dev || metadata.ino !== identity.ino ||
metadata.nlink !== expectedLinks || (metadata.mode & 0o777) !== expectedMode ||
metadata.size !== expectedSize
) {
throw new TypeError(`provider guardian ${label} metadata is invalid`);
}
}
function hashInheritedFile(fd: number, size: number): string {
const digest = createHash("sha256");
const buffer = Buffer.allocUnsafe(Math.min(65_536, size));
let position = 0;
while (position < size) {
const requested = Math.min(buffer.byteLength, size - position);
const bytesRead = readSync(fd, buffer, 0, requested, position);
if (bytesRead <= 0) throw new Error("provider guardian sealed publish read was truncated");
digest.update(buffer.subarray(0, bytesRead));
position += bytesRead;
}
return digest.digest("hex");
}
async function prepareCommit(payload: Buffer): Promise<void> {
if (!transaction) throw new Error("provider guardian transaction identity is unavailable");
decodeProviderGuardianCommit(payload, transaction.guard.nonce);
const removedRaw = await cleanupOwnedProviderReport({
reportPath: bootstrap.rawPath,
reportDev: bootstrap.rawIdentity.dev,
reportIno: bootstrap.rawIdentity.ino,
});
if (!removedRaw) throw new Error("provider guardian raw output disappeared before commit");
state = "commitPending";
}
async function succeedOnCommittedEof(): Promise<void> {
const closeErrors: Error[] = [];
closePrivateFds(closeErrors);
if (closeErrors.length > 0) {
await failClosed(
126,
"provider guardian private descriptor close failed",
false,
closeErrors,
);
return;
}
terminal = true;
if (deadline) clearTimeout(deadline);
liveness.removeAllListeners();
liveness.destroy();
closeControlInputBestEffort();
process.exit(0);
}
async function failClosed(
exitCode: number,
message: string,
forceSignal = false,
priorErrors: readonly Error[] = [],
): Promise<void> {
if (terminal) return;
terminal = true;
if (deadline) clearTimeout(deadline);
liveness.removeAllListeners();
liveness.destroy();
const failures = [new Error(message), ...priorErrors];
await cleanupOwnedPath(bootstrap.rawStagingPath, bootstrap.rawIdentity, failures);
await cleanupOwnedPath(bootstrap.rawPath, bootstrap.rawIdentity, failures);
await cleanupOwnedPath(bootstrap.sealedTempPath, bootstrap.sealedIdentity, failures);
await cleanupOwnedPath(bootstrap.sealedPath, bootstrap.sealedIdentity, failures);
closePrivateFds(failures);
closeControlInputBestEffort();
writeAggregateDiagnostic("provider guardian failed", failures);
if (forceSignal) {
try {
process.kill(process.pid, "SIGKILL");
} finally {
process.exit(exitCode);
}
}
process.exit(exitCode);
}
async function cleanupOwnedPath(
target: string,
identity: OwnedIdentity,
errors: Error[],
): Promise<void> {
try {
await cleanupOwnedProviderReport({
reportPath: target,
reportDev: identity.dev,
reportIno: identity.ino,
});
} catch (error) {
errors.push(toError(error));
}
}
function closePrivateFds(errors: Error[]): void {
if (privateFdsClosed) return;
privateFdsClosed = true;
for (const fd of [RAW_STAGING_FD, SEALED_TEMP_FD]) {
try {
closeSync(fd);
} catch (error) {
errors.push(toError(error));
}
}
}
function closeControlInputBestEffort(): void {
try {
closeSync(0);
} catch {
// Terminal cleanup and the exit status must not depend on a diagnostic fd.
}
}
function writeAggregateDiagnostic(label: string, failures: readonly Error[]): void {
const aggregate = failures.length > 1
? new AggregateError(failures, label, { cause: failures[0] })
: failures[0];
const detail = aggregate instanceof AggregateError
? aggregate.errors.map((error) => toError(error).message).join("; ")
: aggregate?.message ?? label;
try {
writeSync(2, `${label}: ${detail}\n`);
} catch {
// A closed parent-side pipe must not convert fail-closed termination to exit 0.
}
}
function assertInheritedDirectory(fd: number, canonicalPath: string, label: string): void {
const descriptorMetadata = fstatSync(fd);
const pathMetadata = lstatSync(canonicalPath);
if (
!descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() ||
pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev ||
descriptorMetadata.ino !== pathMetadata.ino
) {
throw new TypeError(`provider guardian inherited ${label} fd is not a directory`);
}
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+166
View File
@@ -0,0 +1,166 @@
import { spawn } from "node:child_process";
import { closeSync, createReadStream, writeSync } from "node:fs";
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
const MAX_FRAME_BYTES = 16_777_216;
const reportIdentity = parseReportIdentity(process.argv.slice(2));
let pending = Buffer.alloc(0);
let expectedBytes: number | undefined;
let provider: ReturnType<typeof spawn> | undefined;
let providerClosed = false;
let livenessLost = false;
const liveness = createReadStream("", { fd: 0, autoClose: false });
liveness.on("data", (chunk: Buffer | string) => {
if (provider) {
terminateForProtocolFailure("provider scope received trailing protocol bytes");
return;
}
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
if (expectedBytes === undefined && pending.byteLength >= 4) {
expectedBytes = pending.readUInt32BE(0);
if (expectedBytes <= 0 || expectedBytes > MAX_FRAME_BYTES) {
terminateForProtocolFailure("provider scope frame length is invalid");
return;
}
}
if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) {
launchProvider(pending.subarray(4));
pending = Buffer.alloc(0);
} else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) {
terminateForProtocolFailure("provider scope frame has trailing bytes");
}
});
liveness.once("end", () => terminateForParentLoss());
liveness.once("error", () => terminateForParentLoss());
function launchProvider(payload: Buffer): void {
const frame = parseFrame(payload);
if (
frame.reportPath !== reportIdentity.reportPath ||
frame.reportDev !== reportIdentity.reportDev ||
frame.reportIno !== reportIdentity.reportIno
) {
throw new TypeError("provider scope frame identity does not match its launch identity");
}
const bwrapInput = Buffer.from(frame.bwrapInputBase64, "base64");
provider = spawn("/usr/bin/bwrap", ["--args", "0"], {
detached: true,
stdio: ["pipe", "inherit", "inherit"],
});
provider.stdin?.end(bwrapInput);
provider.once("error", (error) => finishProvider(frame, null, null, error));
provider.once("close", (code, signal) => finishProvider(frame, code, signal));
}
async function finishProvider(
frame: ReturnType<typeof parseFrame>,
code: number | null,
signal: NodeJS.Signals | null,
error?: Error,
): Promise<void> {
if (providerClosed) return;
providerClosed = true;
if (livenessLost) await cleanupOwnedProviderReport(frame);
closeLivenessInput();
if (error) {
writeSync(2, `${error.message}\n`);
process.exit(1);
}
if (signal) process.exit(128 + signalNumber(signal));
process.exit(code ?? 1);
}
function terminateForParentLoss(): void {
if (livenessLost) return;
livenessLost = true;
if (!provider || providerClosed) {
void cleanupAfterParentLossAndExit();
return;
}
try {
process.kill(-provider.pid!, "SIGKILL");
} catch (error) {
if (!hasErrorCode(error, "ESRCH")) throw error;
}
}
async function cleanupAfterParentLossAndExit(): Promise<void> {
try {
await cleanupOwnedProviderReport(reportIdentity);
} catch (error) {
writeSync(2, `${error instanceof Error ? error.message : String(error)}\n`);
}
closeLivenessInput();
process.exit(125);
}
function terminateForProtocolFailure(message: string): void {
writeSync(2, `${message}\n`);
terminateForParentLoss();
}
function closeLivenessInput(): void {
liveness.removeAllListeners();
liveness.destroy();
try {
closeSync(0);
} catch (error) {
if (!hasErrorCode(error, "EBADF")) throw error;
}
}
function parseFrame(payload: Buffer): Readonly<{
bwrapInputBase64: string;
reportPath: string;
reportDev: number;
reportIno: number;
}> {
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)) as Record<string, unknown>;
if (
typeof value.bwrapInputBase64 !== "string" ||
typeof value.reportPath !== "string" || !value.reportPath.startsWith("/") ||
!Number.isSafeInteger(value.reportDev) || Number(value.reportDev) <= 0 ||
!Number.isSafeInteger(value.reportIno) || Number(value.reportIno) <= 0
) {
throw new TypeError("provider scope frame payload is invalid");
}
return {
bwrapInputBase64: value.bwrapInputBase64,
reportPath: value.reportPath,
reportDev: Number(value.reportDev),
reportIno: Number(value.reportIno),
};
}
function parseReportIdentity(arguments_: readonly string[]): Readonly<{
cpuSeconds: number;
reportPath: string;
reportDev: number;
reportIno: number;
}> {
const [cpuValue, reportPath, devValue, inoValue, ...trailing] = arguments_;
const cpuSeconds = Number(cpuValue);
const reportDev = Number(devValue);
const reportIno = Number(inoValue);
if (
trailing.length > 0 ||
!Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0 ||
typeof reportPath !== "string" || !reportPath.startsWith("/") || reportPath.includes("\0") ||
!Number.isSafeInteger(reportDev) || reportDev <= 0 ||
!Number.isSafeInteger(reportIno) || reportIno <= 0
) {
throw new TypeError("provider scope launch identity is invalid");
}
return { cpuSeconds, reportPath, reportDev, reportIno };
}
function signalNumber(signal: NodeJS.Signals): number {
return signal === "SIGKILL" ? 9 : signal === "SIGXCPU" ? 24 : 1;
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+24 -4
View File
@@ -43,7 +43,7 @@ export async function superviseProviderEvidence(input: Readonly<{
throw new TypeError("provider invocation nonce must contain exactly 32 bytes");
}
const invocationNonce = nonceBytes.toString("hex");
const now = (dependencies.nowEpochMs ?? Date.now)();
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({
captured,
verify: async ({ extractionRoot, manifest }) => {
@@ -71,13 +71,22 @@ export async function superviseProviderEvidence(input: Readonly<{
distSha256: manifest.distSha256,
lockfileSha256: manifest.lockfileSha256,
}),
secretScanAttestation: Object.freeze({
status: "PASS" as const,
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
sourceSetSha256: local.identity.sourceSetSha256,
policySha256: local.identity.secretScan.policySha256,
sarifSha256: local.identity.secretScan.sarifSha256,
scanInputSha256: local.identity.secretScan.scanInputSha256,
}),
vulnerabilityInvocationNonce:
input.kind === "vulnerability" ? invocationNonce : "0".repeat(64),
provenanceInvocationNonce:
input.kind === "provenance" ? invocationNonce : "0".repeat(64),
});
const issuedAt = new Date(now).toISOString();
const expiresAt = new Date(now + 60 * 60 * 1_000).toISOString();
const issuedNow = nowEpochMs();
const issuedAt = new Date(issuedNow).toISOString();
const expiresAt = new Date(issuedNow + 60 * 60 * 1_000).toISOString();
await input.executeProvider({
candidateRoot: extractionRoot,
environment: providerInvocationEnvironment({
@@ -98,7 +107,7 @@ export async function superviseProviderEvidence(input: Readonly<{
capturedReport,
expectedContext,
trust: input.trust,
nowEpochMs: () => now,
nowEpochMs,
});
return Object.freeze({ evidence, invocationNonce, expectedContext });
},
@@ -135,6 +144,17 @@ export function providerInvocationEnvironment(input: Readonly<{
CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256,
CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256,
CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256,
SECRET_SCAN_STATUS: input.expectedContext.secretScanAttestation.status,
SECRET_SCAN_LOCAL_EVIDENCE_ASSESSMENT_SHA256:
input.expectedContext.secretScanAttestation.localEvidenceAssessmentSha256,
SECRET_SCAN_SOURCE_SET_SHA256:
input.expectedContext.secretScanAttestation.sourceSetSha256,
SECRET_SCAN_POLICY_SHA256:
input.expectedContext.secretScanAttestation.policySha256,
SECRET_SCAN_SARIF_SHA256:
input.expectedContext.secretScanAttestation.sarifSha256,
SECRET_SCAN_INPUT_SHA256:
input.expectedContext.secretScanAttestation.scanInputSha256,
});
}
+42
View File
@@ -0,0 +1,42 @@
import { createPublicKey } from "node:crypto";
import path from "node:path";
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
import {
providerPublicKeyFingerprint,
type ProviderTrust,
} from "./provider-evidence.ts";
export async function readProviderTrust(
configuredRoot: string,
publicKeyPath: string | undefined,
keyId: string | undefined,
): Promise<ProviderTrust | null> {
if (!publicKeyPath || !keyId?.trim()) return null;
try {
const root = path.resolve(configuredRoot);
const absolute = path.resolve(root, publicKeyPath);
const relative = path.relative(root, absolute);
const outside =
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative);
const bytes = await readBoundedRegularFile({
root: outside ? path.dirname(absolute) : root,
relativePath: outside
? path.basename(absolute)
: relative.replaceAll(path.sep, "/"),
maxBytes: 1_048_576,
});
const publicKey = createPublicKey(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
);
return Object.freeze({
keyId,
publicKey,
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
});
} catch {
return null;
}
}
+37
View File
@@ -35,6 +35,42 @@ export const RELEASE_CANDIDATE_MANIFEST_PATH =
export const LOCAL_EVIDENCE_ASSESSMENT_PATH =
"artifacts/security/local-evidence-assessment.json";
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
"scripts/contracts/release-artifacts.ts",
"scripts/create-release-candidate.ts",
"scripts/generate-supply-chain.ts",
"scripts/lib/build-manifest-outputs.ts",
"scripts/lib/json-schema.ts",
"scripts/lib/local-policy-evidence.ts",
"scripts/lib/local-release-evidence.ts",
"scripts/lib/release-candidate.ts",
"scripts/lib/release-input-evidence.ts",
"scripts/lib/release-runtime-coherence.ts",
"scripts/lib/repository-file-inventory.ts",
"scripts/lib/secret-scan-evaluator.ts",
"scripts/lib/secret-scan-policy.ts",
"scripts/lib/secret-scan.ts",
"scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts",
"src/contracts/release-artifacts.ts",
"src/features/installed-contract-contributions.ts",
"src/features/installed-feature-contracts.ts",
] as const);
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
"config/security/dependency-baseline.approval.json",
"config/security/dependency-baseline.json",
"config/security/dependency-change-evidence.json",
"config/security/dependency-policy.json",
"config/security/secret-scan-policy.json",
"config/security/vulnerability-exceptions.json",
"config/security/vulnerability-policy.json",
"schemas/artifacts/build-manifest.schema.json",
"schemas/artifacts/dependency-inventory.schema.json",
"schemas/artifacts/supply-chain-verification.schema.json",
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
] as const);
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
"pnpm-lock.yaml",
"artifacts/performance/bundle.json",
@@ -52,6 +88,7 @@ export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
"artifacts/security/supply-chain-coherence.json",
"artifacts/security/supply-chain-verification.json",
"artifacts/security/vulnerability-report.json",
...LOCAL_EVIDENCE_POLICY_INPUT_PATHS,
]);
export type DistOutput = Readonly<{
+1 -1
View File
@@ -231,7 +231,7 @@ export async function pruneRemovalFixtureCiContract(options: Readonly<{
contract.artifactSchemas = contract.artifactSchemas.filter(({ id }) =>
referencedSchemaIds.has(id)
);
const validated = parseCiGateContract(contract);
const validated = parseCiGateContract(contract, { mode: "removal-fixture" });
await Promise.all([
writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`),
writeFile(gatesPath, `${JSON.stringify(validated, null, 2)}\n`),
+12 -1
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
@@ -175,6 +176,7 @@ export async function evaluateSecretScan(input: Readonly<{
const excluded = new Set(
input.policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
);
const scanInputs: Readonly<{ path: string; bytes: number; sha256: string }>[] = [];
for (const scanFile of [...new Set(scanFiles)].sort()) {
const normalized = scanFile.replaceAll("\\", "/");
if (
@@ -186,8 +188,15 @@ export async function evaluateSecretScan(input: Readonly<{
) {
continue;
}
const content = await input.readText(scanFile);
const bytes = Buffer.from(content, "utf8");
scanInputs.push(Object.freeze({
path: normalized,
bytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
}));
findings.push(
...findSecretMatches(normalized, await input.readText(scanFile), {
...findSecretMatches(normalized, content, {
allowlist: input.policy.allowlist,
now,
}),
@@ -235,6 +244,8 @@ export async function evaluateSecretScan(input: Readonly<{
findings: Object.freeze(findings),
policyFailures: Object.freeze(policyFailures),
scanFiles: Object.freeze([...scanFiles]),
scanInputs: Object.freeze(scanInputs),
scanInputSha256: supplyChainDigest(scanInputs),
sarif,
});
}
@@ -0,0 +1,94 @@
import { appendFile } from "node:fs/promises";
import {
cleanupFinalizedPromotion,
finalizeVerifiedPromotion,
} from "./promotion-stager.ts";
export async function runStageVerifiedPromotionCli(
environment: NodeJS.ProcessEnv,
dependencies: Readonly<{
cwd?: () => string;
finalize?: typeof finalizeVerifiedPromotion;
cleanup?: typeof cleanupFinalizedPromotion;
appendOutput?: (path: string, content: string) => Promise<void>;
writeStdout?: (content: string) => void;
}> = {},
): Promise<void> {
const required = (name: string): string => {
const value = environment[name];
if (!value) throw new TypeError(`promotion staging environment is missing ${name}`);
return value;
};
const attempt = Number(
environment.GITEA_RUN_ATTEMPT ??
environment.GITHUB_RUN_ATTEMPT ??
required("CI_RUN_ATTEMPT"),
);
if (!Number.isInteger(attempt) || attempt < 1 || attempt > 1_000) {
throw new TypeError("promotion staging run attempt is invalid");
}
const runnerTempRoot = required("RUNNER_TEMP");
const staged = await (dependencies.finalize ?? finalizeVerifiedPromotion)({
repositoryRoot: (dependencies.cwd ?? process.cwd)(),
archivePath: required("CANDIDATE_ARCHIVE_PATH"),
expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"),
vulnerabilityReportPath: required("VULNERABILITY_REPORT_PATH"),
provenanceAttestationPath: required("PROVENANCE_ATTESTATION_PATH"),
vulnerabilityPublicKeyPath: required("VULNERABILITY_PUBLIC_KEY_PATH"),
vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"),
provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"),
provenanceKeyId: required("PROVENANCE_KEY_ID"),
expectedRun: {
id:
environment.GITEA_RUN_ID ??
environment.GITHUB_RUN_ID ??
required("CI_RUN_ID"),
attempt,
sourceRevision:
environment.EXPECTED_SOURCE_REVISION ?? required("VITE_COMMIT_SHA"),
},
vulnerabilityInvocationNonce: required("VULNERABILITY_INVOCATION_NONCE"),
provenanceInvocationNonce: required("PROVENANCE_INVOCATION_NONCE"),
runnerTempRoot,
});
try {
const output = required("GITHUB_OUTPUT");
const content = [
`staging_root=${staged.stagingRoot}`,
`cleanup_token=${staged.cleanupToken}`,
`runner_temp_dev=${staged.runnerTempIdentity.dev}`,
`runner_temp_ino=${staged.runnerTempIdentity.ino}`,
`staging_dev=${staged.stagingIdentity.dev}`,
`staging_ino=${staged.stagingIdentity.ino}`,
"",
].join("\n");
await (dependencies.appendOutput ?? defaultAppendOutput)(output, content);
} catch (error) {
try {
await (dependencies.cleanup ?? cleanupFinalizedPromotion)({
runnerTempRoot,
stagingRoot: staged.stagingRoot,
cleanupToken: staged.cleanupToken,
runnerTempIdentity: staged.runnerTempIdentity,
stagingIdentity: staged.stagingIdentity,
});
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
"promotion output publication and direct staging cleanup both failed",
{ cause: cleanupError },
);
}
throw error;
}
(dependencies.writeStdout ?? process.stdout.write.bind(process.stdout))(
`Promotion staging: ${staged.files
.map(({ name, sha256 }) => `${name}=${sha256}`)
.join(", ")} PASS\n`,
);
}
async function defaultAppendOutput(path: string, content: string): Promise<void> {
await appendFile(path, content, { encoding: "utf8" });
}
+13 -6
View File
@@ -15,6 +15,17 @@ export type ValidatedJsonArtifactInput = Readonly<{
value: unknown;
}>;
export function serializeValidatedJsonArtifact(
input: ValidatedJsonArtifactInput,
): Buffer {
const parsed = input.schema.parse(input.value);
const serialized = JSON.stringify(parsed, null, 2);
if (serialized === undefined) {
throw new TypeError("Validated JSON artifact is not serializable");
}
return Buffer.from(`${serialized}\n`, "utf8");
}
export type ValidatedJsonArtifactFileSystem = Readonly<{
open: (path: string, flags: number, mode: number) => Promise<{
writeFile(data: string, encoding: "utf8"): Promise<unknown>;
@@ -64,11 +75,7 @@ export function createValidatedJsonArtifactWriter(
return async function writeArtifact(
input: ValidatedJsonArtifactInput,
): Promise<void> {
const parsed = input.schema.parse(input.value);
const serialized = JSON.stringify(parsed, null, 2);
if (serialized === undefined) {
throw new TypeError("Validated JSON artifact is not serializable");
}
const serialized = serializeValidatedJsonArtifact(input);
const temporaryPath = path.join(
path.dirname(input.path),
@@ -88,7 +95,7 @@ export function createValidatedJsonArtifactWriter(
let writeFailed = false;
let writeFailure: unknown;
try {
await handle.writeFile(`${serialized}\n`, "utf8");
await handle.writeFile(serialized.toString("utf8"), "utf8");
await handle.sync();
} catch (error) {
writeFailed = true;
+439 -115
View File
@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { access, appendFile, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { access, appendFile, lstat } from "node:fs/promises";
import path from "node:path";
import {
@@ -9,15 +9,40 @@ import {
vulnerabilityProviderReportSchema,
} from "./lib/provider-evidence.ts";
import { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts";
import { readProviderTrust } from "./lib/promotion-verifier.ts";
import { readProviderTrust } from "./lib/provider-trust.ts";
import { superviseProviderEvidence } from "./lib/provider-supervisor.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { serializeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import {
assertSafePublishLeaf,
ensureSafePublishDirectory,
} from "./lib/ci-gate-log.ts";
import {
encodeProviderBwrapInput,
encodeProviderScopeFrame,
formatProviderCgroupUnitName,
systemctlKillProviderArguments,
systemdRunProviderArguments,
} from "./lib/provider-cgroup.ts";
import {
assertProviderGuardianLeasePaths,
createProviderScopeGuardianLatch,
startProviderGuardian,
type ProviderGuardianLease,
} from "./lib/provider-guardian-client.ts";
import { createProviderOutputLimiter } from "./lib/provider-output-limiter.ts";
const kind = process.argv[process.argv.indexOf("--kind") + 1];
const PROVIDER_TMP_BYTES = 16_777_216;
const PROVIDER_MASK_BYTES = 1_048_576;
const PROVIDER_MAX_OUTPUT_BYTES = 1_048_576;
const DEFAULT_PROVIDER_CPU_SECONDS = 1_200;
const DEFAULT_PROVIDER_TIMEOUT_MS = 30 * 60 * 1_000;
const PROVIDER_POSTPROCESS_TIMEOUT_MS = 10 * 60 * 1_000;
const PROVIDER_REAP_TIMEOUT_MS = 5_000;
const PROVIDER_CONTROL_TIMEOUT_MS = 5_000;
const PROVIDER_CONTROL_MAX_OUTPUT_BYTES = 65_536;
const PROVIDER_CONTROL_POLL_MS = 25;
const MINIMUM_PROVIDER_SYSTEMD_VERSION = 254;
if (kind !== "vulnerability" && kind !== "provenance") {
process.stderr.write("Usage: run-and-validate-provider --kind vulnerability|provenance\n");
process.exit(2);
@@ -67,7 +92,12 @@ const workspaceRoot = process.cwd();
const reportAbsolute = path.resolve(reportPath);
const rawDirectory = path.dirname(reportAbsolute);
const sealedAbsolute = path.resolve(sealedPath);
const expectedRawLeaf = kind === "vulnerability"
? "vulnerability-report.json"
: "provenance-attestation.json";
const expectedRawDirectory = path.resolve(workspaceRoot, "provider-evidence/untrusted");
if (
reportAbsolute !== path.join(expectedRawDirectory, expectedRawLeaf) ||
path.basename(rawDirectory) !== "untrusted" ||
path.dirname(rawDirectory) !== path.dirname(sealedAbsolute) ||
reportAbsolute === sealedAbsolute
@@ -79,46 +109,98 @@ await prepareMissingProviderOutput(workspaceRoot, sealedAbsolute, sealedPath, "s
await access("/usr/bin/bwrap", constants.X_OK).catch(() => {
throw new Error("provider sandbox unavailable: /usr/bin/bwrap is required");
});
await access("/usr/bin/prlimit", constants.X_OK).catch(() => {
throw new Error("provider sandbox unavailable: /usr/bin/prlimit is required");
});
await access("/usr/bin/systemd-run", constants.X_OK).catch(() => {
throw new Error("provider cgroup unavailable: /usr/bin/systemd-run is required");
});
await access("/usr/bin/systemctl", constants.X_OK).catch(() => {
throw new Error("provider cgroup unavailable: /usr/bin/systemctl is required");
});
await assertProviderCgroupManagerAvailable();
const trust = await readProviderTrust(workspaceRoot, publicKeyPath, keyId);
if (!trust) throw new TypeError("provider supervisor trust key is invalid");
const supervised = await superviseProviderEvidence({
kind,
archivePath,
expectedArchiveSha256: archiveSha256,
expectedRun: { id: runId, attempt: runAttempt, sourceRevision },
trust,
executeProvider: async ({ candidateRoot, environment }) => {
const childEnvironment = createProviderEnvironment(kind, reportPath, environment);
await runProviderInSandbox(
command,
childEnvironment,
rawDirectory,
workspaceRoot,
candidateRoot,
let guardianLease: ProviderGuardianLease | undefined;
let guardianTerminalStarted = false;
try {
const providerWallTimeoutMs = providerTimeoutMs();
const supervised = await superviseProviderEvidence({
kind,
archivePath,
expectedArchiveSha256: archiveSha256,
expectedRun: { id: runId, attempt: runAttempt, sourceRevision },
trust,
executeProvider: async ({ candidateRoot, environment }) => {
guardianLease = await startProviderGuardian({
kind,
workspaceRoot,
leaseMs: providerWallTimeoutMs + PROVIDER_POSTPROCESS_TIMEOUT_MS,
guardianScript: path.join(workspaceRoot, "scripts/lib/provider-raw-guardian.ts"),
});
const activeGuardian = guardianLease;
assertProviderGuardianLeasePaths(activeGuardian, {
rawPath: reportAbsolute,
sealedPath: sealedAbsolute,
});
const childEnvironment = createProviderEnvironment(kind, reportPath, environment);
await runProviderInSandbox(
kind,
command,
childEnvironment,
activeGuardian.rawPath,
activeGuardian.rawIdentity,
workspaceRoot,
candidateRoot,
providerWallTimeoutMs,
activeGuardian.prematureExit,
);
await assertOwnedProviderOutput(activeGuardian.rawPath, activeGuardian.rawIdentity);
},
captureReport: async () => {
if (!guardianLease) throw new Error("provider raw guardian lease was not established");
await assertOwnedProviderOutput(guardianLease.rawPath, guardianLease.rawIdentity);
return readBoundedRegularFile({
root: workspaceRoot,
relativePath: path.relative(workspaceRoot, guardianLease.rawPath).replaceAll(path.sep, "/"),
maxBytes: 8_388_608,
});
},
});
if (!guardianLease) throw new Error("provider raw guardian lease was not established");
await assertSafePublishLeaf(sealedAbsolute, sealedPath);
await guardianLease.publish(serializeValidatedJsonArtifact({
path: sealedPath,
schema:
kind === "vulnerability"
? vulnerabilityProviderReportSchema
: provenanceProviderAttestationSchema,
value: supervised.evidence,
}));
if (process.env.GITHUB_OUTPUT) {
await appendFile(
process.env.GITHUB_OUTPUT,
`invocation_nonce=${supervised.invocationNonce}\n`,
"utf8",
);
},
captureReport: () =>
readBoundedRegularFile({
root: workspaceRoot,
relativePath: path.relative(workspaceRoot, reportAbsolute).replaceAll(path.sep, "/"),
maxBytes: 8_388_608,
}),
});
await assertSafePublishLeaf(sealedAbsolute, sealedPath);
await writeValidatedJsonArtifact({
path: sealedPath,
schema:
kind === "vulnerability"
? vulnerabilityProviderReportSchema
: provenanceProviderAttestationSchema,
value: supervised.evidence,
});
if (process.env.GITHUB_OUTPUT) {
await appendFile(
process.env.GITHUB_OUTPUT,
`invocation_nonce=${supervised.invocationNonce}\n`,
"utf8",
);
}
guardianTerminalStarted = true;
await guardianLease.commit();
} catch (providerError) {
const failures = [toError(providerError)];
if (guardianLease && !guardianTerminalStarted) {
try {
await guardianLease.abort();
} catch (guardianError) {
failures.push(toError(guardianError));
}
}
if (failures.length > 1) {
throw new AggregateError(failures, "provider lifecycle and owned output cleanup failed", {
cause: providerError,
});
}
throw providerError;
}
process.stdout.write(`${kind} provider supervised validation: PASS\n`);
@@ -139,7 +221,7 @@ function createProviderEnvironment(
? { VULNERABILITY_REPORT_PATH: rawReportPath }
: { PROVENANCE_ATTESTATION_PATH: rawReportPath }),
};
for (const name of ["LANG", "LC_ALL", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"] as const) {
for (const name of ["LANG", "LC_ALL"] as const) {
if (process.env[name]) environment[name] = process.env[name];
}
const credentialPrefix = `${providerKind.toUpperCase()}_PROVIDER_`;
@@ -152,85 +234,312 @@ function createProviderEnvironment(
}
async function runProviderInSandbox(
providerKind: "vulnerability" | "provenance",
command: string,
environment: NodeJS.ProcessEnv,
rawDirectory: string,
reportAbsolute: string,
reportIdentity: Readonly<{ dev: number; ino: number }>,
workspaceRoot: string,
candidateRoot: string,
timeoutMs: number,
guardianExit: Promise<Error>,
): Promise<void> {
const scratch = await mkdtemp(path.join(tmpdir(), "ci-provider-sandbox-"));
try {
await mkdir(path.join(scratch, "provider-home"));
await writeFile(path.join(scratch, "node"), "", { mode: 0o500 });
const arguments_ = [
"--die-with-parent",
"--new-session",
"--as-pid-1",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
"--dev", "/dev",
"--proc", "/proc",
"--bind", scratch, "/tmp",
"--dir", "/etc",
];
for (const source of ["/usr", "/bin", "/lib", "/lib64"]) {
if (await exists(source)) arguments_.push("--ro-bind", source, source);
}
// setup-node commonly installs outside /usr. Expose only the exact trusted
// runtime binary, never its credential-bearing user/toolcache directory.
arguments_.push("--ro-bind", process.execPath, "/tmp/node");
for (const source of [
"/etc/ca-certificates",
"/etc/ssl",
"/etc/resolv.conf",
"/etc/hosts",
"/etc/nsswitch.conf",
"/etc/passwd",
"/etc/group",
]) {
if (await exists(source)) arguments_.push("--ro-bind", source, source);
}
for (const directory of missingDestinationAncestors(workspaceRoot)) {
arguments_.push("--dir", directory);
}
arguments_.push(
"--ro-bind", workspaceRoot, workspaceRoot,
);
if (await exists(path.join(workspaceRoot, ".git"))) {
arguments_.push("--tmpfs", path.join(workspaceRoot, ".git"));
}
arguments_.push(
"--bind", rawDirectory, rawDirectory,
"--ro-bind", candidateRoot, "/candidate",
"--chdir", workspaceRoot,
"/bin/sh", "-eu", "-c", command,
);
await new Promise<void>((resolve, reject) => {
const child = spawn("/usr/bin/bwrap", arguments_, {
env: { ...environment, PATH: `/tmp:${environment.PATH ?? ""}` },
stdio: "inherit",
});
let settled = false;
const finish = (error?: Error): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
error ? reject(error) : resolve();
};
const timeout = setTimeout(() => {
child.kill("SIGKILL");
finish(new Error("sandboxed external provider command timed out"));
}, 30 * 60 * 1_000);
child.once("error", (error) => finish(error));
child.once("close", (code, signal) => {
if (code === 0 && signal === null) finish();
else finish(new Error(`sandboxed external provider failed: exit=${code ?? "none"}, signal=${signal ?? "none"}`));
});
});
} finally {
await rm(scratch, { recursive: true, force: true });
const cpuSeconds = providerCpuSeconds();
const bwrapArguments = [
"--die-with-parent", "--new-session", "--as-pid-1",
"--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-net",
"--dev", "/dev", "--remount-ro", "/dev",
"--proc", "/proc", "--remount-ro", "/proc",
"--size", String(PROVIDER_TMP_BYTES), "--tmpfs", "/tmp",
"--dir", "/tmp/provider-home",
"--size", String(PROVIDER_MASK_BYTES), "--tmpfs", "/etc",
];
for (const source of ["/usr", "/bin", "/lib", "/lib64"]) {
if (await exists(source)) bwrapArguments.push("--ro-bind", source, source);
}
bwrapArguments.push("--ro-bind", process.execPath, "/tmp/node");
for (const source of [
"/etc/ca-certificates", "/etc/ssl", "/etc/nsswitch.conf", "/etc/passwd", "/etc/group",
]) {
if (await exists(source)) bwrapArguments.push("--ro-bind", source, source);
}
bwrapArguments.push("--remount-ro", "/etc");
for (const directory of missingDestinationAncestors(workspaceRoot)) {
bwrapArguments.push("--dir", directory);
}
bwrapArguments.push("--ro-bind", workspaceRoot, workspaceRoot);
if (await exists(path.join(workspaceRoot, ".git"))) {
bwrapArguments.push(
"--size", String(PROVIDER_MASK_BYTES),
"--tmpfs", path.join(workspaceRoot, ".git"),
"--remount-ro", path.join(workspaceRoot, ".git"),
);
}
bwrapArguments.push(
"--ro-bind", candidateRoot, "/candidate",
"--remount-ro", "/tmp",
"--remount-ro", "/",
"--bind", reportAbsolute, reportAbsolute,
"--chdir", workspaceRoot,
"--", "/usr/bin/prlimit",
"--core=0:0",
"--fsize=8388607:8388607",
"--nofile=64:64",
`--cpu=${cpuSeconds}:${cpuSeconds}`,
"--", "/bin/sh", "-eu", "-c",
'exec /bin/sh -eu -c "$PROVIDER_COMMAND"',
);
const unitName = formatProviderCgroupUnitName(
providerKind,
process.pid,
randomBytes(12).toString("hex"),
);
const bwrapInput = encodeProviderBwrapInput(bwrapArguments, {
...environment,
PATH: `/tmp:${environment.PATH ?? ""}`,
PROVIDER_COMMAND: command,
});
const scopeFrame = encodeProviderScopeFrame({
bwrapInput,
reportPath: reportAbsolute,
reportDev: reportIdentity.dev,
reportIno: reportIdentity.ino,
});
await waitForProvider(
spawn("/usr/bin/systemd-run", systemdRunProviderArguments(
unitName,
timeoutMs,
cpuSeconds,
process.execPath,
path.join(workspaceRoot, "scripts/lib/provider-scope-wrapper.ts"),
reportAbsolute,
reportIdentity.dev,
reportIdentity.ino,
), {
env: providerCgroupClientEnvironment(),
stdio: ["pipe", "pipe", "pipe"],
}),
timeoutMs,
unitName,
scopeFrame,
guardianExit,
);
}
async function waitForProvider(
child: ReturnType<typeof spawn>,
timeoutMs: number,
unitName: string,
scopeFrame: Buffer,
guardianExit: Promise<Error>,
): Promise<void> {
let termination: "guardian" | "timeout" | "output" | undefined;
let guardianError: Error | undefined;
let signalTermination!: () => void;
let kill: Promise<void> | undefined;
const terminationStarted = new Promise<void>((resolve) => { signalTermination = resolve; });
const close = new Promise<Readonly<{ code: number | null; error?: Error; signal: NodeJS.Signals | null }>>((resolve) => {
child.once("error", (error) => resolve({ code: null, error, signal: null }));
child.once("close", (code, signal) => resolve({ code, signal }));
});
let inputError: Error | undefined;
child.stdin?.once("error", (error) => { inputError = error; });
if (child.stdin) child.stdin.write(scopeFrame);
else inputError = new Error("systemd-run provider argument pipe is unavailable");
const terminate = (reason: "guardian" | "timeout" | "output"): void => {
if (termination) return;
termination = reason;
kill = killProviderUnit(unitName);
signalTermination();
};
const guardianLatch = createProviderScopeGuardianLatch(guardianExit);
void guardianLatch.activeFailure.then((error) => {
guardianError = error;
terminate("guardian");
});
const outputLimiter = createProviderOutputLimiter(
PROVIDER_MAX_OUTPUT_BYTES,
() => terminate("output"),
);
const capture = (chunk: Buffer | string): void => {
if (termination) return;
outputLimiter.consume(chunk);
};
child.stdout?.on("data", capture);
child.stderr?.on("data", capture);
const timeout = setTimeout(() => terminate("timeout"), timeoutMs);
try {
const first = await Promise.race([
close.then((result) => ({ type: "close" as const, result })),
terminationStarted.then(() => ({ type: "termination" as const })),
]);
let collection: Promise<void> | undefined;
if (first.type === "close" && !termination) {
collection = waitForProviderUnitCollected(unitName);
const scopeOutcome = await Promise.race([
collection.then(() => "collected" as const),
terminationStarted.then(() => "termination" as const),
]);
if (scopeOutcome === "collected") {
await guardianLatch.close();
const boundaryFailure = guardianLatch.failure();
if (boundaryFailure && !termination) {
guardianError = boundaryFailure;
terminate("guardian");
}
}
}
if (termination) {
let killError: Error | undefined;
try { await kill; } catch (error) { killError = toError(error); child.kill("SIGKILL"); }
const closed = await waitForProviderClose(close);
if (!closed) child.kill("SIGKILL");
let collectionError: Error | undefined;
try {
await (collection ?? waitForProviderUnitCollected(unitName));
} catch (error) {
collectionError = toError(error);
}
await guardianLatch.close();
const reason = termination === "timeout"
? "sandboxed external provider command timed out"
: termination === "output"
? `sandboxed external provider output exceeded the ${PROVIDER_MAX_OUTPUT_BYTES}-byte aggregate limit`
: `provider raw guardian failed${guardianError ? `: ${guardianError.message}` : ""}`;
if (!closed) throw new Error(`${reason} and systemd-run did not close within the reap bound`);
if (collectionError) throw new Error(`${reason}; provider cgroup collection failed: ${collectionError.message}`);
if (killError) throw new Error(`${reason}; provider cgroup kill failed: ${killError.message}`);
throw new Error(reason);
}
const result = first.type === "close" ? first.result : await close;
await collection;
if (result.error) throw result.error;
if (result.code !== 0 || result.signal !== null) {
throw new Error(`sandboxed external provider failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}`);
}
if (inputError) throw inputError;
} finally {
clearTimeout(timeout);
await guardianLatch.close();
}
}
async function waitForProviderClose(
close: Promise<Readonly<{ code: number | null; error?: Error; signal: NodeJS.Signals | null }>>,
): Promise<boolean> {
let timer: NodeJS.Timeout | undefined;
const closed = await Promise.race([
close.then(() => true),
new Promise<false>((resolve) => { timer = setTimeout(() => resolve(false), PROVIDER_REAP_TIMEOUT_MS); }),
]);
if (timer) clearTimeout(timer);
return closed;
}
async function assertProviderCgroupManagerAvailable(): Promise<void> {
const output = await runBoundedSystemctl(
["--user", "show", "--property=Version", "--value"],
"provider cgroup user manager probe",
);
const match = /^([0-9]+)/u.exec(output.trim());
const version = match ? Number(match[1]) : Number.NaN;
if (!Number.isSafeInteger(version) || version < MINIMUM_PROVIDER_SYSTEMD_VERSION) {
throw new Error(`provider cgroup unavailable: systemd ${MINIMUM_PROVIDER_SYSTEMD_VERSION} or newer is required`);
}
}
async function killProviderUnit(unitName: string): Promise<void> {
await runBoundedSystemctl(systemctlKillProviderArguments(unitName), "provider cgroup unit kill");
}
async function waitForProviderUnitCollected(unitName: string): Promise<void> {
const deadline = Date.now() + PROVIDER_CONTROL_TIMEOUT_MS;
let loadState = "unknown";
while (Date.now() <= deadline) {
loadState = (await runBoundedSystemctl(
["--user", "show", unitName, "--property=LoadState", "--value"],
"provider cgroup collection probe",
)).trim();
if (loadState === "not-found") return;
await new Promise<void>((resolve) => setTimeout(resolve, PROVIDER_CONTROL_POLL_MS));
}
throw new Error(`provider unit remained loaded with state ${loadState || "unknown"}`);
}
async function runBoundedSystemctl(arguments_: readonly string[], label: string): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const child = spawn("/usr/bin/systemctl", arguments_, {
env: providerCgroupClientEnvironment(),
stdio: ["ignore", "pipe", "pipe"],
});
let bytes = 0;
const chunks: Buffer[] = [];
let settled = false;
let termination: "output" | "timeout" | undefined;
let reap: NodeJS.Timeout | undefined;
const finish = (error?: Error): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (reap) clearTimeout(reap);
error ? reject(error) : resolve(Buffer.concat(chunks).toString("utf8"));
};
const terminate = (reason: "output" | "timeout"): void => {
if (termination) return;
termination = reason;
child.kill("SIGKILL");
reap = setTimeout(() => finish(new Error(`${label} ${reason} bound was exceeded and systemctl did not close`)), PROVIDER_REAP_TIMEOUT_MS);
};
const capture = (chunk: Buffer | string): void => {
if (termination) return;
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const remaining = PROVIDER_CONTROL_MAX_OUTPUT_BYTES - bytes;
if (remaining > 0) { chunks.push(value.subarray(0, remaining)); bytes += Math.min(value.length, remaining); }
if (value.length > remaining) terminate("output");
};
child.stdout?.on("data", capture);
child.stderr?.on("data", capture);
const timeout = setTimeout(() => terminate("timeout"), PROVIDER_CONTROL_TIMEOUT_MS);
child.once("error", (error) => finish(error));
child.once("close", (code, signal) => {
if (termination) return finish(new Error(`${label} ${termination} bound was exceeded`));
if (code === 0 && signal === null) return finish();
const detail = Buffer.concat(chunks).toString("utf8").trim();
finish(new Error(`${label} failed: exit=${code ?? "none"}, signal=${signal ?? "none"}${detail ? `, output=${detail}` : ""}`));
});
});
}
function providerCgroupClientEnvironment(): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" };
for (const name of ["DBUS_SESSION_BUS_ADDRESS", "HOME", "LANG", "LC_ALL", "LOGNAME", "USER", "XDG_RUNTIME_DIR"] as const) {
if (process.env[name]) environment[name] = process.env[name];
}
return environment;
}
function providerCpuSeconds(): number {
const value = process.env.PROVIDER_SUPERVISOR_CPU_SECONDS;
if (!value) return DEFAULT_PROVIDER_CPU_SECONDS;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > DEFAULT_PROVIDER_CPU_SECONDS) {
throw new TypeError("PROVIDER_SUPERVISOR_CPU_SECONDS must be a positive integer no greater than 1200");
}
return parsed;
}
function providerTimeoutMs(): number {
const value = process.env.PROVIDER_SUPERVISOR_TIMEOUT_MS;
if (!value) return DEFAULT_PROVIDER_TIMEOUT_MS;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > DEFAULT_PROVIDER_TIMEOUT_MS) {
throw new TypeError("PROVIDER_SUPERVISOR_TIMEOUT_MS must be a positive integer no greater than 1800000");
}
return parsed;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function missingDestinationAncestors(target: string): string[] {
@@ -269,6 +578,21 @@ async function prepareMissingProviderOutput(
}
}
async function assertOwnedProviderOutput(
absolutePath: string,
identity: Readonly<{ dev: number; ino: number }>,
): Promise<void> {
const metadata = await lstat(absolutePath);
if (
metadata.isSymbolicLink() ||
!metadata.isFile() ||
metadata.dev !== identity.dev ||
metadata.ino !== identity.ino
) {
throw new Error("provider raw output identity changed");
}
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
+8 -12
View File
@@ -9,23 +9,17 @@ import {
isValidSourceDateEpoch,
} from "./lib/build-environment.ts";
import { classifyGateStepResult } from "./lib/ci-step-result.ts";
import {
indexCiGateContract,
loadCiGateContract,
} from "./contracts/ci-gates.ts";
import { withCiGatePreflight } from "./contracts/ci-gates.ts";
import { validateCiArtifact } from "./lib/ci-artifact-validator.ts";
import { writeCiGateLogAtomic } from "./lib/ci-gate-log.ts";
const gateId = process.argv
const requestedGateId = process.argv
.slice(2)
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
const contract = await loadCiGateContract(process.cwd());
const contractIndex = indexCiGateContract(contract);
const gate = gateId ? contractIndex.gates.get(gateId) : undefined;
if (!gateId || !gate) {
process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n");
process.exit(2);
}
await withCiGatePreflight(
process.cwd(),
requestedGateId,
async ({ contractIndex, gateId, gate }) => {
const logArtifact = contractIndex.artifacts.get(gate.logArtifactId);
if (!logArtifact) throw new TypeError(`CI gate log artifact disappeared: ${gate.logArtifactId}`);
@@ -252,4 +246,6 @@ if (!passed) {
}
process.stdout.write(
`${gateId} ${gate.name}: PASS (${gate.retentionClassId})\n`,
);
},
);
+2 -43
View File
@@ -1,44 +1,3 @@
import { appendFile } from "node:fs/promises";
import { runStageVerifiedPromotionCli } from "./lib/stage-verified-promotion-cli.ts";
import { finalizeVerifiedPromotion } from "./lib/promotion-stager.ts";
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new TypeError(`promotion staging environment is missing ${name}`);
return value;
};
const staged = await finalizeVerifiedPromotion({
repositoryRoot: process.cwd(),
archivePath: required("CANDIDATE_ARCHIVE_PATH"),
expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"),
vulnerabilityReportPath: required("VULNERABILITY_REPORT_PATH"),
provenanceAttestationPath: required("PROVENANCE_ATTESTATION_PATH"),
vulnerabilityPublicKeyPath: required("VULNERABILITY_PUBLIC_KEY_PATH"),
vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"),
provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"),
provenanceKeyId: required("PROVENANCE_KEY_ID"),
expectedRun: {
id: process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? required("CI_RUN_ID"),
attempt: Number(process.env.GITEA_RUN_ATTEMPT ?? process.env.GITHUB_RUN_ATTEMPT ?? required("CI_RUN_ATTEMPT")),
sourceRevision: process.env.EXPECTED_SOURCE_REVISION ?? required("VITE_COMMIT_SHA"),
},
vulnerabilityInvocationNonce: required("VULNERABILITY_INVOCATION_NONCE"),
provenanceInvocationNonce: required("PROVENANCE_INVOCATION_NONCE"),
runnerTempRoot: required("RUNNER_TEMP"),
});
const output = required("GITHUB_OUTPUT");
await appendFile(
output,
[
`staging_root=${staged.stagingRoot}`,
`cleanup_token=${staged.cleanupToken}`,
`runner_temp_dev=${staged.runnerTempIdentity.dev}`,
`runner_temp_ino=${staged.runnerTempIdentity.ino}`,
"",
].join("\n"),
{ encoding: "utf8" },
);
process.stdout.write(
`Promotion staging: ${staged.files.map(({ name, sha256 }) => `${name}=${sha256}`).join(", ")} PASS\n`,
);
await runStageVerifiedPromotionCli(process.env);
+22 -20
View File
@@ -1,9 +1,11 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import { manualA11yReportArtifactSchema } from "./contracts/release-artifacts.ts";
import {
MANUAL_A11Y_ROUTE_IDS,
validateManualA11yEvidence,
} from "./lib/manual-a11y-evidence.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type ManualA11yResult = Readonly<{
routeId: string;
@@ -34,34 +36,34 @@ for (const routeId of MANUAL_A11Y_ROUTE_IDS) {
passed: validation.passed && failures.length === 0,
});
}
const releaseIds = new Set(results.map((result) => result.releaseId));
const releaseIds = new Set(
results.map((result) => result.releaseId).filter((releaseId): releaseId is string => Boolean(releaseId)),
);
const coherentRelease =
releaseIds.size === 1 && results.every((result) => Boolean(result.releaseId));
const passed =
results.every((result) => result.passed) &&
releaseIds.size === 1 &&
results.every((result) => Boolean(result.releaseId));
coherentRelease;
await mkdir("artifacts/tests/a11y-manual", { recursive: true });
await writeFile(
"artifacts/tests/a11y-manual/report.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: MANUAL_A11Y_ROUTE_IDS,
results,
coherentRelease: releaseIds.size === 1,
passed,
},
null,
2,
)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/tests/a11y-manual/report.json",
schema: manualA11yReportArtifactSchema,
value: {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: MANUAL_A11Y_ROUTE_IDS,
results,
coherentRelease,
passed,
},
});
if (!passed) {
const failures = results
.filter((result) => !result.passed)
.map((result) => `${result.routeId}: ${result.failures.join(", ")}`);
if (releaseIds.size !== 1) failures.push("release IDs do not match");
if (!coherentRelease) failures.push("release IDs do not match");
process.stderr.write(
`Manual accessibility evidence is incomplete:\n${failures.join("\n")}\n`,
);
+35 -7
View File
@@ -1,18 +1,36 @@
import { readFile } from "node:fs/promises";
import {
captureCiCandidateArchive,
withVerifiedCapturedCandidate,
} from "./lib/ci-candidate-archive.ts";
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({
extractionRoot: process.cwd(),
expectedManifest: candidate,
});
const argument = (name: string): string | undefined => {
const index = process.argv.indexOf(name);
if (index < 0) return undefined;
const value = process.argv[index + 1];
if (!value || value.startsWith("--")) {
throw new TypeError(`${name} requires a value`);
}
return value;
};
const archivePath = argument("--archive");
const expectedSha256 = argument("--sha256");
if (Boolean(archivePath) !== Boolean(expectedSha256)) {
throw new TypeError("--archive and --sha256 must be supplied together");
}
const result = archivePath && expectedSha256
? await withVerifiedCapturedCandidate({
captured: await captureCiCandidateArchive({ archivePath, expectedSha256 }),
verify: ({ extractionRoot, manifest }) =>
verifyArchivedLocalEvidence({ extractionRoot, expectedManifest: manifest }),
})
: await verifyCheckoutEvidence();
if (result.status !== "PASS") {
process.stderr.write(
`Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`,
@@ -20,3 +38,13 @@ if (result.status !== "PASS") {
process.exit(1);
}
process.stdout.write("Archived local evidence verification: PASS\n");
async function verifyCheckoutEvidence() {
const candidate = releaseCandidateManifestSchema.parse(
JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")),
);
return verifyArchivedLocalEvidence({
extractionRoot: process.cwd(),
expectedManifest: candidate,
});
}
+19 -19
View File
@@ -1,4 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import { documentationReviewArtifactSchema } from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type DocumentationReview = Readonly<{
sourcePath: string;
@@ -60,24 +63,21 @@ const passed =
results.length === 2 &&
results.every((result) => result.passed);
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/documentation-review.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
status: ledger.status,
reviewer: ledger.reviewer,
standard: ledger.standard,
evidenceReport: ledger.evidenceReport,
reportDigestValid,
results,
passed,
},
null,
2,
)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/quality/documentation-review.json",
schema: documentationReviewArtifactSchema,
value: {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
status: ledger.status,
reviewer: ledger.reviewer,
standard: ledger.standard,
evidenceReport: ledger.evidenceReport,
reportDigestValid,
results,
passed,
},
});
if (!passed) {
process.stderr.write(
"Documentation readiness: FAIL_UNVERIFIED (canonical scoped-review evidence is incomplete)\n",
+94
View File
@@ -0,0 +1,94 @@
import path from "node:path";
import { readdir } from "node:fs/promises";
import { PROMOTED_FILE_NAMES } from "./contracts/promotion-artifacts.ts";
import { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts";
import { verifyExactPromotionBundle } from "./lib/exact-promotion-bundle.ts";
import { readProviderTrust } from "./lib/provider-trust.ts";
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new TypeError(`exact promotion verification environment is missing ${name}`);
return value;
};
const rootArgument = process.argv.indexOf("--root");
const bundleRoot = path.resolve(
rootArgument >= 0
? process.argv[rootArgument + 1] ?? ""
: required("PROMOTION_BUNDLE_ROOT"),
);
if (rootArgument >= 0 && !process.argv[rootArgument + 1]) {
throw new TypeError("--root requires a promotion bundle directory");
}
const trustRoot = process.cwd();
const [vulnerabilityTrust, provenanceTrust] = await Promise.all([
readProviderTrust(
trustRoot,
required("VULNERABILITY_PUBLIC_KEY_PATH"),
required("VULNERABILITY_KEY_ID"),
),
readProviderTrust(
trustRoot,
required("PROVENANCE_PUBLIC_KEY_PATH"),
required("PROVENANCE_KEY_ID"),
),
]);
if (!vulnerabilityTrust || !provenanceTrust) {
throw new TypeError("exact promotion verification trust keys are invalid");
}
const names = (await readdir(bundleRoot)).sort(asciiCompare);
if (
JSON.stringify(names) !==
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
) {
throw new TypeError("promotion bundle directory must contain exactly the canonical five files");
}
const files = Object.fromEntries(
await Promise.all(
PROMOTED_FILE_NAMES.map(async (name) => [
name,
await readBoundedRegularFile({
root: bundleRoot,
relativePath: name,
maxBytes: name === "release-candidate.tar.gz" ? 268_435_456 : 16_777_216,
}),
] as const),
),
);
await verifyExactPromotionBundle(files, {
vulnerabilityTrust,
provenanceTrust,
expected: {
run: {
id: required("EXPECTED_PROMOTION_RUN_ID"),
attempt: requiredPositiveInteger("EXPECTED_PROMOTION_RUN_ATTEMPT"),
},
sourceRevision: required("EXPECTED_PROMOTION_SOURCE_REVISION"),
archiveSha256: required("EXPECTED_PROMOTION_ARCHIVE_SHA256"),
...optionalDigest("sourceSetSha256", "EXPECTED_PROMOTION_SOURCE_SET_SHA256"),
...optionalDigest("bundleSha256", "EXPECTED_PROMOTION_BUNDLE_SHA256"),
...optionalDigest("distSha256", "EXPECTED_PROMOTION_DIST_SHA256"),
...optionalDigest("lockfileSha256", "EXPECTED_PROMOTION_LOCKFILE_SHA256"),
},
});
process.stdout.write("Exact promotion bundle verification: PASS\n");
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function requiredPositiveInteger(name: string): number {
const value = required(name);
if (!/^[1-9][0-9]*$/u.test(value) || !Number.isSafeInteger(Number(value))) {
throw new TypeError(`${name} must be a positive safe integer`);
}
return Number(value);
}
function optionalDigest(
property: "sourceSetSha256" | "bundleSha256" | "distSha256" | "lockfileSha256",
environmentName: string,
): Readonly<Record<string, string>> {
const value = process.env[environmentName];
return value ? { [property]: value } : {};
}
+16 -17
View File
@@ -1,6 +1,8 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import { hostingHeadersArtifactSchema } from "./contracts/release-artifacts.ts";
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type Document = Record<string, unknown>;
type ResponseHeaders = Record<string, Record<string, string>>;
@@ -194,22 +196,19 @@ results.push({
const passed = results.every((result) => result.passed);
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/hosting-headers.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
mode,
baseUrl: liveTarget?.observedOrigin ?? null,
providerVerificationRequired: mode !== "live",
results,
passed,
},
null,
2,
)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/release/hosting-headers.json",
schema: hostingHeadersArtifactSchema,
value: {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
mode,
baseUrl: liveTarget?.observedOrigin ?? null,
providerVerificationRequired: mode !== "live",
results,
passed,
},
});
if (!passed) {
process.stderr.write(
-26
View File
@@ -1,26 +0,0 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const report = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: path.resolve(process.env.CANDIDATE_ROOT ?? process.cwd()),
providerEvidenceRoot: process.cwd(),
trustRoot: process.cwd(),
});
await mkdir("artifacts/security", { recursive: true });
await writeValidatedJsonArtifact({
path: "artifacts/security/provider-verification.json",
schema: providerVerificationArtifactSchema,
value: report,
});
if (report.status !== "PASS") {
process.stderr.write(
`Provider evidence is FAIL_UNVERIFIED:\n- ${report.failures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write("Provider evidence: PASS\n");
+20 -21
View File
@@ -1,9 +1,11 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { reproducibleBuildArtifactSchema } from "./contracts/release-artifacts.ts";
import { assertCiBuildEnvironment } from "./lib/build-environment.ts";
import { supplyChainDigest } from "./lib/supply-chain.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
assertCiBuildEnvironment(process.env);
@@ -54,26 +56,23 @@ const passed =
firstDigest === secondDigest;
await mkdir("artifacts/release", { recursive: true });
await writeFile(
"artifacts/release/reproducible-build.json",
`${JSON.stringify(
{
schemaVersion: 1,
sourceDateEpoch: deterministicEnvironment.SOURCE_DATE_EPOCH,
buildId: process.env.VITE_BUILD_ID ?? "local-build",
commitSha: process.env.VITE_COMMIT_SHA ?? "local",
releaseId: process.env.RELEASE_ID ?? "local-release",
runnerImage:
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
firstDigest,
secondDigest,
restored: restoreBuild.status === 0,
status: passed ? "PASS" : "FAIL",
},
null,
2,
)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/release/reproducible-build.json",
schema: reproducibleBuildArtifactSchema,
value: {
schemaVersion: 1,
sourceDateEpoch: deterministicEnvironment.SOURCE_DATE_EPOCH,
buildId: process.env.VITE_BUILD_ID ?? "local-build",
commitSha: process.env.VITE_COMMIT_SHA ?? "local",
releaseId: process.env.RELEASE_ID ?? "local-release",
runnerImage:
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
firstDigest,
secondDigest,
restored: restoreBuild.status === 0,
status: passed ? "PASS" : "FAIL",
},
});
if (!passed) {
process.stderr.write(
`Reproducible build failed: first=${firstDigest} second=${secondDigest}\n`,
-26
View File
@@ -1,26 +0,0 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const report = await verifyPromotionInputs({
artifactType: "promotion-verification",
repositoryRoot: path.resolve(process.env.CANDIDATE_ROOT ?? process.cwd()),
providerEvidenceRoot: process.cwd(),
trustRoot: process.cwd(),
});
await mkdir("artifacts/security", { recursive: true });
await writeValidatedJsonArtifact({
path: "artifacts/security/promotion-verification.json",
schema: providerVerificationArtifactSchema,
value: report,
});
if (report.status !== "PASS") {
process.stderr.write(
`Supply-chain promotion is FAIL_UNVERIFIED:\n- ${report.failures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write("Supply-chain promotion evidence: PASS\n");
+15 -16
View File
@@ -1,20 +1,19 @@
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import { automatedA11yArtifactSchema } from "./contracts/release-artifacts.ts";
import { MANUAL_A11Y_ROUTE_IDS } from "./lib/manual-a11y-evidence.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
await mkdir("artifacts/tests", { recursive: true });
await writeFile(
"artifacts/tests/a11y.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: MANUAL_A11Y_ROUTE_IDS,
threshold: { critical: 0, serious: 0 },
automatedStatus: "passed",
manualReview: "see artifacts/tests/a11y-manual/report.json",
},
null,
2,
)}\n`,
);
await writeValidatedJsonArtifact({
path: "artifacts/tests/a11y.json",
schema: automatedA11yArtifactSchema,
value: {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: MANUAL_A11Y_ROUTE_IDS,
threshold: { critical: 0, serious: 0 },
automatedStatus: "passed",
manualReview: "see artifacts/tests/a11y-manual/report.json",
},
});
@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
import { cp, mkdtemp, readFile, rm, symlink } from "node:fs/promises";
import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -119,6 +119,40 @@ it(
failures: [],
}),
);
const outsideRoot = await mkdtemp(path.join(tmpdir(), "security-followup-outside-"));
try {
await mkdir(path.join(outsideRoot, "config/security"), { recursive: true });
await writeFile(
path.join(outsideRoot, "config/security/dependency-policy.json"),
'{"contradictoryCheckoutCanary":"FAIL"}\n',
);
const outsideVerification = spawnSync(
process.execPath,
[
path.join(sourceRoot, "scripts/verify-archived-local-evidence.ts"),
"--archive",
archivePath,
"--sha256",
expectedSha256,
],
{
cwd: outsideRoot,
encoding: "utf8",
timeout: 120_000,
maxBuffer: 32 * 1024 * 1024,
},
);
expect(
outsideVerification.status,
`${outsideVerification.stdout}\n${outsideVerification.stderr}`,
).toBe(0);
expect(outsideVerification.stdout).toContain(
"Archived local evidence verification: PASS",
);
} finally {
await rm(outsideRoot, { recursive: true, force: true });
}
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
@@ -164,6 +164,35 @@ jobs:
artifacts/security/supply-chain-coherence.json \\
artifacts/security/supply-chain-verification.json \\
artifacts/security/vulnerability-report.json \\
config/security/dependency-baseline.approval.json \\
config/security/dependency-baseline.json \\
config/security/dependency-change-evidence.json \\
config/security/dependency-policy.json \\
config/security/secret-scan-policy.json \\
config/security/vulnerability-exceptions.json \\
config/security/vulnerability-policy.json \\
schemas/artifacts/build-manifest.schema.json \\
schemas/artifacts/dependency-inventory.schema.json \\
schemas/artifacts/supply-chain-verification.schema.json \\
scripts/contracts/release-artifacts.ts \\
scripts/create-release-candidate.ts \\
scripts/generate-supply-chain.ts \\
scripts/lib/build-manifest-outputs.ts \\
scripts/lib/json-schema.ts \\
scripts/lib/local-policy-evidence.ts \\
scripts/lib/local-release-evidence.ts \\
scripts/lib/release-candidate.ts \\
scripts/lib/release-input-evidence.ts \\
scripts/lib/release-runtime-coherence.ts \\
scripts/lib/repository-file-inventory.ts \\
scripts/lib/secret-scan-evaluator.ts \\
scripts/lib/secret-scan-policy.ts \\
scripts/lib/secret-scan.ts \\
scripts/lib/supply-chain.ts \\
scripts/lib/validated-json-artifact.ts \\
src/contracts/release-artifacts.ts \\
src/features/installed-contract-contributions.ts \\
src/features/installed-feature-contracts.ts \\
artifacts/release/release-candidate.json
node scripts/verify-ci-candidate-archive.ts --archive ".release/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --github-output "$GITHUB_OUTPUT"
- name: Upload release candidate
@@ -330,8 +359,10 @@ jobs:
PROMOTION_CLEANUP_TOKEN: \${{ steps.finalize.outputs.cleanup_token }}
PROMOTION_RUNNER_TEMP_DEV: \${{ steps.finalize.outputs.runner_temp_dev }}
PROMOTION_RUNNER_TEMP_INO: \${{ steps.finalize.outputs.runner_temp_ino }}
PROMOTION_STAGING_DEV: \${{ steps.finalize.outputs.staging_dev }}
PROMOTION_STAGING_INO: \${{ steps.finalize.outputs.staging_ino }}
run: |
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then
if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then
node scripts/cleanup-verified-promotion.ts
fi
@@ -0,0 +1,82 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import {
bundleOutputInventoryArtifactSchema,
bundlePerformanceArtifactSchema,
} from "../../scripts/contracts/release-artifacts.ts";
const sha256 = "a".repeat(64);
const inventory = {
schemaVersion: 1,
generatedAt: "2026-08-02T00:00:00.000Z",
context: {
nodeVersion: "v24.14.0",
packageManager: "pnpm@11.17.0",
runnerImage: "fixture@sha256:abc",
},
outputs: [
{ path: "dist/assets/entry.js", bytes: 100, gzipBytes: 50, sha256 },
{ path: "dist/assets/lazy.js", bytes: 80, gzipBytes: 40, sha256 },
{ path: "dist/index.html", bytes: 20, gzipBytes: 15, sha256 },
],
} as const;
const completed = {
...inventory,
measurements: {
initialJsGzipBytes: 50,
lazyChunks: [{ path: "assets/lazy.js", gzipBytes: 40 }],
},
classification: {
initialFiles: ["assets/entry.js"],
lazyFiles: ["assets/lazy.js"],
missingImports: [],
},
missingOutputs: [],
thresholds: { initialJsGzipBytes: 60, lazyChunkGzipBytes: 45 },
results: {
initialPassed: true,
lazyResults: [
{ path: "assets/lazy.js", gzipBytes: 40, threshold: 45, passed: true },
],
passed: true,
},
fixtures: [
{ name: "initial-js-over-budget", passed: true },
{ name: "lazy-chunk-over-budget", passed: true },
],
passed: true,
} as const;
describe("bundle artifact contracts", () => {
it("separates the raw output inventory from final budget evidence", () => {
expect(bundleOutputInventoryArtifactSchema.parse(inventory)).toEqual(inventory);
expect(() => bundleOutputInventoryArtifactSchema.parse(completed)).toThrow();
expect(bundlePerformanceArtifactSchema.parse(completed)).toEqual(completed);
expect(() => bundlePerformanceArtifactSchema.parse(inventory)).toThrow();
});
it.each([
["initial total", { measurements: { ...completed.measurements, initialJsGzipBytes: 49 } }],
["lazy classification", { classification: { ...completed.classification, lazyFiles: [] } }],
["missing output", { missingOutputs: ["assets/missing.js"], passed: true }],
["threshold result", { results: { ...completed.results, initialPassed: false } }],
["lazy result", { results: { ...completed.results, lazyResults: [] } }],
["fixture identity", { fixtures: [{ name: "other", passed: true }, completed.fixtures[1]] }],
["overall result", { passed: false }],
] as const)("rejects inconsistent %s evidence", (_name, mutation) => {
expect(() =>
bundlePerformanceArtifactSchema.parse({ ...completed, ...mutation })
).toThrow();
});
it("publishes final evidence only through the validated atomic writer", async () => {
const source = await readFile("scripts/check-bundle.ts", "utf8");
expect(source).toContain("bundleOutputInventoryArtifactSchema.parse");
expect(source).toContain("writeValidatedJsonArtifact");
expect(source).not.toMatch(/\bwriteFile\s*\(/u);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
describe("provider output limiter", () => {
it("counts secret-bearing bytes without retaining or forwarding them", async () => {
const { createProviderOutputLimiter } = await import(
"../../scripts/lib/provider-output-limiter.ts"
);
const exceeded = vi.fn();
const limiter = createProviderOutputLimiter(64, exceeded);
const credential = Buffer.from("provider-credential-must-not-reach-ci");
const stdout = vi.spyOn(process.stdout, "write");
const stderr = vi.spyOn(process.stderr, "write");
try {
limiter.consume(credential);
expect(limiter.bytes()).toBe(credential.byteLength);
expect(exceeded).not.toHaveBeenCalled();
expect(stdout).not.toHaveBeenCalled();
expect(stderr).not.toHaveBeenCalled();
expect(JSON.stringify(limiter)).not.toContain(credential.toString("utf8"));
} finally {
stdout.mockRestore();
stderr.mockRestore();
}
});
it("signals once when aggregate stdout and stderr exceed the byte budget", async () => {
const { createProviderOutputLimiter } = await import(
"../../scripts/lib/provider-output-limiter.ts"
);
const exceeded = vi.fn();
const limiter = createProviderOutputLimiter(5, exceeded);
limiter.consume(Buffer.from("abc"));
limiter.consume("def");
limiter.consume("ignored");
expect(limiter.bytes()).toBe(6);
expect(exceeded).toHaveBeenCalledTimes(1);
});
});
File diff suppressed because it is too large Load Diff
+9 -264
View File
@@ -24,7 +24,6 @@ import {
evaluatePromotionEvidence,
providerEvidenceSignaturePayload,
providerPublicKeyFingerprint,
providerVerificationArtifactSchema,
} from "../../scripts/lib/provider-evidence.ts";
import {
createReleaseCandidateManifest,
@@ -32,7 +31,6 @@ import {
verifyReleaseCandidate,
} from "../../scripts/lib/release-candidate.ts";
import { deterministicSupplyChainGeneratedAt } from "../../scripts/lib/supply-chain-time.ts";
import { verifyPromotionInputs } from "../../scripts/lib/promotion-verifier.ts";
import {
indexCiGateContract,
loadCiGateContract,
@@ -57,11 +55,6 @@ const sourceIdentity = Object.freeze({
revision: "a".repeat(40),
sourceSetSha256: "b".repeat(64),
});
const localIdentity = Object.freeze({
sourceRevision: sourceIdentity.revision,
sourceSetSha256: sourceIdentity.sourceSetSha256,
assessmentSha256: "c".repeat(64),
});
const expectedProviderContext = Object.freeze({
run: Object.freeze({ id: "fixture-run", attempt: 1 }),
source: sourceIdentity,
@@ -73,6 +66,14 @@ const expectedProviderContext = Object.freeze({
}),
vulnerabilityInvocationNonce: "5".repeat(64),
provenanceInvocationNonce: "6".repeat(64),
secretScanAttestation: Object.freeze({
status: "PASS" as const,
localEvidenceAssessmentSha256: "7".repeat(64),
sourceSetSha256: sourceIdentity.sourceSetSha256,
policySha256: "8".repeat(64),
sarifSha256: "9".repeat(64),
scanInputSha256: "a".repeat(64),
}),
});
function signedProviderEvidence(
@@ -118,6 +119,7 @@ function providerPair(input: Readonly<{
run: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.vulnerabilityInvocationNonce },
source: expectedProviderContext.source,
candidate,
secretScanAttestation: expectedProviderContext.secretScanAttestation,
findings: [],
}, "fixture-vulnerability-key", input.vulnerabilityKeys.privateKey, vulnerabilityFingerprint),
provenanceAttestation: signedProviderEvidence({
@@ -143,264 +145,7 @@ function providerTrust(
return { keyId, publicKey, publicKeyFingerprint };
}
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,
candidate: Awaited<ReturnType<typeof createReleaseCandidateManifest>>,
overrides: Readonly<{ distSha256?: string }> = {},
) {
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const archiveBytes = "fixture archive\n";
const candidateIdentity = {
archiveSha256: createHash("sha256").update(archiveBytes).digest("hex"),
bundleSha256: candidate.bundleSha256,
distSha256: overrides.distSha256 ?? candidate.distSha256,
lockfileSha256: candidate.lockfileSha256,
};
const vulnerabilityReport = signedProviderEvidence(
{
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "fixture-vulnerability-provider",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { id: "fixture-run", attempt: 1, invocationNonce: "5".repeat(64) },
source: sourceIdentity,
candidate: candidateIdentity,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
);
const provenanceAttestation = signedProviderEvidence(
{
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { id: "fixture-run", attempt: 1, invocationNonce: "6".repeat(64) },
source: sourceIdentity,
candidate: candidateIdentity,
subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
providerPublicKeyFingerprint(provenanceKeys.publicKey),
);
await mkdir(path.join(root, "provider"), { recursive: true });
await Promise.all([
writeFile(path.join(root, "provider/candidate.tar.gz"), archiveBytes),
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 {
CANDIDATE_ARCHIVE_PATH: "provider/candidate.tar.gz",
CANDIDATE_ARCHIVE_SHA256: createHash("sha256")
.update(archiveBytes)
.digest("hex"),
CI_RUN_ID: "fixture-run",
CI_RUN_ATTEMPT: "1",
EXPECTED_SOURCE_REVISION: sourceIdentity.revision,
VULNERABILITY_INVOCATION_NONCE: "5".repeat(64),
PROVENANCE_INVOCATION_NONCE: "6".repeat(64),
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("emits a strict role-bound v3 verification record from exact input bytes", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v3-"));
try {
const manifest = await createMinimalCandidateTree(root);
const environment = await writeProviderEnvironment(root, manifest);
const report = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment,
verifyLocalEvidence: async () => ({
status: "PASS" as const,
identity: localIdentity,
failures: [] as const,
}),
nowEpochMs: () => NOW,
});
expect(providerVerificationArtifactSchema.parse(report)).toEqual(
expect.objectContaining({
schemaVersion: 3,
artifactType: "provider-verification",
candidate: expect.objectContaining({
archiveSha256: environment.CANDIDATE_ARCHIVE_SHA256,
}),
providerEvidence: expect.objectContaining({
vulnerabilityReportSha256: createHash("sha256")
.update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!)))
.digest("hex"),
provenanceAttestationSha256: createHash("sha256")
.update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!)))
.digest("hex"),
}),
}),
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
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);
const acceptLocalEvidence = async () => ({
status: "PASS" as const,
identity: localIdentity,
failures: [] as const,
});
const valid = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
const absent = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment: {},
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
const replayedNonce = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment: {
...validEnvironment,
VULNERABILITY_INVOCATION_NONCE: "9".repeat(64),
},
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
const wrongEnvironment = await writeProviderEnvironment(root, manifest, {
distSha256: "3".repeat(64),
});
const wrongDigest = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment: wrongEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
const postAttestationMutation = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
expect({
valid: valid.status,
absent: absent.status,
wrongDigest: wrongDigest.status,
replayedNonce: replayedNonce.status,
postAttestationMutation: postAttestationMutation.status,
}).toEqual({
valid: "PASS",
absent: "FAIL_UNVERIFIED",
wrongDigest: "FAIL_UNVERIFIED",
replayedNonce: "FAIL_UNVERIFIED",
postAttestationMutation: "FAIL_UNVERIFIED",
});
expect(replayedNonce.failures).toContain("vulnerability report invocation nonce mismatch");
} 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);
const localVerificationPath = path.join(
root,
"artifacts/security/supply-chain-verification.json",
);
const before = await readFile(localVerificationPath, "utf8");
const result = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment,
nowEpochMs: () => NOW,
});
expect(result.status).toBe("FAIL_UNVERIFIED");
expect(result.failures).toEqual(
expect.arrayContaining([
"local evidence assessment is missing or invalid",
"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",
@@ -0,0 +1,213 @@
import { spawn } from "node:child_process";
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
loadCiGateContract,
parseCiGateContract,
} from "../../scripts/contracts/ci-gates.ts";
import { generateCiWorkflow } from "../../scripts/generate-ci-workflow.ts";
import { validatePackageScriptGraph } from "../../scripts/lib/package-script-graph.ts";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("selective Task 3 contract closure", () => {
it("builds a private offline aggregate-cgroup provider launch without argv secrets", async () => {
const {
encodeProviderBwrapInput,
encodeProviderScopeFrame,
formatProviderCgroupUnitName,
systemctlKillProviderArguments,
systemdRunProviderArguments,
} = await import("../../scripts/lib/provider-cgroup.ts");
const unit = formatProviderCgroupUnitName(
"vulnerability",
42,
"0123456789abcdef01234567",
);
const command = "node provider.mjs --token command-secret";
const credential = "credential-secret";
const launch = systemdRunProviderArguments(
unit,
1_800_000,
1_200,
"/trusted/node",
"/workspace/scripts/lib/provider-scope-wrapper.ts",
"/exact/report.json",
12,
34,
);
expect(launch).toEqual(expect.arrayContaining([
"--scope",
"--property=MemoryMax=1073741824",
"--property=MemorySwapMax=0",
"--property=TasksMax=64",
"--property=CPUQuota=100%",
"--property=KillMode=control-group",
"/trusted/node",
"/workspace/scripts/lib/provider-scope-wrapper.ts",
]));
expect(launch.join("\0")).not.toContain(command);
expect(launch.join("\0")).not.toContain(credential);
expect(
encodeProviderBwrapInput(
["--unshare-net", "--bind", "/exact/report.json", "/exact/report.json"],
{ PROVIDER_COMMAND: command, PROVIDER_CREDENTIAL: credential },
),
).toEqual(expect.any(Buffer));
expect(systemctlKillProviderArguments(unit)).toEqual([
"--user",
"kill",
"--kill-whom=all",
"--signal=SIGKILL",
unit,
]);
const frame = encodeProviderScopeFrame({
bwrapInput: Buffer.from("private-bwrap-vector\0"),
reportPath: "/exact/report.json",
reportDev: 12,
reportIno: 34,
});
expect(frame.readUInt32BE(0)).toBe(frame.byteLength - 4);
expect(frame.subarray(4).toString("utf8")).toContain(
Buffer.from("private-bwrap-vector\0").toString("base64"),
);
expect(launch.join("\0")).not.toContain("private-bwrap-vector");
});
it("removes only the pinned raw inode during parent-loss cleanup", async () => {
const { cleanupOwnedProviderReport } = await import(
"../../scripts/lib/provider-raw-cleanup.ts"
);
const root = await mkdtemp(path.join(tmpdir(), "provider-raw-cleanup-"));
roots.push(root);
const reportPath = path.join(root, "raw.json");
const originalPath = path.join(root, "original.json");
await writeFile(reportPath, "owned\n");
const identity = await lstat(reportPath);
await rename(reportPath, originalPath);
await writeFile(reportPath, "unrelated\n");
await expect(cleanupOwnedProviderReport({
reportPath,
reportDev: identity.dev,
reportIno: identity.ino,
})).resolves.toBe(false);
await expect(readFile(reportPath, "utf8")).resolves.toBe("unrelated\n");
await rm(reportPath);
await rename(originalPath, reportPath);
await expect(cleanupOwnedProviderReport({
reportPath,
reportDev: identity.dev,
reportIno: identity.ino,
})).resolves.toBe(true);
await expect(lstat(reportPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(readdir(root)).resolves.toEqual([]);
});
it("uses early liveness EOF to clean the pinned raw file without waiting for a command frame", async () => {
const root = await mkdtemp(path.join(tmpdir(), "provider-scope-eof-"));
roots.push(root);
const reportPath = path.join(root, "raw.json");
await writeFile(reportPath, "partial\n");
const identity = await lstat(reportPath);
const child = spawn(process.execPath, [
path.resolve("scripts/lib/provider-scope-wrapper.ts"),
"1",
reportPath,
String(identity.dev),
String(identity.ino),
], { stdio: ["pipe", "pipe", "pipe"] });
const completion = waitForChildResult(child);
await new Promise<void>((resolve) => setTimeout(resolve, 75));
expect(child.exitCode).toBeNull();
await expect(readFile(reportPath, "utf8")).resolves.toBe("partial\n");
child.stdin.end();
const result = await within(completion, 1_000, "provider scope EOF close");
expect(result).toEqual({ code: 125, signal: null });
await expect(lstat(reportPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(readdir(root)).resolves.toEqual([]);
});
it("tracks npm run-script dependencies instead of bypassing the graph", () => {
expect(validatePackageScriptGraph({ root: "npm run-script missing" }, "root"))
.toContain("package script missing: root -> missing");
});
it("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
const canonical = await loadCiGateContract(process.cwd());
expect(canonical.gates).toHaveLength(26);
expect(canonical.commands).toHaveLength(81);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(93);
expect(canonical.artifacts).toHaveLength(105);
expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5);
const orphan = JSON.parse(JSON.stringify(canonical)) as Record<string, any>;
orphan.retention.classes.push({ id: "unused", policy: "never referenced" });
expect(() => parseCiGateContract(orphan)).toThrow(/five canonical retention|orphan retention/u);
});
it("rejects the retired validate-candidate-archive grammar", async () => {
const canonical = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
canonical.jobs.find((job: Record<string, any>) => job.id === "vulnerability_provider")
.steps.splice(4, 0, {
kind: "validate-candidate-archive",
archivePath: ".release/candidate/release-candidate.tar.gz",
});
expect(() => parseCiGateContract(canonical)).toThrow(
/invalid discriminator|forbidden|canonical job step sequence/iu,
);
});
it("publishes a generated workflow as exactly 0644 under a restrictive umask", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-workflow-mode-"));
roots.push(root);
await mkdir(path.join(root, ".gitea/workflows"), { recursive: true });
const previous = process.umask(0o777);
try {
const contract = await loadCiGateContract(process.cwd());
await generateCiWorkflow({ root, contract, check: false });
} finally {
process.umask(previous);
}
const target = path.join(root, ".gitea/workflows/quality-gates.yml");
const metadata = await lstat(target);
expect(metadata.mode & 0o777).toBe(0o644);
expect((await readFile(target, "utf8")).startsWith("# GENERATED FILE")).toBe(true);
});
});
async function waitForChildResult(
child: ReturnType<typeof spawn>,
): Promise<Readonly<{ code: number | null; signal: NodeJS.Signals | null }>> {
return await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => resolve({ code, signal }));
});
}
async function within<T>(operation: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
@@ -42,6 +42,25 @@ afterEach(async () => {
});
describe("validated JSON artifact writer", () => {
it("returns the exact schema-validated bytes used by durable publication", async () => {
const artifact = await import("../../scripts/lib/validated-json-artifact.ts") as
Record<string, unknown>;
expect(artifact.serializeValidatedJsonArtifact).toBeTypeOf("function");
const serializeValidatedJsonArtifact = artifact.serializeValidatedJsonArtifact as (
input: Readonly<{ path: string; schema: z.ZodType; value: unknown }>,
) => Buffer;
expect(serializeValidatedJsonArtifact({
path: "/unused/artifact.json",
schema: z.object({ value: z.string() }).strict(),
value: { value: "sealed" },
})).toEqual(Buffer.from('{\n "value": "sealed"\n}\n'));
expect(() => serializeValidatedJsonArtifact({
path: "/unused/artifact.json",
schema: z.object({ value: z.string() }).strict(),
value: { value: 7 },
})).toThrow();
});
it("syncs an O_NOFOLLOW exclusive temp and its directory around rename", async () => {
const events: string[] = [];
let openFlags = 0;