refactor: adapter 구현중..
This commit is contained in:
@@ -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
@@ -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": [],
|
||||
|
||||
@@ -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
@@ -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 H–L 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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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: ${[
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
@@ -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`,
|
||||
|
||||
@@ -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");
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user