From d6458dcd9f2805b7cf5252081c6b6b97132684bf Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 00:40:41 -0400 Subject: [PATCH 01/14] feat(release): signed managed-release pipeline and policy-enforcing CI Lands the release slice of the managed-cloud implementation verbatim: scripts/release/ (deterministic build/verify, signed guest-image and offline host-package builders, tag-bound version resolution, fail-closed CI authorization, and their tests) plus the release-artifacts workflow. The release policy requires an exact CI job matrix, so ci.yml is reshaped to it: the go job now tests nehemiahd, the guest agent, and the public gateway (with govulncheck); the workspace job gains the npm audit gate, the release policy checks, a root build, and the Postgres-backed control-plane migration replay + full suite; the new wire-contract job checks generated model drift (bringing scripts/openapi-models.mjs); shell now also lints authorize-ci.sh. Two workspace steps whose slices have not landed yet (live-harness syntax, deploy promotion policy) are deferred to those slices. Lockfile bumps via npm audit fix satisfy the new audit-level=high gate. Co-authored-by: Cursor --- .github/workflows/ci.yml | 108 +- .github/workflows/release.yml | 557 ++++++++ .gitignore | 1 + package-lock.json | 68 +- scripts/openapi-models.mjs | 62 + scripts/release/authorize-ci.sh | 72 + scripts/release/build-guest-images.sh | 314 +++++ .../release/build-managed-host-packages.sh | 8 + scripts/release/build.mjs | 674 +++++++++ scripts/release/check.mjs | 892 ++++++++++++ scripts/release/ci-policy.mjs | 393 ++++++ .../release/fetch-managed-runtime-assets.sh | 100 ++ scripts/release/formula.mjs | 43 + scripts/release/guest-images/Dockerfile | 130 ++ .../apply-python-runtime-overlays.py | 230 ++++ .../release/guest-images/assemble-rootfs.sh | 129 ++ scripts/release/guest-images/desktop-init | 76 ++ scripts/release/guest-images/headless-init | 53 + .../guest-images/inspect-guest-image.sh | 150 ++ scripts/release/guest-images/policy.json | 174 +++ .../prepare-vulnerability-scanner.sh | 106 ++ .../release/guest-images/scan-final-rootfs.sh | 113 ++ .../guest-images/verify-scan-evidence.mjs | 215 +++ .../guest-images/vulnerability-allowlist.json | 4 + .../release/inspect-managed-host-packages.sh | 8 + .../release/inspect-managed-runtime-assets.sh | 99 ++ scripts/release/lib.mjs | 1212 +++++++++++++++++ .../release/managed-host-packages-policy.json | 69 + scripts/release/managed-runtime-policy.json | 45 + scripts/release/managed_host_packages.py | 756 ++++++++++ scripts/release/resolve-version.mjs | 33 + scripts/release/templates/nehemiah.rb.tpl | 19 + scripts/release/test/ci-policy.test.mjs | 246 ++++ scripts/release/test/release.test.mjs | 559 ++++++++ scripts/release/verify-ci.mjs | 81 ++ scripts/release/verify-tag.mjs | 28 + scripts/release/verify.mjs | 19 + 37 files changed, 7799 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/openapi-models.mjs create mode 100755 scripts/release/authorize-ci.sh create mode 100755 scripts/release/build-guest-images.sh create mode 100755 scripts/release/build-managed-host-packages.sh create mode 100644 scripts/release/build.mjs create mode 100644 scripts/release/check.mjs create mode 100644 scripts/release/ci-policy.mjs create mode 100755 scripts/release/fetch-managed-runtime-assets.sh create mode 100644 scripts/release/formula.mjs create mode 100644 scripts/release/guest-images/Dockerfile create mode 100644 scripts/release/guest-images/apply-python-runtime-overlays.py create mode 100755 scripts/release/guest-images/assemble-rootfs.sh create mode 100755 scripts/release/guest-images/desktop-init create mode 100755 scripts/release/guest-images/headless-init create mode 100755 scripts/release/guest-images/inspect-guest-image.sh create mode 100644 scripts/release/guest-images/policy.json create mode 100755 scripts/release/guest-images/prepare-vulnerability-scanner.sh create mode 100755 scripts/release/guest-images/scan-final-rootfs.sh create mode 100755 scripts/release/guest-images/verify-scan-evidence.mjs create mode 100644 scripts/release/guest-images/vulnerability-allowlist.json create mode 100755 scripts/release/inspect-managed-host-packages.sh create mode 100755 scripts/release/inspect-managed-runtime-assets.sh create mode 100644 scripts/release/lib.mjs create mode 100644 scripts/release/managed-host-packages-policy.json create mode 100644 scripts/release/managed-runtime-policy.json create mode 100755 scripts/release/managed_host_packages.py create mode 100644 scripts/release/resolve-version.mjs create mode 100644 scripts/release/templates/nehemiah.rb.tpl create mode 100644 scripts/release/test/ci-policy.test.mjs create mode 100644 scripts/release/test/release.test.mjs create mode 100644 scripts/release/verify-ci.mjs create mode 100644 scripts/release/verify-tag.mjs create mode 100644 scripts/release/verify.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8e4d41..9ba9d22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,47 +7,129 @@ on: jobs: go: - name: nehemiahd (vet + build, amd64 + arm64) + name: host + guest agents (test, vet, build) runs-on: ubuntu-latest defaults: run: working-directory: nehemiahd steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: nehemiahd/go.mod - cache-dependency-path: nehemiahd/go.sum + cache-dependency-path: | + nehemiahd/go.sum + guest-agent/go.sum + - run: go test -race ./... - run: go vet ./... + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... - run: go build ./... # The infra ships to x86_64 and arm64 hosts (and the Mac/Lima local path # is arm64) — keep both compiling. - run: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build ./... - run: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./... + - name: Test and vet guest agent + working-directory: guest-agent + run: go test -race ./... && go vet ./... && go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + - name: Build guest agent (amd64 + arm64) + working-directory: guest-agent + run: | + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./... + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build ./... + - name: Test, vet, and build public gateway + working-directory: gateway + run: go test -race ./... && go vet ./... && go build ./... && go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... - web: - name: web + sdk (check, lint, tests) + workspace: + name: workspace (check, lint, test, build) runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: nehemiah_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d nehemiah_ci" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: npm - run: npm ci + - run: npm audit --audit-level=high + - name: Check release authorization and artifact policy + run: | + set -euo pipefail + find scripts/release -type f -name '*.mjs' -print0 | sort -z | xargs -0 -n1 node --check + node --test scripts/release/test/*.test.mjs + node scripts/release/check.mjs + npx --no-install prettier --check .github/workflows/ci.yml .github/workflows/release.yml 'scripts/release/**/*.mjs' docs/nehemiah/distribution.md # The web workspace's vitest browser-mode project drives real chromium. - run: npx playwright install --with-deps chromium - run: npm run check - run: npm run lint - - run: npm test -w nehemiah-sdk - - run: npm test -w web - - run: npm run build -w nehemiah-sdk + - run: npm test + - run: npm run build + - name: Apply control-plane migration to a fresh PostgreSQL database + # The second pass proves migrations are replay-safe and that every + # applied file has the expected immutable checksum. + run: npm run migrate -w @nehemiah/nehemiah && npm run migrate -w @nehemiah/nehemiah + env: + DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/nehemiah_ci + # Run the complete control-plane suite with DATABASE_URL set. Tests that + # are safely skipped during a no-database developer run (tenant HTTP, + # lifecycle, fork, volume, quota, and usage integration) must execute in CI. + # PostgreSQL fixtures deliberately share the one freshly migrated database; + # serialize files so one suite's global worker/cleanup cannot mutate another + # suite's tenant while it is asserting append-only audit and usage records. + - name: Run complete control-plane suite against PostgreSQL + run: npm test -w @nehemiah/nehemiah -- --no-file-parallelism + env: + DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/nehemiah_ci + NEHEMIAH_DB_ROLE_TEST: "1" + + wire-contract: + name: generated wire contract (drift, type-check, compile) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.19.0 + cache: npm + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: 3.12.12 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: 1.25.0 + cache: false + - run: npm ci + - run: npm -w apps/nehemiah run build + - name: Check generated model drift + run: node scripts/openapi-models.mjs --check + - name: Type-check generated TypeScript model + run: npx tsc --noEmit --target ES2023 --module NodeNext --moduleResolution NodeNext generated/openapi/typescript/models.ts + - name: Compile generated Python model + run: python -m py_compile generated/openapi/python/models.py + - name: Format and test generated Go model + run: | + gofmt -d generated/openapi/go/models.go | tee "$RUNNER_TEMP/openapi-models-gofmt.diff" + test ! -s "$RUNNER_TEMP/openapi-models-gofmt.diff" + GO111MODULE=off go test ./generated/openapi/go shell: name: infra scripts (shellcheck) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # Errors only — the scripts are heavy on intentional word-splitting and # ssh-heredoc patterns that trip stylistic levels. - - run: shellcheck -S error infra/*.sh infra/latitude/*.sh infra/local/*.sh + - run: shellcheck -S error infra/*.sh infra/latitude/*.sh infra/local/*.sh scripts/release/authorize-ci.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2c7ba91 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,557 @@ +name: Release artifacts + +on: + pull_request: + paths: + - ".github/workflows/release.yml" + - "scripts/release/**" + - "docs/nehemiah/distribution.md" + - "infra/latitude/**" + - "packages/cli/**" + - "packages/sdk/**" + - "nehemiahd/**" + - "guest-agent/**" + - "gateway/**" + - "package.json" + - "package-lock.json" + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: Exact package version to build without publishing + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + name: Validate release inputs + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Check out the source commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: npm + + - name: Install locked Node dependencies + run: npm ci + + - name: Check release scripts and workflow policy + run: | + set -euo pipefail + find scripts/release -type f -name '*.mjs' -print0 | sort -z | xargs -0 -n1 node --check + node --test scripts/release/test/*.test.mjs + node scripts/release/check.mjs + bash infra/latitude/test/managed-provisioning.test.sh + npx --no-install prettier --check .github/workflows/ci.yml .github/workflows/release.yml 'scripts/release/**/*.mjs' docs/nehemiah/distribution.md infra/latitude/README.md + + - name: Check CLI and SDK + run: | + npm run check --workspace nehemiah-sdk + npm run test --workspace nehemiah-sdk + npm run check --workspace nehemiah-cli + npm run test --workspace nehemiah-cli + + - name: Resolve exact release version + id: version + env: + RELEASE_INPUT_VERSION: ${{ inputs.version }} + run: node scripts/release/resolve-version.mjs + + - name: Verify signed annotated tag before build + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + evidence_dir="$(mktemp -d)" + trap 'rm -rf "$evidence_dir"' EXIT + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${GITHUB_REF_NAME}" > "$evidence_dir/ref.json" + tag_object="$(jq -er '.object.sha' "$evidence_dir/ref.json")" + gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" > "$evidence_dir/tag.json" + node scripts/release/verify-tag.mjs \ + --ref "$evidence_dir/ref.json" \ + --tag "$evidence_dir/tag.json" \ + --expected-tag "$GITHUB_REF_NAME" \ + --expected-commit "$(git rev-parse HEAD)" + + - name: Authorize protected default-branch CI for the exact tag commit + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: scripts/release/authorize-ci.sh + + - name: Require immutable GitHub Releases before build + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + test "$(gh api "repos/${GITHUB_REPOSITORY}/immutable-releases" --jq '.enabled')" = "true" + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "release $GITHUB_REF_NAME already exists" >&2 + exit 1 + fi + + build: + name: Build deterministic release artifacts + needs: + - validate + - guest-images + - host-packages + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the source commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: npm + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: nehemiahd/go.mod + cache-dependency-path: | + nehemiahd/go.sum + guest-agent/go.sum + gateway/go.sum + + - name: Install locked Node dependencies + run: npm ci + + - name: Download deterministic guest images + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-guest-images-* + path: guest-images + merge-multiple: true + + - name: Download deterministic managed-host package repositories + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-host-packages-* + path: host-packages + merge-multiple: true + + - name: Fetch and inspect retained managed runtime assets + run: scripts/release/fetch-managed-runtime-assets.sh managed-runtime-assets + + - name: Build static binaries and CLI package + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + node scripts/release/build.mjs \ + --version "$RELEASE_VERSION" \ + --out release-dist \ + --source-date-epoch "$(git show -s --format=%ct HEAD)" \ + --commit "$(git rev-parse HEAD)" \ + --repository "$GITHUB_REPOSITORY" \ + --guest-images guest-images \ + --host-packages host-packages \ + --runtime-assets managed-runtime-assets + + node scripts/release/build.mjs \ + --version "$RELEASE_VERSION" \ + --out release-dist-repeat \ + --source-date-epoch "$(git show -s --format=%ct HEAD)" \ + --commit "$(git rev-parse HEAD)" \ + --repository "$GITHUB_REPOSITORY" \ + --guest-images guest-images \ + --host-packages host-packages \ + --runtime-assets managed-runtime-assets + diff --recursive --no-dereference release-dist release-dist-repeat + rm -rf release-dist-repeat + + - name: Verify immutable checksum set + run: node scripts/release/verify.mjs --directory release-dist + + - name: Retain build-only artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-build-${{ needs.validate.outputs.version }} + path: release-dist/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 + + guest-images: + name: Build ${{ matrix.arch }} signed guest images + needs: validate + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + permissions: + contents: read + steps: + - name: Check out the source commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: guest-agent/go.mod + cache-dependency-path: guest-agent/go.sum + + - name: Build native static guest agent + run: | + set -euo pipefail + mkdir -p guest-image-input + env CGO_ENABLED=0 GOOS=linux GOARCH="${{ matrix.arch }}" \ + GOFLAGS=-mod=readonly \ + go build -trimpath -buildvcs=false -ldflags='-s -w -buildid=' \ + -o guest-image-input/bc-guest-agent ./guest-agent + + - name: Build and inspect immutable guest images twice + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + scripts/release/build-guest-images.sh \ + --version "$RELEASE_VERSION" \ + --arch "${{ matrix.arch }}" \ + --output guest-images \ + --source-date-epoch "$(git show -s --format=%ct HEAD)" \ + --guest-agent guest-image-input/bc-guest-agent + + - name: Retain exact guest image inputs + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-guest-images-${{ matrix.arch }} + path: | + guest-images/*.ext4.gz + guest-images/*.json + if-no-files-found: error + compression-level: 0 + retention-days: 14 + + host-packages: + name: Build ${{ matrix.arch }} offline managed-host packages + needs: validate + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + contents: read + steps: + - name: Check out the source commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Build and inspect the immutable package closure twice + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + mkdir host-packages host-packages-repeat + artifact="nehemiah-host-packages_${RELEASE_VERSION}_ubuntu24.04_linux_${{ matrix.arch }}.tar.gz" + for output in host-packages host-packages-repeat; do + scripts/release/build-managed-host-packages.sh \ + --version "$RELEASE_VERSION" \ + --arch "${{ matrix.arch }}" \ + --source-date-epoch "$(git show -s --format=%ct HEAD)" \ + --output "$output/$artifact" + scripts/release/inspect-managed-host-packages.sh \ + --archive "$output/$artifact" \ + --version "$RELEASE_VERSION" \ + --arch "${{ matrix.arch }}" + done + cmp --silent "host-packages/$artifact" "host-packages-repeat/$artifact" + + wrong_arch=arm64 + if [[ "${{ matrix.arch }}" == arm64 ]]; then + wrong_arch=amd64 + fi + if scripts/release/inspect-managed-host-packages.sh \ + --archive "host-packages/$artifact" \ + --version "$RELEASE_VERSION" \ + --arch "$wrong_arch"; then + echo "package inspector accepted the wrong architecture" >&2 + exit 1 + fi + wrong_version=0.0.0 + if [[ "$RELEASE_VERSION" == 0.0.0 ]]; then + wrong_version=0.0.1 + fi + if scripts/release/inspect-managed-host-packages.sh \ + --archive "host-packages/$artifact" \ + --version "$wrong_version" \ + --arch "${{ matrix.arch }}"; then + echo "package inspector accepted the wrong release version" >&2 + exit 1 + fi + cp "host-packages/$artifact" host-packages-repeat/tampered.tar.gz + truncate --size=-1 host-packages-repeat/tampered.tar.gz + if scripts/release/inspect-managed-host-packages.sh \ + --archive host-packages-repeat/tampered.tar.gz \ + --version "$RELEASE_VERSION" \ + --arch "${{ matrix.arch }}"; then + echo "package inspector accepted a truncated archive" >&2 + exit 1 + fi + + - name: Retain exact managed-host package repository + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-host-packages-${{ matrix.arch }} + path: host-packages/*.tar.gz + if-no-files-found: error + compression-level: 0 + retention-days: 14 + + attest: + name: Attest tag-authorized artifacts + if: github.event_name == 'push' + needs: + - validate + - build + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + id-token: write + attestations: write + artifact-metadata: write + steps: + - name: Check out release verification code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Reauthorize protected default-branch CI before attestation + env: + GH_TOKEN: ${{ github.token }} + run: scripts/release/authorize-ci.sh + + - name: Download checksummed build + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-build-${{ needs.validate.outputs.version }} + path: release-dist + + - name: Verify immutable checksum set before signing + run: node scripts/release/verify.mjs --directory release-dist + + - name: Attest every checksummed artifact and manifest + id: attest_artifacts + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-checksums: release-dist/SHA256SUMS + + - name: Attest checksum metadata + id: attest_checksums + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: release-dist/SHA256SUMS + + - name: Bundle offline provenance + run: | + cp "${{ steps.attest_artifacts.outputs.bundle-path }}" release-dist/artifact-provenance.sigstore.json + cp "${{ steps.attest_checksums.outputs.bundle-path }}" release-dist/checksums-provenance.sigstore.json + + - name: Verify the exact attested payload + run: >- + node scripts/release/verify.mjs + --directory release-dist + --allow-unchecksummed artifact-provenance.sigstore.json,checksums-provenance.sigstore.json + + - name: Retain attested release payload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: attested-release-${{ needs.validate.outputs.version }} + path: release-dist/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 + + release: + name: Publish signed immutable GitHub Release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: + - validate + - attest + runs-on: ubuntu-latest + environment: release + permissions: + actions: read + contents: write + attestations: read + steps: + - name: Check out the authorized tag + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Reauthorize protected default-branch CI before signing + env: + GH_TOKEN: ${{ github.token }} + run: scripts/release/authorize-ci.sh + + - name: Download attested payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: attested-release-${{ needs.validate.outputs.version }} + path: release-dist + + - name: Verify checksums, tag signature, and provenance before publish + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + node scripts/release/verify.mjs \ + --directory release-dist \ + --allow-unchecksummed artifact-provenance.sigstore.json,checksums-provenance.sigstore.json + + evidence_dir="$(mktemp -d)" + trap 'rm -rf "$evidence_dir"' EXIT + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${GITHUB_REF_NAME}" > "$evidence_dir/ref.json" + tag_object="$(jq -er '.object.sha' "$evidence_dir/ref.json")" + gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" > "$evidence_dir/tag.json" + node scripts/release/verify-tag.mjs \ + --ref "$evidence_dir/ref.json" \ + --tag "$evidence_dir/tag.json" \ + --expected-tag "$GITHUB_REF_NAME" \ + --expected-commit "$(git rev-parse HEAD)" + test "$(gh api "repos/${GITHUB_REPOSITORY}/immutable-releases" --jq '.enabled')" = "true" + + while read -r _ artifact; do + gh attestation verify "release-dist/$artifact" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \ + --source-ref "$GITHUB_REF" \ + --deny-self-hosted-runners + done < release-dist/SHA256SUMS + gh attestation verify release-dist/SHA256SUMS \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \ + --source-ref "$GITHUB_REF" \ + --deny-self-hosted-runners + + - name: Sign checksum metadata for managed hosts + env: + MINISIGN_SECRET_KEY_B64: ${{ secrets.NEHEMIAH_RELEASE_MINISIGN_SECRET_KEY_B64 }} + MINISIGN_PUBLIC_KEY: ${{ vars.NEHEMIAH_RELEASE_MINISIGN_PUBLIC_KEY }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + set +x + : "${MINISIGN_SECRET_KEY_B64:?release environment is missing the Minisign secret key}" + [[ "$MINISIGN_PUBLIC_KEY" =~ ^RW[A-Za-z0-9+/]{54}$ ]] || { + echo "release environment has an invalid Minisign public key" >&2 + exit 1 + } + + signing_dir="$(mktemp -d)" + cleanup_signing_key() { + shred -u "$signing_dir/minisign.key" 2>/dev/null || true + rm -rf "$signing_dir" + } + trap cleanup_signing_key EXIT + + minisign_deb="$signing_dir/minisign_0.11-1_amd64.deb" + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + https://archive.ubuntu.com/ubuntu/pool/universe/m/minisign/minisign_0.11-1_amd64.deb \ + -o "$minisign_deb" + printf '%s %s\n' \ + 854c5f9dddaa99a02915f8cacd41e03442cb6cda25f7bbc53c0a3d297bcd064f \ + "$minisign_deb" | sha256sum --check --strict --status + sudo dpkg --install "$minisign_deb" + + printf '%s' "$MINISIGN_SECRET_KEY_B64" | base64 --decode \ + > "$signing_dir/minisign.key" + chmod 0600 "$signing_dir/minisign.key" + unset MINISIGN_SECRET_KEY_B64 + minisign -R -s "$signing_dir/minisign.key" -p "$signing_dir/minisign.pub" + [[ "$(tail -n 1 "$signing_dir/minisign.pub")" == "$MINISIGN_PUBLIC_KEY" ]] || { + echo "configured Minisign public key does not match the signing key" >&2 + exit 1 + } + minisign -S -W \ + -s "$signing_dir/minisign.key" \ + -m release-dist/SHA256SUMS \ + -x release-dist/SHA256SUMS.minisig \ + -c "Boring Computers release checksums" \ + -t "version=$RELEASE_VERSION commit=$GITHUB_SHA" + minisign -Vm release-dist/SHA256SUMS \ + -x release-dist/SHA256SUMS.minisig \ + -P "$MINISIGN_PUBLIC_KEY" + node scripts/release/verify.mjs \ + --directory release-dist \ + --allow-unchecksummed artifact-provenance.sigstore.json,checksums-provenance.sigstore.json,SHA256SUMS.minisig + + - name: Create immutable GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + node scripts/release/verify.mjs \ + --directory release-dist \ + --allow-unchecksummed artifact-provenance.sigstore.json,checksums-provenance.sigstore.json,SHA256SUMS.minisig + assets=( + release-dist/SHA256SUMS + release-dist/SHA256SUMS.minisig + release-dist/artifact-provenance.sigstore.json + release-dist/checksums-provenance.sigstore.json + ) + while read -r _ artifact; do + assets+=("release-dist/$artifact") + done < release-dist/SHA256SUMS + release_flags=() + if [[ "$RELEASE_VERSION" == *-* ]]; then + release_flags+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" "${assets[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --generate-notes \ + "${release_flags[@]}" + + - name: Verify signed immutable release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + for attempt in 1 2 3 4 5; do + if gh release verify "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY"; then + exit 0 + fi + sleep 2 + done + echo "GitHub did not produce a valid immutable release attestation" >&2 + exit 1 diff --git a/.gitignore b/.gitignore index 2d0804a..1a959c2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ test-results # SDK build output packages/sdk/dist/ +apps/nehemiah/dist/ # Claude runtime .claude/ diff --git a/package-lock.json b/package-lock.json index dedaac3..da52fc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1545,12 +1545,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -1708,12 +1708,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -3324,9 +3324,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.68.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.68.0.tgz", - "integrity": "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g==", + "version": "2.70.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz", + "integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4557,15 +4557,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/bytes": { @@ -5400,9 +5400,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -5681,9 +5681,9 @@ } }, "node_modules/hono": { - "version": "4.12.27", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", - "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -5778,9 +5778,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -6386,9 +6386,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -6802,9 +6802,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -6821,7 +6821,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7730,9 +7730,9 @@ } }, "node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/scripts/openapi-models.mjs b/scripts/openapi-models.mjs new file mode 100644 index 0000000..86e7acb --- /dev/null +++ b/scripts/openapi-models.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const check = process.argv.slice(2).includes("--check"); +const unknown = process.argv + .slice(2) + .filter((argument) => argument !== "--check"); +if (unknown.length) throw new Error(`unknown arguments: ${unknown.join(", ")}`); + +const loadBuiltModule = async (relativePath) => { + try { + return await import(pathToFileURL(resolve(root, relativePath)).href); + } catch (error) { + throw new Error( + `OpenAPI model generation requires a current control-plane build. Run npm -w apps/nehemiah run build first. (${String(error)})`, + ); + } +}; + +const { openApiDocument } = await loadBuiltModule( + "apps/nehemiah/dist/http/openapi.js", +); +const { generateOpenApiModels } = await loadBuiltModule( + "apps/nehemiah/dist/http/openapi-models.js", +); +const generated = generateOpenApiModels(openApiDocument); +const canonicalContents = (file) => { + if (file.path !== "generated/openapi/go/models.go") return file.contents; + try { + return execFileSync("gofmt", [], { + encoding: "utf8", + input: file.contents, + }); + } catch (error) { + throw new Error( + "OpenAPI Go model generation requires gofmt. Install the Go version documented in generated/openapi/README.md.", + { cause: error }, + ); + } +}; +let drift = false; +for (const file of generated) { + const destination = resolve(root, file.path); + const contents = canonicalContents(file); + if (check) { + const existing = await readFile(destination, "utf8").catch(() => undefined); + if (existing !== contents) { + process.stderr.write(`OpenAPI model drift: ${file.path}\n`); + drift = true; + } + continue; + } + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, contents, "utf8"); + process.stdout.write(`Generated ${file.path}\n`); +} +if (drift) process.exitCode = 1; diff --git a/scripts/release/authorize-ci.sh b/scripts/release/authorize-ci.sh new file mode 100755 index 0000000..ba9e572 --- /dev/null +++ b/scripts/release/authorize-ci.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Fail-closed GitHub release authorization. This is run both before release +# builds and again inside the protected release environment before signing. +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_SHA:?GITHUB_SHA is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +[[ "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "release repository is unsafe" >&2 + exit 1 +} +[[ "$GITHUB_SHA" =~ ^[0-9a-f]{40,64}$ ]] || { + echo "release commit is not a full Git object id" >&2 + exit 1 +} + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +repository_root="$(cd -- "$script_dir/../.." && pwd -P)" +evidence_dir="$(mktemp -d)" +cleanup() { + rm -rf -- "$evidence_dir" +} +trap cleanup EXIT +api_version="X-GitHub-Api-Version: 2022-11-28" + +gh api -H "$api_version" \ + "repos/${GITHUB_REPOSITORY}" > "$evidence_dir/repository.json" +gh api -H "$api_version" \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml" \ + > "$evidence_dir/workflow.json" +default_branch="$(jq -er '.default_branch | select(type == "string" and length > 0)' "$evidence_dir/repository.json")" +encoded_branch="$(jq -nr --arg value "$default_branch" '$value | @uri')" +gh api -H "$api_version" \ + "repos/${GITHUB_REPOSITORY}/branches/${encoded_branch}" \ + > "$evidence_dir/branch.json" +default_head="$(jq -er '.commit.sha | select(test("^[0-9a-f]{40,64}$"))' "$evidence_dir/branch.json")" +gh api -H "$api_version" \ + "repos/${GITHUB_REPOSITORY}/compare/${GITHUB_SHA}...${default_head}" \ + > "$evidence_dir/comparison.json" +gh api -H "$api_version" \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${GITHUB_SHA}&event=push&status=completed&per_page=100" \ + > "$evidence_dir/runs.json" + +verify_args=( + --repository "$GITHUB_REPOSITORY" + --commit "$GITHUB_SHA" + --repository-evidence "$evidence_dir/repository.json" + --workflow-evidence "$evidence_dir/workflow.json" + --branch-evidence "$evidence_dir/branch.json" + --comparison-evidence "$evidence_dir/comparison.json" + --runs-evidence "$evidence_dir/runs.json" +) +run_id="$(node "$script_dir/verify-ci.mjs" --phase select "${verify_args[@]}")" +[[ "$run_id" =~ ^[1-9][0-9]*$ ]] || { + echo "release CI verifier returned an unsafe run id" >&2 + exit 1 +} +gh api -H "$api_version" \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?filter=latest&per_page=100" \ + > "$evidence_dir/jobs.json" +node "$script_dir/verify-ci.mjs" \ + --phase verify \ + "${verify_args[@]}" \ + --jobs-evidence "$evidence_dir/jobs.json" + +# Keep this check last: it catches an unexpected invocation from outside the +# checked-out repository after all evidence paths have remained private. +[[ "$(git -C "$repository_root" rev-parse HEAD)" == "$GITHUB_SHA" ]] || { + echo "checked-out release commit changed during authorization" >&2 + exit 1 +} diff --git a/scripts/release/build-guest-images.sh b/scripts/release/build-guest-images.sh new file mode 100755 index 0000000..576dd2c --- /dev/null +++ b/scripts/release/build-guest-images.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +# Build production guest rootfs artifacts. Unlike infra/latitude/build-*.sh, +# this path accepts only the immutable policy committed with release tooling. +set -euo pipefail +export LC_ALL=C +umask 022 + +usage() { + echo "usage: build-guest-images.sh --version V --arch ARCH --output DIR --source-date-epoch EPOCH --guest-agent PATH" >&2 + exit 64 +} + +version= +arch= +output= +source_date_epoch= +guest_agent= +while [[ "$#" -gt 0 ]]; do + [[ "$#" -ge 2 ]] || usage + case "$1" in + --version) version="$2" ;; + --arch) arch="$2" ;; + --output) output="$2" ;; + --source-date-epoch) source_date_epoch="$2" ;; + --guest-agent) guest_agent="$2" ;; + *) usage ;; + esac + shift 2 +done + +[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)([-+][0-9A-Za-z.-]+)?$ ]] || usage +case "$arch" in amd64 | arm64) ;; *) usage ;; esac +[[ "$source_date_epoch" =~ ^[1-9][0-9]*$ ]] || usage +[[ -n "$output" && "$output" != / && "$output" != "$HOME" ]] || usage +[[ -f "$guest_agent" && ! -L "$guest_agent" && -x "$guest_agent" ]] || { echo "guest agent must be an executable regular file" >&2; exit 1; } +guest_agent="$(cd "$(dirname "$guest_agent")" && pwd)/$(basename "$guest_agent")" +for command in curl date docker jq python3 sha256sum file; do + command -v "$command" >/dev/null || { echo "missing build dependency: $command" >&2; exit 1; } +done + +case "$(uname -m)" in + x86_64) native_arch=amd64 ;; + aarch64 | arm64) native_arch=arm64 ;; + *) echo "unsupported native builder architecture" >&2; exit 1 ;; +esac +[[ "$native_arch" == "$arch" ]] || { + echo "production guest images require a native $arch runner (found $native_arch)" >&2 + exit 1 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +assets_dir="$script_dir/guest-images" +policy="$assets_dir/policy.json" +jq -e '.contractVersion == 1 and .rootfsProfile == "signed-developer-ext4-v1"' "$policy" >/dev/null +base_image="$(jq -er --arg arch "$arch" '.architectures[$arch].ociBase.reference' "$policy")" +[[ "$base_image" =~ @sha256:[0-9a-f]{64}$ ]] || { echo "OCI base is not digest-pinned" >&2; exit 1; } +alpine_release="$(jq -er '.alpineRepositorySnapshot.release' "$policy")" +[[ "$alpine_release" == v3.23 ]] || { echo "unsupported Alpine guest release policy" >&2; exit 1; } +snapshot_captured_at="$(jq -er '.alpineRepositorySnapshot.capturedAt' "$policy")" +max_index_age_hours="$(jq -er '.alpineRepositorySnapshot.maxIndexAgeHours' "$policy")" +snapshot_epoch="$(date -u -d "$snapshot_captured_at" +%s 2>/dev/null)" \ + || { echo "invalid Alpine repository capture timestamp" >&2; exit 1; } +now_epoch="$(date -u +%s)" +[[ "$max_index_age_hours" =~ ^[1-9][0-9]*$ ]] \ + || { echo "invalid Alpine repository freshness policy" >&2; exit 1; } +(( snapshot_epoch <= now_epoch && now_epoch - snapshot_epoch <= max_index_age_hours * 3600 )) \ + || { echo "Alpine repository snapshot is stale or future-dated" >&2; exit 1; } +apk_arch="$(jq -er --arg arch "$arch" '.alpineRepositorySnapshot.architectures[$arch].apkArchitecture' "$policy")" +[[ ( "$arch" == amd64 && "$apk_arch" == x86_64 ) || ( "$arch" == arm64 && "$apk_arch" == aarch64 ) ]] \ + || { echo "invalid Alpine repository architecture" >&2; exit 1; } +apk_main_index_url="$(jq -er --arg arch "$arch" '.alpineRepositorySnapshot.architectures[$arch].main.url' "$policy")" +apk_community_index_url="$(jq -er --arg arch "$arch" '.alpineRepositorySnapshot.architectures[$arch].community.url' "$policy")" +apk_main_index_sha="$(jq -er --arg arch "$arch" '.alpineRepositorySnapshot.architectures[$arch].main.sha256' "$policy")" +apk_community_index_sha="$(jq -er --arg arch "$arch" '.alpineRepositorySnapshot.architectures[$arch].community.sha256' "$policy")" +for repository in main community; do + url_variable="apk_${repository}_index_url" + sha_variable="apk_${repository}_index_sha" + url="${!url_variable}" + sha="${!sha_variable}" + [[ "$url" == "https://dl-cdn.alpinelinux.org/alpine/${alpine_release}/${repository}/${apk_arch}/APKINDEX.tar.gz" ]] \ + || { echo "unsafe Alpine $repository index URL" >&2; exit 1; } + [[ "$sha" =~ ^[0-9a-f]{64}$ ]] || { echo "Alpine $repository index is not digest-pinned" >&2; exit 1; } + published_at="$(jq -er --arg arch "$arch" --arg repository "$repository" \ + '.alpineRepositorySnapshot.architectures[$arch][$repository].publishedAt' "$policy")" + published_epoch="$(date -u -d "$published_at" +%s 2>/dev/null)" \ + || { echo "invalid Alpine $repository publication timestamp" >&2; exit 1; } + (( published_epoch <= snapshot_epoch && snapshot_epoch - published_epoch <= max_index_age_hours * 3600 )) \ + || { echo "Alpine $repository index publication is stale or future-dated" >&2; exit 1; } +done +apk_main_repository="${apk_main_index_url%/"$apk_arch"/APKINDEX.tar.gz}" +apk_community_repository="${apk_community_index_url%/"$apk_arch"/APKINDEX.tar.gz}" +common_packages="$(jq -er '.commonPackages | if length > 0 and all(test("^[a-z0-9][a-z0-9+.-]*$")) then join(" ") else error("unsafe common package allowlist") end' "$policy")" +desktop_packages="$(jq -er '.desktopPackages | if length > 0 and all(test("^[a-z0-9][a-z0-9+.-]*$")) then join(" ") else error("unsafe desktop package allowlist") end' "$policy")" +node_version="$(jq -er --arg arch "$arch" '.architectures[$arch].ociBase.nodeVersion' "$policy")" +npm_version="$(jq -er '.npmRuntime.version' "$policy")" +npm_tarball_url="$(jq -er '.npmRuntime.tarball.url' "$policy")" +npm_tarball_sha="$(jq -er '.npmRuntime.tarball.sha256' "$policy")" +brace_expansion_version="$(jq -er '.npmRuntime.overlays[] | select(.name == "brace-expansion") | .version' "$policy")" +brace_expansion_url="$(jq -er '.npmRuntime.overlays[] | select(.name == "brace-expansion") | .url' "$policy")" +brace_expansion_sha="$(jq -er '.npmRuntime.overlays[] | select(.name == "brace-expansion") | .sha256' "$policy")" +ip_address_version="$(jq -er '.npmRuntime.overlays[] | select(.name == "ip-address") | .version' "$policy")" +ip_address_url="$(jq -er '.npmRuntime.overlays[] | select(.name == "ip-address") | .url' "$policy")" +ip_address_sha="$(jq -er '.npmRuntime.overlays[] | select(.name == "ip-address") | .sha256' "$policy")" +pip_version="$(jq -er '.pythonRuntime.pip.version' "$policy")" +pip_wheel_url="$(jq -er '.pythonRuntime.pip.url' "$policy")" +pip_wheel_sha="$(jq -er '.pythonRuntime.pip.sha256' "$policy")" +setuptools_version="$(jq -er '.pythonRuntime.setuptools.version' "$policy")" +setuptools_wheel_url="$(jq -er '.pythonRuntime.setuptools.url' "$policy")" +setuptools_wheel_sha="$(jq -er '.pythonRuntime.setuptools.sha256' "$policy")" +pip_vendor_msgpack_version="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "msgpack" and .format == "sdist") | .version' "$policy")" +pip_vendor_msgpack_url="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "msgpack" and .format == "sdist") | .url' "$policy")" +pip_vendor_msgpack_sha="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "msgpack" and .format == "sdist") | .sha256' "$policy")" +pip_vendor_setuptools_version="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "setuptools" and .format == "wheel") | .version' "$policy")" +pip_vendor_setuptools_url="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "setuptools" and .format == "wheel") | .url' "$policy")" +pip_vendor_setuptools_sha="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "setuptools" and .format == "wheel") | .sha256' "$policy")" +[[ "$node_version" =~ ^24\.[0-9]+\.[0-9]+$ && "$npm_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || { echo "invalid Node/npm runtime policy" >&2; exit 1; } +[[ "$pip_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$setuptools_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || { echo "invalid Python runtime policy" >&2; exit 1; } +[[ "$pip_vendor_msgpack_version" == 1.2.1 && "$pip_vendor_setuptools_version" == 80.9.0 ]] \ + || { echo "invalid pip vendor security overlay policy" >&2; exit 1; } +for input in \ + "$npm_tarball_url|$npm_tarball_sha|npm" \ + "$brace_expansion_url|$brace_expansion_sha|brace-expansion" \ + "$ip_address_url|$ip_address_sha|ip-address"; do + IFS='|' read -r url sha name <<< "$input" + [[ "$url" =~ ^https://registry\.npmjs\.org/[a-z0-9-]+/-/[a-z0-9.-]+\.tgz$ && "$sha" =~ ^[0-9a-f]{64}$ ]] \ + || { echo "unsafe or unpinned $name runtime input" >&2; exit 1; } +done +for input in \ + "$pip_vendor_msgpack_url|$pip_vendor_msgpack_sha|msgpack-$pip_vendor_msgpack_version.tar.gz" \ + "$pip_vendor_setuptools_url|$pip_vendor_setuptools_sha|setuptools-$pip_vendor_setuptools_version-py3-none-any.whl"; do + IFS='|' read -r url sha filename <<< "$input" + [[ "$url" =~ ^https://files\.pythonhosted\.org/packages/[0-9a-f/]+/[A-Za-z0-9._-]+$ && \ + "${url##*/}" == "$filename" && "$sha" =~ ^[0-9a-f]{64}$ ]] \ + || { echo "unsafe or unpinned pip vendor security overlay" >&2; exit 1; } +done +for input in \ + "$pip_wheel_url|$pip_wheel_sha|pip-$pip_version-py3-none-any.whl" \ + "$setuptools_wheel_url|$setuptools_wheel_sha|setuptools-$setuptools_version-py3-none-any.whl"; do + IFS='|' read -r url sha filename <<< "$input" + [[ "$url" =~ ^https://files\.pythonhosted\.org/packages/[0-9a-f/]+/[A-Za-z0-9._-]+$ && \ + "${url##*/}" == "$filename" && "$sha" =~ ^[0-9a-f]{64}$ ]] \ + || { echo "unsafe or unpinned Python runtime input" >&2; exit 1; } +done + +mkdir -p "$output" +[[ -d "$output" && ! -L "$output" ]] || { echo "output must be a non-symlink directory" >&2; exit 1; } +output="$(cd "$output" && pwd)" +scratch_parent="${NEHEMIAH_GUEST_IMAGE_TMPDIR:-${TMPDIR:-/tmp}}" +[[ -d "$scratch_parent" && ! -L "$scratch_parent" ]] \ + || { echo "guest image scratch parent must be a non-symlink directory" >&2; exit 1; } +scratch_parent="$(cd "$scratch_parent" && pwd)" +[[ "$scratch_parent" != / && "$scratch_parent" != "$HOME" ]] \ + || { echo "unsafe guest image scratch parent" >&2; exit 1; } +work_root="$(mktemp -d "$scratch_parent/nehemiah-guest-images.XXXXXX")" +declare -a created_containers=() created_tags=() +cleanup() { + local container tag + for container in "${created_containers[@]-}"; do docker rm -f "$container" >/dev/null 2>&1 || true; done + for tag in "${created_tags[@]-}"; do docker image rm "$tag" >/dev/null 2>&1 || true; done + # Docker export preserves root ownership. Clean the exact private mktemp + # mount from a root container so interrupts never leave undeletable files. + if [[ -d "$work_root" && "$work_root" == "$scratch_parent/nehemiah-guest-images."* ]]; then + docker run --rm --network none --entrypoint /bin/sh \ + --volume "$work_root:/work:rw" "$base_image" \ + -c 'rm -rf -- /work/* /work/.[!.]* /work/..?*' >/dev/null 2>&1 || true + rm -rf -- "$work_root" || true + fi +} +trap cleanup EXIT + +scanner_root="$work_root/scanner" +"$assets_dir/prepare-vulnerability-scanner.sh" "$policy" "$arch" "$scanner_root" + +agent_description="$(file -b "$guest_agent")" +expected_machine='x86-64' +[[ "$arch" == arm64 ]] && expected_machine='ARM aarch64' +[[ "$agent_description" == *'ELF 64-bit LSB'* && "$agent_description" == *"$expected_machine"* && \ + "$agent_description" == *'statically linked'* ]] \ + || { echo "guest agent is not a static $arch ELF" >&2; exit 1; } + +build_one() { + local flavor="$1" repetition="$2" + local work="$work_root/${flavor}-${repetition}" + local tag="nehemiah-guest-build:${version//+/-}-${arch}-${flavor}-${repetition}-$$" + local container export_tar packages image init_script artifact + mkdir -p "$work" + created_tags+=("$tag") + docker build --pull --platform "linux/$arch" --target "$flavor" \ + --build-arg "BASE_IMAGE=$base_image" \ + --build-arg "APK_MAIN_REPOSITORY=$apk_main_repository" \ + --build-arg "APK_COMMUNITY_REPOSITORY=$apk_community_repository" \ + --build-arg "APK_MAIN_INDEX_SHA256=$apk_main_index_sha" \ + --build-arg "APK_COMMUNITY_INDEX_SHA256=$apk_community_index_sha" \ + --build-arg "COMMON_PACKAGES=$common_packages" \ + --build-arg "DESKTOP_PACKAGES=$desktop_packages" \ + --build-arg "NODE_VERSION=$node_version" \ + --build-arg "NPM_VERSION=$npm_version" \ + --build-arg "NPM_TARBALL_URL=$npm_tarball_url" \ + --build-arg "NPM_TARBALL_SHA256=$npm_tarball_sha" \ + --build-arg "BRACE_EXPANSION_VERSION=$brace_expansion_version" \ + --build-arg "BRACE_EXPANSION_URL=$brace_expansion_url" \ + --build-arg "BRACE_EXPANSION_SHA256=$brace_expansion_sha" \ + --build-arg "IP_ADDRESS_VERSION=$ip_address_version" \ + --build-arg "IP_ADDRESS_URL=$ip_address_url" \ + --build-arg "IP_ADDRESS_SHA256=$ip_address_sha" \ + --build-arg "PIP_VERSION=$pip_version" \ + --build-arg "PIP_WHEEL_URL=$pip_wheel_url" \ + --build-arg "PIP_WHEEL_SHA256=$pip_wheel_sha" \ + --build-arg "SETUPTOOLS_VERSION=$setuptools_version" \ + --build-arg "SETUPTOOLS_WHEEL_URL=$setuptools_wheel_url" \ + --build-arg "SETUPTOOLS_WHEEL_SHA256=$setuptools_wheel_sha" \ + --build-arg "PIP_VENDOR_MSGPACK_VERSION=$pip_vendor_msgpack_version" \ + --build-arg "PIP_VENDOR_MSGPACK_URL=$pip_vendor_msgpack_url" \ + --build-arg "PIP_VENDOR_MSGPACK_SHA256=$pip_vendor_msgpack_sha" \ + --build-arg "PIP_VENDOR_SETUPTOOLS_VERSION=$pip_vendor_setuptools_version" \ + --build-arg "PIP_VENDOR_SETUPTOOLS_URL=$pip_vendor_setuptools_url" \ + --build-arg "PIP_VENDOR_SETUPTOOLS_SHA256=$pip_vendor_setuptools_sha" \ + --tag "$tag" --file "$assets_dir/Dockerfile" "$assets_dir" + + container="$(docker create "$tag")" + created_containers+=("$container") + export_tar="$work/rootfs.tar" + docker export --output "$export_tar" "$container" + docker rm "$container" >/dev/null + created_containers=("${created_containers[@]/$container}") + + packages="$work/packages.json" + docker run --rm --network none --entrypoint /bin/bash "$tag" -o pipefail -c \ + 'awk '\''/^P:/ { name=substr($0, 3) } /^V:/ { print name "\t" substr($0, 3) }'\'' /lib/apk/db/installed | LC_ALL=C sort | jq -Rn '\''[inputs | select(length > 0) | split("\t") | {name: .[0], version: .[1]}]'\''' \ + > "$packages" + + init_script="$assets_dir/headless-init" + [[ "$flavor" == desktop ]] && init_script="$assets_dir/desktop-init" + image="$work/nehemiah-guest-${flavor}_${version}_linux_${arch}.ext4" + docker run --rm --network none \ + --entrypoint /bin/bash \ + --env "SOURCE_DATE_EPOCH=$source_date_epoch" \ + --env "OUTPUT_UID=$(id -u)" --env "OUTPUT_GID=$(id -g)" \ + --volume "$work:/work:rw" \ + --volume "$guest_agent:/input/bc-guest-agent:ro" \ + --volume "$init_script:/input/boring-init:ro" \ + --volume "$policy:/input/policy.json:ro" \ + --volume "$assets_dir/assemble-rootfs.sh:/input/assemble-rootfs.sh:ro" \ + "$tag" /input/assemble-rootfs.sh \ + /work/rootfs.tar /input/bc-guest-agent /input/boring-init \ + /input/policy.json /work/packages.json "/work/$(basename "$image")" \ + "$flavor" "$arch" "$version" + artifact="${image}.gz" + "$assets_dir/inspect-guest-image.sh" "$policy" "$artifact" "$version" "$arch" "$flavor" + if [[ "$repetition" == 1 ]]; then + artifact_sha="$(sha256sum "$artifact" | awk '{print $1}')" + docker run --rm --network none \ + --entrypoint /bin/bash \ + --env "OUTPUT_UID=$(id -u)" --env "OUTPUT_GID=$(id -g)" \ + --volume "$work:/work:rw" \ + --volume "$scanner_root/trivy:/input/trivy:ro" \ + --volume "$scanner_root/cache:/input/trivy-cache:rw" \ + --volume "$scanner_root/database-evidence.json:/input/database-evidence.json:ro" \ + --volume "$policy:/input/policy.json:ro" \ + --volume "$assets_dir/vulnerability-allowlist.json:/input/vulnerability-allowlist.json:ro" \ + --volume "$assets_dir/scan-final-rootfs.sh:/input/scan-final-rootfs.sh:ro" \ + "$tag" /input/scan-final-rootfs.sh \ + /input/trivy /input/trivy-cache /input/database-evidence.json \ + /input/policy.json /input/vulnerability-allowlist.json /work/rootfs \ + "$flavor" "$arch" "$artifact_sha" /work/scan-evidence.json + fi + built_artifact="$artifact" +} + +for flavor in python desktop; do + built_artifact="" + build_one "$flavor" 1 + first="$built_artifact" + built_artifact="" + build_one "$flavor" 2 + second="$built_artifact" + first_sha="$(sha256sum "$first" | awk '{print $1}')" + second_sha="$(sha256sum "$second" | awk '{print $1}')" + [[ "$first_sha" == "$second_sha" ]] || { + echo "$flavor guest image build is not deterministic" >&2 + exit 1 + } + destination="$output/nehemiah-guest-${flavor}_${version}_linux_${arch}.ext4.gz" + install -m 0644 "$first" "$destination" + "$assets_dir/inspect-guest-image.sh" "$policy" "$destination" "$version" "$arch" "$flavor" + printf 'built %s sha256=%s\n' "$destination" "$first_sha" +done + +scan_artifact="$output/nehemiah-guest-scan_${version}_linux_${arch}.json" +policy_sha="$(sha256sum "$policy" | awk '{print $1}')" +jq -n \ + --arg version "$version" --arg architecture "$arch" --arg policySha256 "$policy_sha" \ + --slurpfile scanner "$scanner_root/database-evidence.json" \ + --slurpfile pythonSummary "$work_root/python-1/scan-evidence.json" \ + --rawfile pythonReport "$work_root/python-1/scan-evidence.json.trivy.json" \ + --slurpfile desktopSummary "$work_root/desktop-1/scan-evidence.json" \ + --rawfile desktopReport "$work_root/desktop-1/scan-evidence.json.trivy.json" ' + { + schemaVersion: 1, + version: $version, + architecture: $architecture, + policySha256: $policySha256, + scanner: $scanner[0], + scans: [ + {summary: $pythonSummary[0], report: $pythonReport}, + {summary: $desktopSummary[0], report: $desktopReport} + ] + }' > "$scan_artifact" +max_evidence_bytes="$(jq -er '.vulnerabilityScan.maxEvidenceBytes' "$policy")" +(( $(stat -c %s "$scan_artifact") <= max_evidence_bytes )) \ + || { echo "guest vulnerability evidence exceeds policy" >&2; exit 1; } +printf 'built %s sha256=%s\n' "$scan_artifact" "$(sha256sum "$scan_artifact" | awk '{print $1}')" diff --git a/scripts/release/build-managed-host-packages.sh b/scripts/release/build-managed-host-packages.sh new file mode 100755 index 0000000..d26753d --- /dev/null +++ b/scripts/release/build-managed-host-packages.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Trusted release-build entry point. Network package resolution is permitted +# only here; managed hosts consume the resulting signed flat repository offline. +set -euo pipefail +export LC_ALL=C + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "$script_dir/managed_host_packages.py" build "$@" diff --git a/scripts/release/build.mjs b/scripts/release/build.mjs new file mode 100644 index 0000000..611d38c --- /dev/null +++ b/scripts/release/build.mjs @@ -0,0 +1,674 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { + chmod, + copyFile, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + rmdir, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createGunzip } from "node:zlib"; +import { + assertDirectoryEmpty, + assertRegularFile, + createManifest, + invariant, + MANAGED_GUEST_POLICY, + MANAGED_RUNTIME_POLICY, + parseArguments, + renderFormula, + resolveRepositoryOutputDirectory, + runCommand, + sha256File, + validateCommit, + validateRepository, + validateVersion, + verifyReleaseDirectory, + writeChecksums, + writeManifest, +} from "./lib.mjs"; + +const releaseDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(releaseDirectory, "../.."); + +const options = parseArguments(process.argv.slice(2), [ + "version", + "out", + "source-date-epoch", + "commit", + "repository", + "guest-images", + "host-packages", + "runtime-assets", +]); +const version = validateVersion(options.version); +const commit = validateCommit(options.commit); +const repository = validateRepository(options.repository); +invariant(options["guest-images"], "--guest-images is required"); +const guestImageDirectory = path.resolve( + repositoryRoot, + options["guest-images"], +); +const guestImageDirectoryStats = await lstat(guestImageDirectory).catch( + (error) => { + if (error?.code === "ENOENT") + throw new Error("guest image directory is missing"); + throw error; + }, +); +invariant( + guestImageDirectoryStats.isDirectory() && + !guestImageDirectoryStats.isSymbolicLink(), + "guest image input must be a non-symlink directory", +); +invariant(options["runtime-assets"], "--runtime-assets is required"); +const runtimeAssetDirectory = path.resolve( + repositoryRoot, + options["runtime-assets"], +); +const runtimeAssetDirectoryStats = await lstat(runtimeAssetDirectory).catch( + (error) => { + if (error?.code === "ENOENT") + throw new Error("managed runtime asset directory is missing"); + throw error; + }, +); +invariant( + runtimeAssetDirectoryStats.isDirectory() && + !runtimeAssetDirectoryStats.isSymbolicLink(), + "managed runtime asset input must be a non-symlink directory", +); +invariant(options["host-packages"], "--host-packages is required"); +const hostPackageDirectory = path.resolve( + repositoryRoot, + options["host-packages"], +); +const hostPackageDirectoryStats = await lstat(hostPackageDirectory).catch( + (error) => { + if (error?.code === "ENOENT") + throw new Error("managed host package directory is missing"); + throw error; + }, +); +invariant( + hostPackageDirectoryStats.isDirectory() && + !hostPackageDirectoryStats.isSymbolicLink(), + "managed host package input must be a non-symlink directory", +); +const sourceDateEpoch = Number(options["source-date-epoch"]); +invariant( + Number.isSafeInteger(sourceDateEpoch) && sourceDateEpoch > 0, + "--source-date-epoch must be a positive integer", +); +const outputDirectory = resolveRepositoryOutputDirectory( + repositoryRoot, + options.out, +); + +const cliPackage = JSON.parse( + await readFile( + path.join(repositoryRoot, "packages/cli/package.json"), + "utf8", + ), +); +const sdkPackage = JSON.parse( + await readFile( + path.join(repositoryRoot, "packages/sdk/package.json"), + "utf8", + ), +); +invariant( + cliPackage.version === version, + `CLI package version ${cliPackage.version} does not match ${version}`, +); +invariant( + sdkPackage.version === version, + `SDK package version ${sdkPackage.version} does not match ${version}`, +); +invariant( + cliPackage.dependencies?.["nehemiah-sdk"] === `^${version}`, + `CLI nehemiah-sdk dependency must be ^${version}`, +); + +const outputDirectoryExisted = await assertDirectoryEmpty(outputDirectory); +const scratchDirectory = await mkdtemp( + path.join(repositoryRoot, ".nehemiah-release-"), +); +const stagedOutputDirectory = path.join(scratchDirectory, "output"); +await mkdir(stagedOutputDirectory, { mode: 0o755 }); + +const goComponents = [ + { + component: "nehemiahd", + source: "nehemiahd", + versionSymbol: "main.Version", + }, + { component: "bc-guest-agent", source: "guest-agent" }, + { component: "bc-gateway", source: "gateway" }, +]; + +async function assertStaticLinuxBinary(binaryPath, arch) { + const { stdout } = await runCommand("file", ["--brief", binaryPath]); + invariant( + stdout.includes("ELF 64-bit LSB"), + `${binaryPath} is not a 64-bit Linux ELF binary: ${stdout.trim()}`, + ); + invariant( + stdout.includes("statically linked"), + `${binaryPath} is dynamically linked: ${stdout.trim()}`, + ); + const expectedArchitecture = arch === "amd64" ? "x86-64" : "ARM aarch64"; + invariant( + stdout.includes(expectedArchitecture), + `${binaryPath} is not ${arch}: ${stdout.trim()}`, + ); +} + +async function buildGoArtifact(definition, arch) { + const artifactName = `${definition.component}_${version}_linux_${arch}.tar.gz`; + const binaryDirectory = path.join( + scratchDirectory, + `${definition.component}-${arch}-binary`, + ); + const stagingDirectory = path.join( + scratchDirectory, + `${definition.component}-${arch}-archive`, + ); + await mkdir(binaryDirectory, { recursive: true }); + await mkdir(stagingDirectory, { recursive: true }); + const binaryPath = path.join(binaryDirectory, definition.component); + const linkerFlags = ["-s", "-w", "-buildid="]; + if (definition.versionSymbol) + linkerFlags.push("-X", `${definition.versionSymbol}=${version}`); + + await runCommand( + "go", + [ + "build", + "-trimpath", + "-buildvcs=false", + "-ldflags", + linkerFlags.join(" "), + "-o", + binaryPath, + ".", + ], + { + cwd: path.join(repositoryRoot, definition.source), + env: { + ...process.env, + CGO_ENABLED: "0", + GOARCH: arch, + GOOS: "linux", + GOFLAGS: "-mod=readonly", + SOURCE_DATE_EPOCH: String(sourceDateEpoch), + }, + }, + ); + await assertStaticLinuxBinary(binaryPath, arch); + await chmod(binaryPath, 0o755); + await copyFile(binaryPath, path.join(stagingDirectory, definition.component)); + await copyFile( + path.join(repositoryRoot, "LICENSE"), + path.join(stagingDirectory, "LICENSE"), + ); + await copyFile( + path.join(repositoryRoot, "NOTICE"), + path.join(stagingDirectory, "NOTICE"), + ); + await chmod(path.join(stagingDirectory, definition.component), 0o755); + await chmod(path.join(stagingDirectory, "LICENSE"), 0o644); + await chmod(path.join(stagingDirectory, "NOTICE"), 0o644); + + await runCommand("tar", [ + "--sort=name", + "--format=ustar", + "--owner=0", + "--group=0", + "--numeric-owner", + `--mtime=@${sourceDateEpoch}`, + "--mode=u+rwX,go+rX,go-w", + "--use-compress-program=gzip -n -9", + "-cf", + path.join(stagedOutputDirectory, artifactName), + "-C", + stagingDirectory, + ".", + ]); + return artifactName; +} + +async function buildCliArtifact() { + await runCommand("npm", ["run", "build", "--workspace", "nehemiah-sdk"], { + cwd: repositoryRoot, + }); + await runCommand("npm", ["run", "build", "--workspace", "nehemiah-cli"], { + cwd: repositoryRoot, + }); + const packageDirectory = path.join(scratchDirectory, "cli-package"); + await mkdir(path.join(packageDirectory, "dist"), { recursive: true }); + await runCommand(path.join(repositoryRoot, "node_modules/.bin/esbuild"), [ + path.join(repositoryRoot, "packages/cli/dist/index.js"), + "--bundle", + "--platform=node", + "--target=node20", + "--format=esm", + "--packages=bundle", + "--legal-comments=external", + `--outfile=${path.join(packageDirectory, "dist/cli.js")}`, + ]); + await writeFile( + path.join(packageDirectory, "dist/index.js"), + [ + "#!/usr/bin/env node", + 'import { runCli } from "./cli.js";', + "process.exitCode = await runCli(process.argv.slice(2));", + "", + ].join("\n"), + { encoding: "utf8", mode: 0o755 }, + ); + await chmod(path.join(packageDirectory, "dist/index.js"), 0o755); + await copyFile( + path.join(repositoryRoot, "packages/cli/README.md"), + path.join(packageDirectory, "README.md"), + ); + await copyFile( + path.join(repositoryRoot, "LICENSE"), + path.join(packageDirectory, "LICENSE"), + ); + await copyFile( + path.join(repositoryRoot, "NOTICE"), + path.join(packageDirectory, "NOTICE"), + ); + const distributablePackage = { + name: cliPackage.name, + version: cliPackage.version, + description: cliPackage.description, + license: cliPackage.license, + repository: cliPackage.repository, + homepage: cliPackage.homepage, + keywords: cliPackage.keywords, + type: "module", + main: "./dist/cli.js", + bin: cliPackage.bin, + files: ["dist", "README.md", "LICENSE", "NOTICE"], + engines: cliPackage.engines, + }; + await writeFile( + path.join(packageDirectory, "package.json"), + `${JSON.stringify(distributablePackage, null, 2)}\n`, + { + encoding: "utf8", + mode: 0o644, + }, + ); + const packDirectory = path.join(scratchDirectory, "npm-pack"); + await mkdir(packDirectory); + const { stdout } = await runCommand( + "npm", + [ + "pack", + packageDirectory, + "--ignore-scripts", + "--json", + "--pack-destination", + packDirectory, + ], + { cwd: repositoryRoot }, + ); + let packResult; + try { + packResult = JSON.parse(stdout); + } catch (error) { + throw new Error("npm pack did not return JSON", { cause: error }); + } + invariant( + Array.isArray(packResult) && packResult.length === 1, + "npm pack returned an unexpected artifact set", + ); + const expectedName = `nehemiah-cli-${version}.tgz`; + invariant( + packResult[0]?.filename === expectedName, + `npm pack created ${String(packResult[0]?.filename)}, expected ${expectedName}`, + ); + const artifactPath = path.join(stagedOutputDirectory, expectedName); + await rename(path.join(packDirectory, expectedName), artifactPath); + + const installDirectory = path.join(scratchDirectory, "offline-cli-install"); + await mkdir(installDirectory); + await runCommand( + "npm", + [ + "install", + "--offline", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + "--global", + "--prefix", + installDirectory, + artifactPath, + ], + { + cwd: scratchDirectory, + env: { + ...process.env, + npm_config_cache: path.join(scratchDirectory, "empty-npm-cache"), + }, + }, + ); + const installedPackage = JSON.parse( + await readFile( + path.join(installDirectory, "lib/node_modules/nehemiah-cli/package.json"), + "utf8", + ), + ); + invariant( + installedPackage.dependencies === undefined && + installedPackage.optionalDependencies === undefined, + "release CLI package must not contain runtime npm dependencies", + ); + const { stdout: helpOutput } = await runCommand( + path.join(installDirectory, "bin/bc"), + ["help"], + ); + invariant( + helpOutput.includes("Boring Computers command line"), + "offline-installed CLI smoke test failed", + ); + return expectedName; +} + +const managedHostFiles = [ + "infra/latitude/bootstrap.sh", + "infra/latitude/cloud-init.sh", + "infra/latitude/managed-host-packages.py", + "infra/latitude/managed-host-preflight.sh", + "infra/latitude/net-setup.sh", + "infra/latitude/validate-managed-release.py", + "infra/latitude/verify-minisign.py", + "infra/latitude/boring-net.service", + "infra/latitude/nehemiahd.service", + "infra/latitude/verify-isolation.sh", + "infra/latitude/wireguard-config.py", +]; + +async function sha256UncompressedGzip(filePath) { + const hash = createHash("sha256"); + const gunzip = createGunzip(); + createReadStream(filePath).pipe(gunzip); + for await (const chunk of gunzip) hash.update(chunk); + return hash.digest("hex"); +} + +async function stageGuestImageArtifacts() { + const expectedNames = []; + const rootfsDigests = { amd64: {}, arm64: {} }; + for (const arch of ["amd64", "arm64"]) { + for (const flavor of ["python", "desktop"]) { + expectedNames.push( + `nehemiah-guest-${flavor}_${version}_linux_${arch}.ext4.gz`, + ); + } + } + for (const arch of ["amd64", "arm64"]) + expectedNames.push(`nehemiah-guest-scan_${version}_linux_${arch}.json`); + expectedNames.sort(); + const entries = await readdir(guestImageDirectory, { + withFileTypes: true, + }); + const actualNames = entries.map(({ name }) => name).sort(); + invariant( + JSON.stringify(actualNames) === JSON.stringify(expectedNames), + "guest image directory must contain the exact four release images", + ); + for (const arch of ["amd64", "arm64"]) { + for (const flavor of ["python", "desktop"]) { + const name = `nehemiah-guest-${flavor}_${version}_linux_${arch}.ext4.gz`; + const source = path.join(guestImageDirectory, name); + const stats = await assertRegularFile(source, name); + invariant( + stats.size > 0 && + stats.size <= MANAGED_GUEST_POLICY.flavors[flavor].maxCompressedBytes, + `${name} exceeds the signed image size policy`, + ); + await runCommand( + path.join( + repositoryRoot, + "scripts/release/guest-images/inspect-guest-image.sh", + ), + [ + path.join(repositoryRoot, "scripts/release/guest-images/policy.json"), + source, + version, + arch, + flavor, + ], + ); + await copyFile(source, path.join(stagedOutputDirectory, name)); + await chmod(path.join(stagedOutputDirectory, name), 0o644); + rootfsDigests[arch][flavor] = await sha256UncompressedGzip(source); + } + const scanName = `nehemiah-guest-scan_${version}_linux_${arch}.json`; + const scanSource = path.join(guestImageDirectory, scanName); + const scanStats = await assertRegularFile(scanSource, scanName); + invariant( + scanStats.size > 0 && + scanStats.size <= + MANAGED_GUEST_POLICY.vulnerabilityScan.maxEvidenceBytes, + `${scanName} exceeds the signed scan evidence size policy`, + ); + await runCommand( + "node", + [ + path.join( + repositoryRoot, + "scripts/release/guest-images/verify-scan-evidence.mjs", + ), + "--evidence", + scanSource, + "--images", + guestImageDirectory, + "--version", + version, + "--arch", + arch, + ], + { cwd: repositoryRoot }, + ); + await copyFile(scanSource, path.join(stagedOutputDirectory, scanName)); + await chmod(path.join(stagedOutputDirectory, scanName), 0o644); + } + return { names: expectedNames, rootfsDigests }; +} + +async function stageManagedHostPackageArtifacts() { + const entries = await readdir(hostPackageDirectory, { withFileTypes: true }); + const expectedNames = ["amd64", "arm64"].map( + (arch) => + `nehemiah-host-packages_${version}_ubuntu24.04_linux_${arch}.tar.gz`, + ); + invariant( + JSON.stringify(entries.map(({ name }) => name).sort()) === + JSON.stringify(expectedNames), + "managed host package directory must contain the exact architecture set", + ); + const manifests = {}; + for (const arch of ["amd64", "arm64"]) { + const name = `nehemiah-host-packages_${version}_ubuntu24.04_linux_${arch}.tar.gz`; + const source = path.join(hostPackageDirectory, name); + await assertRegularFile(source, name); + await runCommand( + path.join( + repositoryRoot, + "scripts/release/inspect-managed-host-packages.sh", + ), + ["--archive", source, "--version", version, "--arch", arch], + ); + const { stdout } = await runCommand("tar", [ + "-xOf", + source, + "./manifest.json", + ]); + let manifest; + try { + manifest = JSON.parse(stdout); + } catch (error) { + throw new Error(`${name} contains invalid package manifest JSON`, { + cause: error, + }); + } + manifests[arch] = { + ...manifest, + manifestSha256: createHash("sha256").update(stdout).digest("hex"), + }; + await copyFile(source, path.join(stagedOutputDirectory, name)); + await chmod(path.join(stagedOutputDirectory, name), 0o644); + } + return { names: expectedNames, manifests }; +} + +async function stageManagedRuntimeArtifacts() { + await runCommand( + path.join( + repositoryRoot, + "scripts/release/inspect-managed-runtime-assets.sh", + ), + [ + path.join(repositoryRoot, "scripts/release/managed-runtime-policy.json"), + runtimeAssetDirectory, + ], + ); + const expectedNames = []; + for (const arch of ["amd64", "arm64"]) { + for (const component of ["firecracker", "kernel"]) { + const policy = MANAGED_RUNTIME_POLICY.architectures[arch][component]; + const source = path.join(runtimeAssetDirectory, policy.artifact); + const stats = await assertRegularFile(source, policy.artifact); + invariant( + stats.size > 0 && stats.size <= policy.maxBytes, + `${policy.artifact} exceeds the retained runtime size policy`, + ); + invariant( + (await sha256File(source)) === policy.sha256, + `${policy.artifact} does not match the reviewed runtime digest`, + ); + await copyFile(source, path.join(stagedOutputDirectory, policy.artifact)); + await chmod(path.join(stagedOutputDirectory, policy.artifact), 0o644); + expectedNames.push(policy.artifact); + } + } + return expectedNames.sort(); +} + +async function buildManagedHostBootstrapArtifact() { + const artifactName = `nehemiah-host-bootstrap_${version}.tar.gz`; + const stagingDirectory = path.join( + scratchDirectory, + "managed-host-bootstrap-archive", + ); + await mkdir(stagingDirectory, { recursive: true }); + for (const relativePath of managedHostFiles) { + const destination = path.join(stagingDirectory, relativePath); + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(path.join(repositoryRoot, relativePath), destination); + await chmod( + destination, + relativePath.endsWith(".sh") || relativePath.endsWith(".py") + ? 0o755 + : 0o644, + ); + } + for (const name of ["LICENSE", "NOTICE"]) { + await copyFile( + path.join(repositoryRoot, name), + path.join(stagingDirectory, name), + ); + await chmod(path.join(stagingDirectory, name), 0o644); + } + await runCommand("tar", [ + "--sort=name", + "--format=ustar", + "--owner=0", + "--group=0", + "--numeric-owner", + `--mtime=@${sourceDateEpoch}`, + "--mode=u+rwX,go+rX,go-w", + "--use-compress-program=gzip -n -9", + "-cf", + path.join(stagedOutputDirectory, artifactName), + "-C", + stagingDirectory, + ".", + ]); + return artifactName; +} + +try { + const artifactNames = []; + for (const component of goComponents) { + for (const arch of ["amd64", "arm64"]) + artifactNames.push(await buildGoArtifact(component, arch)); + } + const cliArtifact = await buildCliArtifact(); + artifactNames.push(cliArtifact); + artifactNames.push(await buildManagedHostBootstrapArtifact()); + const guestImages = await stageGuestImageArtifacts(); + artifactNames.push(...guestImages.names); + const hostPackages = await stageManagedHostPackageArtifacts(); + artifactNames.push(...hostPackages.names); + artifactNames.push(...(await stageManagedRuntimeArtifacts())); + + const template = await readFile( + path.join(releaseDirectory, "templates/nehemiah.rb.tpl"), + "utf8", + ); + const formula = renderFormula(template, { + version, + repository, + sha256: await sha256File(path.join(stagedOutputDirectory, cliArtifact)), + }); + await writeFile(path.join(stagedOutputDirectory, "nehemiah.rb"), formula, { + encoding: "utf8", + mode: 0o644, + }); + artifactNames.push("nehemiah.rb"); + + const manifest = createManifest({ + version, + commit, + sourceDateEpoch, + repository, + guestRootfsDigests: guestImages.rootfsDigests, + hostPackageManifests: hostPackages.manifests, + }); + await writeManifest(stagedOutputDirectory, manifest); + await writeChecksums(stagedOutputDirectory, [ + ...artifactNames, + "release-manifest.json", + ]); + await verifyReleaseDirectory(stagedOutputDirectory); + if (outputDirectoryExisted) await rmdir(outputDirectory); + try { + await rename(stagedOutputDirectory, outputDirectory); + } catch (error) { + if (outputDirectoryExisted) await mkdir(outputDirectory, { mode: 0o755 }); + throw error; + } + process.stdout.write( + `${JSON.stringify({ outputDirectory, version, artifacts: manifest.artifacts.map(({ name }) => name) }, null, 2)}\n`, + ); +} finally { + await rm(scratchDirectory, { recursive: true, force: true }); +} diff --git a/scripts/release/check.mjs b/scripts/release/check.mjs new file mode 100644 index 0000000..4d471fc --- /dev/null +++ b/scripts/release/check.mjs @@ -0,0 +1,892 @@ +#!/usr/bin/env node + +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateReleaseWorkflowPolicy } from "./ci-policy.mjs"; +import { invariant, validateVersion } from "./lib.mjs"; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const workflow = await readFile( + path.join(repositoryRoot, ".github/workflows/release.yml"), + "utf8", +); +const ciWorkflow = await readFile( + path.join(repositoryRoot, ".github/workflows/ci.yml"), + "utf8", +); +const ciAuthorization = await readFile( + path.join(repositoryRoot, "scripts/release/authorize-ci.sh"), + "utf8", +); +const ciAuthorizationStat = await stat( + path.join(repositoryRoot, "scripts/release/authorize-ci.sh"), +); +const formulaTemplate = await readFile( + path.join(repositoryRoot, "scripts/release/templates/nehemiah.rb.tpl"), + "utf8", +); +const buildScript = await readFile( + path.join(repositoryRoot, "scripts/release/build.mjs"), + "utf8", +); +const distributionDocumentation = await readFile( + path.join(repositoryRoot, "docs/nehemiah/distribution.md"), + "utf8", +); +const cloudInit = await readFile( + path.join(repositoryRoot, "infra/latitude/cloud-init.sh"), + "utf8", +); +const bootstrap = await readFile( + path.join(repositoryRoot, "infra/latitude/bootstrap.sh"), + "utf8", +); +const rootfsBuilder = await readFile( + path.join(repositoryRoot, "infra/latitude/build-rootfs.sh"), + "utf8", +); +const guestPolicy = await readFile( + path.join(repositoryRoot, "scripts/release/guest-images/policy.json"), + "utf8", +); +const managedRuntimePolicyContents = await readFile( + path.join(repositoryRoot, "scripts/release/managed-runtime-policy.json"), + "utf8", +); +const managedRuntimeFetcher = await readFile( + path.join(repositoryRoot, "scripts/release/fetch-managed-runtime-assets.sh"), + "utf8", +); +const managedRuntimeInspector = await readFile( + path.join( + repositoryRoot, + "scripts/release/inspect-managed-runtime-assets.sh", + ), + "utf8", +); +const managedHostPackagePolicyContents = await readFile( + path.join( + repositoryRoot, + "scripts/release/managed-host-packages-policy.json", + ), + "utf8", +); +const managedHostPackageBuilder = await readFile( + path.join(repositoryRoot, "scripts/release/managed_host_packages.py"), + "utf8", +); +const managedHostPackageInstaller = await readFile( + path.join(repositoryRoot, "infra/latitude/managed-host-packages.py"), + "utf8", +); +const managedReleaseValidator = await readFile( + path.join(repositoryRoot, "infra/latitude/validate-managed-release.py"), + "utf8", +); +const managedWireGuardValidator = await readFile( + path.join(repositoryRoot, "infra/latitude/wireguard-config.py"), + "utf8", +); +const managedNetworkSetup = await readFile( + path.join(repositoryRoot, "infra/latitude/net-setup.sh"), + "utf8", +); +const managedHostPreflight = await readFile( + path.join(repositoryRoot, "infra/latitude/managed-host-preflight.sh"), + "utf8", +); +const guestImageBuilder = await readFile( + path.join(repositoryRoot, "scripts/release/build-guest-images.sh"), + "utf8", +); +const guestImageDockerfile = await readFile( + path.join(repositoryRoot, "scripts/release/guest-images/Dockerfile"), + "utf8", +); +const pythonRuntimeOverlay = await readFile( + path.join( + repositoryRoot, + "scripts/release/guest-images/apply-python-runtime-overlays.py", + ), + "utf8", +); +const guestScanner = await readFile( + path.join( + repositoryRoot, + "scripts/release/guest-images/prepare-vulnerability-scanner.sh", + ), + "utf8", +); +const guestScanGate = await readFile( + path.join( + repositoryRoot, + "scripts/release/guest-images/scan-final-rootfs.sh", + ), + "utf8", +); +const userDataRenderer = await readFile( + path.join(repositoryRoot, "infra/latitude/render-user-data.sh"), + "utf8", +); +const latitudeProvisioner = await readFile( + path.join(repositoryRoot, "infra/latitude/provision.sh"), + "utf8", +); +const cliPackage = JSON.parse( + await readFile( + path.join(repositoryRoot, "packages/cli/package.json"), + "utf8", + ), +); +const sdkPackage = JSON.parse( + await readFile( + path.join(repositoryRoot, "packages/sdk/package.json"), + "utf8", + ), +); + +function requireText(haystack, needle, label = needle) { + invariant(haystack.includes(needle), `release check is missing ${label}`); +} + +function requireBefore(haystack, first, second) { + const firstIndex = haystack.indexOf(first); + const secondIndex = haystack.indexOf(second); + invariant( + firstIndex >= 0 && secondIndex >= 0 && firstIndex < secondIndex, + `${first} must appear before ${second}`, + ); +} + +invariant( + !workflow.includes("pull_request_target"), + "release workflow must not use pull_request_target", +); +validateReleaseWorkflowPolicy(workflow, ciWorkflow, ciAuthorization); +invariant( + ciAuthorizationStat.isFile() && (ciAuthorizationStat.mode & 0o111) !== 0, + "release CI authorization script must be executable", +); +invariant( + !/\bnpm\s+publish\b/.test(workflow), + "release workflow must not publish to npm", +); +invariant( + !/\bbrew\s+(tap|create|install)\b/.test(workflow), + "release workflow must not mutate a Homebrew tap", +); +requireText( + workflow, + "permissions:\n contents: read", + "default read-only permissions", +); +requireText(workflow, "ubuntu-24.04-arm", "native arm64 guest image runner"); +requireText( + workflow, + "scripts/release/build-guest-images.sh", + "production guest image build", +); +requireText( + workflow, + "scripts/release/fetch-managed-runtime-assets.sh managed-runtime-assets", + "trusted retained runtime fetch", +); +invariant( + workflow.match(/--runtime-assets managed-runtime-assets/g)?.length === 2, + "both deterministic release builds must use the retained runtime directory", +); +requireText( + workflow, + "scripts/release/build-managed-host-packages.sh", + "production managed-host package build", +); +requireText( + workflow, + "scripts/release/inspect-managed-host-packages.sh", + "offline managed-host package inspection", +); +invariant( + workflow.match(/--host-packages host-packages/g)?.length === 2, + "both deterministic release builds must use retained host packages", +); +requireText( + workflow, + 'cmp --silent "host-packages/$artifact" "host-packages-repeat/$artifact"', + "two-pass managed-host package comparison", +); +requireText( + workflow, + "diff --recursive --no-dereference release-dist release-dist-repeat", + "two-pass deterministic release comparison", +); +requireText(workflow, "workflow_dispatch:", "manual build trigger"); +requireText( + workflow, + "github.event_name == 'push'", + "tag-only publish condition", +); +requireText( + workflow, + "scripts/release/verify-tag.mjs", + "signed annotated tag verification", +); +requireText( + workflow, + "repos/${GITHUB_REPOSITORY}/immutable-releases", + "immutable-release preflight", +); +requireText(workflow, "actions/attest@", "artifact provenance attestation"); +requireText( + workflow, + "subject-checksums: release-dist/SHA256SUMS", + "per-artifact checksum attestation", +); +requireText(workflow, "gh attestation verify", "provenance verification"); +requireText( + workflow, + "gh release verify", + "signed immutable release verification", +); +requireBefore( + workflow, + "scripts/release/verify-tag.mjs", + "node scripts/release/build.mjs", +); +requireBefore(workflow, "node scripts/release/verify.mjs", "gh release create"); +requireBefore( + workflow, + "Sign checksum metadata for managed hosts", + "gh release create", +); +requireText( + workflow, + "NEHEMIAH_RELEASE_MINISIGN_SECRET_KEY_B64", + "protected Minisign signing key", +); +requireText( + workflow, + "NEHEMIAH_RELEASE_MINISIGN_PUBLIC_KEY", + "reviewed Minisign public key", +); +requireText( + workflow, + "854c5f9dddaa99a02915f8cacd41e03442cb6cda25f7bbc53c0a3d297bcd064f", + "pinned Minisign package digest", +); +requireText(workflow, "SHA256SUMS.minisig", "managed-host checksum signature"); +requireText(buildScript, '"--offline"', "offline CLI installation smoke test"); +requireText( + buildScript, + '"--packages=bundle"', + "self-contained CLI dependency bundle", +); +requireText( + buildScript, + "installedPackage.dependencies === undefined", + "dependency-free release package assertion", +); +requireText( + buildScript, + "resolveRepositoryOutputDirectory", + "release output path containment", +); +requireText( + distributionDocumentation, + "Managed-host bootstrap contract", + "managed cloud-init distribution contract", +); +requireText( + buildScript, + "buildManagedHostBootstrapArtifact", + "deterministic managed-host bootstrap artifact", +); +requireText( + buildScript, + "stageManagedRuntimeArtifacts", + "retained managed runtime staging", +); +requireText( + buildScript, + "stageManagedHostPackageArtifacts", + "retained managed-host package staging", +); +requireText( + buildScript, + "inspect-managed-runtime-assets.sh", + "retained runtime type inspection", +); +requireText(cloudInit, "minisign -Vm", "fail-closed Minisign verification"); +requireText( + managedReleaseValidator, + '"DAEMON_ARTIFACT": f"nehemiahd_{version}_linux_{arch}.tar.gz"', + "versioned daemon artifact", +); +requireText( + managedReleaseValidator, + '"HOST_ARTIFACT": host["bootstrapArtifact"]', + "versioned host bootstrap artifact", +); +requireText( + managedReleaseValidator, + 'host["inputs"][candidate] != RUNTIME[candidate]', + "signed managed-host input pins", +); +requireText( + cloudInit, + "install_guest_image python /opt/boring/rootfs/rootfs.ext4", + "signed python image install", +); +requireText( + cloudInit, + "install_guest_image desktop /opt/boring/rootfs/desktop.ext4", + "signed desktop image install", +); +requireText(cloudInit, "gzip --test", "compressed image validation"); +requireText(cloudInit, "e2fsck -fn", "guest filesystem validation"); +requireText( + bootstrap, + "NEHEMIAH_FIRECRACKER_ARCHIVE", + "local signed-release Firecracker input", +); +requireText( + bootstrap, + "NEHEMIAH_KERNEL_IMAGE", + "local signed-release kernel input", +); +requireText( + cloudInit, + 'fetch_release_artifact "$FIRECRACKER_ARTIFACT"', + "release-only Firecracker fetch", +); +requireText( + cloudInit, + 'fetch_release_artifact "$KERNEL_ARTIFACT"', + "release-only kernel fetch", +); +invariant( + !bootstrap.includes("download_verified") && + !bootstrap.includes("NEHEMIAH_FIRECRACKER_URL") && + !bootstrap.includes("NEHEMIAH_KERNEL_URL") && + !bootstrap.includes("curl --fail"), + "managed bootstrap must not fetch runtime inputs from upstream", +); +requireText( + rootfsBuilder, + "managed rootfs builds are forbidden", + "local-only mutable rootfs guard", +); +invariant( + !bootstrap.includes("MINIROOTFS") && !bootstrap.includes("build-rootfs.sh"), + "managed bootstrap must not build a guest rootfs", +); +invariant( + !cloudInit.includes("MINIROOTFS") && !cloudInit.includes("build-rootfs.sh"), + "managed cloud-init must not fall back to a minimal guest build", +); +const parsedGuestPolicy = JSON.parse(guestPolicy); +const parsedManagedRuntimePolicy = JSON.parse(managedRuntimePolicyContents); +const parsedManagedHostPackagePolicy = JSON.parse( + managedHostPackagePolicyContents, +); +invariant( + parsedManagedRuntimePolicy.contractVersion === 1 && + JSON.stringify( + Object.keys(parsedManagedRuntimePolicy.architectures).sort(), + ) === JSON.stringify(["amd64", "arm64"]), + "managed runtime policy contract is not exact", +); +const expectedRuntimePins = { + amd64: { + firecracker: { + version: "1.15.1", + artifact: "nehemiah-runtime-firecracker_1.15.1_linux_amd64.tgz", + sha256: + "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a", + firecrackerSha256: + "7e8b57e88c459396d4680d83dcdd8c7f72305447cb55b11f4ac98ad70a3f7825", + jailerSha256: + "4830a9b1fc6cece036d8992ff12f1fe9c5247aacad77f42c7aba683c7a08622e", + }, + kernel: { + version: "6.1.155", + artifact: "nehemiah-runtime-kernel_6.1.155_linux_amd64.bin", + sha256: + "e20e46d0c36c55c0d1014eb20576171b3f3d922260d9f792017aeff53af3d4f2", + }, + }, + arm64: { + firecracker: { + version: "1.15.1", + artifact: "nehemiah-runtime-firecracker_1.15.1_linux_arm64.tgz", + sha256: + "00654ac1e702a22744121ea9f10a4f792ebd7c3a744cba587dfac9fcb79b41a5", + firecrackerSha256: + "e9ce7466c3b0d879d7a9158f4bf710dd5e131bbc5e580e5269fec66d5b5a0f0a", + jailerSha256: + "7faa581395fd1994ee005efc0a9c8826b4a9f0616dd942c2486adb8a8eac13f0", + }, + kernel: { + version: "6.1.155", + artifact: "nehemiah-runtime-kernel_6.1.155_linux_arm64.bin", + sha256: + "e3544b10603acbf3db492cb52e000d22ba202cb4b63b9add027565683e11c591", + }, + }, +}; +for (const arch of ["amd64", "arm64"]) { + for (const component of ["firecracker", "kernel"]) { + const runtime = parsedManagedRuntimePolicy.architectures[arch][component]; + const expected = expectedRuntimePins[arch][component]; + invariant( + runtime.version === expected.version && + runtime.artifact === expected.artifact && + runtime.sha256 === expected.sha256 && + (component !== "firecracker" || + (runtime.firecrackerSha256 === expected.firecrackerSha256 && + runtime.jailerSha256 === expected.jailerSha256)) && + runtime.format === + (component === "firecracker" ? "tgz" : "linux-kernel") && + Number.isSafeInteger(runtime.maxBytes) && + runtime.maxBytes > 0 && + runtime.maxBytes <= 64 * 1024 * 1024 && + /^https:\/\//.test(runtime.sourceUrl) && + !/[?#]/.test(runtime.sourceUrl) && + !runtime.sourceUrl.includes("/latest"), + `${arch} ${component} runtime input must be exact and digest-pinned`, + ); + } +} +requireText( + managedRuntimeFetcher, + "curl --fail --silent --show-error --location --proto '=https'", + "TLS-only managed runtime fetch", +); +requireText( + managedRuntimeFetcher, + '"$script_dir/inspect-managed-runtime-assets.sh"', + "post-download runtime inspection", +); +requireText( + managedRuntimeInspector, + "exact artifact set", + "retained runtime exact-set rejection", +); +requireText( + managedRuntimeInspector, + "wrong-architecture binary", + "retained runtime architecture rejection", +); +invariant( + parsedManagedHostPackagePolicy.contractVersion === 1 && + JSON.stringify(parsedManagedHostPackagePolicy.architectures) === + JSON.stringify(["amd64", "arm64"]) && + parsedManagedHostPackagePolicy.operatingSystem?.id === "ubuntu" && + parsedManagedHostPackagePolicy.operatingSystem?.version === "24.04" && + parsedManagedHostPackagePolicy.snapshot?.baseUrl === + "https://snapshot.ubuntu.com/ubuntu/20260809T000000Z" && + parsedManagedHostPackagePolicy.snapshot?.capturedAt === + "2026-08-09T00:00:00Z" && + parsedManagedHostPackagePolicy.snapshot?.maxAgeHours <= 168, + "managed-host package snapshot policy is not exact and fresh", +); +for (const suite of ["noble", "noble-security", "noble-updates"]) { + invariant( + /^[0-9a-f]{64}$/.test( + parsedManagedHostPackagePolicy.snapshot?.suites?.[suite]?.inReleaseSha256, + ), + `${suite} package repository metadata must be digest-pinned`, + ); +} +for (const runtimePackage of [ + "apt", + "bash", + "ca-certificates", + "curl", + "dnsmasq", + "e2fsprogs", + "file", + "iproute2", + "ipset", + "iptables", + "jq", + "kmod", + "minisign", + "openssl", + "python3", + "systemd", + "wireguard-tools", +]) { + invariant( + parsedManagedHostPackagePolicy.rootPackages.includes(runtimePackage), + `managed-host package policy omits ${runtimePackage}`, + ); +} +requireText( + managedHostPackageBuilder, + "production package repositories require a native", + "native package architecture guard", +); +requireText( + managedHostPackageBuilder, + "APT selected a package outside the retained repository", + "empty-host exact closure resolution", +); +requireText( + managedHostPackageInstaller, + '"--no-download"', + "network-free local APT install", +); +requireText( + managedHostPackageInstaller, + "installed package file drift detected", + "installed package drift gate", +); +for (const [label, contents] of [ + ["cloud-init", cloudInit], + ["managed bootstrap", bootstrap], + ["managed network setup", managedNetworkSetup], +]) { + invariant( + !/\b(?:apt|apt-get|aptitude)\s+(?:install|update|upgrade|full-upgrade|dist-upgrade)\b/.test( + contents, + ), + `${label} must not mutate a network package repository`, + ); +} +for (const arch of ["amd64", "arm64"]) { + const runtime = parsedGuestPolicy.architectures[arch].ociBase; + invariant( + runtime.nodeVersion === "24.19.0", + `${arch} Node 24 LTS pin is stale`, + ); + invariant( + /^docker\.io\/library\/node@sha256:[0-9a-f]{64}$/.test(runtime.reference), + `${arch} Node OCI base must be digest-pinned`, + ); + const apk = parsedGuestPolicy.alpineRepositorySnapshot.architectures[arch]; + const apkArch = arch === "amd64" ? "x86_64" : "aarch64"; + invariant( + apk.apkArchitecture === apkArch, + `${arch} APK architecture mismatch`, + ); + for (const repository of ["main", "community"]) { + invariant( + apk[repository].url === + `https://dl-cdn.alpinelinux.org/alpine/v3.23/${repository}/${apkArch}/APKINDEX.tar.gz` && + /^[0-9a-f]{64}$/.test(apk[repository].sha256), + `${arch} ${repository} APK index must be digest-pinned`, + ); + } +} +invariant( + parsedGuestPolicy.alpineRepositorySnapshot.release === "v3.23" && + parsedGuestPolicy.alpineRepositorySnapshot.capturedAt === + "2026-08-09T10:08:00Z" && + parsedGuestPolicy.alpineRepositorySnapshot.maxIndexAgeHours === 168, + "guest APK indexes must match the reviewed security refresh", +); +invariant( + parsedGuestPolicy.npmRuntime.version === "11.19.0" && + /^[0-9a-f]{64}$/.test(parsedGuestPolicy.npmRuntime.tarball.sha256) && + parsedGuestPolicy.npmRuntime.overlays.every((entry) => + /^[0-9a-f]{64}$/.test(entry.sha256), + ), + "guest npm runtime and security overlays must be digest-pinned", +); +invariant( + parsedGuestPolicy.pythonRuntime.pip.version === "26.2.1" && + parsedGuestPolicy.pythonRuntime.setuptools.version === "84.0.0" && + [ + parsedGuestPolicy.pythonRuntime.pip, + parsedGuestPolicy.pythonRuntime.setuptools, + ].every( + (entry) => + entry.url.startsWith("https://files.pythonhosted.org/packages/") && + /^[0-9a-f]{64}$/.test(entry.sha256), + ) && + JSON.stringify( + parsedGuestPolicy.pythonRuntime.pipVendorOverlays.map( + ({ name, version, format }) => ({ name, version, format }), + ), + ) === + JSON.stringify([ + { name: "msgpack", version: "1.2.1", format: "sdist" }, + { name: "setuptools", version: "80.9.0", format: "wheel" }, + ]) && + parsedGuestPolicy.pythonRuntime.pipVendorOverlays.every( + (entry) => + entry.url.startsWith("https://files.pythonhosted.org/packages/") && + /^[0-9a-f]{64}$/.test(entry.sha256), + ), + "guest Python packaging runtime must be current and digest-pinned", +); +invariant( + parsedGuestPolicy.vulnerabilityScan.version === "0.72.0" && + parsedGuestPolicy.vulnerabilityScan.maxDatabaseAgeHours <= 24, + "guest vulnerability scanner must be pinned with a fresh DB policy", +); +for (const scanner of Object.values( + parsedGuestPolicy.vulnerabilityScan.artifacts, +)) { + invariant( + scanner.url.startsWith( + "https://github.com/aquasecurity/trivy/releases/download/v0.72.0/", + ) && /^[0-9a-f]{64}$/.test(scanner.sha256), + "Trivy binary must use exact HTTPS and SHA-256", + ); +} +requireText( + guestImageBuilder, + "production guest images require a native", + "native architecture guard", +); +requireText( + guestImageBuilder, + "first_sha", + "deterministic guest image comparison", +); +requireText( + guestImageDockerfile, + 'test "$actual_indexes" = "$expected_indexes"', + "APK index digest enforcement before package resolution", +); +for (const securityOverlay of ["brace-expansion", "ip-address"]) { + requireText( + guestImageDockerfile, + `node_modules/${securityOverlay}`, + `pinned npm ${securityOverlay} security overlay`, + ); +} +requireText( + guestImageDockerfile, + "apply-python-runtime-overlays.py", + "digest-pinned pip vendor security overlays", +); +requireText( + pythonRuntimeOverlay, + "update_vendor_sbom", + "pip CycloneDX inventory security overlay", +); +requireText(guestScanner, "databaseSha256", "recorded scanner database digest"); +requireText( + guestScanner, + "max_age_hours * 3600", + "scanner DB freshness rejection", +); +requireText( + guestScanGate, + "--offline-scan", + "network-free final filesystem scan", +); +requireText( + guestScanGate, + "bc-guest-agent", + "injected guest-agent scan assertion", +); +requireText( + userDataRenderer, + "output already exists; refusing to overwrite it", + "private no-overwrite user-data rendering", +); +for (const key of [ + "NEHEMIAH_OTEL_ENABLED", + "NEHEMIAH_OTEL_ENDPOINT", + "NEHEMIAH_OTEL_AUTHORIZATION", + "NEHEMIAH_SERVICE_VERSION", + "NEHEMIAH_INSTANCE_ID", + "NEHEMIAH_DEPLOYMENT_ENVIRONMENT", + "NEHEMIAH_OTEL_EXPORT_INTERVAL_MS", + "NEHEMIAH_OTEL_EXPORT_TIMEOUT_MS", + "NEHEMIAH_OTEL_TRACE_SAMPLE_RATIO", +]) { + requireText(userDataRenderer, key, `required managed telemetry input ${key}`); + requireText( + cloudInit, + `: "\${${key}:?required}"`, + `cloud-init telemetry requirement ${key}`, + ); + requireText( + cloudInit, + `${key}=\${${key}}`, + `daemon telemetry environment ${key}`, + ); +} +for (const key of [ + "NEHEMIAH_RUNTIME_COHORT_ID", + "NEHEMIAH_RUNTIME_CONTRACT_VERSION", + "NEHEMIAH_RUNTIME_ARCH", + "NEHEMIAH_RUNTIME_PYTHON_SHA256", + "NEHEMIAH_RUNTIME_DESKTOP_SHA256", + "NEHEMIAH_RUNTIME_KERNEL_SHA256", + "NEHEMIAH_RUNTIME_FIRECRACKER_SHA256", + "NEHEMIAH_RUNTIME_JAILER_SHA256", +]) { + requireText( + cloudInit, + `${key}=`, + `managed runtime cohort environment ${key}`, + ); +} +requireText( + managedReleaseValidator, + '"contract_version=4\\n"', + "canonical managed runtime cohort", +); +requireText( + managedReleaseValidator, + 'host.get("runtimeCohorts", {}).get(candidate) != expected_cohort', + "signed runtime cohort verification", +); +requireText( + cloudInit, + 'python3 "$RELEASE_DIR/host/infra/latitude/managed-host-packages.py" install', + "signed offline package installation", +); +requireBefore( + cloudInit, + "/usr/local/libexec/nehemiah-verify-minisign", + 'managed-host-packages.py" install', +); +requireText( + userDataRenderer, + 'wireguard-config.py" canonicalize-base64', + "typed WireGuard canonicalization", +); +for (const forbiddenField of [ + "PreUp", + "PostUp", + "PreDown", + "PostDown", + "DNS", + "Table", + "SaveConfig", +]) { + invariant( + !managedWireGuardValidator.includes(`"${forbiddenField}"`), + `managed WireGuard allowlist unexpectedly permits ${forbiddenField}`, + ); +} +requireText( + managedWireGuardValidator, + "AllowedIPs may contain only distinct CP/gateway host routes", + "WireGuard host-route-only policy", +); +requireText( + managedWireGuardValidator, + "AllowedIPs must exactly match the approved CP/gateway host routes", + "WireGuard exact role-bound route policy", +); +for (const key of [ + "NEHEMIAH_WIREGUARD_CONTROL_PLANE_ADDRESS", + "NEHEMIAH_WIREGUARD_GATEWAY_ADDRESS", +]) { + requireText(userDataRenderer, key, `required WireGuard role input ${key}`); + requireText( + cloudInit, + `: "\${${key}:?required}"`, + `cloud-init WireGuard role requirement ${key}`, + ); + requireText( + cloudInit, + `${key}=\${${key}}`, + `daemon WireGuard role environment ${key}`, + ); +} +for (const script of [cloudInit, managedHostPreflight, managedNetworkSetup]) { + requireText( + script, + "--control-plane-address", + "WireGuard control-plane role verification", + ); + requireText( + script, + "--gateway-address", + "WireGuard gateway role verification", + ); + requireText(script, "--guest-subnet", "WireGuard guest-subnet exclusion"); +} +for (const key of [ + "LATITUDE_OS_ID", + "LATITUDE_OS_SLUG", + "LATITUDE_OS_VERSION", + "LATITUDE_OS_ARCH", +]) { + requireText(userDataRenderer, key, `required provider image input ${key}`); + requireText( + cloudInit, + `: "\${${key}:?required}"`, + `cloud-init provider image ${key}`, + ); +} +requireText( + latitudeProvisioner, + 'header = "Authorization: Bearer ${API_KEY}"', + "private curl authorization config", +); +requireText( + latitudeProvisioner, + "/plans/operating_systems?page%5Bsize%5D=100", + "live provider image id lookup", +); +requireText( + latitudeProvisioner, + "before creating user data or any billable server", + "provider image lookup ordering", +); +invariant( + !latitudeProvisioner.includes("LATITUDE_OS:-") && + !latitudeProvisioner.includes('LATITUDE_OS="'), + "provisioning must not default to a mutable provider OS slug", +); +for (const [label, contents] of [ + ["cloud-init", cloudInit], + ["managed bootstrap", bootstrap], +]) { + invariant( + !contents.includes("releases/latest") && + !contents.includes("/latest/download"), + `${label} must not fetch mutable latest assets`, + ); +} + +const actionReferences = [ + ...workflow.matchAll(/\buses:\s*([^\s@]+)@([^\s#]+)/g), +]; +invariant(actionReferences.length > 0, "release workflow contains no actions"); +for (const [, action, reference] of actionReferences) { + invariant( + /^[0-9a-f]{40}$/.test(reference), + `${action} must be pinned to a full commit SHA`, + ); +} + +for (const token of ["@@VERSION@@", "@@REPOSITORY@@", "@@SHA256@@"]) { + requireText(formulaTemplate, token, `Homebrew token ${token}`); +} +requireText( + formulaTemplate, + "*std_npm_args", + "Homebrew standard npm install arguments", +); +requireText(formulaTemplate, 'depends_on "node"', "Homebrew Node dependency"); + +validateVersion(cliPackage.version); +validateVersion(sdkPackage.version); +invariant( + cliPackage.version === sdkPackage.version, + "CLI and SDK package versions must match for a release", +); +invariant( + cliPackage.dependencies?.["nehemiah-sdk"] === `^${sdkPackage.version}`, + "CLI must depend on the matching SDK release line", +); +invariant( + cliPackage.devDependencies?.esbuild, + "CLI must declare the bundler used for self-contained releases", +); + +process.stdout.write(`release checks passed for ${cliPackage.version}\n`); diff --git a/scripts/release/ci-policy.mjs b/scripts/release/ci-policy.mjs new file mode 100644 index 0000000..286bcf9 --- /dev/null +++ b/scripts/release/ci-policy.mjs @@ -0,0 +1,393 @@ +import { invariant, validateCommit, validateRepository } from "./lib.mjs"; + +export const REQUIRED_CI_WORKFLOW = Object.freeze({ + name: "CI", + path: ".github/workflows/ci.yml", + jobs: Object.freeze([ + Object.freeze({ + id: "go", + name: "host + guest agents (test, vet, build)", + }), + Object.freeze({ + id: "workspace", + name: "workspace (check, lint, test, build)", + }), + Object.freeze({ + id: "wire-contract", + name: "generated wire contract (drift, type-check, compile)", + }), + Object.freeze({ id: "shell", name: "infra scripts (shellcheck)" }), + ]), +}); + +function validatePagedEvidence(evidence, property, label) { + invariant( + evidence && typeof evidence === "object" && !Array.isArray(evidence), + `${label} evidence must be an object`, + ); + invariant( + Number.isSafeInteger(evidence.total_count) && evidence.total_count >= 0, + `${label} evidence has an invalid total_count`, + ); + invariant( + Array.isArray(evidence[property]), + `${label} evidence is missing ${property}`, + ); + invariant( + evidence.total_count === evidence[property].length, + `${label} evidence is incomplete or paginated`, + ); + return evidence[property]; +} + +function validWorkflowRunPath(path, defaultBranch) { + return ( + path === REQUIRED_CI_WORKFLOW.path || + path === `${REQUIRED_CI_WORKFLOW.path}@${defaultBranch}` + ); +} + +// Select a completed, successful push run for the exact release commit only +// after GitHub proves the commit belongs to the repository's protected default +// branch. All API payloads are treated as untrusted evidence and checked again +// here rather than relying on shell filtering or a human-readable check name. +export function selectAuthorizedCIRun( + { repository, workflow, branch, comparison, runs }, + { expectedRepository, expectedCommit }, +) { + validateRepository(expectedRepository); + validateCommit(expectedCommit); + invariant( + repository?.full_name === expectedRepository, + "repository evidence does not match the release repository", + ); + const defaultBranch = repository?.default_branch; + invariant( + typeof defaultBranch === "string" && defaultBranch.length > 0, + "repository evidence is missing its default branch", + ); + invariant( + workflow?.name === REQUIRED_CI_WORKFLOW.name && + workflow?.path === REQUIRED_CI_WORKFLOW.path && + workflow?.state === "active" && + Number.isSafeInteger(workflow?.id) && + workflow.id > 0, + "required CI workflow is missing, inactive, or has unexpected identity", + ); + invariant( + branch?.name === defaultBranch, + "branch evidence is not for the repository default branch", + ); + invariant( + branch?.protected === true, + "repository default branch is not protected", + ); + const defaultHead = validateCommit(branch?.commit?.sha); + invariant( + comparison?.base_commit?.sha === expectedCommit, + "commit comparison is not based on the exact release commit", + ); + invariant( + comparison?.merge_base_commit?.sha === expectedCommit && + comparison?.behind_by === 0 && + (comparison?.status === "ahead" || comparison?.status === "identical"), + "release commit is not on the protected default branch", + ); + if (comparison.status === "identical") { + invariant( + defaultHead === expectedCommit, + "identical comparison does not match the protected default-branch head", + ); + } + + const workflowRuns = validatePagedEvidence( + runs, + "workflow_runs", + "CI workflow runs", + ); + for (const run of workflowRuns) { + invariant( + Number.isSafeInteger(run?.id) && run.id > 0, + "CI workflow evidence contains an invalid run id", + ); + invariant( + run?.workflow_id === workflow.id && + run?.name === REQUIRED_CI_WORKFLOW.name && + validWorkflowRunPath(run?.path, defaultBranch), + "CI workflow run has unexpected workflow identity", + ); + invariant( + run?.head_sha === expectedCommit && + run?.head_branch === defaultBranch && + run?.event === "push" && + run?.repository?.full_name === expectedRepository && + run?.head_repository?.full_name === expectedRepository, + "CI workflow run is not a default-branch push for the exact release commit", + ); + invariant( + Number.isSafeInteger(run?.run_attempt) && run.run_attempt > 0, + "CI workflow run has an invalid attempt", + ); + } + const successful = workflowRuns + .filter((run) => run.status === "completed" && run.conclusion === "success") + .sort( + (left, right) => + left.run_attempt - right.run_attempt || left.id - right.id, + ); + invariant( + successful.length > 0, + "exact release commit has no successful default-branch CI push run", + ); + return { defaultBranch, defaultHead, run: successful.at(-1) }; +} + +export function validateAuthorizedCIJobs( + jobsEvidence, + { run, expectedCommit, defaultBranch }, +) { + validateCommit(expectedCommit); + const jobs = validatePagedEvidence(jobsEvidence, "jobs", "CI workflow jobs"); + const byName = new Map(); + for (const job of jobs) { + invariant( + Number.isSafeInteger(job?.id) && job.id > 0, + "CI workflow evidence contains an invalid job id", + ); + invariant( + job?.run_id === run.id && + job?.head_sha === expectedCommit && + job?.head_branch === defaultBranch && + job?.workflow_name === REQUIRED_CI_WORKFLOW.name, + "CI job is not bound to the authorized exact-SHA workflow run", + ); + invariant( + typeof job?.name === "string" && !byName.has(job.name), + "CI workflow contains a missing or duplicate job name", + ); + byName.set(job.name, job); + } + const requiredNames = REQUIRED_CI_WORKFLOW.jobs + .map(({ name }) => name) + .sort(); + invariant( + JSON.stringify([...byName.keys()].sort()) === JSON.stringify(requiredNames), + "CI workflow did not execute the exact required job matrix", + ); + for (const name of requiredNames) { + const job = byName.get(name); + invariant( + job.status === "completed" && job.conclusion === "success", + `required CI job did not succeed: ${name}`, + ); + } + return true; +} + +export function parseWorkflowJobNames(workflow) { + invariant(typeof workflow === "string", "workflow YAML must be text"); + const jobs = new Map(); + let inJobs = false; + let current; + for (const line of workflow.split("\n")) { + if (/^jobs:\s*(?:#.*)?$/.test(line)) { + inJobs = true; + current = undefined; + continue; + } + if (!inJobs) continue; + if (/^[^\s#]/.test(line)) break; + const job = line.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):\s*(?:#.*)?$/); + if (job) { + current = job[1]; + invariant(!jobs.has(current), `duplicate workflow job id: ${current}`); + jobs.set(current, undefined); + continue; + } + const name = line.match(/^ name:\s*(.+?)\s*$/); + if (current && name && jobs.get(current) === undefined) { + jobs.set(current, name[1].replace(/^(?:"(.*)"|'(.*)')$/, "$1$2")); + } + } + return jobs; +} + +function workflowJobBlock(workflow, jobID) { + const lines = workflow.split("\n"); + const start = lines.findIndex((line) => + new RegExp(`^ ${jobID}:\\s*(?:#.*)?$`).test(line), + ); + invariant(start >= 0, `release workflow is missing job ${jobID}`); + let end = lines.length; + for (let index = start + 1; index < lines.length; index++) { + if (/^ [A-Za-z_][A-Za-z0-9_-]*:\s*(?:#.*)?$/.test(lines[index])) { + end = index; + break; + } + } + return lines.slice(start, end).join("\n"); +} + +function workflowStepBlock(job, stepName) { + const lines = job.split("\n"); + const marker = ` - name: ${stepName}`; + const start = lines.findIndex((line) => line === marker); + invariant(start >= 0, `release workflow is missing step ${stepName}`); + let end = lines.length; + for (let index = start + 1; index < lines.length; index++) { + if (/^ - name:\s+/.test(lines[index])) { + end = index; + break; + } + } + return lines.slice(start, end).join("\n"); +} + +// Offline policy validation runs in both CI and the release workflow. Keeping +// the job-name matrix exact makes adding or removing a CI job a reviewed +// release-policy change rather than silently weakening old-tag authorization. +export function validateReleaseWorkflowPolicy( + releaseWorkflow, + ciWorkflow, + authorizationScript, +) { + const actualJobs = parseWorkflowJobNames(ciWorkflow); + const expectedIDs = REQUIRED_CI_WORKFLOW.jobs.map(({ id }) => id).sort(); + invariant( + JSON.stringify([...actualJobs.keys()].sort()) === + JSON.stringify(expectedIDs), + "CI workflow jobs do not match the exact release-required matrix", + ); + for (const { id, name } of REQUIRED_CI_WORKFLOW.jobs) { + invariant( + actualJobs.get(id) === name, + `CI job ${id} does not match its release-required check name`, + ); + } + invariant( + ciWorkflow.includes("push:\n branches: [main]"), + "CI must run on pushes to the configured default branch", + ); + const ciWorkspaceJob = workflowJobBlock(ciWorkflow, "workspace"); + for (const required of [ + "Check release authorization and artifact policy", + "node --test scripts/release/test/*.test.mjs", + "node scripts/release/check.mjs", + "prettier --check .github/workflows/ci.yml .github/workflows/release.yml", + ]) { + invariant( + ciWorkspaceJob.includes(required), + `required exact-SHA CI does not enforce release policy: ${required}`, + ); + } + invariant( + typeof authorizationScript === "string", + "release CI authorization script is unavailable", + ); + for (const required of [ + "set -euo pipefail", + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml", + "branches/${encoded_branch}", + "compare/${GITHUB_SHA}...${default_head}", + "head_sha=${GITHUB_SHA}&event=push&status=completed&per_page=100", + 'verify-ci.mjs" --phase select', + "jobs?filter=latest&per_page=100", + "--phase verify", + 'rev-parse HEAD)\" == "$GITHUB_SHA"', + ]) { + invariant( + authorizationScript.includes(required), + `release CI authorization script is missing: ${required}`, + ); + } + invariant( + !authorizationScript.includes("|| true") && + !authorizationScript.includes("--paginate"), + "release CI authorization must not suppress or truncate evidence errors", + ); + + const validateJob = workflowJobBlock(releaseWorkflow, "validate"); + invariant( + validateJob.includes("actions: read") && + validateJob.includes("contents: read"), + "release validation needs read-only Actions and contents evidence", + ); + const authorizationStep = workflowStepBlock( + validateJob, + "Authorize protected default-branch CI for the exact tag commit", + ); + for (const required of [ + "if: github.event_name == 'push'", + "GH_TOKEN: ${{ github.token }}", + "run: scripts/release/authorize-ci.sh", + ]) { + invariant( + authorizationStep.includes(required), + `release validation is missing exact-SHA CI policy: ${required}`, + ); + } + invariant( + !authorizationStep.includes("continue-on-error"), + "exact-SHA CI authorization must fail the validation job", + ); + const buildJob = workflowJobBlock(releaseWorkflow, "build"); + const guestImagesJob = workflowJobBlock(releaseWorkflow, "guest-images"); + const attestJob = workflowJobBlock(releaseWorkflow, "attest"); + const releaseJob = workflowJobBlock(releaseWorkflow, "release"); + invariant( + buildJob.includes("- validate") && + guestImagesJob.includes("needs: validate"), + "every release build must depend on authorization", + ); + invariant( + attestJob.includes("- validate") && + attestJob.includes("- build") && + attestJob.includes("actions: read"), + "attestation must depend on authorized deterministic builds", + ); + const attestationAuthorization = workflowStepBlock( + attestJob, + "Reauthorize protected default-branch CI before attestation", + ); + invariant( + attestationAuthorization.includes("GH_TOKEN: ${{ github.token }}") && + attestationAuthorization.includes( + "run: scripts/release/authorize-ci.sh", + ) && + !attestationAuthorization.includes("continue-on-error"), + "attestation job must reauthorize exact-SHA CI before signing", + ); + invariant( + releaseJob.includes("- validate") && + releaseJob.includes("- attest") && + releaseJob.includes("actions: read"), + "publication must depend on authorization and attestation", + ); + invariant( + releaseJob.includes( + "if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')", + ) && releaseJob.includes("environment: release"), + "publication must remain limited to v* tags in the protected release environment", + ); + const publicationAuthorization = workflowStepBlock( + releaseJob, + "Reauthorize protected default-branch CI before signing", + ); + invariant( + publicationAuthorization.includes("GH_TOKEN: ${{ github.token }}") && + publicationAuthorization.includes( + "run: scripts/release/authorize-ci.sh", + ) && + !publicationAuthorization.includes("continue-on-error"), + "protected release job must reauthorize exact-SHA CI before signing", + ); + invariant( + releaseJob.includes("gh release create") && + !validateJob.includes("gh release create") && + !buildJob.includes("gh release create") && + !guestImagesJob.includes("gh release create") && + !attestJob.includes("gh release create"), + "GitHub Release publication escaped the authorized release job", + ); + return true; +} diff --git a/scripts/release/fetch-managed-runtime-assets.sh b/scripts/release/fetch-managed-runtime-assets.sh new file mode 100755 index 0000000..1fe712e --- /dev/null +++ b/scripts/release/fetch-managed-runtime-assets.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail +export LC_ALL=C + +if [[ "$#" -ne 1 ]]; then + echo "usage: fetch-managed-runtime-assets.sh OUTPUT_DIRECTORY" >&2 + exit 64 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +policy="$script_dir/managed-runtime-policy.json" +requested_output="$1" + +[[ -f "$policy" && ! -L "$policy" ]] \ + || { echo "managed runtime policy is missing or unsafe" >&2; exit 1; } +output_name="$(basename -- "$requested_output")" +output_parent="$(cd "$(dirname -- "$requested_output")" && pwd -P)" +[[ "$output_name" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]{0,254}$ && \ + "$output_name" != . && "$output_name" != .. ]] \ + || { echo "unsafe managed runtime output name" >&2; exit 1; } +output="$output_parent/$output_name" +[[ ! -e "$output" && ! -L "$output" ]] \ + || { echo "managed runtime output already exists" >&2; exit 1; } + +jq --exit-status ' + .contractVersion == 1 and + ((.architectures | keys | sort) == ["amd64", "arm64"]) and + ([.architectures | to_entries[] as $arch | + ($arch.value | to_entries[]) as $component | + {arch: $arch.key, component: $component.key, input: $component.value}] | + length == 4 and all( + (.component == "firecracker" or .component == "kernel") and + ((.input | keys | sort) == + (if .component == "firecracker" then + ["artifact", "firecrackerSha256", "format", "jailerSha256", "maxBytes", "sha256", "sourceUrl", "version"] + else + ["artifact", "format", "maxBytes", "sha256", "sourceUrl", "version"] + end)) and + (.input.version | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")) and + (.input.artifact | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._+-]{0,254}$")) and + (.input.format == (if .component == "firecracker" then "tgz" else "linux-kernel" end)) and + (.input.maxBytes | type == "number" and . > 0 and . <= 67108864) and + (.input.sha256 | type == "string" and test("^[0-9a-f]{64}$")) and + (if .component == "firecracker" then + (.input.firecrackerSha256 | type == "string" and test("^[0-9a-f]{64}$")) and + (.input.jailerSha256 | type == "string" and test("^[0-9a-f]{64}$")) + else true end) and + (.input.sourceUrl | type == "string" and + test("^https://[A-Za-z0-9.-]+(?::[0-9]{1,5})?/[A-Za-z0-9._~%+,:=@/-]+$") and + (contains("/latest") | not)) + )) +' "$policy" >/dev/null || { + echo "managed runtime policy is malformed or mutable" >&2 + exit 1 +} + +staging="$(mktemp -d "$output_parent/.${output_name}.partial.XXXXXX")" +partial="" +cleanup() { + local status=$? + if [[ -n "$staging" && -d "$staging" && \ + "$staging" == "$output_parent/.${output_name}.partial."* ]]; then + rm -rf -- "$staging" + fi + return "$status" +} +trap cleanup EXIT + +for arch in amd64 arm64; do + for component in firecracker kernel; do + artifact="$(jq -er --arg arch "$arch" --arg component "$component" \ + '.architectures[$arch][$component].artifact' "$policy")" + source_url="$(jq -er --arg arch "$arch" --arg component "$component" \ + '.architectures[$arch][$component].sourceUrl' "$policy")" + expected_sha="$(jq -er --arg arch "$arch" --arg component "$component" \ + '.architectures[$arch][$component].sha256' "$policy")" + max_bytes="$(jq -er --arg arch "$arch" --arg component "$component" \ + '.architectures[$arch][$component].maxBytes' "$policy")" + partial="$staging/.${artifact}.partial" + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + --retry 3 --retry-all-errors --max-filesize "$max_bytes" \ + "$source_url" -o "$partial" + [[ -f "$partial" && ! -L "$partial" && -s "$partial" && \ + "$(stat -c %s "$partial")" -le "$max_bytes" ]] \ + || { echo "$arch $component download violates the size policy" >&2; exit 1; } + printf '%s %s\n' "$expected_sha" "$partial" \ + | sha256sum --check --strict --status \ + || { echo "$arch $component download checksum mismatch" >&2; exit 1; } + chmod 0644 "$partial" + mv -T -- "$partial" "$staging/$artifact" + partial="" + done +done + +"$script_dir/inspect-managed-runtime-assets.sh" "$policy" "$staging" +[[ ! -e "$output" && ! -L "$output" ]] \ + || { echo "managed runtime output appeared during the build" >&2; exit 1; } +mv -T -- "$staging" "$output" +staging="" +printf 'retained managed runtime assets in %s\n' "$output" diff --git a/scripts/release/formula.mjs b/scripts/release/formula.mjs new file mode 100644 index 0000000..7e067dc --- /dev/null +++ b/scripts/release/formula.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + invariant, + parseArguments, + parseChecksums, + renderFormula, + validateRepository, + validateVersion, +} from "./lib.mjs"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const options = parseArguments(process.argv.slice(2), [ + "version", + "repository", + "checksums", + "out", + "template", +]); +const version = validateVersion(options.version); +const repository = validateRepository(options.repository); +invariant(options.checksums, "--checksums is required"); +invariant(options.out, "--out is required"); +const checksums = parseChecksums( + await readFile(path.resolve(options.checksums), "utf8"), +); +const cliName = `nehemiah-cli-${version}.tgz`; +invariant(checksums.has(cliName), `${cliName} is missing from SHA256SUMS`); +const templatePath = path.resolve( + options.template ?? path.join(scriptDirectory, "templates/nehemiah.rb.tpl"), +); +const formula = renderFormula(await readFile(templatePath, "utf8"), { + version, + repository, + sha256: checksums.get(cliName), +}); +await writeFile(path.resolve(options.out), formula, { + encoding: "utf8", + mode: 0o644, +}); diff --git a/scripts/release/guest-images/Dockerfile b/scripts/release/guest-images/Dockerfile new file mode 100644 index 0000000..54de6af --- /dev/null +++ b/scripts/release/guest-images/Dockerfile @@ -0,0 +1,130 @@ +# syntax=docker/dockerfile:1.7 +# Production inputs are supplied by policy.json. The OCI platform manifest, +# Alpine repository indexes, npm tarball, and npm security overlays are all +# digest-pinned. No tag or unverified package index participates in the build. +ARG BASE_IMAGE +FROM ${BASE_IMAGE} AS common + +ARG APK_MAIN_REPOSITORY +ARG APK_COMMUNITY_REPOSITORY +ARG APK_MAIN_INDEX_SHA256 +ARG APK_COMMUNITY_INDEX_SHA256 +ARG COMMON_PACKAGES +ARG NODE_VERSION +ARG NPM_VERSION +ARG NPM_TARBALL_URL +ARG NPM_TARBALL_SHA256 +ARG BRACE_EXPANSION_VERSION +ARG BRACE_EXPANSION_URL +ARG BRACE_EXPANSION_SHA256 +ARG IP_ADDRESS_VERSION +ARG IP_ADDRESS_URL +ARG IP_ADDRESS_SHA256 +ARG PIP_VERSION +ARG PIP_WHEEL_URL +ARG PIP_WHEEL_SHA256 +ARG SETUPTOOLS_VERSION +ARG SETUPTOOLS_WHEEL_URL +ARG SETUPTOOLS_WHEEL_SHA256 +ARG PIP_VENDOR_MSGPACK_VERSION +ARG PIP_VENDOR_MSGPACK_URL +ARG PIP_VENDOR_MSGPACK_SHA256 +ARG PIP_VENDOR_SETUPTOOLS_VERSION +ARG PIP_VENDOR_SETUPTOOLS_URL +ARG PIP_VENDOR_SETUPTOOLS_SHA256 + +COPY --chmod=0555 apply-python-runtime-overlays.py /usr/local/libexec/apply-python-runtime-overlays.py + +RUN set -eu; \ + test -n "$APK_MAIN_REPOSITORY"; \ + test -n "$APK_COMMUNITY_REPOSITORY"; \ + test -n "$APK_MAIN_INDEX_SHA256"; \ + test -n "$APK_COMMUNITY_INDEX_SHA256"; \ + printf '%s\n' "$APK_MAIN_REPOSITORY" "$APK_COMMUNITY_REPOSITORY" > /etc/apk/repositories; \ + apk update; \ + actual_indexes="$(sha256sum /var/cache/apk/APKINDEX.*.tar.gz | awk '{print $1}' | sort)"; \ + expected_indexes="$(printf '%s\n%s\n' "$APK_MAIN_INDEX_SHA256" "$APK_COMMUNITY_INDEX_SHA256" | sort)"; \ + test "$actual_indexes" = "$expected_indexes"; \ + apk upgrade --available; \ + apk add ${COMMON_PACKAGES}; \ + test "$(node --version)" = "v${NODE_VERSION}"; \ + git --version >/dev/null; \ + curl --version >/dev/null; \ + test -x /bin/sh; \ + curl --fail --location --proto '=https' --tlsv1.2 "$PIP_WHEEL_URL" --output /tmp/pip.whl; \ + echo "$PIP_WHEEL_SHA256 /tmp/pip.whl" | sha256sum -c -; \ + curl --fail --location --proto '=https' --tlsv1.2 "$SETUPTOOLS_WHEEL_URL" --output /tmp/setuptools.whl; \ + echo "$SETUPTOOLS_WHEEL_SHA256 /tmp/setuptools.whl" | sha256sum -c -; \ + curl --fail --location --proto '=https' --tlsv1.2 "$PIP_VENDOR_MSGPACK_URL" --output /tmp/pip-vendor-msgpack.tar.gz; \ + echo "$PIP_VENDOR_MSGPACK_SHA256 /tmp/pip-vendor-msgpack.tar.gz" | sha256sum -c -; \ + curl --fail --location --proto '=https' --tlsv1.2 "$PIP_VENDOR_SETUPTOOLS_URL" --output /tmp/pip-vendor-setuptools.whl; \ + echo "$PIP_VENDOR_SETUPTOOLS_SHA256 /tmp/pip-vendor-setuptools.whl" | sha256sum -c -; \ + python_site="/usr/lib/python$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')/site-packages"; \ + install -d -m 0755 "$python_site"; \ + python3 -m zipfile -e /tmp/pip.whl "$python_site"; \ + python3 -m zipfile -e /tmp/setuptools.whl "$python_site"; \ + python3 /usr/local/libexec/apply-python-runtime-overlays.py \ + --site-packages "$python_site" \ + --msgpack-archive /tmp/pip-vendor-msgpack.tar.gz \ + --msgpack-version "$PIP_VENDOR_MSGPACK_VERSION" \ + --setuptools-wheel /tmp/pip-vendor-setuptools.whl \ + --setuptools-version "$PIP_VENDOR_SETUPTOOLS_VERSION"; \ + printf '%s\n' '#!/bin/sh' 'exec python3 -m pip "$@"' > /usr/local/bin/pip; \ + chmod 0755 /usr/local/bin/pip; \ + ln -s pip /usr/local/bin/pip3; \ + test "$(PYTHONDONTWRITEBYTECODE=1 python3 -c 'import importlib.metadata; print(importlib.metadata.version("pip"))')" = "$PIP_VERSION"; \ + test "$(PYTHONDONTWRITEBYTECODE=1 python3 -c 'import importlib.metadata; print(importlib.metadata.version("setuptools"))')" = "$SETUPTOOLS_VERSION"; \ + test "$(PYTHONDONTWRITEBYTECODE=1 python3 -c 'from pip._vendor import msgpack; print(".".join(map(str, msgpack.version)))')" = "$PIP_VENDOR_MSGPACK_VERSION"; \ + PYTHONDONTWRITEBYTECODE=1 python3 -c 'from pip._vendor import pkg_resources' 2>/dev/null; \ + PYTHONDONTWRITEBYTECODE=1 python3 -m pip debug --verbose >/dev/null; \ + jq -e --arg msgpack "$PIP_VENDOR_MSGPACK_VERSION" --arg setuptools "$PIP_VENDOR_SETUPTOOLS_VERSION" \ + '([.components[] | select(.name == "msgpack") | .version] == [$msgpack]) and \ + ([.components[] | select(.name == "setuptools") | .version] == [$setuptools])' \ + "$python_site/pip/_vendor/bom.cdx.json" >/dev/null; \ + curl --fail --location --proto '=https' --tlsv1.2 "$NPM_TARBALL_URL" --output /tmp/npm.tgz; \ + echo "$NPM_TARBALL_SHA256 /tmp/npm.tgz" | sha256sum -c -; \ + mkdir /tmp/npm; \ + tar -xzf /tmp/npm.tgz -C /tmp/npm --strip-components=1; \ + test "$(jq -r .version /tmp/npm/package.json)" = "$NPM_VERSION"; \ + rm -rf /usr/local/lib/node_modules/npm; \ + mv /tmp/npm /usr/local/lib/node_modules/npm; \ + curl --fail --location --proto '=https' --tlsv1.2 "$BRACE_EXPANSION_URL" --output /tmp/brace-expansion.tgz; \ + echo "$BRACE_EXPANSION_SHA256 /tmp/brace-expansion.tgz" | sha256sum -c -; \ + mkdir /tmp/brace-expansion; \ + tar -xzf /tmp/brace-expansion.tgz -C /tmp/brace-expansion --strip-components=1; \ + test "$(jq -r .name /tmp/brace-expansion/package.json)" = brace-expansion; \ + test "$(jq -r .version /tmp/brace-expansion/package.json)" = "$BRACE_EXPANSION_VERSION"; \ + rm -rf /usr/local/lib/node_modules/npm/node_modules/brace-expansion; \ + mv /tmp/brace-expansion /usr/local/lib/node_modules/npm/node_modules/brace-expansion; \ + curl --fail --location --proto '=https' --tlsv1.2 "$IP_ADDRESS_URL" --output /tmp/ip-address.tgz; \ + echo "$IP_ADDRESS_SHA256 /tmp/ip-address.tgz" | sha256sum -c -; \ + mkdir /tmp/ip-address; \ + tar -xzf /tmp/ip-address.tgz -C /tmp/ip-address --strip-components=1; \ + test "$(jq -r .name /tmp/ip-address/package.json)" = ip-address; \ + test "$(jq -r .version /tmp/ip-address/package.json)" = "$IP_ADDRESS_VERSION"; \ + rm -rf /usr/local/lib/node_modules/npm/node_modules/ip-address; \ + mv /tmp/ip-address /usr/local/lib/node_modules/npm/node_modules/ip-address; \ + test "$(npm --version)" = "$NPM_VERSION"; \ + test "$(jq -r .version /usr/local/lib/node_modules/npm/node_modules/tar/package.json)" = 7.5.19; \ + test "$(jq -r .version /usr/local/lib/node_modules/npm/node_modules/undici/package.json)" = 6.27.0; \ + rm -rf /root/.npm /var/cache/misc/* /var/log/* /tmp/* /var/tmp/*; \ + rm -f /usr/local/libexec/apply-python-runtime-overlays.py; \ + rm -f /etc/machine-id; \ + : > /etc/machine-id + +FROM common AS python +RUN rm -rf /var/cache/apk/* + +FROM common AS desktop +ARG DESKTOP_PACKAGES +RUN set -eu; \ + printf '%032d\n' 0 > /etc/machine-id; \ + apk add ${DESKTOP_PACKAGES}; \ + command -v Xvfb >/dev/null; \ + command -v openbox >/dev/null; \ + command -v x11vnc >/dev/null; \ + command -v chromium >/dev/null; \ + command -v socat >/dev/null; \ + rm -rf /var/cache/apk/* /var/cache/misc/* /var/log/* /tmp/* /var/tmp/*; \ + rm -f /etc/machine-id; \ + : > /etc/machine-id diff --git a/scripts/release/guest-images/apply-python-runtime-overlays.py b/scripts/release/guest-images/apply-python-runtime-overlays.py new file mode 100644 index 0000000..423de7f --- /dev/null +++ b/scripts/release/guest-images/apply-python-runtime-overlays.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Apply digest-verified security overlays to pip's vendored runtime. + +The caller verifies both archives before invoking this script. This script +then accepts only the exact upstream package layouts and exact source snippets +reviewed for the pinned versions. It intentionally avoids executing either +distribution's build backend. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import tarfile +import tempfile +import zipfile +from pathlib import Path, PurePosixPath + + +MSGPACK_FILES = ( + "__init__.py", + "exceptions.py", + "ext.py", + "fallback.py", +) + +SETUPTOOLS_VENDOR_PATH = "pkg_resources/__init__.py" + +UPSTREAM_IMPORTS = """sys.path.extend(((vendor_path := os.path.join(os.path.dirname(os.path.dirname(__file__)), 'setuptools', '_vendor')) not in sys.path) * [vendor_path]) # fmt: skip +# workaround for #4476 +sys.modules.pop('backports', None) +""" + +PIP_IMPORTS = """# pip provides its own isolated dependency namespace. +# workaround for #4476 +sys.modules.pop('backports', None) +""" + +UPSTREAM_DEPENDENCIES = """import packaging.markers +import packaging.requirements +import packaging.specifiers +import packaging.utils +import packaging.version +from jaraco.text import drop_comment, join_continuation, yield_lines +from platformdirs import user_cache_dir as _user_cache_dir +""" + +PIP_DEPENDENCIES = """from pip._vendor import packaging +from pip._vendor.packaging import markers, requirements, specifiers, utils, version +from pip._internal.utils._jaraco_text import ( + drop_comment, + join_continuation, + yield_lines, +) +from pip._vendor.platformdirs import user_cache_dir as _user_cache_dir +""" + +UPSTREAM_WARNING = """warnings.warn( + \"pkg_resources is deprecated as an API. \" + \"See https://setuptools.pypa.io/en/latest/pkg_resources.html. \" + \"The pkg_resources package is slated for removal as early as \" + \"2025-11-30. Refrain from using this package or pin to \" + \"Setuptools<81.\", + UserWarning, + stacklevel=2, +) +""" + +PIP_WARNING = """# The vendored compatibility module is internal to pip. Do not emit +# setuptools' public-API deprecation warning when pip imports its fallback. +""" + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def safe_member(name: str) -> bool: + path = PurePosixPath(name) + return bool(name) and not path.is_absolute() and ".." not in path.parts + + +def install_msgpack(archive: Path, destination: Path, version: str) -> None: + prefix = f"msgpack-{version}/msgpack/" + expected = {f"{prefix}{name}" for name in MSGPACK_FILES} + with tarfile.open(archive, mode="r:gz") as bundle: + members = {member.name: member for member in bundle.getmembers()} + if any(not safe_member(name) for name in members): + fail("msgpack overlay contains an unsafe archive entry") + if not expected.issubset(members): + fail("msgpack overlay is missing its reviewed pure-Python runtime") + if any( + not members[name].isfile() or members[name].size > 2 * 1024 * 1024 + for name in expected + ): + fail("msgpack overlay contains an unsafe runtime member") + with tempfile.TemporaryDirectory(prefix="pip-msgpack-overlay.") as raw: + staging = Path(raw) / "msgpack" + staging.mkdir(mode=0o755) + for name in MSGPACK_FILES: + member = members[f"{prefix}{name}"] + source = bundle.extractfile(member) + if source is None: + fail("msgpack overlay member could not be read") + (staging / name).write_bytes(source.read()) + shutil.rmtree(destination) + shutil.copytree(staging, destination) + + +def install_pkg_resources(archive: Path, destination: Path) -> None: + with zipfile.ZipFile(archive) as bundle: + infos = bundle.infolist() + if any(not safe_member(info.filename) for info in infos): + fail("setuptools overlay contains an unsafe archive entry") + try: + info = bundle.getinfo(SETUPTOOLS_VENDOR_PATH) + except KeyError: + fail("setuptools overlay is missing pkg_resources") + if info.file_size <= 0 or info.file_size > 2 * 1024 * 1024: + fail("setuptools pkg_resources member has an unsafe size") + source = bundle.read(info).decode("utf-8") + + replacements = ( + (UPSTREAM_IMPORTS, PIP_IMPORTS), + (UPSTREAM_DEPENDENCIES, PIP_DEPENDENCIES), + (UPSTREAM_WARNING, PIP_WARNING), + ) + for old, new in replacements: + if source.count(old) != 1: + fail("setuptools overlay no longer matches the reviewed pip patch") + source = source.replace(old, new) + destination.write_text(source, encoding="utf-8", newline="\n") + + +def update_vendor_inventory(path: Path, msgpack_version: str, setuptools_version: str) -> None: + source = path.read_text(encoding="utf-8") + replacements = ( + ("msgpack==1.1.2\n", f"msgpack=={msgpack_version}\n"), + ("setuptools==70.3.0\n", f"setuptools=={setuptools_version}\n"), + ) + for old, new in replacements: + if source.count(old) != 1: + fail("pip vendor inventory no longer matches the reviewed baseline") + source = source.replace(old, new) + path.write_text(source, encoding="utf-8", newline="\n") + + +def update_vendor_sbom(path: Path, msgpack_version: str, setuptools_version: str) -> None: + document = json.loads(path.read_text(encoding="utf-8")) + expected = {"msgpack": "1.1.2", "setuptools": "70.3.0"} + replacement = { + "msgpack": msgpack_version, + "setuptools": setuptools_version, + } + components = document.get("components") + if not isinstance(components, list): + fail("pip vendor SBOM has no component inventory") + seen: set[str] = set() + for component in components: + name = component.get("name") if isinstance(component, dict) else None + if name not in expected: + continue + if name in seen or component.get("version") != expected[name]: + fail("pip vendor SBOM no longer matches the reviewed baseline") + old_purl = f"pkg:pypi/{name}@{expected[name]}" + if component.get("bom-ref") != old_purl or component.get("purl") != old_purl: + fail("pip vendor SBOM component identity is malformed") + component["version"] = replacement[name] + seen.add(name) + if seen != set(expected): + fail("pip vendor SBOM is missing a security overlay component") + + old_purls = {f"pkg:pypi/{name}@{version}": name for name, version in expected.items()} + + def replace_purls(value: object) -> object: + if isinstance(value, str) and value in old_purls: + name = old_purls[value] + return f"pkg:pypi/{name}@{replacement[name]}" + if isinstance(value, list): + return [replace_purls(item) for item in value] + if isinstance(value, dict): + return {key: replace_purls(item) for key, item in value.items()} + return value + + document = replace_purls(document) + encoded = json.dumps(document, indent=2, ensure_ascii=False) + "\n" + for name, old_version in expected.items(): + if old_version in encoded or f"pkg:pypi/{name}@{old_version}" in encoded: + fail("pip vendor SBOM retained a superseded component") + path.write_text(encoded, encoding="utf-8", newline="\n") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--site-packages", required=True, type=Path) + parser.add_argument("--msgpack-archive", required=True, type=Path) + parser.add_argument("--msgpack-version", required=True) + parser.add_argument("--setuptools-wheel", required=True, type=Path) + parser.add_argument("--setuptools-version", required=True) + args = parser.parse_args() + + pip_vendor = args.site_packages / "pip" / "_vendor" + for path in (pip_vendor, args.msgpack_archive, args.setuptools_wheel): + if not path.exists() or path.is_symlink(): + fail(f"unsafe or missing Python overlay input: {path}") + + install_msgpack( + args.msgpack_archive, + pip_vendor / "msgpack", + args.msgpack_version, + ) + install_pkg_resources( + args.setuptools_wheel, + pip_vendor / "pkg_resources" / "__init__.py", + ) + update_vendor_inventory( + pip_vendor / "vendor.txt", + args.msgpack_version, + args.setuptools_version, + ) + update_vendor_sbom( + pip_vendor / "bom.cdx.json", + args.msgpack_version, + args.setuptools_version, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/release/guest-images/assemble-rootfs.sh b/scripts/release/guest-images/assemble-rootfs.sh new file mode 100755 index 0000000..6feebed --- /dev/null +++ b/scripts/release/guest-images/assemble-rootfs.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Runs inside the digest-pinned guest build image. It does not use the network. +set -euo pipefail +export LC_ALL=C +umask 022 + +if [[ "$#" -ne 9 ]]; then + echo "usage: assemble-rootfs.sh EXPORT AGENT INIT POLICY PACKAGES IMAGE FLAVOR ARCH VERSION" >&2 + exit 64 +fi + +export_tar="$1" +guest_agent="$2" +init_script="$3" +policy_path="$4" +packages_path="$5" +image_path="$6" +flavor="$7" +arch="$8" +version="$9" + +case "$flavor" in python | desktop) ;; *) echo "invalid flavor" >&2; exit 64 ;; esac +case "$arch" in amd64 | arm64) ;; *) echo "invalid architecture" >&2; exit 64 ;; esac +[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)([-+][0-9A-Za-z.-]+)?$ ]] \ + || { echo "invalid version" >&2; exit 64; } +for input in "$export_tar" "$guest_agent" "$init_script" "$policy_path" "$packages_path"; do + [[ -f "$input" && ! -L "$input" ]] || { echo "unsafe or missing input: $input" >&2; exit 1; } +done + +work_dir="$(dirname "$image_path")" +root_dir="$work_dir/rootfs" +[[ ! -e "$root_dir" ]] || { echo "rootfs staging path already exists" >&2; exit 1; } +mkdir -p "$root_dir" +tar --extract --file "$export_tar" --directory "$root_dir" --numeric-owner --same-owner \ + --exclude='./dev/*' --exclude='dev/*' + +rm -rf "${root_dir:?}/dev" "$root_dir/proc" "$root_dir/run" "$root_dir/sys" "$root_dir/tmp" +install -d -m 0755 \ + "$root_dir/dev" "$root_dir/dev/pts" "$root_dir/proc" "$root_dir/run" \ + "$root_dir/sys" "$root_dir/tmp" "$root_dir/workspace" \ + "$root_dir/opt/boring/bin" "$root_dir/usr/share/nehemiah" +chmod 1777 "$root_dir/tmp" +install -m 0755 "$guest_agent" "$root_dir/opt/boring/bin/bc-guest-agent" +install -m 0755 "$init_script" "$root_dir/sbin/boring-init" +if [[ "$flavor" == python ]]; then + ln -sfn boring-init "$root_dir/sbin/init" +fi + +policy_sha="$(sha256sum "$policy_path" | awk '{print $1}')" +base_reference="$(jq -er --arg arch "$arch" '.architectures[$arch].ociBase.reference' "$policy_path")" +repository_snapshot="$(jq -er '.alpineRepositorySnapshot.capturedAt' "$policy_path")" +main_index_sha="$(jq -er --arg arch "$arch" '.alpineRepositorySnapshot.architectures[$arch].main.sha256' "$policy_path")" +community_index_sha="$(jq -er --arg arch "$arch" '.alpineRepositorySnapshot.architectures[$arch].community.sha256' "$policy_path")" +npm_tarball_sha="$(jq -er '.npmRuntime.tarball.sha256' "$policy_path")" +pip_wheel_sha="$(jq -er '.pythonRuntime.pip.sha256' "$policy_path")" +setuptools_wheel_sha="$(jq -er '.pythonRuntime.setuptools.sha256' "$policy_path")" +pip_vendor_msgpack_sha="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "msgpack") | .sha256' "$policy_path")" +pip_vendor_setuptools_sha="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "setuptools") | .sha256' "$policy_path")" +source_date_epoch="${SOURCE_DATE_EPOCH:?SOURCE_DATE_EPOCH is required}" +jq -n \ + --arg version "$version" \ + --arg arch "$arch" \ + --arg flavor "$flavor" \ + --arg baseReference "$base_reference" \ + --arg repositorySnapshot "$repository_snapshot" \ + --arg mainIndexSha256 "$main_index_sha" \ + --arg communityIndexSha256 "$community_index_sha" \ + --arg npmTarballSha256 "$npm_tarball_sha" \ + --arg pipWheelSha256 "$pip_wheel_sha" \ + --arg setuptoolsWheelSha256 "$setuptools_wheel_sha" \ + --arg pipVendorMsgpackSha256 "$pip_vendor_msgpack_sha" \ + --arg pipVendorSetuptoolsSha256 "$pip_vendor_setuptools_sha" \ + --arg policySha256 "$policy_sha" \ + --argjson sourceDateEpoch "$source_date_epoch" \ + --slurpfile requestedPolicy "$policy_path" \ + --slurpfile installedPackages "$packages_path" \ + '{ + schemaVersion: 1, + version: $version, + architecture: $arch, + flavor: $flavor, + sourceDateEpoch: $sourceDateEpoch, + provenance: { + baseOCIReference: $baseReference, + alpineRepositoryCapturedAt: $repositorySnapshot, + apkIndexSha256: { + main: $mainIndexSha256, + community: $communityIndexSha256 + }, + npmTarballSha256: $npmTarballSha256, + pythonRuntimeSha256: { + pip: $pipWheelSha256, + setuptools: $setuptoolsWheelSha256, + pipVendorOverlays: { + msgpack: $pipVendorMsgpackSha256, + setuptools: $pipVendorSetuptoolsSha256 + } + }, + policySha256: $policySha256 + }, + requestedPolicy: $requestedPolicy[0], + installedPackages: $installedPackages[0] + }' > "$root_dir/usr/share/nehemiah/image-manifest.json" +chmod 0644 "$root_dir/usr/share/nehemiah/image-manifest.json" + +# Package scripts and container export can create build-time timestamps. The +# release timestamp is the sole timestamp allowed into the ext4 payload. +find "$root_dir" -xdev -exec touch -h -d "@${source_date_epoch}" '{}' + + +image_bytes="$(jq -er --arg flavor "$flavor" '.flavors[$flavor].imageBytes' "$policy_path")" +filesystem_uuid="$(jq -er --arg arch "$arch" --arg flavor "$flavor" \ + '.architectures[$arch].filesystemUUIDs[$flavor]' "$policy_path")" +[[ "$image_bytes" =~ ^[1-9][0-9]*$ && $((image_bytes % 4096)) -eq 0 ]] \ + || { echo "invalid image size policy" >&2; exit 1; } +[[ "$filesystem_uuid" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] \ + || { echo "invalid filesystem UUID policy" >&2; exit 1; } + +truncate --size "$image_bytes" "$image_path" +export E2FSPROGS_FAKE_TIME="$source_date_epoch" +mke2fs -q -F -t ext4 -b 4096 -d "$root_dir" \ + -U "$filesystem_uuid" -L "bc-${flavor}" -O '^has_journal' \ + -E "lazy_itable_init=0,lazy_journal_init=0,hash_seed=${filesystem_uuid}" \ + "$image_path" +e2fsck -fn "$image_path" >/dev/null + +# gzip's header is normalized and the output owner is restored to the caller. +gzip -n -9 "$image_path" +chown "${OUTPUT_UID:?}:${OUTPUT_GID:?}" "${image_path}.gz" +chown -R "${OUTPUT_UID}:${OUTPUT_GID}" "$root_dir" diff --git a/scripts/release/guest-images/desktop-init b/scripts/release/guest-images/desktop-init new file mode 100755 index 0000000..773f181 --- /dev/null +++ b/scripts/release/guest-images/desktop-init @@ -0,0 +1,76 @@ +#!/bin/sh +set -eu + +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t devtmpfs devtmpfs /dev +mkdir -p /dev/pts /dev/shm /run /tmp /workspace +mount -t devpts devpts /dev/pts -o mode=0620,ptmxmode=0666 +ln -sf pts/ptmx /dev/ptmx +mount -t tmpfs tmpfs /dev/shm +mount -t tmpfs tmpfs /run +mount -t tmpfs tmpfs /tmp +hostname boring-desktop +ip link set lo up +ip link set dev eth0 up 2>/dev/null || true + +# Keep guest DNS on the managed bridge. The kernel's ip=dhcp data is primary; +# a DHCP default gateway is the only fallback, never a direct public resolver. +rm -f /etc/resolv.conf +if [ -s /proc/net/pnp ]; then + cat /proc/net/pnp > /etc/resolv.conf +else + dns_gateway="$(ip -4 route show default | awk '$1 == "default" { print $3; exit }')" + if [ -n "$dns_gateway" ]; then + printf 'nameserver %s\n' "$dns_gateway" > /etc/resolv.conf + fi +fi + +mkdir -p /run/nehemiah /root/.config/openbox /tmp/.X11-unix /run/dbus +chmod 1777 /tmp/.X11-unix +cat > /root/.config/openbox/autostart <<'EOF' +xsetroot -solid '#10141f' & +xterm -geometry 100x28+30+50 -title 'Boring Computer' & +EOF + +dbus-uuidgen --ensure +dbus-daemon --system --fork +Xvfb :0 -screen 0 1280x800x24 -ac -nolisten tcp & +export DISPLAY=:0 +i=0 +while [ ! -S /tmp/.X11-unix/X0 ] && [ "$i" -lt 100 ]; do + i=$((i + 1)) + sleep 0.1 +done +openbox & +chromium_bin=/usr/lib/chromium/chromium +[ -x "$chromium_bin" ] || chromium_bin=chromium +"$chromium_bin" --no-sandbox --test-type --disable-dev-shm-usage --disable-gpu \ + --no-first-run --disable-features=Translate --password-store=basic \ + --user-data-dir=/root/.chromium --window-size=900,600 --window-position=16,20 \ + about:blank >/var/log/chromium.log 2>&1 & +x11vnc -display :0 -forever -shared -rfbport 5900 -nopw -noxdamage -threads -defer 10 & +# Preserve the desktop contract: VNC is bridged from guest CID port 5900. +socat VSOCK-LISTEN:5900,reuseaddr,fork TCP:127.0.0.1:5900 & + +start_guest_agent() { + while true; do + rm -f /run/bc-guest-agent.ready + /opt/boring/bin/bc-guest-agent + sleep 1 + done +} +start_guest_agent & + +i=0 +while [ ! -e /run/bc-guest-agent.ready ] && [ "$i" -lt 100 ]; do + i=$((i + 1)) + sleep 0.05 +done +if [ -e /run/bc-guest-agent.ready ]; then + printf '%s\n' NEHEMIAH_READY > /dev/ttyS0 +else + printf '%s\n' 'guest agent failed to become ready' > /dev/ttyS0 +fi + +exec /bin/sh diff --git a/scripts/release/guest-images/headless-init b/scripts/release/guest-images/headless-init new file mode 100755 index 0000000..b08b0d6 --- /dev/null +++ b/scripts/release/guest-images/headless-init @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu + +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t devtmpfs devtmpfs /dev +mkdir -p /dev/pts /dev/shm /run /tmp /workspace +mount -t devpts devpts /dev/pts -o mode=0620,ptmxmode=0666 +ln -sf pts/ptmx /dev/ptmx +mount -t tmpfs tmpfs /dev/shm +mount -t tmpfs tmpfs /run +mount -t tmpfs tmpfs /tmp +hostname boring-computer +ip link set lo up + +# Firecracker supplies the interface; nehemiahd readdresses it through the +# authenticated guest-agent channel before the machine becomes available. +ip link set dev eth0 up 2>/dev/null || true + +# Keep guest DNS on the managed bridge. The kernel's ip=dhcp data is primary; +# a DHCP default gateway is the only fallback, never a direct public resolver. +rm -f /etc/resolv.conf +if [ -s /proc/net/pnp ]; then + cat /proc/net/pnp > /etc/resolv.conf +else + dns_gateway="$(ip -4 route show default | awk '$1 == "default" { print $3; exit }')" + if [ -n "$dns_gateway" ]; then + printf 'nameserver %s\n' "$dns_gateway" > /etc/resolv.conf + fi +fi + +mkdir -p /run/nehemiah +start_guest_agent() { + while true; do + rm -f /run/bc-guest-agent.ready + /opt/boring/bin/bc-guest-agent + sleep 1 + done +} +start_guest_agent & + +i=0 +while [ ! -e /run/bc-guest-agent.ready ] && [ "$i" -lt 100 ]; do + i=$((i + 1)) + sleep 0.05 +done +if [ -e /run/bc-guest-agent.ready ]; then + printf '%s\n' NEHEMIAH_READY > /dev/ttyS0 +else + printf '%s\n' 'guest agent failed to become ready' > /dev/ttyS0 +fi + +exec /bin/sh diff --git a/scripts/release/guest-images/inspect-guest-image.sh b/scripts/release/guest-images/inspect-guest-image.sh new file mode 100755 index 0000000..5a55ed1 --- /dev/null +++ b/scripts/release/guest-images/inspect-guest-image.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail +export LC_ALL=C + +if [[ "$#" -ne 5 ]]; then + echo "usage: inspect-guest-image.sh POLICY ARTIFACT VERSION ARCH FLAVOR" >&2 + exit 64 +fi +policy="$1" +artifact="$2" +version="$3" +arch="$4" +flavor="$5" + +case "$arch" in amd64 | arm64) ;; *) echo "invalid architecture" >&2; exit 64 ;; esac +case "$flavor" in python | desktop) ;; *) echo "invalid flavor" >&2; exit 64 ;; esac +for input in "$policy" "$artifact"; do + [[ -f "$input" && ! -L "$input" ]] || { echo "unsafe or missing input: $input" >&2; exit 1; } +done + +expected_name="nehemiah-guest-${flavor}_${version}_linux_${arch}.ext4.gz" +[[ "$(basename "$artifact")" == "$expected_name" ]] \ + || { echo "unexpected guest image artifact name" >&2; exit 1; } +max_compressed="$(jq -er --arg flavor "$flavor" '.flavors[$flavor].maxCompressedBytes' "$policy")" +actual_compressed="$(stat -c %s "$artifact")" +(( actual_compressed > 0 && actual_compressed <= max_compressed )) \ + || { echo "compressed guest image exceeds policy" >&2; exit 1; } +gzip --test "$artifact" + +inspect_dir="$(mktemp -d)" +trap 'rm -rf -- "$inspect_dir"' EXIT +image="$inspect_dir/image.ext4" +gzip -dc "$artifact" | dd of="$image" bs=4M conv=sparse status=none +expected_bytes="$(jq -er --arg flavor "$flavor" '.flavors[$flavor].imageBytes' "$policy")" +[[ "$(stat -c %s "$image")" == "$expected_bytes" ]] \ + || { echo "uncompressed guest image size does not match policy" >&2; exit 1; } +magic="$(dd if="$image" bs=1 skip=1080 count=2 status=none | od -An -tx1 | tr -d ' \n')" +[[ "$magic" == 53ef ]] || { echo "guest image is not ext4" >&2; exit 1; } +e2fsck -fn "$image" >/dev/null + +dump_file() { + local source_path="$1" destination="$2" + debugfs -R "dump -p $source_path $destination" "$image" >/dev/null 2>&1 + [[ -f "$destination" && ! -L "$destination" ]] +} + +manifest="$inspect_dir/image-manifest.json" +agent="$inspect_dir/bc-guest-agent" +node_binary="$inspect_dir/node" +init="$inspect_dir/boring-init" +dump_file /usr/share/nehemiah/image-manifest.json "$manifest" +dump_file /opt/boring/bin/bc-guest-agent "$agent" +dump_file /usr/local/bin/node "$node_binary" +dump_file /sbin/boring-init "$init" + +policy_sha="$(sha256sum "$policy" | awk '{print $1}')" +base_reference="$(jq -er --arg arch "$arch" '.architectures[$arch].ociBase.reference' "$policy")" +repository_snapshot="$(jq -er '.alpineRepositorySnapshot.capturedAt' "$policy")" +main_index_sha="$(jq -er --arg arch "$arch" '.alpineRepositorySnapshot.architectures[$arch].main.sha256' "$policy")" +community_index_sha="$(jq -er --arg arch "$arch" '.alpineRepositorySnapshot.architectures[$arch].community.sha256' "$policy")" +npm_tarball_sha="$(jq -er '.npmRuntime.tarball.sha256' "$policy")" +pip_wheel_sha="$(jq -er '.pythonRuntime.pip.sha256' "$policy")" +setuptools_wheel_sha="$(jq -er '.pythonRuntime.setuptools.sha256' "$policy")" +pip_vendor_msgpack_sha="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "msgpack") | .sha256' "$policy")" +pip_vendor_setuptools_sha="$(jq -er '.pythonRuntime.pipVendorOverlays[] | select(.name == "setuptools") | .sha256' "$policy")" +jq --exit-status \ + --arg version "$version" --arg arch "$arch" --arg flavor "$flavor" \ + --arg policySha "$policy_sha" --arg baseReference "$base_reference" \ + --arg repositorySnapshot "$repository_snapshot" --arg mainIndexSha "$main_index_sha" \ + --arg communityIndexSha "$community_index_sha" --arg npmTarballSha "$npm_tarball_sha" \ + --arg pipWheelSha "$pip_wheel_sha" --arg setuptoolsWheelSha "$setuptools_wheel_sha" \ + --arg pipVendorMsgpackSha "$pip_vendor_msgpack_sha" \ + --arg pipVendorSetuptoolsSha "$pip_vendor_setuptools_sha" ' + .schemaVersion == 1 and + .version == $version and + .architecture == $arch and + .flavor == $flavor and + .provenance.policySha256 == $policySha and + .provenance.baseOCIReference == $baseReference and + .provenance.alpineRepositoryCapturedAt == $repositorySnapshot and + .provenance.apkIndexSha256.main == $mainIndexSha and + .provenance.apkIndexSha256.community == $communityIndexSha and + .provenance.npmTarballSha256 == $npmTarballSha and + .provenance.pythonRuntimeSha256.pip == $pipWheelSha and + .provenance.pythonRuntimeSha256.setuptools == $setuptoolsWheelSha and + .provenance.pythonRuntimeSha256.pipVendorOverlays.msgpack == $pipVendorMsgpackSha and + .provenance.pythonRuntimeSha256.pipVendorOverlays.setuptools == $pipVendorSetuptoolsSha and + (.provenance.baseOCIReference | test("@sha256:[0-9a-f]{64}$")) and + ([.provenance.apkIndexSha256.main, .provenance.apkIndexSha256.community, + .provenance.npmTarballSha256, .provenance.pythonRuntimeSha256.pip, + .provenance.pythonRuntimeSha256.setuptools, + .provenance.pythonRuntimeSha256.pipVendorOverlays.msgpack, + .provenance.pythonRuntimeSha256.pipVendorOverlays.setuptools] | + all(test("^[0-9a-f]{64}$"))) and + (.installedPackages | type == "array" and length > 0) and + (.installedPackages | all( + (.name | type == "string" and length > 0) and + (.version | type == "string" and length > 0) + )) + ' "$manifest" >/dev/null + +agent_description="$(file -b "$agent")" +node_description="$(file -b "$node_binary")" +expected_machine='x86-64' +[[ "$arch" == arm64 ]] && expected_machine='ARM aarch64' +[[ "$agent_description" == *'ELF 64-bit LSB'* && "$agent_description" == *"$expected_machine"* && \ + "$agent_description" == *'statically linked'* ]] \ + || { echo "guest agent is not a static $arch ELF" >&2; exit 1; } +[[ "$node_description" == *'ELF 64-bit LSB'* && "$node_description" == *"$expected_machine"* ]] \ + || { echo "Node runtime is not an $arch ELF" >&2; exit 1; } + +for required in ca-certificates curl git; do + jq -e --arg package "$required" '.installedPackages | any(.name == $package)' \ + "$manifest" >/dev/null || { echo "guest image is missing $required" >&2; exit 1; } +done +for required_path in \ + /bin/sh \ + /usr/bin/python3 \ + /usr/local/bin/pip \ + /usr/local/bin/npm \ + /usr/local/lib/node_modules/npm/bin/npm-cli.js \ + /opt/boring/bin/bc-guest-agent; do + debugfs -R "stat $required_path" "$image" 2>&1 | grep -q 'Inode:' \ + || { echo "guest image is missing $required_path" >&2; exit 1; } +done +grep -q 'bc-guest-agent' "$init" +grep -q 'rm -f /run/bc-guest-agent.ready' "$init" +grep -q '/proc/net/pnp' "$init" +if grep -Eq 'nameserver (1\.1\.1\.1|8\.8\.8\.8)' "$init"; then + echo "guest init bypasses managed DNS" >&2 + exit 1 +fi +if grep -q '/run/nehemiah/guest-agent.ready' "$init"; then + echo "guest init removes the wrong readiness marker" >&2 + exit 1 +fi +if [[ "$flavor" == desktop ]]; then + grep -q 'VSOCK-LISTEN:5900' "$init" + # The dollar sign is intentionally literal: this is the one launch line. + # shellcheck disable=SC2016 + [[ "$(grep -c '^"\$chromium_bin" ' "$init")" == 1 ]] \ + || { echo "desktop init must launch Chromium exactly once" >&2; exit 1; } + for required_path in /usr/bin/Xvfb /usr/bin/openbox /usr/bin/x11vnc /usr/bin/chromium /usr/bin/socat; do + debugfs -R "stat $required_path" "$image" 2>&1 | grep -q 'Inode:' \ + || { echo "desktop image is missing $required_path" >&2; exit 1; } + done +fi + +printf 'verified %s: compressed=%s bytes, ext4=%s bytes\n' \ + "$expected_name" "$actual_compressed" "$expected_bytes" diff --git a/scripts/release/guest-images/policy.json b/scripts/release/guest-images/policy.json new file mode 100644 index 0000000..7b9fa41 --- /dev/null +++ b/scripts/release/guest-images/policy.json @@ -0,0 +1,174 @@ +{ + "contractVersion": 1, + "rootfsProfile": "signed-developer-ext4-v1", + "alpineRepositorySnapshot": { + "release": "v3.23", + "capturedAt": "2026-08-09T10:08:00Z", + "maxIndexAgeHours": 168, + "architectures": { + "amd64": { + "apkArchitecture": "x86_64", + "main": { + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/main/x86_64/APKINDEX.tar.gz", + "sha256": "49f58afe62a4cfd6ae7434fce1e2561040f8d500f0b02ebbc2de86dcd6828925", + "publishedAt": "2026-08-07T14:00:48Z" + }, + "community": { + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/community/x86_64/APKINDEX.tar.gz", + "sha256": "4cd00267e46333285b9011fb052ff73879fd2384c0383cdfd9f41d56088b7ce4", + "publishedAt": "2026-08-05T10:04:43Z" + } + }, + "arm64": { + "apkArchitecture": "aarch64", + "main": { + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/main/aarch64/APKINDEX.tar.gz", + "sha256": "36de4080ea7a5fbe7468e561e3bfc75ad0953388cdfdc15be5d33ec685865a18", + "publishedAt": "2026-08-07T14:00:48Z" + }, + "community": { + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/community/aarch64/APKINDEX.tar.gz", + "sha256": "e3e38ce10db429436d5f484303838da6c15f119a44e1391a7e0f309187dcabfc", + "publishedAt": "2026-08-05T10:04:43Z" + } + } + } + }, + "npmRuntime": { + "version": "11.19.0", + "tarball": { + "url": "https://registry.npmjs.org/npm/-/npm-11.19.0.tgz", + "sha256": "31e9770f7dc71119a58509353b27917557aaf0ac9b5ef1a0465ee7d8ec67ae75" + }, + "overlays": [ + { + "name": "brace-expansion", + "version": "5.0.9", + "url": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "sha256": "5d06001fddd25cbee90c96db4dc5b7b57711b984c3141e28d10f143deb52dbaf" + }, + { + "name": "ip-address", + "version": "10.3.1", + "url": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "sha256": "ad1790063beea11a312c801df30d58e147de762f4f77787552376eb7424623e5" + } + ] + }, + "pythonRuntime": { + "pip": { + "version": "26.2.1", + "url": "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", + "sha256": "71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e" + }, + "setuptools": { + "version": "84.0.0", + "url": "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", + "sha256": "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670" + }, + "pipVendorOverlays": [ + { + "name": "msgpack", + "version": "1.2.1", + "format": "sdist", + "url": "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", + "sha256": "04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647" + }, + { + "name": "setuptools", + "version": "80.9.0", + "format": "wheel", + "url": "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", + "sha256": "062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922" + } + ] + }, + "commonPackages": [ + "bash", + "ca-certificates", + "coreutils", + "curl", + "e2fsprogs", + "file", + "findutils", + "git", + "gzip", + "iproute2", + "jq", + "procps", + "python3", + "tar", + "util-linux" + ], + "desktopPackages": [ + "chromium", + "dbus", + "dbus-x11", + "figlet", + "font-liberation", + "openbox", + "socat", + "x11vnc", + "xsetroot", + "font-misc-misc", + "xterm", + "xvfb" + ], + "architectures": { + "amd64": { + "ociBase": { + "name": "node", + "version": "24.19.0-alpine3.23", + "reference": "docker.io/library/node@sha256:5098ee834c9345ddd7fc2828a01dc90aa6de0e9ed6804a09a959b19a1fded97a", + "nodeVersion": "24.19.0", + "bundledNpmVersion": "11.17.0" + }, + "filesystemUUIDs": { + "python": "fd54e328-8f62-4d86-8a01-ec1943140001", + "desktop": "fd54e328-8f62-4d86-8a01-ec1943140002" + } + }, + "arm64": { + "ociBase": { + "name": "node", + "version": "24.19.0-alpine3.23", + "reference": "docker.io/library/node@sha256:0bec8aadbd59918cfe259ed8b261dc650da84f21f343f61c4938eaf085e8c0d2", + "nodeVersion": "24.19.0", + "bundledNpmVersion": "11.17.0" + }, + "filesystemUUIDs": { + "python": "fd54e328-8f62-4d86-8a02-ec1943140001", + "desktop": "fd54e328-8f62-4d86-8a02-ec1943140002" + } + } + }, + "flavors": { + "python": { + "imageBytes": 2147483648, + "maxCompressedBytes": 805306368 + }, + "desktop": { + "imageBytes": 6442450944, + "maxCompressedBytes": 2147483648 + } + }, + "vulnerabilityScan": { + "tool": "trivy", + "version": "0.72.0", + "maxDatabaseAgeHours": 24, + "maxEvidenceBytes": 104857600, + "severities": ["HIGH", "CRITICAL"], + "artifacts": { + "amd64": { + "url": "https://github.com/aquasecurity/trivy/releases/download/v0.72.0/trivy_0.72.0_Linux-64bit.tar.gz", + "sha256": "bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea" + }, + "arm64": { + "url": "https://github.com/aquasecurity/trivy/releases/download/v0.72.0/trivy_0.72.0_Linux-ARM64.tar.gz", + "sha256": "2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467" + } + }, + "allowlist": "vulnerability-allowlist.json", + "allowlistSha256": "af779c1dea4c6b472330c33e22f55b6e880c3973478e0a3639ed3e4a9fa62ac4" + } +} diff --git a/scripts/release/guest-images/prepare-vulnerability-scanner.sh b/scripts/release/guest-images/prepare-vulnerability-scanner.sh new file mode 100755 index 0000000..d6ac478 --- /dev/null +++ b/scripts/release/guest-images/prepare-vulnerability-scanner.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Fetches a checksum-pinned scanner and a fresh vulnerability database. The DB +# is intentionally refreshed for each release security decision; its exact +# content digest and timestamps are recorded in signed scan evidence. +set -euo pipefail +export LC_ALL=C +umask 022 + +if [[ "$#" -ne 3 ]]; then + echo "usage: prepare-vulnerability-scanner.sh POLICY ARCH OUTPUT" >&2 + exit 64 +fi +policy="$1" +arch="$2" +output="$3" +case "$arch" in amd64 | arm64) ;; *) echo "invalid architecture" >&2; exit 64 ;; esac +[[ -f "$policy" && ! -L "$policy" ]] || { echo "unsafe or missing policy" >&2; exit 1; } +mkdir -p "$output" +[[ -d "$output" && ! -L "$output" ]] || { echo "unsafe scanner output directory" >&2; exit 1; } + +tool="$(jq -er '.vulnerabilityScan.tool' "$policy")" +version="$(jq -er '.vulnerabilityScan.version' "$policy")" +url="$(jq -er --arg arch "$arch" '.vulnerabilityScan.artifacts[$arch].url' "$policy")" +expected_sha="$(jq -er --arg arch "$arch" '.vulnerabilityScan.artifacts[$arch].sha256' "$policy")" +max_age_hours="$(jq -er '.vulnerabilityScan.maxDatabaseAgeHours' "$policy")" +[[ "$tool" == trivy && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || { echo "unsupported vulnerability scanner policy" >&2; exit 1; } +[[ "$url" == "https://github.com/aquasecurity/trivy/releases/download/v${version}/"* && \ + "$url" != *'/latest/'* && "$expected_sha" =~ ^[0-9a-f]{64}$ ]] \ + || { echo "vulnerability scanner is not immutable" >&2; exit 1; } +[[ "$max_age_hours" =~ ^[1-9][0-9]*$ && "$max_age_hours" -le 48 ]] \ + || { echo "unsafe scanner database age policy" >&2; exit 1; } + +archive="$output/trivy.tar.gz" +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + --max-filesize 104857600 --retry 3 --retry-all-errors "$url" -o "$archive" +printf '%s %s\n' "$expected_sha" "$archive" | sha256sum --check --strict --status \ + || { echo "vulnerability scanner checksum mismatch" >&2; exit 1; } +python3 - "$archive" <<'PY' +import pathlib +import sys +import tarfile + +with tarfile.open(sys.argv[1], "r:gz") as bundle: + members = bundle.getmembers() + names = {member.name for member in members} + if "trivy" not in names or len(members) > 32: + raise SystemExit("scanner archive has an unexpected artifact set") + for member in members: + path = pathlib.PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or not (member.isfile() or member.isdir()): + raise SystemExit("scanner archive contains an unsafe entry") + binary = bundle.getmember("trivy") + if not binary.isfile() or binary.size > 200 * 1024 * 1024: + raise SystemExit("scanner binary is missing or oversized") + bundle.extract(binary, pathlib.Path(sys.argv[1]).parent, filter="data") +PY +chmod 0755 "$output/trivy" +[[ "$("$output/trivy" --version)" == "Version: $version" ]] \ + || { echo "vulnerability scanner version mismatch" >&2; exit 1; } + +cache="$output/cache" +mkdir -p "$cache" +"$output/trivy" --cache-dir "$cache" image --download-db-only --quiet +metadata="$cache/db/metadata.json" +database="$cache/db/trivy.db" +[[ -f "$metadata" && ! -L "$metadata" && -f "$database" && ! -L "$database" ]] \ + || { echo "vulnerability database is missing" >&2; exit 1; } + +now="$(date -u +%s)" +updated_at="$(jq -er '.UpdatedAt' "$metadata")" +next_update="$(jq -er '.NextUpdate' "$metadata")" +updated_epoch="$(date -u -d "$updated_at" +%s)" +next_epoch="$(date -u -d "$next_update" +%s)" +age_seconds=$((now - updated_epoch)) +(( age_seconds >= -300 && age_seconds <= max_age_hours * 3600 )) \ + || { echo "vulnerability database is stale or future-dated" >&2; exit 1; } +(( now <= next_epoch )) || { echo "vulnerability database is past NextUpdate" >&2; exit 1; } + +archive_sha="$(sha256sum "$archive" | awk '{print $1}')" +binary_sha="$(sha256sum "$output/trivy" | awk '{print $1}')" +database_sha="$(sha256sum "$database" | awk '{print $1}')" +downloaded_at="$(jq -er '.DownloadedAt' "$metadata")" +observed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +jq -n \ + --arg tool "$tool" --arg version "$version" \ + --arg archiveSha256 "$archive_sha" --arg binarySha256 "$binary_sha" \ + --arg databaseSha256 "$database_sha" --arg updatedAt "$updated_at" \ + --arg nextUpdate "$next_update" --arg downloadedAt "$downloaded_at" \ + --arg observedAt "$observed_at" --argjson maxDatabaseAgeHours "$max_age_hours" \ + '{ + tool: $tool, + version: $version, + archiveSha256: $archiveSha256, + binarySha256: $binarySha256, + database: { + sha256: $databaseSha256, + updatedAt: $updatedAt, + nextUpdate: $nextUpdate, + downloadedAt: $downloadedAt, + observedAt: $observedAt, + maxAgeHours: $maxDatabaseAgeHours + } + }' > "$output/database-evidence.json" +printf 'prepared Trivy %s with DB updated %s sha256=%s\n' \ + "$version" "$updated_at" "$database_sha" diff --git a/scripts/release/guest-images/scan-final-rootfs.sh b/scripts/release/guest-images/scan-final-rootfs.sh new file mode 100755 index 0000000..4654ae6 --- /dev/null +++ b/scripts/release/guest-images/scan-final-rootfs.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Runs as root in the already-built guest container with the exact assembled +# root filesystem mounted read-only. No network is available during scanning. +set -euo pipefail +export LC_ALL=C +umask 022 + +if [[ "$#" -ne 10 ]]; then + echo "usage: scan-final-rootfs.sh TRIVY CACHE DB_EVIDENCE POLICY ALLOWLIST ROOT FLAVOR ARCH ARTIFACT_SHA OUTPUT" >&2 + exit 64 +fi +trivy="$1" +cache="$2" +database_evidence="$3" +policy="$4" +allowlist="$5" +rootfs="$6" +flavor="$7" +arch="$8" +artifact_sha="$9" +output="${10}" +case "$flavor" in python | desktop) ;; *) echo "invalid flavor" >&2; exit 64 ;; esac +case "$arch" in amd64 | arm64) ;; *) echo "invalid architecture" >&2; exit 64 ;; esac +[[ "$artifact_sha" =~ ^[0-9a-f]{64}$ ]] || { echo "invalid artifact digest" >&2; exit 64; } +for input in "$trivy" "$database_evidence" "$policy" "$allowlist"; do + [[ -f "$input" && ! -L "$input" ]] || { echo "unsafe or missing scan input" >&2; exit 1; } +done +[[ -d "$cache" && ! -L "$cache" && -d "$rootfs" && ! -L "$rootfs" ]] \ + || { echo "unsafe scan directory" >&2; exit 1; } + +allowlist_sha="$(sha256sum "$allowlist" | awk '{print $1}')" +expected_allowlist_sha="$(jq -er '.vulnerabilityScan.allowlistSha256' "$policy")" +[[ "$allowlist_sha" == "$expected_allowlist_sha" ]] \ + || { echo "vulnerability allowlist checksum mismatch" >&2; exit 1; } +jq -e ' + .schemaVersion == 1 and + (.exceptions | type == "array") and + (.exceptions | all( + (keys | sort) == ["expiresAt", "flavor", "id", "package", "reason"] and + (.id | test("^CVE-[0-9]{4}-[0-9]{4,}$")) and + (.package | test("^[A-Za-z0-9][A-Za-z0-9+._:-]*$")) and + (.flavor == "python" or .flavor == "desktop") and + (.expiresAt | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and + (.reason | type == "string" and length >= 20 and length <= 500) + )) +' "$allowlist" >/dev/null || { echo "invalid vulnerability allowlist" >&2; exit 1; } + +report="${output}.trivy.json" +"$trivy" --cache-dir "$cache" rootfs \ + --skip-db-update --offline-scan --scanners vuln --pkg-types os,library \ + --detection-priority comprehensive --severity HIGH,CRITICAL \ + --list-all-pkgs --format json --output "$report" "$rootfs" + +now="$(date -u +%s)" +max_expiry=$((now + 30 * 24 * 3600)) +jq -e --arg flavor "$flavor" --argjson now "$now" --argjson maxExpiry "$max_expiry" \ + --slurpfile allowlists "$allowlist" ' + def findings: + [.Results[]? as $result | $result.Vulnerabilities[]? | + select(.Severity == "HIGH" or .Severity == "CRITICAL") | + { + id: .VulnerabilityID, + package: .PkgName, + installedVersion: .InstalledVersion, + fixedVersion: (.FixedVersion // ""), + severity: .Severity, + target: $result.Target, + class: $result.Class, + type: $result.Type + }]; + $allowlists[0] as $allowlist | + (findings) as $findings | + ([$allowlist.exceptions[] | select(.flavor == $flavor)] | unique_by([.id, .package, .flavor]) | length) == + ([$allowlist.exceptions[] | select(.flavor == $flavor)] | length) and + ([$allowlist.exceptions[] | select(.flavor == $flavor) | + (.expiresAt | fromdateiso8601) | select(. <= $now or . > $maxExpiry)] | length) == 0 and + ($findings | all(. as $finding | + any($allowlist.exceptions[]; + .flavor == $flavor and .id == $finding.id and .package == $finding.package))) and + ([$allowlist.exceptions[] | select(.flavor == $flavor) as $exception | + select(any($findings[]; .id == $exception.id and .package == $exception.package) | not)] | length) == 0 +' "$report" >/dev/null || { + echo "unapproved high/critical vulnerability or invalid/stale exception" >&2 + jq -r '[.Results[]? as $result | $result.Vulnerabilities[]? | select(.Severity == "HIGH" or .Severity == "CRITICAL") | [.VulnerabilityID,.PkgName,.InstalledVersion,(.FixedVersion // ""),.Severity,$result.Target] | @tsv] | .[]' "$report" >&2 + exit 1 +} + +# list-all-pkgs must prove that the injected static Go guest agent was included +# in the final filesystem scan, not merely assumed equivalent to the OCI stage. +jq -e '[.Results[]? | select(.Target | endswith("opt/boring/bin/bc-guest-agent"))] | length == 1' \ + "$report" >/dev/null || { echo "Trivy did not inventory the injected guest agent" >&2; exit 1; } + +report_sha="$(sha256sum "$report" | awk '{print $1}')" +finding_count="$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "HIGH" or .Severity == "CRITICAL")] | length' "$report")" +exception_count="$(jq --arg flavor "$flavor" '[.exceptions[] | select(.flavor == $flavor)] | length' "$allowlist")" +jq -n \ + --arg flavor "$flavor" --arg architecture "$arch" \ + --arg artifactSha256 "$artifact_sha" --arg reportSha256 "$report_sha" \ + --arg allowlistSha256 "$allowlist_sha" \ + --argjson findingCount "$finding_count" --argjson exceptionCount "$exception_count" \ + --slurpfile scanner "$database_evidence" ' + { + flavor: $flavor, + architecture: $architecture, + artifactSha256: $artifactSha256, + scanner: $scanner[0], + reportSha256: $reportSha256, + allowlistSha256: $allowlistSha256, + highCriticalFindings: $findingCount, + approvedExceptions: $exceptionCount, + rejectedFindings: 0 + }' > "$output" +chown "${OUTPUT_UID:?}:${OUTPUT_GID:?}" "$output" "$report" diff --git a/scripts/release/guest-images/verify-scan-evidence.mjs b/scripts/release/guest-images/verify-scan-evidence.mjs new file mode 100755 index 0000000..1470e3c --- /dev/null +++ b/scripts/release/guest-images/verify-scan-evidence.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assertRegularFile, + invariant, + parseArguments, + sha256File, + validateVersion, +} from "../lib.mjs"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const options = parseArguments(process.argv.slice(2), [ + "evidence", + "images", + "version", + "arch", +]); +const version = validateVersion(options.version); +const arch = options.arch; +invariant( + arch === "amd64" || arch === "arm64", + "invalid evidence architecture", +); +const evidencePath = path.resolve(options.evidence); +const imageDirectory = path.resolve(options.images); +await assertRegularFile(evidencePath, "guest scan evidence"); + +const policyPath = path.join(scriptDirectory, "policy.json"); +const allowlistPath = path.join( + scriptDirectory, + "vulnerability-allowlist.json", +); +const [policyBytes, allowlistBytes, evidenceBytes] = await Promise.all([ + readFile(policyPath), + readFile(allowlistPath), + readFile(evidencePath), +]); +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const policy = JSON.parse(policyBytes); +const allowlist = JSON.parse(allowlistBytes); +const evidence = JSON.parse(evidenceBytes); +const now = Date.now(); + +invariant( + evidence.schemaVersion === 1, + "unsupported guest scan evidence schema", +); +invariant(evidence.version === version, "guest scan evidence version mismatch"); +invariant( + evidence.architecture === arch, + "guest scan evidence architecture mismatch", +); +invariant( + evidence.policySha256 === sha256(policyBytes), + "guest scan policy digest mismatch", +); +invariant( + evidenceBytes.length <= policy.vulnerabilityScan.maxEvidenceBytes, + "guest scan evidence exceeds policy", +); + +const scanner = evidence.scanner; +invariant(scanner?.tool === "trivy", "unsupported guest scanner"); +invariant( + scanner.version === policy.vulnerabilityScan.version, + "guest scanner version mismatch", +); +invariant( + scanner.archiveSha256 === policy.vulnerabilityScan.artifacts[arch].sha256, + "guest scanner archive digest mismatch", +); +for (const digest of [ + scanner.archiveSha256, + scanner.binarySha256, + scanner.database?.sha256, +]) { + invariant(/^[0-9a-f]{64}$/.test(digest), "invalid scanner evidence digest"); +} +invariant( + scanner.database.maxAgeHours === policy.vulnerabilityScan.maxDatabaseAgeHours, + "scanner DB freshness policy mismatch", +); +const updatedAt = Date.parse(scanner.database.updatedAt); +const nextUpdate = Date.parse(scanner.database.nextUpdate); +const downloadedAt = Date.parse(scanner.database.downloadedAt); +const observedAt = Date.parse(scanner.database.observedAt); +for (const timestamp of [updatedAt, nextUpdate, downloadedAt, observedAt]) { + invariant(Number.isFinite(timestamp), "invalid scanner database timestamp"); +} +invariant(updatedAt <= now + 5 * 60_000, "scanner database is future-dated"); +invariant( + now - updatedAt <= policy.vulnerabilityScan.maxDatabaseAgeHours * 3_600_000, + "scanner database is stale", +); +invariant(now <= nextUpdate, "scanner database is past NextUpdate"); +invariant( + downloadedAt >= updatedAt && observedAt >= downloadedAt, + "scanner database evidence timestamps are inconsistent", +); + +invariant( + policy.vulnerabilityScan.allowlistSha256 === sha256(allowlistBytes), + "vulnerability allowlist digest mismatch", +); +invariant( + allowlist.schemaVersion === 1 && Array.isArray(allowlist.exceptions), + "invalid vulnerability allowlist", +); + +invariant( + Array.isArray(evidence.scans) && evidence.scans.length === 2, + "guest evidence must contain two scans", +); +const seen = new Set(); +for (const scan of evidence.scans) { + const summary = scan?.summary; + const flavor = summary?.flavor; + invariant(flavor === "python" || flavor === "desktop", "invalid scan flavor"); + invariant(!seen.has(flavor), `duplicate ${flavor} scan`); + seen.add(flavor); + invariant(summary.architecture === arch, "scan architecture mismatch"); + invariant( + JSON.stringify(summary.scanner) === JSON.stringify(scanner), + "scan uses different scanner evidence", + ); + invariant( + summary.allowlistSha256 === sha256(allowlistBytes), + "scan allowlist digest mismatch", + ); + invariant(summary.rejectedFindings === 0, "scan contains rejected findings"); + invariant( + typeof scan.report === "string" && scan.report.length > 0, + "scan report is missing", + ); + invariant( + sha256(scan.report) === summary.reportSha256, + "scan report digest mismatch", + ); + + const imageName = `nehemiah-guest-${flavor}_${version}_linux_${arch}.ext4.gz`; + invariant( + summary.artifactSha256 === + (await sha256File(path.join(imageDirectory, imageName))), + `scan does not cover ${imageName}`, + ); + const report = JSON.parse(scan.report); + invariant(Array.isArray(report.Results), "invalid Trivy filesystem report"); + invariant( + report.Results.some(({ Target }) => + Target?.endsWith("opt/boring/bin/bc-guest-agent"), + ), + "Trivy report did not inventory the injected guest agent", + ); + const findings = report.Results.flatMap((result) => + (result.Vulnerabilities ?? []) + .filter(({ Severity }) => Severity === "HIGH" || Severity === "CRITICAL") + .map(({ VulnerabilityID, PkgName }) => ({ + id: VulnerabilityID, + package: PkgName, + })), + ); + const exceptions = allowlist.exceptions.filter( + (exception) => exception.flavor === flavor, + ); + invariant( + new Set(exceptions.map(({ id, package: name }) => `${id}\0${name}`)) + .size === exceptions.length, + `duplicate ${flavor} vulnerability exception`, + ); + for (const exception of exceptions) { + const expiry = Date.parse(exception.expiresAt); + invariant( + Number.isFinite(expiry) && + expiry > now && + expiry <= now + 30 * 24 * 3_600_000, + `stale or overlong ${exception.id} exception`, + ); + invariant( + typeof exception.reason === "string" && exception.reason.length >= 20, + `missing review reason for ${exception.id}`, + ); + invariant( + findings.some( + (finding) => + finding.id === exception.id && finding.package === exception.package, + ), + `unused ${exception.id} exception`, + ); + } + for (const finding of findings) { + invariant( + exceptions.some( + (exception) => + exception.id === finding.id && exception.package === finding.package, + ), + `unapproved ${finding.id} in ${finding.package}`, + ); + } + invariant( + summary.highCriticalFindings === findings.length && + summary.approvedExceptions === exceptions.length, + "scan finding counts do not match the report", + ); +} +invariant( + seen.has("python") && seen.has("desktop"), + "guest scan flavor set is incomplete", +); +process.stdout.write( + `verified ${path.basename(evidencePath)}: Trivy ${scanner.version}, DB ${scanner.database.updatedAt} (${scanner.database.sha256})\n`, +); diff --git a/scripts/release/guest-images/vulnerability-allowlist.json b/scripts/release/guest-images/vulnerability-allowlist.json new file mode 100644 index 0000000..f226765 --- /dev/null +++ b/scripts/release/guest-images/vulnerability-allowlist.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "exceptions": [] +} diff --git a/scripts/release/inspect-managed-host-packages.sh b/scripts/release/inspect-managed-host-packages.sh new file mode 100755 index 0000000..b8d7847 --- /dev/null +++ b/scripts/release/inspect-managed-host-packages.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# This inspector is deliberately network-free. It verifies bytes, Debian +# identity metadata, architecture and the complete empty-host APT closure. +set -euo pipefail +export LC_ALL=C + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "$script_dir/managed_host_packages.py" inspect "$@" diff --git a/scripts/release/inspect-managed-runtime-assets.sh b/scripts/release/inspect-managed-runtime-assets.sh new file mode 100755 index 0000000..fd48702 --- /dev/null +++ b/scripts/release/inspect-managed-runtime-assets.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail +export LC_ALL=C + +if [[ "$#" -ne 2 ]]; then + echo "usage: inspect-managed-runtime-assets.sh POLICY DIRECTORY" >&2 + exit 64 +fi +policy="$1" +directory="$2" +[[ -f "$policy" && ! -L "$policy" && -d "$directory" && ! -L "$directory" ]] \ + || { echo "unsafe or missing managed runtime input" >&2; exit 1; } + +mapfile -t expected_names < <(jq -er \ + '[.architectures[][] | .artifact] | sort | .[]' "$policy") +mapfile -t actual_names < <(find "$directory" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort) +[[ "${#expected_names[@]}" -eq 4 && \ + "$(printf '%s\n' "${expected_names[@]}")" == "$(printf '%s\n' "${actual_names[@]}")" ]] \ + || { echo "managed runtime directory does not contain the exact artifact set" >&2; exit 1; } + +work="$(mktemp -d "${TMPDIR:-/tmp}/managed-runtime-inspect.XXXXXX")" +trap 'rm -rf -- "$work"' EXIT +for arch in amd64 arm64; do + expected_machine='x86-64' + [[ "$arch" == arm64 ]] && expected_machine='ARM aarch64' + for component in firecracker kernel; do + artifact="$(jq -er --arg arch "$arch" --arg component "$component" \ + '.architectures[$arch][$component].artifact' "$policy")" + expected_sha="$(jq -er --arg arch "$arch" --arg component "$component" \ + '.architectures[$arch][$component].sha256' "$policy")" + max_bytes="$(jq -er --arg arch "$arch" --arg component "$component" \ + '.architectures[$arch][$component].maxBytes' "$policy")" + input="$directory/$artifact" + [[ -f "$input" && ! -L "$input" && "$expected_sha" =~ ^[0-9a-f]{64}$ && \ + "$max_bytes" =~ ^[1-9][0-9]*$ && "$(stat -c %s "$input")" -le "$max_bytes" ]] \ + || { echo "unsafe $arch $component runtime artifact" >&2; exit 1; } + printf '%s %s\n' "$expected_sha" "$input" | sha256sum --check --strict --status \ + || { echo "$arch $component runtime checksum mismatch" >&2; exit 1; } + + if [[ "$component" == firecracker ]]; then + extracted="$work/$arch" + mkdir -p "$extracted" + python3 - "$input" "$extracted" <<'PY' +import pathlib +import sys +import tarfile + +archive, destination = sys.argv[1:] +with tarfile.open(archive, "r:gz") as bundle: + members = bundle.getmembers() + if not members or len(members) > 128 or sum(m.size for m in members) > 256 * 1024 * 1024: + raise SystemExit("Firecracker archive exceeds inspection bounds") + seen = set() + for member in members: + path = pathlib.PurePosixPath(member.name) + canonical = str(path) + if ( + path.is_absolute() + or ".." in path.parts + or canonical in seen + or not (member.isdir() or member.isfile()) + ): + raise SystemExit("Firecracker archive contains an unsafe entry") + seen.add(canonical) + bundle.extractall(destination, members=members, filter="data") +PY + mapfile -t firecracker_bins < <(find "$extracted" -type f -name "firecracker-*" ! -name '*.debug' | sort) + mapfile -t jailer_bins < <(find "$extracted" -type f -name "jailer-*" ! -name '*.debug' | sort) + [[ "${#firecracker_bins[@]}" -eq 1 && "${#jailer_bins[@]}" -eq 1 ]] \ + || { echo "$arch Firecracker archive has an unexpected executable set" >&2; exit 1; } + firecracker_sha="$(jq -er --arg arch "$arch" '.architectures[$arch].firecracker.firecrackerSha256' "$policy")" + jailer_sha="$(jq -er --arg arch "$arch" '.architectures[$arch].firecracker.jailerSha256' "$policy")" + printf '%s %s\n' "$firecracker_sha" "${firecracker_bins[0]}" \ + | sha256sum --check --strict --status \ + || { echo "$arch installed Firecracker digest mismatch" >&2; exit 1; } + printf '%s %s\n' "$jailer_sha" "${jailer_bins[0]}" \ + | sha256sum --check --strict --status \ + || { echo "$arch installed jailer digest mismatch" >&2; exit 1; } + for binary in "${firecracker_bins[0]}" "${jailer_bins[0]}"; do + description="$(file -b "$binary")" + [[ "$description" == *'ELF 64-bit LSB'* && "$description" == *"$expected_machine"* ]] \ + || { echo "$arch Firecracker archive contains a wrong-architecture binary" >&2; exit 1; } + done + else + description="$(file -b "$input")" + if [[ "$arch" == amd64 ]]; then + [[ "$description" == *'Linux kernel x86 boot executable'* || \ + ( "$description" == *'ELF 64-bit LSB'* && "$description" == *'x86-64'* ) ]] \ + || { echo "amd64 kernel has an invalid executable type" >&2; exit 1; } + else + [[ "$description" == *'Linux kernel ARM64 boot executable'* || \ + ( "$description" == *'ELF 64-bit LSB'* && "$description" == *'ARM aarch64'* ) ]] \ + || { echo "arm64 kernel has an invalid executable type" >&2; exit 1; } + fi + fi + done +done + +printf 'verified exact managed runtime artifact set in %s\n' "$directory" diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs new file mode 100644 index 0000000..fee413d --- /dev/null +++ b/scripts/release/lib.mjs @@ -0,0 +1,1212 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { + appendFile, + lstat, + readFile, + readdir, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; +const SAFE_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,254}$/; +const SHA256 = /^[0-9a-f]{64}$/; +const COMMIT_SHA = /^[0-9a-f]{40,64}$/; +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +const releaseDirectory = path.dirname(fileURLToPath(import.meta.url)); +export const MANAGED_GUEST_POLICY = JSON.parse( + readFileSync(path.join(releaseDirectory, "guest-images/policy.json"), "utf8"), +); +export const MANAGED_RUNTIME_POLICY = JSON.parse( + readFileSync( + path.join(releaseDirectory, "managed-runtime-policy.json"), + "utf8", + ), +); +export const MANAGED_HOST_PACKAGE_POLICY = JSON.parse( + readFileSync( + path.join(releaseDirectory, "managed-host-packages-policy.json"), + "utf8", + ), +); + +// The trusted release build is the only consumer of sourceUrl. Managed hosts +// receive these retained artifacts through the signed release instead. +const MANAGED_HOST_INPUTS = Object.fromEntries( + ["amd64", "arm64"].map((arch) => [ + arch, + Object.fromEntries( + ["firecracker", "kernel"].map((component) => { + const input = MANAGED_RUNTIME_POLICY.architectures[arch][component]; + return [ + component, + { + version: input.version, + artifact: input.artifact, + format: input.format, + maxBytes: input.maxBytes, + sha256: input.sha256, + ...(component === "firecracker" + ? { + firecrackerSha256: input.firecrackerSha256, + jailerSha256: input.jailerSha256, + } + : {}), + }, + ]; + }), + ), + ]), +); + +export function invariant(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function validateManagedRuntimePolicy() { + invariant( + MANAGED_RUNTIME_POLICY?.contractVersion === 1 && + JSON.stringify( + Object.keys(MANAGED_RUNTIME_POLICY.architectures).sort(), + ) === JSON.stringify(["amd64", "arm64"]), + "managed runtime policy has an unsupported contract", + ); + for (const arch of ["amd64", "arm64"]) { + const architecture = MANAGED_RUNTIME_POLICY.architectures[arch]; + invariant( + JSON.stringify(Object.keys(architecture).sort()) === + JSON.stringify(["firecracker", "kernel"]), + `managed ${arch} runtime policy is not exact`, + ); + for (const component of ["firecracker", "kernel"]) { + const input = architecture[component]; + invariant( + input && + typeof input === "object" && + !Array.isArray(input) && + JSON.stringify(Object.keys(input).sort()) === + JSON.stringify( + component === "firecracker" + ? [ + "artifact", + "firecrackerSha256", + "format", + "jailerSha256", + "maxBytes", + "sha256", + "sourceUrl", + "version", + ] + : [ + "artifact", + "format", + "maxBytes", + "sha256", + "sourceUrl", + "version", + ], + ), + `managed ${arch} ${component} runtime policy is not exact`, + ); + invariant( + typeof input.version === "string" && + /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(input.version), + `invalid managed ${arch} ${component} version`, + ); + validateSafeFileName(input.artifact); + invariant( + input.artifact === + `nehemiah-runtime-${component}_${input.version}_linux_${arch}.${component === "firecracker" ? "tgz" : "bin"}`, + `invalid managed ${arch} ${component} artifact name`, + ); + invariant( + input.format === (component === "firecracker" ? "tgz" : "linux-kernel"), + `invalid managed ${arch} ${component} format`, + ); + invariant( + Number.isSafeInteger(input.maxBytes) && + input.maxBytes > 0 && + input.maxBytes <= 64 * 1024 * 1024, + `invalid managed ${arch} ${component} size bound`, + ); + invariant( + typeof input.sha256 === "string" && SHA256.test(input.sha256), + `invalid managed ${arch} ${component} SHA-256`, + ); + if (component === "firecracker") { + invariant( + SHA256.test(input.firecrackerSha256) && + SHA256.test(input.jailerSha256), + `invalid managed ${arch} installed VMM SHA-256`, + ); + } + let parsed; + try { + parsed = new URL(input.sourceUrl); + } catch { + throw new Error(`invalid managed ${arch} ${component} source URL`); + } + invariant( + parsed.protocol === "https:" && + !parsed.username && + !parsed.password && + !parsed.search && + !parsed.hash && + !input.sourceUrl.includes("/latest"), + `unsafe or mutable managed ${arch} ${component} source URL`, + ); + } + } +} + +export function validateVersion(version) { + invariant( + typeof version === "string" && SEMVER.test(version), + `invalid semantic version: ${String(version)}`, + ); + return version; +} + +export function validateCommit(commit) { + invariant( + typeof commit === "string" && COMMIT_SHA.test(commit), + `invalid commit SHA: ${String(commit)}`, + ); + return commit; +} + +export function validateRepository(repository) { + invariant( + typeof repository === "string" && REPOSITORY.test(repository), + `invalid GitHub repository: ${String(repository)}`, + ); + return repository; +} + +export function validateSafeFileName(name) { + invariant( + typeof name === "string" && SAFE_FILE_NAME.test(name), + `unsafe artifact filename: ${String(name)}`, + ); + invariant( + name !== "." && name !== ".." && path.basename(name) === name, + `unsafe artifact filename: ${name}`, + ); + return name; +} + +export function resolveRepositoryOutputDirectory( + repositoryRoot, + requestedPath, +) { + invariant( + typeof requestedPath === "string" && requestedPath.length > 0, + "--out is required", + ); + validateSafeFileName(requestedPath); + invariant( + !requestedPath.startsWith("."), + "--out must be a visible repository child directory", + ); + invariant( + !path.isAbsolute(requestedPath), + "--out must be repository-relative", + ); + const root = path.resolve(repositoryRoot); + const resolved = path.resolve(root, requestedPath); + invariant( + path.dirname(resolved) === root, + "--out must be a direct child of the repository root", + ); + return resolved; +} + +export function parseArguments(argv, allowed) { + const result = {}; + for (let index = 0; index < argv.length; index += 2) { + const option = argv[index]; + const value = argv[index + 1]; + invariant( + option?.startsWith("--"), + `expected an option, received: ${String(option)}`, + ); + invariant( + value !== undefined && !value.startsWith("--"), + `missing value for ${option}`, + ); + const name = option.slice(2); + invariant(allowed.includes(name), `unknown option: ${option}`); + invariant(result[name] === undefined, `duplicate option: ${option}`); + result[name] = value; + } + return result; +} + +export async function runCommand(command, args, options = {}) { + try { + const result = await execFileAsync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + if (options.echoStdout && result.stdout) + process.stdout.write(result.stdout); + if (options.echoStderr && result.stderr) + process.stderr.write(result.stderr); + return result; + } catch (error) { + const details = [error?.stdout, error?.stderr] + .filter(Boolean) + .join("\n") + .trim(); + throw new Error( + `${command} ${args.join(" ")} failed${details ? `:\n${details}` : ""}`, + { + cause: error, + }, + ); + } +} + +export async function assertRegularFile(filePath, label = filePath) { + let stats; + try { + stats = await lstat(filePath); + } catch (error) { + if (error?.code === "ENOENT") throw new Error(`${label} is missing`); + throw error; + } + invariant( + stats.isFile() && !stats.isSymbolicLink(), + `${label} must be a regular, non-symlink file`, + ); + return stats; +} + +export async function sha256File(filePath) { + await assertRegularFile(filePath); + const bytes = await readFile(filePath); + return createHash("sha256").update(bytes).digest("hex"); +} + +export function parseChecksums(contents) { + invariant( + typeof contents === "string" && contents.length > 0, + "SHA256SUMS is empty", + ); + invariant(!contents.includes("\r"), "SHA256SUMS must use LF line endings"); + invariant(contents.endsWith("\n"), "SHA256SUMS must end with a newline"); + const lines = contents.slice(0, -1).split("\n"); + invariant( + lines.length > 0 && lines.every(Boolean), + "SHA256SUMS contains an empty line", + ); + + const checksums = new Map(); + for (const line of lines) { + const match = + /^([0-9a-f]{64}) ([ *])([A-Za-z0-9][A-Za-z0-9._+-]{0,254})$/.exec(line); + invariant(match, `malformed SHA256SUMS entry: ${line}`); + const [, digest, , name] = match; + validateSafeFileName(name); + invariant( + name !== "SHA256SUMS", + "SHA256SUMS must not contain a self-reference", + ); + invariant(!checksums.has(name), `duplicate SHA256SUMS entry: ${name}`); + checksums.set(name, digest); + } + return checksums; +} + +export async function writeChecksums(directory, names) { + const sortedNames = [...names].map(validateSafeFileName).sort(); + invariant( + sortedNames.length > 0, + "refusing to write an empty SHA256SUMS file", + ); + invariant( + new Set(sortedNames).size === sortedNames.length, + "duplicate artifact passed to checksum writer", + ); + const lines = []; + for (const name of sortedNames) { + lines.push(`${await sha256File(path.join(directory, name))} ${name}`); + } + await writeFile(path.join(directory, "SHA256SUMS"), `${lines.join("\n")}\n`, { + encoding: "utf8", + mode: 0o644, + }); + return parseChecksums(`${lines.join("\n")}\n`); +} + +function validateManagedHostPackagePolicy() { + const policy = MANAGED_HOST_PACKAGE_POLICY; + invariant( + policy?.contractVersion === 1 && + JSON.stringify(Object.keys(policy).sort()) === + JSON.stringify([ + "architectures", + "components", + "contractVersion", + "limits", + "operatingSystem", + "rootPackages", + "snapshot", + ]), + "managed host package policy contract is not exact", + ); + invariant( + JSON.stringify(policy.operatingSystem) === + JSON.stringify({ id: "ubuntu", version: "24.04", codename: "noble" }) && + JSON.stringify(policy.architectures) === + JSON.stringify(["amd64", "arm64"]) && + JSON.stringify(policy.components) === + JSON.stringify(["main", "universe"]), + "managed host package OS policy is invalid", + ); + invariant( + Array.isArray(policy.rootPackages) && + policy.rootPackages.length > 0 && + JSON.stringify(policy.rootPackages) === + JSON.stringify([...new Set(policy.rootPackages)].sort()) && + policy.rootPackages.every((name) => /^[a-z0-9][a-z0-9+.-]*$/.test(name)), + "managed host root package set is invalid", + ); + for (const required of [ + "apt", + "bash", + "ca-certificates", + "curl", + "dnsmasq", + "e2fsprogs", + "file", + "iproute2", + "ipset", + "iptables", + "jq", + "kmod", + "minisign", + "openssl", + "python3", + "systemd", + "wireguard-tools", + ]) + invariant( + policy.rootPackages.includes(required), + `managed host root package set omits ${required}`, + ); + invariant( + /^https:\/\/snapshot\.ubuntu\.com\/ubuntu\/[0-9]{8}T[0-9]{6}Z$/.test( + policy.snapshot.baseUrl, + ) && + /^2026-08-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test( + policy.snapshot.capturedAt, + ) && + Number.isSafeInteger(policy.snapshot.maxAgeHours) && + policy.snapshot.maxAgeHours > 0 && + policy.snapshot.maxAgeHours <= 168 && + JSON.stringify(Object.keys(policy.snapshot.suites).sort()) === + JSON.stringify(["noble", "noble-security", "noble-updates"]) && + Object.values(policy.snapshot.suites).every( + (suite) => + JSON.stringify(Object.keys(suite)) === + JSON.stringify(["inReleaseSha256"]) && + SHA256.test(suite.inReleaseSha256), + ), + "managed host Ubuntu snapshot policy is not immutable", + ); + invariant( + Number.isSafeInteger(policy.limits.maxArchiveBytes) && + policy.limits.maxArchiveBytes > 0 && + policy.limits.maxArchiveBytes <= 512 * 1024 * 1024, + "managed host package archive bound is invalid", + ); +} + +export function canonicalRuntimeCohort({ + arch, + pythonSha256, + desktopSha256, + kernelSha256, + firecrackerSha256, + jailerSha256, +}) { + invariant(["amd64", "arm64"].includes(arch), "invalid runtime cohort arch"); + for (const [label, value] of Object.entries({ + python: pythonSha256, + desktop: desktopSha256, + kernel: kernelSha256, + firecracker: firecrackerSha256, + jailer: jailerSha256, + })) + invariant( + typeof value === "string" && SHA256.test(value), + `invalid runtime cohort ${label} digest`, + ); + const canonical = [ + "contract_version=4", + `arch=${arch}`, + `python=${pythonSha256}`, + `desktop=${desktopSha256}`, + `kernel=${kernelSha256}`, + `firecracker=${firecrackerSha256}`, + `jailer=${jailerSha256}`, + "", + ].join("\n"); + return { + contractVersion: 4, + arch, + pythonSha256, + desktopSha256, + kernelSha256, + firecrackerSha256, + jailerSha256, + cohortId: createHash("sha256").update(canonical, "utf8").digest("hex"), + }; +} + +function expectedArtifacts(version) { + const artifacts = []; + for (const component of ["nehemiahd", "bc-guest-agent", "bc-gateway"]) { + for (const arch of ["amd64", "arm64"]) { + artifacts.push({ + name: `${component}_${version}_linux_${arch}.tar.gz`, + component, + os: "linux", + arch, + format: "tar.gz", + }); + } + } + for (const flavor of ["python", "desktop"]) { + for (const arch of ["amd64", "arm64"]) { + artifacts.push({ + name: `nehemiah-guest-${flavor}_${version}_linux_${arch}.ext4.gz`, + component: `nehemiah-guest-${flavor}`, + os: "linux", + arch, + format: "ext4.gz", + }); + } + } + for (const arch of ["amd64", "arm64"]) { + artifacts.push({ + name: `nehemiah-guest-scan_${version}_linux_${arch}.json`, + component: "nehemiah-guest-scan", + os: "linux", + arch, + format: "json", + }); + } + for (const arch of ["amd64", "arm64"]) { + for (const component of ["firecracker", "kernel"]) { + const input = MANAGED_RUNTIME_POLICY.architectures[arch][component]; + artifacts.push({ + name: input.artifact, + component: `nehemiah-runtime-${component}`, + os: "linux", + arch, + format: input.format, + }); + } + artifacts.push({ + name: `nehemiah-host-packages_${version}_ubuntu24.04_linux_${arch}.tar.gz`, + component: "nehemiah-host-packages", + os: "linux", + arch, + format: "tar.gz", + }); + } + artifacts.push({ + name: `nehemiah-host-bootstrap_${version}.tar.gz`, + component: "nehemiah-host-bootstrap", + os: "linux", + arch: "any", + format: "tar.gz", + }); + artifacts.push({ + name: `nehemiah-cli-${version}.tgz`, + component: "nehemiah-cli", + os: "any", + arch: "any", + format: "npm", + }); + artifacts.push({ + name: "nehemiah.rb", + component: "homebrew-formula", + os: "macos", + arch: "any", + format: "ruby", + }); + return artifacts.sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + ); +} + +function expectedGuestImages(version, rootfsDigests) { + return Object.fromEntries( + ["amd64", "arm64"].map((arch) => [ + arch, + { + scanEvidence: { + artifact: `nehemiah-guest-scan_${version}_linux_${arch}.json`, + format: "json", + maxBytes: MANAGED_GUEST_POLICY.vulnerabilityScan.maxEvidenceBytes, + }, + ...Object.fromEntries( + ["python", "desktop"].map((flavor) => [ + flavor, + { + artifact: `nehemiah-guest-${flavor}_${version}_linux_${arch}.ext4.gz`, + format: "ext4.gz", + uncompressedBytes: + MANAGED_GUEST_POLICY.flavors[flavor].imageBytes, + uncompressedSha256: rootfsDigests[arch][flavor], + maxCompressedBytes: + MANAGED_GUEST_POLICY.flavors[flavor].maxCompressedBytes, + }, + ]), + ), + }, + ]), + ); +} + +export function createManifest({ + version, + commit, + sourceDateEpoch, + repository, + guestRootfsDigests, + hostPackageManifests, +}) { + validateVersion(version); + validateCommit(commit); + validateRepository(repository); + validateManagedRuntimePolicy(); + validateManagedHostPackagePolicy(); + invariant( + Number.isSafeInteger(sourceDateEpoch) && sourceDateEpoch > 0, + "sourceDateEpoch must be a positive integer", + ); + invariant( + guestRootfsDigests && hostPackageManifests, + "managed runtime evidence is required", + ); + const runtimeCohorts = Object.fromEntries( + ["amd64", "arm64"].map((arch) => { + invariant( + guestRootfsDigests[arch] && + SHA256.test(guestRootfsDigests[arch].python) && + SHA256.test(guestRootfsDigests[arch].desktop), + `managed ${arch} guest rootfs digests are invalid`, + ); + const runtime = MANAGED_RUNTIME_POLICY.architectures[arch]; + return [ + arch, + canonicalRuntimeCohort({ + arch, + pythonSha256: guestRootfsDigests[arch].python, + desktopSha256: guestRootfsDigests[arch].desktop, + kernelSha256: runtime.kernel.sha256, + firecrackerSha256: runtime.firecracker.firecrackerSha256, + jailerSha256: runtime.firecracker.jailerSha256, + }), + ]; + }), + ); + const packageRepositories = Object.fromEntries( + ["amd64", "arm64"].map((arch) => { + const evidence = hostPackageManifests[arch]; + invariant( + evidence && + evidence.contractVersion === 1 && + evidence.architecture === arch && + evidence.releaseVersion === version && + evidence.operatingSystem?.id === "ubuntu" && + evidence.operatingSystem?.version === "24.04" && + evidence.operatingSystem?.codename === "noble" && + evidence.snapshot?.baseUrl === + MANAGED_HOST_PACKAGE_POLICY.snapshot.baseUrl && + evidence.snapshot?.capturedAt === + MANAGED_HOST_PACKAGE_POLICY.snapshot.capturedAt && + Number.isSafeInteger(evidence.packageCount) && + evidence.packageCount > 0 && + evidence.packageCount <= + MANAGED_HOST_PACKAGE_POLICY.limits.maxPackageCount && + SHA256.test(evidence.manifestSha256), + `managed ${arch} package repository evidence is invalid`, + ); + return [ + arch, + { + contractVersion: 1, + artifact: `nehemiah-host-packages_${version}_ubuntu24.04_linux_${arch}.tar.gz`, + format: "tar.gz", + maxBytes: MANAGED_HOST_PACKAGE_POLICY.limits.maxArchiveBytes, + manifestSha256: evidence.manifestSha256, + packageCount: evidence.packageCount, + operatingSystem: structuredClone(evidence.operatingSystem), + snapshot: { + baseUrl: evidence.snapshot.baseUrl, + capturedAt: evidence.snapshot.capturedAt, + }, + }, + ]; + }), + ); + return { + schemaVersion: 5, + managedCloudInitCompatible: true, + managedHost: { + contractVersion: 4, + bootstrapArtifact: `nehemiah-host-bootstrap_${version}.tar.gz`, + rootfsProfile: MANAGED_GUEST_POLICY.rootfsProfile, + inputs: structuredClone(MANAGED_HOST_INPUTS), + guestImagePolicy: structuredClone(MANAGED_GUEST_POLICY), + guestImages: expectedGuestImages(version, guestRootfsDigests), + packageRepositories, + runtimeCohorts, + }, + version, + commit, + sourceDateEpoch, + repository, + artifacts: expectedArtifacts(version), + }; +} + +function validateManagedHostContract(contract, version) { + validateManagedRuntimePolicy(); + validateManagedHostPackagePolicy(); + invariant( + contract && typeof contract === "object" && !Array.isArray(contract), + "managed host contract must be an object", + ); + invariant( + contract.contractVersion === 4, + "unsupported managed host contractVersion", + ); + invariant( + contract.bootstrapArtifact === `nehemiah-host-bootstrap_${version}.tar.gz`, + "managed host bootstrap artifact does not match the release version", + ); + invariant( + contract.rootfsProfile === "signed-developer-ext4-v1", + "managed rootfs profile must use signed developer images", + ); + invariant( + JSON.stringify(contract.inputs) === JSON.stringify(MANAGED_HOST_INPUTS), + "managed host inputs do not match the reviewed retained runtime pins", + ); + for (const arch of ["amd64", "arm64"]) { + const inputs = contract.inputs[arch]; + for (const component of ["firecracker", "kernel"]) { + const input = inputs[component]; + invariant( + typeof input.version === "string" && + /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(input.version), + `invalid ${arch} ${component} version`, + ); + invariant( + typeof input.sha256 === "string" && SHA256.test(input.sha256), + `invalid ${arch} ${component} SHA-256`, + ); + validateSafeFileName(input.artifact); + invariant( + input.format === + (component === "firecracker" ? "tgz" : "linux-kernel") && + Number.isSafeInteger(input.maxBytes) && + input.maxBytes > 0 && + input.maxBytes <= 64 * 1024 * 1024, + `invalid ${arch} ${component} retained artifact policy`, + ); + invariant( + input.sourceUrl === undefined && input.url === undefined, + `managed ${arch} ${component} input must not expose an upstream URL`, + ); + } + } + invariant( + JSON.stringify(contract.guestImagePolicy) === + JSON.stringify(MANAGED_GUEST_POLICY), + "managed guest image policy does not match the reviewed immutable policy", + ); + invariant( + contract.guestImages && + JSON.stringify(Object.keys(contract.guestImages).sort()) === + JSON.stringify(["amd64", "arm64"]), + "managed guest image artifact contract is not exact", + ); + for (const arch of ["amd64", "arm64"]) { + const reference = + contract.guestImagePolicy.architectures[arch].ociBase.reference; + invariant( + /^docker\.io\/library\/node@sha256:[0-9a-f]{64}$/.test(reference), + `managed ${arch} Node base must be digest-pinned`, + ); + const repositorySnapshot = + contract.guestImagePolicy.alpineRepositorySnapshot.architectures[arch]; + const apkArchitecture = arch === "amd64" ? "x86_64" : "aarch64"; + invariant( + repositorySnapshot.apkArchitecture === apkArchitecture, + `managed ${arch} APK architecture is invalid`, + ); + for (const repository of ["main", "community"]) { + const index = repositorySnapshot[repository]; + invariant( + index.url === + `https://dl-cdn.alpinelinux.org/alpine/v3.23/${repository}/${apkArchitecture}/APKINDEX.tar.gz` && + SHA256.test(index.sha256) && + /^2026-08-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test( + index.publishedAt, + ), + `managed ${arch} ${repository} APK index is not immutable`, + ); + } + for (const flavor of ["python", "desktop"]) { + const image = contract.guestImages[arch][flavor]; + invariant( + image && typeof image === "object" && !Array.isArray(image), + "managed guest image artifact contract is not exact", + ); + validateSafeFileName(image.artifact); + invariant( + JSON.stringify(Object.keys(image).sort()) === + JSON.stringify([ + "artifact", + "format", + "maxCompressedBytes", + "uncompressedBytes", + "uncompressedSha256", + ]) && + image.artifact === + `nehemiah-guest-${flavor}_${version}_linux_${arch}.ext4.gz` && + image.format === "ext4.gz" && + image.uncompressedBytes === + MANAGED_GUEST_POLICY.flavors[flavor].imageBytes && + image.maxCompressedBytes === + MANAGED_GUEST_POLICY.flavors[flavor].maxCompressedBytes && + SHA256.test(image.uncompressedSha256), + `invalid ${arch} ${flavor} image size policy`, + ); + } + const scanEvidence = contract.guestImages[arch].scanEvidence; + validateSafeFileName(scanEvidence.artifact); + invariant( + scanEvidence.format === "json" && scanEvidence.maxBytes > 0, + `invalid ${arch} guest scan evidence policy`, + ); + const packages = contract.packageRepositories?.[arch]; + invariant( + packages && + JSON.stringify(Object.keys(packages).sort()) === + JSON.stringify([ + "artifact", + "contractVersion", + "format", + "manifestSha256", + "maxBytes", + "operatingSystem", + "packageCount", + "snapshot", + ]) && + packages.contractVersion === 1 && + packages.artifact === + `nehemiah-host-packages_${version}_ubuntu24.04_linux_${arch}.tar.gz` && + packages.format === "tar.gz" && + packages.maxBytes === + MANAGED_HOST_PACKAGE_POLICY.limits.maxArchiveBytes && + SHA256.test(packages.manifestSha256) && + Number.isSafeInteger(packages.packageCount) && + packages.packageCount > 0 && + packages.packageCount <= + MANAGED_HOST_PACKAGE_POLICY.limits.maxPackageCount && + JSON.stringify(packages.operatingSystem) === + JSON.stringify(MANAGED_HOST_PACKAGE_POLICY.operatingSystem) && + JSON.stringify(packages.snapshot) === + JSON.stringify({ + baseUrl: MANAGED_HOST_PACKAGE_POLICY.snapshot.baseUrl, + capturedAt: MANAGED_HOST_PACKAGE_POLICY.snapshot.capturedAt, + }), + `managed ${arch} package repository contract is invalid`, + ); + const expectedCohort = canonicalRuntimeCohort({ + arch, + pythonSha256: contract.guestImages[arch].python.uncompressedSha256, + desktopSha256: contract.guestImages[arch].desktop.uncompressedSha256, + kernelSha256: MANAGED_RUNTIME_POLICY.architectures[arch].kernel.sha256, + firecrackerSha256: + MANAGED_RUNTIME_POLICY.architectures[arch].firecracker + .firecrackerSha256, + jailerSha256: + MANAGED_RUNTIME_POLICY.architectures[arch].firecracker.jailerSha256, + }); + invariant( + JSON.stringify(contract.runtimeCohorts?.[arch]) === + JSON.stringify(expectedCohort), + `managed ${arch} runtime cohort is invalid`, + ); + } + invariant( + contract.guestImagePolicy.alpineRepositorySnapshot.maxIndexAgeHours > 0 && + contract.guestImagePolicy.alpineRepositorySnapshot.maxIndexAgeHours <= + 168, + "managed APK index freshness window is invalid", + ); + const npmRuntime = contract.guestImagePolicy.npmRuntime; + invariant( + npmRuntime.version === "11.19.0" && + npmRuntime.tarball.url === + "https://registry.npmjs.org/npm/-/npm-11.19.0.tgz" && + SHA256.test(npmRuntime.tarball.sha256), + "managed npm runtime is not exact and digest-pinned", + ); + invariant( + JSON.stringify( + npmRuntime.overlays.map(({ name, version }) => ({ name, version })), + ) === + JSON.stringify([ + { name: "brace-expansion", version: "5.0.9" }, + { name: "ip-address", version: "10.3.1" }, + ]) && + npmRuntime.overlays.every( + (overlay) => + overlay.url === + `https://registry.npmjs.org/${overlay.name}/-/${overlay.name}-${overlay.version}.tgz` && + SHA256.test(overlay.sha256), + ), + "managed npm security overlays are not exact and digest-pinned", + ); + const pythonRuntime = contract.guestImagePolicy.pythonRuntime; + for (const [name, version] of [ + ["pip", "26.2.1"], + ["setuptools", "84.0.0"], + ]) { + const wheel = pythonRuntime[name]; + invariant( + wheel.version === version && + wheel.url.startsWith("https://files.pythonhosted.org/packages/") && + wheel.url.endsWith(`/${name}-${version}-py3-none-any.whl`) && + SHA256.test(wheel.sha256), + `managed Python ${name} wheel is not exact and digest-pinned`, + ); + } + invariant( + JSON.stringify( + pythonRuntime.pipVendorOverlays.map(({ name, version, format }) => ({ + name, + version, + format, + })), + ) === + JSON.stringify([ + { name: "msgpack", version: "1.2.1", format: "sdist" }, + { name: "setuptools", version: "80.9.0", format: "wheel" }, + ]) && + pythonRuntime.pipVendorOverlays.every( + (overlay) => + overlay.url.startsWith("https://files.pythonhosted.org/packages/") && + SHA256.test(overlay.sha256), + ), + "managed pip vendor security overlays are not exact and digest-pinned", + ); +} + +export function validateManifest(manifest) { + invariant( + manifest && typeof manifest === "object" && !Array.isArray(manifest), + "release manifest must be an object", + ); + invariant( + manifest.schemaVersion === 5, + "unsupported release manifest schemaVersion", + ); + invariant( + manifest.managedCloudInitCompatible === true, + "release manifest must declare the managed cloud-init contract", + ); + validateManagedHostContract(manifest.managedHost, manifest.version); + validateVersion(manifest.version); + validateCommit(manifest.commit); + validateRepository(manifest.repository); + invariant( + Number.isSafeInteger(manifest.sourceDateEpoch) && + manifest.sourceDateEpoch > 0, + "invalid manifest sourceDateEpoch", + ); + invariant( + Array.isArray(manifest.artifacts), + "manifest artifacts must be an array", + ); + + const actual = manifest.artifacts.map((artifact) => { + invariant( + artifact && typeof artifact === "object" && !Array.isArray(artifact), + "invalid manifest artifact", + ); + validateSafeFileName(artifact.name); + return { + name: artifact.name, + component: artifact.component, + os: artifact.os, + arch: artifact.arch, + format: artifact.format, + }; + }); + const expected = expectedArtifacts(manifest.version); + invariant( + JSON.stringify(actual) === JSON.stringify(expected), + "manifest does not contain the exact release artifact matrix", + ); + return manifest; +} + +export async function writeManifest(directory, manifest) { + validateManifest(manifest); + await writeFile( + path.join(directory, "release-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + { + encoding: "utf8", + mode: 0o644, + }, + ); +} + +export async function verifyReleaseDirectory( + directory, + { allowedUnchecksummedFiles = [] } = {}, +) { + invariant( + Array.isArray(allowedUnchecksummedFiles), + "allowedUnchecksummedFiles must be an array", + ); + const allowedExtras = new Set(); + for (const name of allowedUnchecksummedFiles) { + validateSafeFileName(name); + invariant(name !== "SHA256SUMS", "SHA256SUMS cannot be an allowed extra"); + invariant( + !allowedExtras.has(name), + `duplicate allowed extra file: ${name}`, + ); + allowedExtras.add(name); + } + + const checksumPath = path.join(directory, "SHA256SUMS"); + await assertRegularFile(checksumPath, "SHA256SUMS"); + const checksums = parseChecksums(await readFile(checksumPath, "utf8")); + invariant( + checksums.has("release-manifest.json"), + "release-manifest.json is not covered by SHA256SUMS", + ); + + const manifestPath = path.join(directory, "release-manifest.json"); + await assertRegularFile(manifestPath, "release-manifest.json"); + let manifest; + try { + manifest = JSON.parse(await readFile(manifestPath, "utf8")); + } catch (error) { + throw new Error("release-manifest.json is not valid JSON", { + cause: error, + }); + } + validateManifest(manifest); + + const expectedNames = [ + ...manifest.artifacts.map(({ name }) => name), + "release-manifest.json", + ].sort(); + const checksumNames = [...checksums.keys()].sort(); + invariant( + JSON.stringify(checksumNames) === JSON.stringify(expectedNames), + "SHA256SUMS does not contain the exact manifest artifact set", + ); + + for (const name of allowedExtras) { + invariant( + !checksums.has(name), + `${name} is both checksummed and allowlisted`, + ); + } + const expectedDirectoryNames = [ + "SHA256SUMS", + ...checksumNames, + ...allowedExtras, + ].sort(); + const entries = await readdir(directory, { withFileTypes: true }); + const actualDirectoryNames = entries.map(({ name }) => name).sort(); + invariant( + JSON.stringify(actualDirectoryNames) === + JSON.stringify(expectedDirectoryNames), + "release directory contains an unexpected physical artifact set", + ); + + for (const [name, expectedDigest] of checksums) { + const artifactPath = path.join(directory, name); + await assertRegularFile(artifactPath, name); + const actualDigest = await sha256File(artifactPath); + invariant(actualDigest === expectedDigest, `checksum mismatch for ${name}`); + } + for (const name of allowedExtras) { + await assertRegularFile(path.join(directory, name), name); + } + return { manifest, checksums }; +} + +export function renderFormula(template, { version, repository, sha256 }) { + validateVersion(version); + validateRepository(repository); + invariant( + typeof sha256 === "string" && SHA256.test(sha256), + "invalid CLI SHA-256 for Homebrew formula", + ); + let rendered = template; + for (const [token, value] of Object.entries({ + VERSION: version, + REPOSITORY: repository, + SHA256: sha256, + })) { + rendered = rendered.replaceAll(`@@${token}@@`, value); + } + invariant( + !/@@[A-Z0-9_]+@@/.test(rendered), + "unresolved Homebrew formula template token", + ); + return rendered.endsWith("\n") ? rendered : `${rendered}\n`; +} + +export function resolveReleaseVersion({ + eventName, + refName, + inputVersion, + cliVersion, + sdkVersion, +}) { + validateVersion(cliVersion); + validateVersion(sdkVersion); + invariant( + cliVersion === sdkVersion, + `CLI version ${cliVersion} does not match SDK version ${sdkVersion}`, + ); + + if (eventName === "push") { + invariant( + typeof refName === "string" && refName.startsWith("v"), + "release pushes must use a v-prefixed tag", + ); + const tagVersion = validateVersion(refName.slice(1)); + invariant( + tagVersion === cliVersion, + `tag version ${tagVersion} does not match package version ${cliVersion}`, + ); + return tagVersion; + } + if (eventName === "workflow_dispatch") { + const requestedVersion = validateVersion(inputVersion); + invariant( + requestedVersion === cliVersion, + `requested version ${requestedVersion} does not match package version ${cliVersion}`, + ); + return requestedVersion; + } + if (eventName === "pull_request") return cliVersion; + throw new Error(`unsupported release event: ${String(eventName)}`); +} + +export async function writeGitHubOutput(filePath, values) { + invariant(filePath, "GITHUB_OUTPUT is not set"); + for (const [name, value] of Object.entries(values)) { + invariant( + /^[A-Za-z_][A-Za-z0-9_]*$/.test(name), + `invalid GitHub output name: ${name}`, + ); + invariant( + !String(value).includes("\n"), + `GitHub output ${name} must be single-line`, + ); + await appendFile(filePath, `${name}=${value}\n`, "utf8"); + } +} + +export function validateSignedTagEvidence( + ref, + tag, + { expectedTag, expectedCommit }, +) { + validateCommit(expectedCommit); + invariant( + typeof expectedTag === "string" && + expectedTag.startsWith("v") && + validateVersion(expectedTag.slice(1)), + "release tag must contain a valid v-prefixed semantic version", + ); + invariant( + ref?.ref === `refs/tags/${expectedTag}`, + "GitHub tag reference does not match the release tag", + ); + invariant( + ref?.object?.type === "tag", + "release tag must be annotated, not lightweight", + ); + invariant( + typeof ref.object.sha === "string", + "GitHub tag reference is missing its tag object SHA", + ); + invariant( + tag?.sha === ref.object.sha, + "annotated tag object does not match the GitHub tag reference", + ); + invariant( + tag?.tag === expectedTag, + "annotated tag name does not match the release tag", + ); + invariant( + tag?.object?.type === "commit", + "annotated release tag must point directly to a commit", + ); + invariant( + tag?.object?.sha === expectedCommit, + "annotated release tag points to an unexpected commit", + ); + invariant( + tag?.verification?.verified === true, + "GitHub did not verify the annotated tag signature", + ); + invariant( + tag?.verification?.reason === "valid", + `GitHub tag signature reason is ${String(tag?.verification?.reason)}`, + ); + return true; +} + +export async function assertDirectoryEmpty(directory) { + let stats; + try { + stats = await lstat(directory); + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } + invariant( + stats.isDirectory() && !stats.isSymbolicLink(), + `${directory} must be a real directory`, + ); + invariant( + (await readdir(directory)).length === 0, + `${directory} must be empty`, + ); + return true; +} diff --git a/scripts/release/managed-host-packages-policy.json b/scripts/release/managed-host-packages-policy.json new file mode 100644 index 0000000..043bc76 --- /dev/null +++ b/scripts/release/managed-host-packages-policy.json @@ -0,0 +1,69 @@ +{ + "contractVersion": 1, + "operatingSystem": { + "id": "ubuntu", + "version": "24.04", + "codename": "noble" + }, + "snapshot": { + "baseUrl": "https://snapshot.ubuntu.com/ubuntu/20260809T000000Z", + "capturedAt": "2026-08-09T00:00:00Z", + "maxAgeHours": 168, + "suites": { + "noble": { + "inReleaseSha256": "cdb2f31d809f589719a53c6ad15f255b27569c4059542ada282aaa21b8e164b0" + }, + "noble-security": { + "inReleaseSha256": "9935b55fc2cc85be31979f7c515c228a2a29642349544109b49462b448d52b93" + }, + "noble-updates": { + "inReleaseSha256": "a74574fbfde481e4fcef9e16c99f1a2cb43e03b2982c475490c188fae90b0908" + } + } + }, + "components": [ + "main", + "universe" + ], + "architectures": [ + "amd64", + "arm64" + ], + "rootPackages": [ + "apt", + "bash", + "ca-certificates", + "coreutils", + "cpio", + "curl", + "dnsmasq", + "e2fsprogs", + "file", + "findutils", + "gawk", + "grep", + "gzip", + "iproute2", + "ipset", + "iptables", + "jq", + "kmod", + "minisign", + "openssl", + "passwd", + "procps", + "python3", + "systemd", + "systemd-sysv", + "tar", + "util-linux", + "wireguard-tools" + ], + "limits": { + "maxArchiveBytes": 268435456, + "maxIndexBytes": 33554432, + "maxPackageBytes": 67108864, + "maxPackageCount": 256, + "maxUnpackedBytes": 1073741824 + } +} diff --git a/scripts/release/managed-runtime-policy.json b/scripts/release/managed-runtime-policy.json new file mode 100644 index 0000000..6eb8d92 --- /dev/null +++ b/scripts/release/managed-runtime-policy.json @@ -0,0 +1,45 @@ +{ + "contractVersion": 1, + "architectures": { + "amd64": { + "firecracker": { + "version": "1.15.1", + "sourceUrl": "https://github.com/firecracker-microvm/firecracker/releases/download/v1.15.1/firecracker-v1.15.1-x86_64.tgz", + "artifact": "nehemiah-runtime-firecracker_1.15.1_linux_amd64.tgz", + "format": "tgz", + "maxBytes": 16777216, + "sha256": "d4a32ab2322d887ca1bc4a4e7afa9cc35393e6362dfc2b3becb389d362e4275a", + "firecrackerSha256": "7e8b57e88c459396d4680d83dcdd8c7f72305447cb55b11f4ac98ad70a3f7825", + "jailerSha256": "4830a9b1fc6cece036d8992ff12f1fe9c5247aacad77f42c7aba683c7a08622e" + }, + "kernel": { + "version": "6.1.155", + "sourceUrl": "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/x86_64/vmlinux-6.1.155", + "artifact": "nehemiah-runtime-kernel_6.1.155_linux_amd64.bin", + "format": "linux-kernel", + "maxBytes": 67108864, + "sha256": "e20e46d0c36c55c0d1014eb20576171b3f3d922260d9f792017aeff53af3d4f2" + } + }, + "arm64": { + "firecracker": { + "version": "1.15.1", + "sourceUrl": "https://github.com/firecracker-microvm/firecracker/releases/download/v1.15.1/firecracker-v1.15.1-aarch64.tgz", + "artifact": "nehemiah-runtime-firecracker_1.15.1_linux_arm64.tgz", + "format": "tgz", + "maxBytes": 16777216, + "sha256": "00654ac1e702a22744121ea9f10a4f792ebd7c3a744cba587dfac9fcb79b41a5", + "firecrackerSha256": "e9ce7466c3b0d879d7a9158f4bf710dd5e131bbc5e580e5269fec66d5b5a0f0a", + "jailerSha256": "7faa581395fd1994ee005efc0a9c8826b4a9f0616dd942c2486adb8a8eac13f0" + }, + "kernel": { + "version": "6.1.155", + "sourceUrl": "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/aarch64/vmlinux-6.1.155", + "artifact": "nehemiah-runtime-kernel_6.1.155_linux_arm64.bin", + "format": "linux-kernel", + "maxBytes": 67108864, + "sha256": "e3544b10603acbf3db492cb52e000d22ba202cb4b63b9add027565683e11c591" + } + } + } +} diff --git a/scripts/release/managed_host_packages.py b/scripts/release/managed_host_packages.py new file mode 100755 index 0000000..f9d4a55 --- /dev/null +++ b/scripts/release/managed_host_packages.py @@ -0,0 +1,756 @@ +#!/usr/bin/env python3 +"""Build and inspect the signed managed-host offline Debian repository. + +Network access is confined to ``build``. ``inspect`` resolves the complete root +package closure against the retained flat repository with an empty dpkg status, +so it cannot accidentally rely on packages installed on the runner. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import gzip +import hashlib +import json +import lzma +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import urllib.parse +import urllib.request + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +POLICY_PATH = ROOT / "scripts/release/managed-host-packages-policy.json" +SHA256 = re.compile(r"[0-9a-f]{64}") +SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+~-]{0,254}") +SAFE_PACKAGE = re.compile(r"[a-z0-9][a-z0-9+.-]{0,127}") +SAFE_VERSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9.+:~_-]{0,255}") +ARCHES = ("amd64", "arm64") + + +def fail(message: str) -> "NoReturn": + raise SystemExit(f"managed host packages: {message}") + + +def sha256_path(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def canonical_json(value: object) -> bytes: + return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode() + + +def exact_keys(value: object, keys: set[str], label: str) -> dict: + if not isinstance(value, dict) or set(value) != keys: + fail(f"{label} does not contain the exact key set") + return value + + +def load_policy() -> dict: + try: + policy = json.loads(POLICY_PATH.read_text()) + except (OSError, json.JSONDecodeError) as error: + fail(f"cannot read policy: {error}") + exact_keys( + policy, + { + "architectures", + "components", + "contractVersion", + "limits", + "operatingSystem", + "rootPackages", + "snapshot", + }, + "policy", + ) + if policy["contractVersion"] != 1: + fail("unsupported policy contract") + operating_system = exact_keys( + policy["operatingSystem"], {"codename", "id", "version"}, "operating system" + ) + if operating_system != {"id": "ubuntu", "version": "24.04", "codename": "noble"}: + fail("managed package policy must target Ubuntu 24.04 noble") + if policy["architectures"] != list(ARCHES): + fail("architecture policy is not exact") + if policy["components"] != ["main", "universe"]: + fail("component policy is not exact") + roots = policy["rootPackages"] + if ( + not isinstance(roots, list) + or roots != sorted(set(roots)) + or not roots + or not all(isinstance(name, str) and SAFE_PACKAGE.fullmatch(name) for name in roots) + ): + fail("root package set must be nonempty, unique, sorted, and safe") + required_roots = { + "apt", + "bash", + "ca-certificates", + "curl", + "dnsmasq", + "e2fsprogs", + "file", + "iproute2", + "ipset", + "iptables", + "jq", + "kmod", + "minisign", + "openssl", + "python3", + "systemd", + "wireguard-tools", + } + if not required_roots.issubset(roots): + fail("root package policy omits a managed runtime dependency") + limits = exact_keys( + policy["limits"], + { + "maxArchiveBytes", + "maxIndexBytes", + "maxPackageBytes", + "maxPackageCount", + "maxUnpackedBytes", + }, + "limits", + ) + bounds = { + "maxArchiveBytes": 512 * 1024 * 1024, + "maxIndexBytes": 64 * 1024 * 1024, + "maxPackageBytes": 128 * 1024 * 1024, + "maxPackageCount": 512, + "maxUnpackedBytes": 2 * 1024 * 1024 * 1024, + } + for key, ceiling in bounds.items(): + if not isinstance(limits[key], int) or not 0 < limits[key] <= ceiling: + fail(f"invalid {key}") + snapshot = exact_keys( + policy["snapshot"], + {"baseUrl", "capturedAt", "maxAgeHours", "suites"}, + "snapshot", + ) + if not re.fullmatch( + r"https://snapshot\.ubuntu\.com/ubuntu/[0-9]{8}T[0-9]{6}Z", snapshot["baseUrl"] + ): + fail("snapshot URL is not an immutable Ubuntu snapshot") + try: + captured = dt.datetime.strptime(snapshot["capturedAt"], "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=dt.timezone.utc + ) + except (TypeError, ValueError): + fail("snapshot capture timestamp is invalid") + if not isinstance(snapshot["maxAgeHours"], int) or not 0 < snapshot["maxAgeHours"] <= 168: + fail("snapshot freshness bound is invalid") + expected_suites = {"noble", "noble-security", "noble-updates"} + suites = exact_keys(snapshot["suites"], expected_suites, "snapshot suites") + for suite, metadata in suites.items(): + exact_keys(metadata, {"inReleaseSha256"}, f"{suite} snapshot metadata") + if not isinstance(metadata["inReleaseSha256"], str) or not SHA256.fullmatch( + metadata["inReleaseSha256"] + ): + fail(f"{suite} InRelease digest is invalid") + policy["_captured"] = captured + return policy + + +class StrictHTTPSRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + source = urllib.parse.urlsplit(request.full_url) + target = urllib.parse.urlsplit(newurl) + if target.scheme != "https" or target.hostname != source.hostname: + fail("snapshot download attempted an unsafe redirect") + return super().redirect_request(request, fp, code, msg, headers, newurl) + + +def download(url: str, output: pathlib.Path, max_bytes: int) -> None: + parsed = urllib.parse.urlsplit(url) + if parsed.scheme != "https" or parsed.username or parsed.password or parsed.query or parsed.fragment: + fail("unsafe snapshot download URL") + opener = urllib.request.build_opener(StrictHTTPSRedirect()) + request = urllib.request.Request(url, headers={"User-Agent": "nehemiah-release-builder/1"}) + try: + with opener.open(request, timeout=60) as response, output.open("xb") as stream: + total = 0 + while True: + block = response.read(1024 * 1024) + if not block: + break + total += len(block) + if total > max_bytes: + fail("snapshot download exceeds its size policy") + stream.write(block) + except (OSError, urllib.error.URLError) as error: + fail(f"snapshot download failed: {error}") + if output.stat().st_size == 0: + fail("snapshot returned an empty object") + + +def parse_release_sha256(contents: str) -> dict[str, tuple[str, int]]: + marker = "SHA256:\n" + if marker not in contents: + fail("InRelease omits SHA256 metadata") + block = contents.split(marker, 1)[1] + entries: dict[str, tuple[str, int]] = {} + for line in block.splitlines(): + match = re.fullmatch(r" ([0-9a-f]{64}) +([0-9]+) (\S+)", line) + if not match: + break + digest, size, name = match.groups() + if name in entries: + fail("InRelease contains duplicate SHA256 metadata") + entries[name] = (digest, int(size)) + return entries + + +def parse_debian_paragraphs(contents: str) -> list[dict[str, str]]: + paragraphs: list[dict[str, str]] = [] + for raw in contents.strip().split("\n\n"): + if not raw.strip(): + continue + fields: dict[str, str] = {} + last = None + for line in raw.splitlines(): + if line.startswith((" ", "\t")): + if last is None: + fail("package metadata has an orphan continuation") + fields[last] += "\n" + line + continue + if ":" not in line: + fail("package metadata contains a malformed field") + name, value = line.split(":", 1) + if value.startswith(" "): + value = value[1:] + if name in fields: + fail("package metadata contains a duplicate field") + fields[name] = value + last = name + paragraphs.append(fields) + return paragraphs + + +def render_debian_paragraph(fields: dict[str, str]) -> str: + return "\n".join(f"{name}: {value}" for name, value in fields.items()) + "\n" + + +def apt_options(work: pathlib.Path, arch: str, source: pathlib.Path, empty_status: bool) -> list[str]: + state = work / "apt-state" + cache = work / "apt-cache" + etc = work / "apt-etc" + (state / "lists/partial").mkdir(parents=True, exist_ok=True) + (cache / "archives/partial").mkdir(parents=True, exist_ok=True) + etc.mkdir(parents=True, exist_ok=True) + status = state / "status" + if empty_status: + status.write_text("") + return [ + "-o", f"Dir::Etc::sourcelist={source}", + "-o", "Dir::Etc::sourceparts=-", + "-o", f"Dir::State={state}", + "-o", f"Dir::State::status={status}", + "-o", f"Dir::Cache={cache}", + "-o", f"APT::Architecture={arch}", + "-o", f"APT::Architectures::={arch}", + "-o", "Acquire::Languages=none", + "-o", "Acquire::AllowInsecureRepositories=true", + ] + + +def run(command: list[str], *, capture: bool = True, env: dict | None = None) -> subprocess.CompletedProcess: + result = subprocess.run( + command, + check=False, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + env=env, + ) + if result.returncode != 0: + detail = ((result.stdout or "") + "\n" + (result.stderr or "")).strip() + fail(f"command failed ({' '.join(command[:3])}): {detail}") + return result + + +def resolve_flat_repository( + flat: pathlib.Path, roots: list[str], arch: str, work: pathlib.Path +) -> list[str]: + source = work / "apt-etc/sources.list" + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text(f"deb [trusted=yes] file:{flat} ./\n") + options = apt_options(work, arch, source, True) + environment = {**os.environ, "LC_ALL": "C", "LANG": "C"} + run(["apt-get", *options, "update"], env=environment) + result = run( + [ + "apt-get", + "--print-uris", + "--yes", + "--no-install-recommends", + "--download-only", + *options, + "install", + *roots, + ], + env=environment, + ) + filenames: list[str] = [] + flat_prefix = flat.resolve().as_posix().rstrip("/") + "/" + for line in result.stdout.splitlines(): + match = re.match(r"^'([^']+)' \S+ [0-9]+ (?:MD5Sum|SHA256):", line) + if not match: + continue + uri = urllib.parse.urlsplit(match.group(1)) + decoded_path = urllib.parse.unquote(uri.path) + if uri.scheme != "file" or uri.netloc or not decoded_path.startswith(flat_prefix): + fail(f"APT selected a package outside the retained repository: {uri}") + relative = decoded_path[len(flat_prefix) :] + if relative.startswith("/") or ".." in pathlib.PurePosixPath(relative).parts: + fail("APT selected an unsafe package path") + filenames.append(relative) + if not filenames or len(filenames) != len(set(filenames)): + fail("APT returned an empty or duplicate dependency closure") + return sorted(filenames) + + +def package_metadata(path: pathlib.Path) -> tuple[str, str, str]: + values = [] + for field in ("Package", "Version", "Architecture"): + value = run(["dpkg-deb", "--field", str(path), field]).stdout.strip() + if not value or "\n" in value or "\r" in value: + fail(f"{path.name} has invalid Debian metadata") + values.append(value) + name, version, arch = values + if not SAFE_PACKAGE.fullmatch(name) or not SAFE_VERSION.fullmatch(version) or arch not in (*ARCHES, "all"): + fail(f"{path.name} has unsafe Debian identity metadata") + return name, version, arch + + +def validate_build_host(arch: str) -> None: + native = run(["dpkg", "--print-architecture"]).stdout.strip() + if native != arch: + fail(f"production package repositories require a native {arch} runner") + os_release = {} + for line in pathlib.Path("/etc/os-release").read_text().splitlines(): + if "=" in line: + key, value = line.split("=", 1) + os_release[key] = value.strip('"') + if os_release.get("ID") != "ubuntu" or os_release.get("VERSION_ID") != "24.04": + fail("production package repositories require an Ubuntu 24.04 runner") + + +def build(args: argparse.Namespace) -> None: + policy = load_policy() + if args.arch not in ARCHES: + fail("unsupported architecture") + if not re.fullmatch(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", args.version): + fail("invalid release version") + if not args.source_date_epoch.isdigit() or int(args.source_date_epoch) <= 0: + fail("source-date-epoch must be a positive integer") + output = pathlib.Path(args.output).resolve() + if output.exists() or output.is_symlink(): + fail("output already exists") + output.parent.mkdir(parents=True, exist_ok=True) + if os.environ.get("NEHEMIAH_PACKAGE_BUILD_TEST_OS") != "1": + validate_build_host(args.arch) + now = dt.datetime.now(dt.timezone.utc) + age = now - policy["_captured"] + if age.total_seconds() < -3600 or age.total_seconds() > policy["snapshot"]["maxAgeHours"] * 3600: + fail("Ubuntu snapshot is outside the reviewed freshness window") + + with tempfile.TemporaryDirectory(prefix=".nehemiah-host-packages-", dir=output.parent) as temporary: + work = pathlib.Path(temporary) + index_paragraphs: list[dict[str, str]] = [] + index_evidence = [] + base_url = policy["snapshot"]["baseUrl"] + for suite, suite_policy in sorted(policy["snapshot"]["suites"].items()): + inrelease = work / f"{suite}.InRelease" + download(f"{base_url}/dists/{suite}/InRelease", inrelease, 4 * 1024 * 1024) + if sha256_path(inrelease) != suite_policy["inReleaseSha256"]: + fail(f"{suite} InRelease digest changed") + release_entries = parse_release_sha256(inrelease.read_text()) + for component in policy["components"]: + relative = f"{component}/binary-{args.arch}/Packages.xz" + if relative not in release_entries: + fail(f"{suite} omits {relative}") + expected_sha, expected_size = release_entries[relative] + if not 0 < expected_size <= policy["limits"]["maxIndexBytes"]: + fail(f"{suite} {relative} violates the index size policy") + index_path = work / f"{suite}-{component}-Packages.xz" + download(f"{base_url}/dists/{suite}/{relative}", index_path, expected_size) + if index_path.stat().st_size != expected_size or sha256_path(index_path) != expected_sha: + fail(f"{suite} {relative} does not match pinned InRelease metadata") + try: + unpacked = lzma.decompress(index_path.read_bytes()).decode() + except (lzma.LZMAError, UnicodeDecodeError) as error: + fail(f"cannot decode {suite} {relative}: {error}") + index_paragraphs.extend(parse_debian_paragraphs(unpacked)) + index_evidence.append( + { + "component": component, + "path": relative, + "sha256": expected_sha, + "size": expected_size, + "suite": suite, + } + ) + + flat = work / "flat" + flat.mkdir() + combined = "\n".join(render_debian_paragraph(fields) for fields in index_paragraphs) + (flat / "Packages").write_text(combined) + selected_paths = resolve_flat_repository(flat, policy["rootPackages"], args.arch, work / "resolve") + if len(selected_paths) > policy["limits"]["maxPackageCount"]: + fail("resolved package closure exceeds its count policy") + + by_filename: dict[str, dict[str, str]] = {} + for fields in index_paragraphs: + filename = fields.get("Filename") + if filename: + if filename in by_filename and fields != by_filename[filename]: + fail("snapshot indexes disagree about a package path") + by_filename[filename] = fields + + stage = work / "stage" + package_dir = stage / "repo/packages" + package_dir.mkdir(parents=True) + selected_fields = [] + manifest_packages = [] + seen_names: set[str] = set() + unpacked_bytes = 0 + for source_path in selected_paths: + fields = by_filename.get(source_path) + if fields is None: + fail(f"APT selected unindexed package {source_path}") + required = {"Architecture", "Filename", "Package", "SHA256", "Size", "Version"} + if not required.issubset(fields): + fail(f"{source_path} lacks required package metadata") + expected_sha = fields["SHA256"] + if not SHA256.fullmatch(expected_sha): + fail(f"{source_path} has an invalid SHA256") + try: + expected_size = int(fields["Size"]) + except ValueError: + fail(f"{source_path} has an invalid size") + if not 0 < expected_size <= policy["limits"]["maxPackageBytes"]: + fail(f"{source_path} violates the package size policy") + filename = pathlib.PurePosixPath(source_path).name + if not SAFE_NAME.fullmatch(filename) or filename in seen_names: + fail("resolved package closure has an unsafe or duplicate filename") + seen_names.add(filename) + destination = package_dir / filename + quoted_path = "/".join(urllib.parse.quote(part, safe="+~._-") for part in source_path.split("/")) + download(f"{base_url}/{quoted_path}", destination, expected_size) + if destination.stat().st_size != expected_size or sha256_path(destination) != expected_sha: + fail(f"{source_path} package bytes do not match the pinned index") + name, version, package_arch = package_metadata(destination) + if (name, version, package_arch) != ( + fields["Package"], + fields["Version"], + fields["Architecture"], + ): + fail(f"{source_path} package identity does not match its index") + if package_arch not in (args.arch, "all"): + fail(f"{source_path} has the wrong architecture") + extracted_size = int(fields.get("Installed-Size", "0")) * 1024 + unpacked_bytes += extracted_size + rewritten = dict(fields) + rewritten["Filename"] = f"packages/{filename}" + selected_fields.append(rewritten) + manifest_packages.append( + { + "architecture": package_arch, + "filename": filename, + "installedBytes": extracted_size, + "name": name, + "sha256": expected_sha, + "size": expected_size, + "sourcePath": source_path, + "version": version, + } + ) + if unpacked_bytes > policy["limits"]["maxUnpackedBytes"]: + fail("resolved package closure exceeds its unpacked size policy") + manifest_packages.sort(key=lambda item: (item["name"], item["architecture"], item["version"])) + selected_fields.sort( + key=lambda fields: (fields["Package"], fields["Architecture"], fields["Version"]) + ) + packages_bytes = ( + "\n".join(render_debian_paragraph(fields) for fields in selected_fields) + ).encode() + packages_gzip = gzip.compress(packages_bytes, compresslevel=9, mtime=0) + (stage / "repo/Packages").write_bytes(packages_bytes) + (stage / "repo/Packages.gz").write_bytes(packages_gzip) + manifest = { + "architecture": args.arch, + "contractVersion": 1, + "operatingSystem": policy["operatingSystem"], + "packageCount": len(manifest_packages), + "packages": manifest_packages, + "releaseVersion": args.version, + "repository": { + "packagesGzipSha256": hashlib.sha256(packages_gzip).hexdigest(), + "packagesSha256": hashlib.sha256(packages_bytes).hexdigest(), + }, + "rootPackages": policy["rootPackages"], + "snapshot": { + "baseUrl": base_url, + "capturedAt": policy["snapshot"]["capturedAt"], + "indexes": sorted( + index_evidence, + key=lambda item: (item["suite"], item["component"], item["path"]), + ), + "suites": policy["snapshot"]["suites"], + }, + "unpackedBytes": unpacked_bytes, + } + (stage / "manifest.json").write_bytes(canonical_json(manifest)) + archive_name = f"nehemiah-host-packages_{args.version}_ubuntu24.04_linux_{args.arch}.tar.gz" + if output.name != archive_name: + fail(f"output filename must be {archive_name}") + run( + [ + "tar", + "--sort=name", + "--format=ustar", + "--owner=0", + "--group=0", + "--numeric-owner", + f"--mtime=@{args.source_date_epoch}", + "--mode=u+rwX,go+rX,go-w", + "--use-compress-program=gzip -n -9", + "-cf", + str(output), + "-C", + str(stage), + ".", + ] + ) + if output.stat().st_size > policy["limits"]["maxArchiveBytes"]: + fail("managed package archive exceeds its size policy") + output.chmod(0o644) + inspect_archive(output, args.version, args.arch) + + +def safe_extract(archive: pathlib.Path, destination: pathlib.Path, policy: dict) -> None: + total = 0 + seen = set() + with tarfile.open(archive, "r:gz") as bundle: + members = bundle.getmembers() + if not members or len(members) > policy["limits"]["maxPackageCount"] + 16: + fail("managed package archive has an invalid entry count") + for member in members: + path = pathlib.PurePosixPath(member.name) + normalized = str(path) + if path.is_absolute() or ".." in path.parts or normalized in seen: + fail("managed package archive has an unsafe or duplicate path") + if not (member.isdir() or member.isfile()): + fail("managed package archive contains a non-regular entry") + seen.add(normalized) + total += member.size + if total > policy["limits"]["maxArchiveBytes"] + policy["limits"]["maxUnpackedBytes"]: + fail("managed package archive exceeds its extraction policy") + bundle.extractall(destination, members=members, filter="data") + + +def validate_manifest(manifest: object, version: str, arch: str, policy: dict) -> dict: + manifest = exact_keys( + manifest, + { + "architecture", + "contractVersion", + "operatingSystem", + "packageCount", + "packages", + "releaseVersion", + "repository", + "rootPackages", + "snapshot", + "unpackedBytes", + }, + "package manifest", + ) + if ( + manifest["contractVersion"] != 1 + or manifest["releaseVersion"] != version + or manifest["architecture"] != arch + or manifest["operatingSystem"] != policy["operatingSystem"] + or manifest["rootPackages"] != policy["rootPackages"] + ): + fail("package manifest identity does not match the release policy") + repository = exact_keys( + manifest["repository"], {"packagesGzipSha256", "packagesSha256"}, "repository metadata" + ) + if not all(isinstance(value, str) and SHA256.fullmatch(value) for value in repository.values()): + fail("repository metadata digest is invalid") + snapshot = exact_keys( + manifest["snapshot"], {"baseUrl", "capturedAt", "indexes", "suites"}, "snapshot evidence" + ) + if ( + snapshot["baseUrl"] != policy["snapshot"]["baseUrl"] + or snapshot["capturedAt"] != policy["snapshot"]["capturedAt"] + or snapshot["suites"] != policy["snapshot"]["suites"] + or not isinstance(snapshot["indexes"], list) + or len(snapshot["indexes"]) != 6 + ): + fail("package manifest snapshot evidence does not match policy") + packages = manifest["packages"] + if ( + not isinstance(packages, list) + or not packages + or len(packages) != manifest["packageCount"] + or len(packages) > policy["limits"]["maxPackageCount"] + ): + fail("package manifest package count is invalid") + if packages != sorted(packages, key=lambda item: (item["name"], item["architecture"], item["version"])): + fail("package manifest is not canonically sorted") + names = set() + for package in packages: + exact_keys( + package, + { + "architecture", + "filename", + "installedBytes", + "name", + "sha256", + "size", + "sourcePath", + "version", + }, + "package entry", + ) + if ( + not SAFE_PACKAGE.fullmatch(package["name"]) + or not SAFE_VERSION.fullmatch(package["version"]) + or package["architecture"] not in (arch, "all") + or not SAFE_NAME.fullmatch(package["filename"]) + or package["filename"] in names + or not SHA256.fullmatch(package["sha256"]) + or not isinstance(package["size"], int) + or not 0 < package["size"] <= policy["limits"]["maxPackageBytes"] + or not isinstance(package["installedBytes"], int) + or package["installedBytes"] < 0 + or pathlib.PurePosixPath(package["sourcePath"]).is_absolute() + or ".." in pathlib.PurePosixPath(package["sourcePath"]).parts + ): + fail("package manifest contains an unsafe entry") + names.add(package["filename"]) + if manifest["unpackedBytes"] != sum(package["installedBytes"] for package in packages): + fail("package manifest unpacked size is inconsistent") + return manifest + + +def inspect_archive(archive: pathlib.Path, version: str, arch: str) -> dict: + policy = load_policy() + if not archive.is_file() or archive.is_symlink() or archive.stat().st_size == 0: + fail("managed package archive is missing or unsafe") + if archive.stat().st_size > policy["limits"]["maxArchiveBytes"]: + fail("managed package archive exceeds its size policy") + with tempfile.TemporaryDirectory(prefix="nehemiah-package-inspect-") as temporary: + extracted = pathlib.Path(temporary) / "archive" + extracted.mkdir() + safe_extract(archive, extracted, policy) + manifest_path = extracted / "manifest.json" + try: + manifest = validate_manifest(json.loads(manifest_path.read_text()), version, arch, policy) + except (OSError, json.JSONDecodeError) as error: + fail(f"invalid package manifest: {error}") + package_index = extracted / "repo/Packages" + package_index_gzip = extracted / "repo/Packages.gz" + if ( + sha256_path(package_index) != manifest["repository"]["packagesSha256"] + or sha256_path(package_index_gzip) != manifest["repository"]["packagesGzipSha256"] + or gzip.decompress(package_index_gzip.read_bytes()) != package_index.read_bytes() + ): + fail("retained repository metadata digest mismatch") + expected_files = { + "manifest.json", + "repo/Packages", + "repo/Packages.gz", + *(f"repo/packages/{package['filename']}" for package in manifest["packages"]), + } + actual_files = { + path.relative_to(extracted).as_posix() for path in extracted.rglob("*") if path.is_file() + } + if actual_files != expected_files: + fail("managed package archive does not contain the exact artifact set") + index_by_filename = {} + for fields in parse_debian_paragraphs(package_index.read_text()): + filename = fields.get("Filename") + if not filename or filename in index_by_filename: + fail("retained Packages index has a missing or duplicate filename") + index_by_filename[filename] = fields + if set(index_by_filename) != { + f"packages/{package['filename']}" for package in manifest["packages"] + }: + fail("retained Packages index does not match the package manifest") + for package in manifest["packages"]: + path = extracted / "repo/packages" / package["filename"] + if path.stat().st_size != package["size"] or sha256_path(path) != package["sha256"]: + fail(f"retained package {package['filename']} digest mismatch") + if package_metadata(path) != ( + package["name"], + package["version"], + package["architecture"], + ): + fail(f"retained package {package['filename']} identity mismatch") + fields = index_by_filename[f"packages/{package['filename']}"] + if ( + fields.get("Package") != package["name"] + or fields.get("Version") != package["version"] + or fields.get("Architecture") != package["architecture"] + or fields.get("SHA256") != package["sha256"] + or fields.get("Size") != str(package["size"]) + ): + fail(f"retained package {package['filename']} index mismatch") + resolved = resolve_flat_repository( + extracted / "repo", manifest["rootPackages"], arch, pathlib.Path(temporary) / "resolve" + ) + if resolved != sorted(f"packages/{package['filename']}" for package in manifest["packages"]): + fail("offline APT resolution does not reproduce the exact package closure") + return manifest + + +def inspect(args: argparse.Namespace) -> None: + manifest = inspect_archive(pathlib.Path(args.archive).resolve(), args.version, args.arch) + if args.print_manifest_sha256: + print(hashlib.sha256(canonical_json(manifest)).hexdigest()) + else: + print( + f"verified managed host package closure: {args.arch} " + f"{manifest['packageCount']} packages" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + build_parser = subparsers.add_parser("build") + build_parser.add_argument("--version", required=True) + build_parser.add_argument("--arch", required=True) + build_parser.add_argument("--output", required=True) + build_parser.add_argument("--source-date-epoch", required=True) + build_parser.set_defaults(function=build) + inspect_parser = subparsers.add_parser("inspect") + inspect_parser.add_argument("--archive", required=True) + inspect_parser.add_argument("--version", required=True) + inspect_parser.add_argument("--arch", required=True) + inspect_parser.add_argument("--print-manifest-sha256", action="store_true") + inspect_parser.set_defaults(function=inspect) + args = parser.parse_args() + args.function(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/release/resolve-version.mjs b/scripts/release/resolve-version.mjs new file mode 100644 index 0000000..f0be70b --- /dev/null +++ b/scripts/release/resolve-version.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveReleaseVersion, writeGitHubOutput } from "./lib.mjs"; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const cliPackage = JSON.parse( + await readFile( + path.join(repositoryRoot, "packages/cli/package.json"), + "utf8", + ), +); +const sdkPackage = JSON.parse( + await readFile( + path.join(repositoryRoot, "packages/sdk/package.json"), + "utf8", + ), +); +const version = resolveReleaseVersion({ + eventName: process.env.GITHUB_EVENT_NAME, + refName: process.env.GITHUB_REF_NAME, + inputVersion: process.env.RELEASE_INPUT_VERSION, + cliVersion: cliPackage.version, + sdkVersion: sdkPackage.version, +}); +if (process.env.GITHUB_OUTPUT) + await writeGitHubOutput(process.env.GITHUB_OUTPUT, { version }); +process.stdout.write(`${version}\n`); diff --git a/scripts/release/templates/nehemiah.rb.tpl b/scripts/release/templates/nehemiah.rb.tpl new file mode 100644 index 0000000..c74fe1a --- /dev/null +++ b/scripts/release/templates/nehemiah.rb.tpl @@ -0,0 +1,19 @@ +class Nehemiah < Formula + desc "Command-line client for Boring Computers Cloud and Nehemiah" + homepage "https://github.com/@@REPOSITORY@@" + url "https://github.com/@@REPOSITORY@@/releases/download/v@@VERSION@@/nehemiah-cli-@@VERSION@@.tgz" + version "@@VERSION@@" + sha256 "@@SHA256@@" + license "Apache-2.0" + + depends_on "node" + + def install + system "npm", "install", *std_npm_args + bin.install_symlink libexec.glob("bin/*") + end + + test do + assert_match "Boring Computers", shell_output("#{bin}/bc help") + end +end diff --git a/scripts/release/test/ci-policy.test.mjs b/scripts/release/test/ci-policy.test.mjs new file mode 100644 index 0000000..38c9900 --- /dev/null +++ b/scripts/release/test/ci-policy.test.mjs @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + REQUIRED_CI_WORKFLOW, + selectAuthorizedCIRun, + validateAuthorizedCIJobs, + validateReleaseWorkflowPolicy, +} from "../ci-policy.mjs"; + +const REPOSITORY = "boringcomputers/nehemiah"; +const COMMIT = "1".repeat(40); +const DEFAULT_HEAD = "2".repeat(40); +const WORKFLOW_ID = 101; +const RUN_ID = 202; + +function authorizationFixture() { + return { + repository: { full_name: REPOSITORY, default_branch: "main" }, + workflow: { + id: WORKFLOW_ID, + name: REQUIRED_CI_WORKFLOW.name, + path: REQUIRED_CI_WORKFLOW.path, + state: "active", + }, + branch: { + name: "main", + protected: true, + commit: { sha: DEFAULT_HEAD }, + }, + comparison: { + status: "ahead", + ahead_by: 2, + behind_by: 0, + base_commit: { sha: COMMIT }, + merge_base_commit: { sha: COMMIT }, + }, + runs: { + total_count: 1, + workflow_runs: [ + { + id: RUN_ID, + workflow_id: WORKFLOW_ID, + run_attempt: 1, + name: REQUIRED_CI_WORKFLOW.name, + path: `${REQUIRED_CI_WORKFLOW.path}@main`, + head_sha: COMMIT, + head_branch: "main", + event: "push", + status: "completed", + conclusion: "success", + repository: { full_name: REPOSITORY }, + head_repository: { full_name: REPOSITORY }, + }, + ], + }, + }; +} + +function jobsFixture() { + return { + total_count: REQUIRED_CI_WORKFLOW.jobs.length, + jobs: REQUIRED_CI_WORKFLOW.jobs.map(({ name }, index) => ({ + id: 1_000 + index, + run_id: RUN_ID, + head_sha: COMMIT, + head_branch: "main", + workflow_name: REQUIRED_CI_WORKFLOW.name, + name, + status: "completed", + conclusion: "success", + })), + }; +} + +function select(evidence = authorizationFixture()) { + return selectAuthorizedCIRun(evidence, { + expectedRepository: REPOSITORY, + expectedCommit: COMMIT, + }); +} + +test("protected default-branch exact-SHA CI evidence authorizes the complete matrix", () => { + const authorization = select(); + assert.equal(authorization.defaultBranch, "main"); + assert.equal(authorization.run.id, RUN_ID); + assert.equal( + validateAuthorizedCIJobs(jobsFixture(), { + run: authorization.run, + expectedCommit: COMMIT, + defaultBranch: authorization.defaultBranch, + }), + true, + ); +}); + +test("off-main and unprotected tag commits cannot authorize publication", () => { + const offMain = authorizationFixture(); + offMain.comparison.status = "diverged"; + offMain.comparison.behind_by = 1; + offMain.comparison.merge_base_commit.sha = "3".repeat(40); + assert.throws(() => select(offMain), /not on the protected default branch/); + + const unprotected = authorizationFixture(); + unprotected.branch.protected = false; + assert.throws(() => select(unprotected), /default branch is not protected/); +}); + +test("missing or failed exact-SHA default-branch CI cannot authorize publication", () => { + const missing = authorizationFixture(); + missing.runs = { total_count: 0, workflow_runs: [] }; + assert.throws( + () => select(missing), + /no successful default-branch CI push run/, + ); + + const failed = authorizationFixture(); + failed.runs.workflow_runs[0].conclusion = "failure"; + assert.throws( + () => select(failed), + /no successful default-branch CI push run/, + ); + + const wrongSHA = authorizationFixture(); + wrongSHA.runs.workflow_runs[0].head_sha = "4".repeat(40); + assert.throws( + () => select(wrongSHA), + /not a default-branch push for the exact release commit/, + ); +}); + +test("missing, skipped, or failed required CI and wire-contract jobs cannot publish", () => { + const authorization = select(); + const validate = (jobs) => + validateAuthorizedCIJobs(jobs, { + run: authorization.run, + expectedCommit: COMMIT, + defaultBranch: authorization.defaultBranch, + }); + + const missing = jobsFixture(); + missing.jobs = missing.jobs.filter( + ({ name }) => + name !== "generated wire contract (drift, type-check, compile)", + ); + missing.total_count = missing.jobs.length; + assert.throws(() => validate(missing), /exact required job matrix/); + + for (const conclusion of ["failure", "skipped"]) { + const unsuccessful = jobsFixture(); + unsuccessful.jobs.find( + ({ name }) => + name === "generated wire contract (drift, type-check, compile)", + ).conclusion = conclusion; + assert.throws( + () => validate(unsuccessful), + /required CI job did not succeed/, + ); + } +}); + +test("release and CI YAML retain an exact fail-closed authorization dependency", async () => { + const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", + ); + const releaseWorkflow = await readFile( + path.join(root, ".github/workflows/release.yml"), + "utf8", + ); + const ciWorkflow = await readFile( + path.join(root, ".github/workflows/ci.yml"), + "utf8", + ); + const authorizationScript = await readFile( + path.join(root, "scripts/release/authorize-ci.sh"), + "utf8", + ); + assert.equal( + validateReleaseWorkflowPolicy( + releaseWorkflow, + ciWorkflow, + authorizationScript, + ), + true, + ); + assert.throws( + () => + validateReleaseWorkflowPolicy( + releaseWorkflow.replace( + "Authorize protected default-branch CI for the exact tag commit", + "Authorization removed", + ), + ciWorkflow, + authorizationScript, + ), + /missing step Authorize protected default-branch CI/, + ); + assert.throws( + () => + validateReleaseWorkflowPolicy( + releaseWorkflow.replace( + " - name: Authorize protected default-branch CI for the exact tag commit\n if: github.event_name == 'push'", + " - name: Authorize protected default-branch CI for the exact tag commit\n if: github.event_name == 'workflow_dispatch'", + ), + ciWorkflow, + authorizationScript, + ), + /missing exact-SHA CI policy/, + ); + assert.throws( + () => + validateReleaseWorkflowPolicy( + releaseWorkflow, + ciWorkflow.replace(" wire-contract:", " omitted-contract:"), + authorizationScript, + ), + /jobs do not match the exact release-required matrix/, + ); + assert.throws( + () => + validateReleaseWorkflowPolicy( + releaseWorkflow, + ciWorkflow.replace( + "node --test scripts/release/test/*.test.mjs", + "echo release-policy-tests-removed", + ), + authorizationScript, + ), + /exact-SHA CI does not enforce release policy/, + ); + assert.throws( + () => + validateReleaseWorkflowPolicy( + releaseWorkflow, + ciWorkflow, + authorizationScript.replace( + "head_sha=${GITHUB_SHA}&event=push", + "branch=main&event=push", + ), + ), + /authorization script is missing/, + ); +}); diff --git a/scripts/release/test/release.test.mjs b/scripts/release/test/release.test.mjs new file mode 100644 index 0000000..6ec94aa --- /dev/null +++ b/scripts/release/test/release.test.mjs @@ -0,0 +1,559 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtemp, + readFile, + rm, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + createManifest, + MANAGED_RUNTIME_POLICY, + parseChecksums, + renderFormula, + resolveRepositoryOutputDirectory, + resolveReleaseVersion, + validateSignedTagEvidence, + verifyReleaseDirectory, + writeChecksums, + writeManifest, +} from "../lib.mjs"; + +const VERSION = "1.2.3-beta.4"; +const COMMIT = "0123456789abcdef0123456789abcdef01234567"; +const REPOSITORY = "boringcomputers/nehemiah"; +const TEST_GUEST_DIGESTS = { + amd64: { python: "1".repeat(64), desktop: "2".repeat(64) }, + arm64: { python: "3".repeat(64), desktop: "4".repeat(64) }, +}; +const TEST_PACKAGE_MANIFESTS = Object.fromEntries( + ["amd64", "arm64"].map((arch, index) => [ + arch, + { + contractVersion: 1, + architecture: arch, + releaseVersion: VERSION, + operatingSystem: { + id: "ubuntu", + version: "24.04", + codename: "noble", + }, + packageCount: 152, + manifestSha256: String(index + 5).repeat(64), + snapshot: { + baseUrl: "https://snapshot.ubuntu.com/ubuntu/20260809T000000Z", + capturedAt: "2026-08-09T00:00:00Z", + }, + }, + ]), +); + +function createTestManifest() { + return createManifest({ + version: VERSION, + commit: COMMIT, + sourceDateEpoch: 1_700_000_000, + repository: REPOSITORY, + guestRootfsDigests: TEST_GUEST_DIGESTS, + hostPackageManifests: TEST_PACKAGE_MANIFESTS, + }); +} + +async function withTempDirectory(run) { + const directory = await mkdtemp(path.join(tmpdir(), "release-test-")); + try { + return await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +async function createFixture(directory) { + const manifest = createTestManifest(); + for (const artifact of manifest.artifacts) { + await writeFile( + path.join(directory, artifact.name), + `fixture:${artifact.name}\n`, + "utf8", + ); + } + await writeManifest(directory, manifest); + await writeChecksums(directory, [ + ...manifest.artifacts.map(({ name }) => name), + "release-manifest.json", + ]); + return manifest; +} + +test("checksum generation is sorted, deterministic, and verifies the exact matrix", async () => { + await withTempDirectory(async (first) => { + await withTempDirectory(async (second) => { + await createFixture(first); + await createFixture(second); + const firstChecksums = await readFile( + path.join(first, "SHA256SUMS"), + "utf8", + ); + const secondChecksums = await readFile( + path.join(second, "SHA256SUMS"), + "utf8", + ); + assert.equal(firstChecksums, secondChecksums); + const names = [...parseChecksums(firstChecksums).keys()]; + assert.deepEqual(names, [...names].sort()); + const result = await verifyReleaseDirectory(first); + assert.equal(result.manifest.version, VERSION); + assert.equal(result.checksums.size, 22); + assert.equal(result.manifest.schemaVersion, 5); + assert.equal(result.manifest.managedCloudInitCompatible, true); + assert.equal( + result.manifest.managedHost.bootstrapArtifact, + `nehemiah-host-bootstrap_${VERSION}.tar.gz`, + ); + assert.equal(result.manifest.managedHost.contractVersion, 4); + assert.equal( + result.manifest.managedHost.rootfsProfile, + "signed-developer-ext4-v1", + ); + assert.deepEqual( + Object.keys(result.manifest.managedHost.inputs.amd64).sort(), + ["firecracker", "kernel"], + ); + for (const arch of ["amd64", "arm64"]) { + const hostImages = result.manifest.managedHost.guestImages[arch]; + for (const flavor of ["python", "desktop"]) { + assert.equal(hostImages[flavor].format, "ext4.gz"); + assert.match( + hostImages[flavor].artifact, + new RegExp( + `^nehemiah-guest-${flavor}_.+_linux_${arch}\\.ext4\\.gz$`, + ), + ); + assert.ok(hostImages[flavor].uncompressedBytes > 0); + assert.ok(hostImages[flavor].maxCompressedBytes > 0); + assert.match(hostImages[flavor].uncompressedSha256, /^[0-9a-f]{64}$/); + } + assert.equal(hostImages.scanEvidence.format, "json"); + } + const policy = result.manifest.managedHost.guestImagePolicy; + assert.equal(policy.architectures.amd64.ociBase.nodeVersion, "24.19.0"); + assert.equal(policy.architectures.arm64.ociBase.nodeVersion, "24.19.0"); + assert.equal(policy.alpineRepositorySnapshot.release, "v3.23"); + assert.equal( + policy.alpineRepositorySnapshot.capturedAt, + "2026-08-09T10:08:00Z", + ); + assert.equal(policy.npmRuntime.version, "11.19.0"); + assert.equal(policy.pythonRuntime.pip.version, "26.2.1"); + assert.equal(policy.pythonRuntime.setuptools.version, "84.0.0"); + assert.deepEqual( + policy.pythonRuntime.pipVendorOverlays.map( + ({ name, version, format }) => ({ name, version, format }), + ), + [ + { name: "msgpack", version: "1.2.1", format: "sdist" }, + { name: "setuptools", version: "80.9.0", format: "wheel" }, + ], + ); + for (const overlay of policy.pythonRuntime.pipVendorOverlays) { + assert.match(overlay.url, /^https:\/\/files\.pythonhosted\.org\//); + assert.match(overlay.sha256, /^[0-9a-f]{64}$/); + } + for (const snapshot of Object.values( + policy.alpineRepositorySnapshot.architectures, + )) { + for (const repository of [snapshot.main, snapshot.community]) { + assert.match(repository.url, /^https:\/\//); + assert.match(repository.sha256, /^[0-9a-f]{64}$/); + } + } + assert.equal(policy.vulnerabilityScan.version, "0.72.0"); + assert.equal(policy.vulnerabilityScan.maxDatabaseAgeHours, 24); + assert.match(policy.vulnerabilityScan.allowlistSha256, /^[0-9a-f]{64}$/); + for (const inputs of Object.values(result.manifest.managedHost.inputs)) { + for (const input of Object.values(inputs)) { + assert.match(input.sha256, /^[0-9a-f]{64}$/); + assert.match(input.artifact, /^nehemiah-runtime-/); + assert.ok(input.maxBytes > 0); + assert.equal(input.url, undefined); + assert.equal(input.sourceUrl, undefined); + } + } + for (const arch of ["amd64", "arm64"]) { + for (const component of ["firecracker", "kernel"]) { + const policy = MANAGED_RUNTIME_POLICY.architectures[arch][component]; + const input = result.manifest.managedHost.inputs[arch][component]; + assert.deepEqual(input, { + version: policy.version, + artifact: policy.artifact, + format: policy.format, + maxBytes: policy.maxBytes, + sha256: policy.sha256, + ...(component === "firecracker" + ? { + firecrackerSha256: policy.firecrackerSha256, + jailerSha256: policy.jailerSha256, + } + : {}), + }); + assert.ok( + result.manifest.artifacts.some( + (artifact) => artifact.name === policy.artifact, + ), + ); + } + assert.match( + result.manifest.managedHost.runtimeCohorts[arch].cohortId, + /^[0-9a-f]{64}$/, + ); + assert.equal( + result.manifest.managedHost.packageRepositories[arch].packageCount, + 152, + ); + } + }); + }); +}); + +test("verification fails closed when checksum metadata is missing", async () => { + await withTempDirectory(async (directory) => { + await createFixture(directory); + await unlink(path.join(directory, "SHA256SUMS")); + await assert.rejects( + verifyReleaseDirectory(directory), + /SHA256SUMS is missing/, + ); + }); +}); + +test("verification rejects a tampered artifact", async () => { + await withTempDirectory(async (directory) => { + const manifest = await createFixture(directory); + await writeFile( + path.join(directory, manifest.artifacts[0].name), + "tampered\n", + "utf8", + ); + await assert.rejects( + verifyReleaseDirectory(directory), + /checksum mismatch/, + ); + }); +}); + +test("verification rejects unexpected files and permits only explicit release metadata", async () => { + await withTempDirectory(async (directory) => { + await createFixture(directory); + await writeFile( + path.join(directory, "unexpected.txt"), + "untrusted\n", + "utf8", + ); + await assert.rejects( + verifyReleaseDirectory(directory), + /unexpected physical artifact set/, + ); + + await unlink(path.join(directory, "unexpected.txt")); + const extras = ["artifact-provenance.sigstore.json", "SHA256SUMS.minisig"]; + for (const extra of extras) { + await writeFile(path.join(directory, extra), "fixture\n", "utf8"); + } + await assert.rejects( + verifyReleaseDirectory(directory), + /unexpected physical artifact set/, + ); + await verifyReleaseDirectory(directory, { + allowedUnchecksummedFiles: extras, + }); + + await assert.rejects( + verifyReleaseDirectory(directory, { + allowedUnchecksummedFiles: [extras[0], extras[0]], + }), + /duplicate allowed extra file/, + ); + }); +}); + +test("managed-host retained runtime pins are immutable release policy", async () => { + await withTempDirectory(async (directory) => { + const manifest = createTestManifest(); + manifest.managedHost.inputs.amd64.firecracker.sha256 = "0".repeat(64); + await assert.rejects( + writeManifest(directory, manifest), + /reviewed retained runtime pins/, + ); + + const mutable = createTestManifest(); + mutable.managedHost.inputs.arm64.kernel.artifact = "other-kernel.bin"; + await assert.rejects( + writeManifest(directory, mutable), + /reviewed retained runtime pins/, + ); + + const mutableGuest = createTestManifest(); + mutableGuest.managedHost.guestImagePolicy.architectures.amd64.ociBase.reference = + "docker.io/library/node:24"; + await assert.rejects( + writeManifest(directory, mutableGuest), + /reviewed immutable policy/, + ); + + const omittedDesktop = createTestManifest(); + delete omittedDesktop.managedHost.guestImages.arm64.desktop; + await assert.rejects( + writeManifest(directory, omittedDesktop), + /artifact contract is not exact/, + ); + }); +}); + +test("cloud-init validator reproduces the signed runtime cohort and rejects drift", async () => { + await withTempDirectory(async (directory) => { + const manifest = createTestManifest(); + const manifestPath = path.join(directory, "release-manifest.json"); + const selectedPath = path.join(directory, "selected.env"); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`, "utf8"); + const accepted = spawnSync( + "python3", + [ + path.resolve("infra/latitude/validate-managed-release.py"), + "--manifest", + manifestPath, + "--version", + VERSION, + "--arch", + "amd64", + "--output", + selectedPath, + ], + { encoding: "utf8" }, + ); + assert.equal(accepted.status, 0, accepted.stderr); + const selected = Object.fromEntries( + (await readFile(selectedPath, "utf8")) + .trimEnd() + .split("\n") + .map((line) => line.split("=", 2)), + ); + assert.equal( + selected.RUNTIME_COHORT_ID, + manifest.managedHost.runtimeCohorts.amd64.cohortId, + ); + assert.equal(selected.RUNTIME_CONTRACT_VERSION, "4"); + assert.equal(selected.PYTHON_SHA256, TEST_GUEST_DIGESTS.amd64.python); + assert.equal(selected.DESKTOP_SHA256, TEST_GUEST_DIGESTS.amd64.desktop); + + manifest.managedHost.runtimeCohorts.amd64.cohortId = "0".repeat(64); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`, "utf8"); + const rejected = spawnSync( + "python3", + [ + path.resolve("infra/latitude/validate-managed-release.py"), + "--manifest", + manifestPath, + "--version", + VERSION, + "--arch", + "amd64", + "--output", + path.join(directory, "rejected.env"), + ], + { encoding: "utf8" }, + ); + assert.notEqual(rejected.status, 0); + assert.match(rejected.stderr, /runtime cohort is invalid/); + }); +}); + +test("verification rejects omitted metadata and symlink artifacts", async () => { + await withTempDirectory(async (directory) => { + const manifest = await createFixture(directory); + const checksumPath = path.join(directory, "SHA256SUMS"); + const lines = (await readFile(checksumPath, "utf8")).trimEnd().split("\n"); + await writeFile(checksumPath, `${lines.slice(1).join("\n")}\n`, "utf8"); + await assert.rejects( + verifyReleaseDirectory(directory), + /exact manifest artifact set/, + ); + + await createFixture(directory); + const artifactPath = path.join(directory, manifest.artifacts[0].name); + await unlink(artifactPath); + await symlink("release-manifest.json", artifactPath); + await assert.rejects( + verifyReleaseDirectory(directory), + /regular, non-symlink/, + ); + }); +}); + +test("checksum parser rejects traversal, duplicates, malformed digests, and self-reference", () => { + const digest = "a".repeat(64); + assert.throws( + () => parseChecksums(`${digest} ../artifact\n`), + /malformed|unsafe/, + ); + assert.throws( + () => parseChecksums(`${digest} artifact\n${digest} artifact\n`), + /duplicate/, + ); + assert.throws( + () => parseChecksums(`${"A".repeat(64)} artifact\n`), + /malformed/, + ); + assert.throws( + () => parseChecksums(`${digest} SHA256SUMS\n`), + /self-reference/, + ); + assert.throws(() => parseChecksums(""), /empty/); +}); + +test("release output is constrained to one visible repository child", () => { + const root = "/workspace/repository"; + assert.equal( + resolveRepositoryOutputDirectory(root, "release-dist"), + "/workspace/repository/release-dist", + ); + for (const unsafe of [ + "", + ".", + "..", + ".git", + "../outside", + "nested/output", + "/tmp/output", + ]) { + assert.throws( + () => resolveRepositoryOutputDirectory(root, unsafe), + /--out|unsafe artifact filename/, + ); + } +}); + +test("Homebrew formula rendering is deterministic and checksum-pinned", () => { + const template = [ + "class Nehemiah < Formula", + ' url "https://github.com/@@REPOSITORY@@/releases/download/v@@VERSION@@/nehemiah-cli-@@VERSION@@.tgz"', + ' sha256 "@@SHA256@@"', + "end", + ].join("\n"); + const sha256 = "b".repeat(64); + const first = renderFormula(template, { + version: VERSION, + repository: REPOSITORY, + sha256, + }); + const second = renderFormula(template, { + version: VERSION, + repository: REPOSITORY, + sha256, + }); + assert.equal(first, second); + assert.match(first, new RegExp(sha256)); + assert.match(first, /releases\/download\/v1\.2\.3-beta\.4/); + assert.doesNotMatch(first, /@@/); +}); + +test("release version resolution is tag-bound and keeps manual runs build-only", () => { + assert.equal( + resolveReleaseVersion({ + eventName: "push", + refName: `v${VERSION}`, + cliVersion: VERSION, + sdkVersion: VERSION, + }), + VERSION, + ); + assert.equal( + resolveReleaseVersion({ + eventName: "workflow_dispatch", + inputVersion: VERSION, + cliVersion: VERSION, + sdkVersion: VERSION, + }), + VERSION, + ); + assert.throws( + () => + resolveReleaseVersion({ + eventName: "push", + refName: "v1.2.4", + cliVersion: VERSION, + sdkVersion: VERSION, + }), + /does not match package version/, + ); + assert.throws( + () => + resolveReleaseVersion({ + eventName: "pull_request", + cliVersion: VERSION, + sdkVersion: "1.2.4", + }), + /does not match SDK version/, + ); +}); + +test("signed tag evidence rejects lightweight, unverified, and commit-mismatched tags", () => { + const tagObjectSha = "f".repeat(40); + const ref = { + ref: `refs/tags/v${VERSION}`, + object: { type: "tag", sha: tagObjectSha }, + }; + const tag = { + tag: `v${VERSION}`, + sha: tagObjectSha, + object: { type: "commit", sha: COMMIT }, + verification: { verified: true, reason: "valid" }, + }; + assert.equal( + validateSignedTagEvidence(ref, tag, { + expectedTag: `v${VERSION}`, + expectedCommit: COMMIT, + }), + true, + ); + assert.throws( + () => + validateSignedTagEvidence( + { ...ref, object: { type: "commit", sha: COMMIT } }, + tag, + { + expectedTag: `v${VERSION}`, + expectedCommit: COMMIT, + }, + ), + /annotated, not lightweight/, + ); + assert.throws( + () => + validateSignedTagEvidence( + ref, + { ...tag, verification: { verified: false, reason: "unsigned" } }, + { + expectedTag: `v${VERSION}`, + expectedCommit: COMMIT, + }, + ), + /did not verify/, + ); + assert.throws( + () => + validateSignedTagEvidence( + ref, + { ...tag, object: { type: "commit", sha: "e".repeat(40) } }, + { + expectedTag: `v${VERSION}`, + expectedCommit: COMMIT, + }, + ), + /unexpected commit/, + ); +}); diff --git a/scripts/release/verify-ci.mjs b/scripts/release/verify-ci.mjs new file mode 100644 index 0000000..7eca184 --- /dev/null +++ b/scripts/release/verify-ci.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + selectAuthorizedCIRun, + validateAuthorizedCIJobs, +} from "./ci-policy.mjs"; +import { invariant, parseArguments } from "./lib.mjs"; + +const options = parseArguments(process.argv.slice(2), [ + "phase", + "repository", + "commit", + "repository-evidence", + "workflow-evidence", + "branch-evidence", + "comparison-evidence", + "runs-evidence", + "jobs-evidence", +]); +for (const required of [ + "phase", + "repository", + "commit", + "repository-evidence", + "workflow-evidence", + "branch-evidence", + "comparison-evidence", + "runs-evidence", +]) { + invariant(options[required], `--${required} is required`); +} +invariant( + options.phase === "select" || options.phase === "verify", + "--phase must be select or verify", +); + +async function readJSON(file, label) { + try { + return JSON.parse(await readFile(path.resolve(file), "utf8")); + } catch (error) { + throw new Error(`${label} is not valid JSON`, { cause: error }); + } +} + +const evidence = { + repository: await readJSON( + options["repository-evidence"], + "repository evidence", + ), + workflow: await readJSON(options["workflow-evidence"], "workflow evidence"), + branch: await readJSON(options["branch-evidence"], "branch evidence"), + comparison: await readJSON( + options["comparison-evidence"], + "comparison evidence", + ), + runs: await readJSON(options["runs-evidence"], "workflow-run evidence"), +}; +const authorization = selectAuthorizedCIRun(evidence, { + expectedRepository: options.repository, + expectedCommit: options.commit, +}); + +if (options.phase === "select") { + process.stdout.write(`${authorization.run.id}\n`); +} else { + invariant(options["jobs-evidence"], "--jobs-evidence is required"); + const jobs = await readJSON( + options["jobs-evidence"], + "workflow-job evidence", + ); + validateAuthorizedCIJobs(jobs, { + run: authorization.run, + expectedCommit: options.commit, + defaultBranch: authorization.defaultBranch, + }); + process.stdout.write( + `verified protected ${authorization.defaultBranch} CI run ${authorization.run.id} for ${options.commit}\n`, + ); +} diff --git a/scripts/release/verify-tag.mjs b/scripts/release/verify-tag.mjs new file mode 100644 index 0000000..cee0949 --- /dev/null +++ b/scripts/release/verify-tag.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + parseArguments, + invariant, + validateSignedTagEvidence, +} from "./lib.mjs"; + +const options = parseArguments(process.argv.slice(2), [ + "ref", + "tag", + "expected-tag", + "expected-commit", +]); +for (const required of ["ref", "tag", "expected-tag", "expected-commit"]) { + invariant(options[required], `--${required} is required`); +} +const ref = JSON.parse(await readFile(path.resolve(options.ref), "utf8")); +const tag = JSON.parse(await readFile(path.resolve(options.tag), "utf8")); +validateSignedTagEvidence(ref, tag, { + expectedTag: options["expected-tag"], + expectedCommit: options["expected-commit"], +}); +process.stdout.write( + `verified signed annotated tag ${options["expected-tag"]}\n`, +); diff --git a/scripts/release/verify.mjs b/scripts/release/verify.mjs new file mode 100644 index 0000000..007835f --- /dev/null +++ b/scripts/release/verify.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { parseArguments, verifyReleaseDirectory } from "./lib.mjs"; + +const options = parseArguments(process.argv.slice(2), [ + "directory", + "allow-unchecksummed", +]); +const directory = path.resolve(options.directory ?? "."); +const allowedUnchecksummedFiles = options["allow-unchecksummed"] + ? options["allow-unchecksummed"].split(",") + : []; +const { manifest, checksums } = await verifyReleaseDirectory(directory, { + allowedUnchecksummedFiles, +}); +process.stdout.write( + `verified ${checksums.size} checksummed files for release ${manifest.version}\n`, +); From 097315a74112484414421cd528b27438b1e62ffe Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 00:50:24 -0400 Subject: [PATCH 02/14] fix(release): patched Go toolchain for govulncheck; shellcheck-clean bootstrap CI resolves Go from the go directive (1.25.0), whose standard library now carries 30 known vulnerabilities fixed in later patches, so govulncheck fails there while a locally newer toolchain passes. Pin toolchain go1.26.5 in nehemiahd, guest-agent, and gateway. Rewrite bootstrap.sh's KVM permission warning as an explicit if: the release validate job runs the provisioning harness, whose default-severity shellcheck flags the "A && B || C" form (SC2015) on current shellcheck. Co-authored-by: Cursor --- gateway/go.mod | 2 ++ guest-agent/go.mod | 2 ++ infra/latitude/bootstrap.sh | 4 +++- nehemiahd/go.mod | 2 ++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/gateway/go.mod b/gateway/go.mod index 5c7fc83..e4684a8 100644 --- a/gateway/go.mod +++ b/gateway/go.mod @@ -2,6 +2,8 @@ module github.com/boringcomputers/nehemiah/gateway go 1.25.0 +toolchain go1.26.5 + require ( github.com/gorilla/websocket v1.5.3 go.opentelemetry.io/otel v1.45.0 diff --git a/guest-agent/go.mod b/guest-agent/go.mod index 0c1d9ed..339dcf3 100644 --- a/guest-agent/go.mod +++ b/guest-agent/go.mod @@ -2,4 +2,6 @@ module github.com/boringcomputers/nehemiah/guest-agent go 1.25.0 +toolchain go1.26.5 + require golang.org/x/sys v0.44.0 diff --git a/infra/latitude/bootstrap.sh b/infra/latitude/bootstrap.sh index 359a02d..2df2006 100755 --- a/infra/latitude/bootstrap.sh +++ b/infra/latitude/bootstrap.sh @@ -161,7 +161,9 @@ log "Signed offline package cohort verified." # -------------------------------------------------------------------------- log "Verifying KVM support..." [ -e /dev/kvm ] || die "/dev/kvm not present - box lacks nested/hardware virtualization" -[ -r /dev/kvm ] && [ -w /dev/kvm ] || warn "/dev/kvm not read/write for root? continuing" +if [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then + warn "/dev/kvm not read/write for root? continuing" +fi if grep -Eqw '(vmx|svm)' /proc/cpuinfo; then log "CPU virtualization extensions (vmx/svm) present." diff --git a/nehemiahd/go.mod b/nehemiahd/go.mod index 76d7c60..e4d2cbd 100644 --- a/nehemiahd/go.mod +++ b/nehemiahd/go.mod @@ -2,6 +2,8 @@ module github.com/boringcomputers/nehemiah/nehemiahd go 1.25.0 +toolchain go1.26.5 + require ( github.com/gorilla/websocket v1.5.3 github.com/klauspost/compress v1.18.6 From ac3613aa7023a12218851b0670a7eee035a68bae Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 00:59:57 -0400 Subject: [PATCH 03/14] fix(release): build the SDK before type-checking the CLI in validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI type-checks against nehemiah-sdk's published dist/ types, which the validate job never built, so every run of the step failed with TS2307/TS18046 cascades — including on the source branch, where this job was red too. Build the SDK workspace between its own checks and the CLI's. Co-authored-by: Cursor --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c7ba91..c4ba10f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,9 @@ jobs: run: | npm run check --workspace nehemiah-sdk npm run test --workspace nehemiah-sdk + # The CLI imports the SDK's published surface (dist/), so build it + # before type-checking the CLI against it. + npm run build --workspace nehemiah-sdk npm run check --workspace nehemiah-cli npm run test --workspace nehemiah-cli From 8f83afebec1504c16255503f210f73a14ce997b5 Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 01:11:32 -0400 Subject: [PATCH 04/14] fix(release): build the guest agent from its own module in guest-images The step ran `go build ./guest-agent` from the repo root, but there is no Go module at the root (each component is its own module), so every guest-images run failed with "cannot find main module". Build with -C guest-agent and emit the binary to an absolute path, matching how build.mjs compiles components (cwd set to the module directory). Co-authored-by: Cursor --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4ba10f..69b00e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -231,10 +231,12 @@ jobs: run: | set -euo pipefail mkdir -p guest-image-input + # guest-agent is its own Go module (there is no module at the repo + # root), so build inside it with -C and emit to an absolute path. env CGO_ENABLED=0 GOOS=linux GOARCH="${{ matrix.arch }}" \ GOFLAGS=-mod=readonly \ - go build -trimpath -buildvcs=false -ldflags='-s -w -buildid=' \ - -o guest-image-input/bc-guest-agent ./guest-agent + go build -C guest-agent -trimpath -buildvcs=false -ldflags='-s -w -buildid=' \ + -o "$PWD/guest-image-input/bc-guest-agent" . - name: Build and inspect immutable guest images twice env: From 8c2ac741ac46df6196c0e941fcdb19148db3599b Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 01:46:28 -0400 Subject: [PATCH 05/14] fix(release): validate the gzip trailer; refresh the Alpine index snapshot The host-packages inspector extracted with tarfile, which stops at the tar end-of-archive marker and never reads the gzip trailer, so the workflow's truncated-archive negative test proved a one-byte-truncated archive inspected cleanly. Decompress the full stream (bounded by the extraction policy) before extracting so the CRC/length trailer is always checked; verified against intact, trailer-truncated, and mid-stream-corrupted archives. Alpine republished the v3.23 indexes after the policy's snapshot was captured, so every guest-image build failed its index digest check. Refresh the four APKINDEX pins from the live repository and update the reviewed capturedAt in both places that pin it (check.mjs and the release test), per the security-refresh ritual. Co-authored-by: Cursor --- scripts/release/check.mjs | 2 +- scripts/release/guest-images/policy.json | 18 +++++++++--------- scripts/release/managed_host_packages.py | 20 ++++++++++++++++++++ scripts/release/test/release.test.mjs | 2 +- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/scripts/release/check.mjs b/scripts/release/check.mjs index 4d471fc..1a9baf5 100644 --- a/scripts/release/check.mjs +++ b/scripts/release/check.mjs @@ -585,7 +585,7 @@ for (const arch of ["amd64", "arm64"]) { invariant( parsedGuestPolicy.alpineRepositorySnapshot.release === "v3.23" && parsedGuestPolicy.alpineRepositorySnapshot.capturedAt === - "2026-08-09T10:08:00Z" && + "2026-08-11T05:45:11Z" && parsedGuestPolicy.alpineRepositorySnapshot.maxIndexAgeHours === 168, "guest APK indexes must match the reviewed security refresh", ); diff --git a/scripts/release/guest-images/policy.json b/scripts/release/guest-images/policy.json index 7b9fa41..e3cef89 100644 --- a/scripts/release/guest-images/policy.json +++ b/scripts/release/guest-images/policy.json @@ -3,33 +3,33 @@ "rootfsProfile": "signed-developer-ext4-v1", "alpineRepositorySnapshot": { "release": "v3.23", - "capturedAt": "2026-08-09T10:08:00Z", + "capturedAt": "2026-08-11T05:45:11Z", "maxIndexAgeHours": 168, "architectures": { "amd64": { "apkArchitecture": "x86_64", "main": { "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/main/x86_64/APKINDEX.tar.gz", - "sha256": "49f58afe62a4cfd6ae7434fce1e2561040f8d500f0b02ebbc2de86dcd6828925", - "publishedAt": "2026-08-07T14:00:48Z" + "sha256": "a1a2d3451af0489a8d7f16b253db1001d916ab58e60feaed081f605fb4ed0640", + "publishedAt": "2026-08-10T12:31:02Z" }, "community": { "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/community/x86_64/APKINDEX.tar.gz", - "sha256": "4cd00267e46333285b9011fb052ff73879fd2384c0383cdfd9f41d56088b7ce4", - "publishedAt": "2026-08-05T10:04:43Z" + "sha256": "7e0789786820c9c522a53d3f40a72ebfa6d39d9b9c9fbaf6afffd9233302e949", + "publishedAt": "2026-08-10T12:33:12Z" } }, "arm64": { "apkArchitecture": "aarch64", "main": { "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/main/aarch64/APKINDEX.tar.gz", - "sha256": "36de4080ea7a5fbe7468e561e3bfc75ad0953388cdfdc15be5d33ec685865a18", - "publishedAt": "2026-08-07T14:00:48Z" + "sha256": "b2d21b682f3642b1e23a79c4a75cec1255443c4aad4a9364236fd79a0e924025", + "publishedAt": "2026-08-10T12:18:59Z" }, "community": { "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/community/aarch64/APKINDEX.tar.gz", - "sha256": "e3e38ce10db429436d5f484303838da6c15f119a44e1391a7e0f309187dcabfc", - "publishedAt": "2026-08-05T10:04:43Z" + "sha256": "5d748ab69d207c6ce056d5f90036b38dd5195b3c047df0df08b7ed01c7362d21", + "publishedAt": "2026-08-10T12:19:54Z" } } } diff --git a/scripts/release/managed_host_packages.py b/scripts/release/managed_host_packages.py index f9d4a55..41ce13c 100755 --- a/scripts/release/managed_host_packages.py +++ b/scripts/release/managed_host_packages.py @@ -24,6 +24,7 @@ import tempfile import urllib.parse import urllib.request +import zlib ROOT = pathlib.Path(__file__).resolve().parents[2] @@ -542,7 +543,26 @@ def build(args: argparse.Namespace) -> None: inspect_archive(output, args.version, args.arch) +def assert_intact_gzip_stream(archive: pathlib.Path, policy: dict) -> None: + # tarfile stops reading at the tar end-of-archive marker and never + # consumes the gzip trailer, so a truncated or tampered tail would + # otherwise extract and inspect cleanly. Decompress the entire stream + # (bounded by the extraction policy) so the CRC/length trailer is + # always validated. + limit = policy["limits"]["maxArchiveBytes"] + policy["limits"]["maxUnpackedBytes"] + decompressed = 0 + try: + with gzip.open(archive, "rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + decompressed += len(block) + if decompressed > limit: + fail("managed package archive exceeds its extraction policy") + except (OSError, EOFError, zlib.error) as error: + fail(f"managed package archive gzip stream is corrupt: {error}") + + def safe_extract(archive: pathlib.Path, destination: pathlib.Path, policy: dict) -> None: + assert_intact_gzip_stream(archive, policy) total = 0 seen = set() with tarfile.open(archive, "r:gz") as bundle: diff --git a/scripts/release/test/release.test.mjs b/scripts/release/test/release.test.mjs index 6ec94aa..b458d69 100644 --- a/scripts/release/test/release.test.mjs +++ b/scripts/release/test/release.test.mjs @@ -146,7 +146,7 @@ test("checksum generation is sorted, deterministic, and verifies the exact matri assert.equal(policy.alpineRepositorySnapshot.release, "v3.23"); assert.equal( policy.alpineRepositorySnapshot.capturedAt, - "2026-08-09T10:08:00Z", + "2026-08-11T05:45:11Z", ); assert.equal(policy.npmRuntime.version, "11.19.0"); assert.equal(policy.pythonRuntime.pip.version, "26.2.1"); From 493633ebac6b4ee5523096e827c635a0cdfff4ca Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 02:21:16 -0400 Subject: [PATCH 06/14] fix(release): ship the napi-rs keyring as the CLI package's runtime dependency esbuild cannot bundle @napi-rs/keyring's native .node binding, so the deterministic-build job failed bundling the CLI. Mark it external and declare it (exact-pinned from the CLI workspace) as the generated package's single runtime dependency; npm resolves the platform binding at install time, matching how the Homebrew formula installs the tarball. Co-authored-by: Cursor --- scripts/release/build.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/release/build.mjs b/scripts/release/build.mjs index 611d38c..1cc83b9 100644 --- a/scripts/release/build.mjs +++ b/scripts/release/build.mjs @@ -264,6 +264,10 @@ async function buildCliArtifact() { "--target=node20", "--format=esm", "--packages=bundle", + // The credential store loads the napi-rs keyring at runtime; its native + // .node binding cannot be bundled, so it stays the package's single + // runtime dependency and npm resolves the platform binding on install. + "--external:@napi-rs/keyring", "--legal-comments=external", `--outfile=${path.join(packageDirectory, "dist/cli.js")}`, ]); @@ -303,6 +307,9 @@ async function buildCliArtifact() { bin: cliPackage.bin, files: ["dist", "README.md", "LICENSE", "NOTICE"], engines: cliPackage.engines, + dependencies: { + "@napi-rs/keyring": cliPackage.dependencies["@napi-rs/keyring"], + }, }; await writeFile( path.join(packageDirectory, "package.json"), From 77eae9619c74094471b4d67a29c5953be9272691 Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 02:55:32 -0400 Subject: [PATCH 07/14] fix(release): vendor the keyring platform bindings into the CLI tarball The packed CLI must install with npm --offline against an empty cache, so the keyring cannot be an ordinary registry dependency. Vendor the napi-rs keyring and all twelve platform bindings into the package as a bundled dependency, each fetched at build time and verified against an exact pinned tarball digest, with the platform bindings nested under the keyring package so its per-platform require resolves offline. The post-install invariant now asserts exactly the bundled keyring dependency and that its vendored tree landed (the offline install itself still proves nothing is fetched), and check.mjs pins the new assertion plus the digest-pinned download path. Verified locally end to end: bundle, vendor, npm pack (11.9 MB), offline global install with an empty cache, vendored tree present, and bc help. Co-authored-by: Cursor --- scripts/release/build.mjs | 151 +++++++++++++++++++++++++++++++++++++- scripts/release/check.mjs | 9 ++- 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/scripts/release/build.mjs b/scripts/release/build.mjs index 1cc83b9..83448f2 100644 --- a/scripts/release/build.mjs +++ b/scripts/release/build.mjs @@ -248,6 +248,124 @@ async function buildGoArtifact(definition, arch) { return artifactName; } +// The credential store loads the napi-rs keyring at runtime; its native +// .node bindings cannot be bundled by esbuild, and the packed CLI must +// install offline, so the keyring and every platform binding are vendored +// into the tarball as a bundled dependency, pinned by exact registry +// tarball digest. +const VENDORED_CLI_KEYRING = Object.freeze({ + name: "@napi-rs/keyring", + version: "1.3.0", + tarballs: Object.freeze([ + { + name: "@napi-rs/keyring", + sha256: + "3303402123327ecfc472e12b5577f4d7acf2e0780d1d646212ca51c8fbf50b84", + }, + { + name: "@napi-rs/keyring-darwin-arm64", + sha256: + "b57f9a3136ab0e74d570370facf26fd0aafaceab41336853edffccb250b0a959", + }, + { + name: "@napi-rs/keyring-darwin-x64", + sha256: + "962dc87ae7e6dfa5c496ee0c61ff0949a22d2c46b23805e0908a04ed4185ea53", + }, + { + name: "@napi-rs/keyring-freebsd-x64", + sha256: + "693f11ec41e64baa36753ff33d2da68891d78e7b93f9226c2e2427ca7afcdcc2", + }, + { + name: "@napi-rs/keyring-linux-arm-gnueabihf", + sha256: + "dcc7976c7a5285c1051170a14cd156bcac88ff92e8ebc80b84e54cf14fae4a41", + }, + { + name: "@napi-rs/keyring-linux-arm64-gnu", + sha256: + "8765084a2d3b53d6bb1b731e050d812b94b4bab71aeafe6cff4e46e773e0e0e7", + }, + { + name: "@napi-rs/keyring-linux-arm64-musl", + sha256: + "f29cac9864985cb268b307ba138966f53f249c6d16ec7adfeb6a64ceb75d5750", + }, + { + name: "@napi-rs/keyring-linux-riscv64-gnu", + sha256: + "45bdbeb4f875c0e412ac68832350d1cd96c9b45b456e034f3ec52caeaac0f118", + }, + { + name: "@napi-rs/keyring-linux-x64-gnu", + sha256: + "c739de9323a5ae7d27b93669661a78b9678e6d66d891435447e628f6432eb05b", + }, + { + name: "@napi-rs/keyring-linux-x64-musl", + sha256: + "9a9743d13272b6a66370bf93d7f64decf076794804f7b9de9fce842e0ec0ae26", + }, + { + name: "@napi-rs/keyring-win32-arm64-msvc", + sha256: + "7dfe816fb394c90b34d1284c7f64e2ee75c23b9e6cc68e97668fb102ce789d65", + }, + { + name: "@napi-rs/keyring-win32-ia32-msvc", + sha256: + "8fbbb969e43ccf942d730fa91e480c8a02b68965681f22fcb4d26b89a3f77bc2", + }, + { + name: "@napi-rs/keyring-win32-x64-msvc", + sha256: + "a775ca1dda8f344a4d92fd2efb46f8c0ae6214ff30f8a066262788f34e43b184", + }, + ]), +}); + +async function vendorCliKeyring(packageDirectory) { + const vendorScratch = path.join(scratchDirectory, "cli-keyring-vendor"); + await mkdir(vendorScratch, { recursive: true }); + const keyringRoot = path.join( + packageDirectory, + "node_modules", + VENDORED_CLI_KEYRING.name, + ); + for (const tarball of VENDORED_CLI_KEYRING.tarballs) { + const shortName = tarball.name.split("/")[1]; + const filename = `${shortName}-${VENDORED_CLI_KEYRING.version}.tgz`; + const url = `https://registry.npmjs.org/${tarball.name}/-/${filename}`; + const response = await fetch(url); + invariant( + response.ok, + `vendored keyring download failed: ${url} (${response.status})`, + ); + const bytes = Buffer.from(await response.arrayBuffer()); + invariant( + createHash("sha256").update(bytes).digest("hex") === tarball.sha256, + `vendored keyring tarball digest mismatch: ${tarball.name}`, + ); + const archivePath = path.join(vendorScratch, filename); + await writeFile(archivePath, bytes, { mode: 0o644 }); + // Platform bindings nest under the keyring package so its dynamic + // per-platform require() resolves them without touching the registry. + const destination = + tarball.name === VENDORED_CLI_KEYRING.name + ? keyringRoot + : path.join(keyringRoot, "node_modules", tarball.name); + await mkdir(destination, { recursive: true }); + await runCommand("tar", [ + "-xzf", + archivePath, + "-C", + destination, + "--strip-components=1", + ]); + } +} + async function buildCliArtifact() { await runCommand("npm", ["run", "build", "--workspace", "nehemiah-sdk"], { cwd: repositoryRoot, @@ -308,9 +426,15 @@ async function buildCliArtifact() { files: ["dist", "README.md", "LICENSE", "NOTICE"], engines: cliPackage.engines, dependencies: { - "@napi-rs/keyring": cliPackage.dependencies["@napi-rs/keyring"], + [VENDORED_CLI_KEYRING.name]: VENDORED_CLI_KEYRING.version, }, + bundleDependencies: [VENDORED_CLI_KEYRING.name], }; + invariant( + cliPackage.dependencies[VENDORED_CLI_KEYRING.name] === + VENDORED_CLI_KEYRING.version, + "vendored keyring version does not match the CLI workspace pin", + ); await writeFile( path.join(packageDirectory, "package.json"), `${JSON.stringify(distributablePackage, null, 2)}\n`, @@ -319,6 +443,7 @@ async function buildCliArtifact() { mode: 0o644, }, ); + await vendorCliKeyring(packageDirectory); const packDirectory = path.join(scratchDirectory, "npm-pack"); await mkdir(packDirectory); const { stdout } = await runCommand( @@ -381,10 +506,28 @@ async function buildCliArtifact() { "utf8", ), ); + // The offline install above already proves nothing is fetched from a + // registry; the only permitted runtime dependency is the vendored, + // bundled OS-keyring binding, which must have landed inside the package. invariant( - installedPackage.dependencies === undefined && - installedPackage.optionalDependencies === undefined, - "release CLI package must not contain runtime npm dependencies", + JSON.stringify(installedPackage.dependencies) === + JSON.stringify({ + [VENDORED_CLI_KEYRING.name]: VENDORED_CLI_KEYRING.version, + }) && + installedPackage.optionalDependencies === undefined && + JSON.stringify( + installedPackage.bundleDependencies ?? + installedPackage.bundledDependencies, + ) === JSON.stringify([VENDORED_CLI_KEYRING.name]), + "release CLI package must vendor exactly the bundled keyring dependency", + ); + await assertRegularFile( + path.join( + installDirectory, + "lib/node_modules/nehemiah-cli/node_modules", + VENDORED_CLI_KEYRING.name, + "package.json", + ), ); const { stdout: helpOutput } = await runCommand( path.join(installDirectory, "bin/bc"), diff --git a/scripts/release/check.mjs b/scripts/release/check.mjs index 1a9baf5..dd378de 100644 --- a/scripts/release/check.mjs +++ b/scripts/release/check.mjs @@ -286,8 +286,13 @@ requireText( ); requireText( buildScript, - "installedPackage.dependencies === undefined", - "dependency-free release package assertion", + "must vendor exactly the bundled keyring dependency", + "vendored-only release package dependency assertion", +); +requireText( + buildScript, + "vendored keyring tarball digest mismatch", + "digest-pinned vendored keyring downloads", ); requireText( buildScript, From 881ba49112d4e1fe3d6763fa7104003d1e392635 Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 03:24:29 -0400 Subject: [PATCH 08/14] fix(release): keep the manifest OS contract in reviewed key order The host-packages packager emits manifest.json with canonically sorted keys, so parsed evidence carries operatingSystem as {codename, id, version}. Cloning that into the release manifest broke the contract validator's JSON.stringify deep-equality against the policy's reviewed {id, version, codename} order. Rebuild the object in policy key order, require the exact evidence key set, and key-sort the test fixture the way the packager does so the suite reproduces the drift. Co-authored-by: Cursor --- scripts/release/lib.mjs | 17 +++++++++++++---- scripts/release/test/release.test.mjs | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs index fee413d..2d4a46b 100644 --- a/scripts/release/lib.mjs +++ b/scripts/release/lib.mjs @@ -635,9 +635,11 @@ export function createManifest({ evidence.contractVersion === 1 && evidence.architecture === arch && evidence.releaseVersion === version && - evidence.operatingSystem?.id === "ubuntu" && - evidence.operatingSystem?.version === "24.04" && - evidence.operatingSystem?.codename === "noble" && + JSON.stringify(Object.keys(evidence.operatingSystem ?? {}).sort()) === + JSON.stringify(["codename", "id", "version"]) && + evidence.operatingSystem.id === "ubuntu" && + evidence.operatingSystem.version === "24.04" && + evidence.operatingSystem.codename === "noble" && evidence.snapshot?.baseUrl === MANAGED_HOST_PACKAGE_POLICY.snapshot.baseUrl && evidence.snapshot?.capturedAt === @@ -658,7 +660,14 @@ export function createManifest({ maxBytes: MANAGED_HOST_PACKAGE_POLICY.limits.maxArchiveBytes, manifestSha256: evidence.manifestSha256, packageCount: evidence.packageCount, - operatingSystem: structuredClone(evidence.operatingSystem), + // The packager emits canonically sorted JSON, so rebuild the object + // in the reviewed policy key order that the contract validator and + // its JSON.stringify deep-equality checks expect. + operatingSystem: { + id: evidence.operatingSystem.id, + version: evidence.operatingSystem.version, + codename: evidence.operatingSystem.codename, + }, snapshot: { baseUrl: evidence.snapshot.baseUrl, capturedAt: evidence.snapshot.capturedAt, diff --git a/scripts/release/test/release.test.mjs b/scripts/release/test/release.test.mjs index b458d69..4f5ecf6 100644 --- a/scripts/release/test/release.test.mjs +++ b/scripts/release/test/release.test.mjs @@ -31,10 +31,23 @@ const TEST_GUEST_DIGESTS = { amd64: { python: "1".repeat(64), desktop: "2".repeat(64) }, arm64: { python: "3".repeat(64), desktop: "4".repeat(64) }, }; +function canonicallySorted(value) { + if (Array.isArray(value)) return value.map(canonicallySorted); + if (value && typeof value === "object") + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonicallySorted(value[key])]), + ); + return value; +} + +// The packager writes manifest.json with canonically sorted keys, so parsed +// evidence arrives key-sorted at every level; the fixture must match. const TEST_PACKAGE_MANIFESTS = Object.fromEntries( ["amd64", "arm64"].map((arch, index) => [ arch, - { + canonicallySorted({ contractVersion: 1, architecture: arch, releaseVersion: VERSION, @@ -49,7 +62,7 @@ const TEST_PACKAGE_MANIFESTS = Object.fromEntries( baseUrl: "https://snapshot.ubuntu.com/ubuntu/20260809T000000Z", capturedAt: "2026-08-09T00:00:00Z", }, - }, + }), ]), ); From 390cd547b722a1844680b24607a3a9ef8fb0d3e7 Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 03:54:49 -0400 Subject: [PATCH 09/14] fix(release): retry transient snapshot download failures with backoff Both package-closure matrix jobs died 25 minutes in when snapshot.ubuntu.com answered 500/502 during the repeat build. Retry server and network failures up to three times with backoff, matching the curl fetchers' --retry 3. Client errors still fail immediately, policy violations are never retried, and every download remains digest-verified, so retries cannot alter the closure. Co-authored-by: Cursor --- scripts/release/managed_host_packages.py | 46 +++++++++++++++++------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/scripts/release/managed_host_packages.py b/scripts/release/managed_host_packages.py index 41ce13c..bc09307 100755 --- a/scripts/release/managed_host_packages.py +++ b/scripts/release/managed_host_packages.py @@ -22,6 +22,8 @@ import sys import tarfile import tempfile +import time +import urllib.error import urllib.parse import urllib.request import zlib @@ -181,19 +183,37 @@ def download(url: str, output: pathlib.Path, max_bytes: int) -> None: fail("unsafe snapshot download URL") opener = urllib.request.build_opener(StrictHTTPSRedirect()) request = urllib.request.Request(url, headers={"User-Agent": "nehemiah-release-builder/1"}) - try: - with opener.open(request, timeout=60) as response, output.open("xb") as stream: - total = 0 - while True: - block = response.read(1024 * 1024) - if not block: - break - total += len(block) - if total > max_bytes: - fail("snapshot download exceeds its size policy") - stream.write(block) - except (OSError, urllib.error.URLError) as error: - fail(f"snapshot download failed: {error}") + # snapshot.ubuntu.com intermittently answers 5xx; retry transient server + # and network failures with backoff, mirroring the curl fetchers' + # --retry 3. Policy failures raise SystemExit and are never retried, and + # every download is digest-verified afterward, so retries cannot alter + # the closure. + last_error: Exception | None = None + for attempt in range(4): + if attempt: + time.sleep(2**attempt) + try: + with opener.open(request, timeout=60) as response, output.open("xb") as stream: + total = 0 + while True: + block = response.read(1024 * 1024) + if not block: + break + total += len(block) + if total > max_bytes: + fail("snapshot download exceeds its size policy") + stream.write(block) + break + except urllib.error.HTTPError as error: + output.unlink(missing_ok=True) + if error.code < 500: + fail(f"snapshot download failed: {error}") + last_error = error + except (OSError, urllib.error.URLError) as error: + output.unlink(missing_ok=True) + last_error = error + else: + fail(f"snapshot download failed: {last_error}") if output.stat().st_size == 0: fail("snapshot returned an empty object") From 069e89f8d71e64b3a710858d3e474164a1b6b042 Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 04:10:26 -0400 Subject: [PATCH 10/14] fix(release): widen the snapshot retry budget to bridge 5xx bursts The snapshot.ubuntu.com load balancer flapped for over ten minutes and outlived the previous three-retry, fourteen-second budget. Retry up to eight attempts with a capped backoff (about two minutes per file) so a burst cannot sink a 25-minute matrix job; client errors still fail immediately and every download stays digest-verified. Co-authored-by: Cursor --- scripts/release/managed_host_packages.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/release/managed_host_packages.py b/scripts/release/managed_host_packages.py index bc09307..6070332 100755 --- a/scripts/release/managed_host_packages.py +++ b/scripts/release/managed_host_packages.py @@ -183,15 +183,15 @@ def download(url: str, output: pathlib.Path, max_bytes: int) -> None: fail("unsafe snapshot download URL") opener = urllib.request.build_opener(StrictHTTPSRedirect()) request = urllib.request.Request(url, headers={"User-Agent": "nehemiah-release-builder/1"}) - # snapshot.ubuntu.com intermittently answers 5xx; retry transient server - # and network failures with backoff, mirroring the curl fetchers' - # --retry 3. Policy failures raise SystemExit and are never retried, and - # every download is digest-verified afterward, so retries cannot alter - # the closure. + # snapshot.ubuntu.com load balancers flap with 5xx bursts that span + # minutes, so retry transient server and network failures with a capped + # backoff patient enough to bridge them. Policy failures raise SystemExit + # and are never retried, and every download is digest-verified afterward, + # so retries cannot alter the closure. last_error: Exception | None = None - for attempt in range(4): + for attempt in range(8): if attempt: - time.sleep(2**attempt) + time.sleep(min(30, 2**attempt)) try: with opener.open(request, timeout=60) as response, output.open("xb") as stream: total = 0 From 5a0c3d4783251968760039963e419d2bb3d5a082 Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 04:34:18 -0400 Subject: [PATCH 11/14] fix(release): record scan observation time at nanosecond precision Trivy stamps DownloadedAt with nanosecond precision while the evidence recorded observedAt in whole seconds, so when both landed in the same second the truncated observation sorted "before" the download and the evidence verifier rejected the ordering. Record observedAt at the same precision so program order is preserved in the timestamps. Co-authored-by: Cursor --- scripts/release/guest-images/prepare-vulnerability-scanner.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/release/guest-images/prepare-vulnerability-scanner.sh b/scripts/release/guest-images/prepare-vulnerability-scanner.sh index d6ac478..5ef554a 100755 --- a/scripts/release/guest-images/prepare-vulnerability-scanner.sh +++ b/scripts/release/guest-images/prepare-vulnerability-scanner.sh @@ -81,7 +81,9 @@ archive_sha="$(sha256sum "$archive" | awk '{print $1}')" binary_sha="$(sha256sum "$output/trivy" | awk '{print $1}')" database_sha="$(sha256sum "$database" | awk '{print $1}')" downloaded_at="$(jq -er '.DownloadedAt' "$metadata")" -observed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +# Nanosecond precision matches Trivy's DownloadedAt; a whole-second stamp can +# land in the same second and sort "before" it, tripping evidence ordering. +observed_at="$(date -u +%Y-%m-%dT%H:%M:%S.%NZ)" jq -n \ --arg tool "$tool" --arg version "$version" \ --arg archiveSha256 "$archive_sha" --arg binarySha256 "$binary_sha" \ From 337454f7e4dc8c5f484efa88c665d1f23a171e32 Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 10:44:55 -0400 Subject: [PATCH 12/14] fix(release): retry snapshot bodies truncated mid-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http.client.IncompleteRead is not an OSError, so a response cut off after streaming began escaped the retry handler on the first attempt and left the partial file behind. Treat http.client transport exceptions as retryable alongside the existing failures, and pin the downloader's whole retry contract — truncation retried without residue, 5xx bursts bridged, client errors immediate, exhaustion fail-closed — with a stubbed-opener harness run from the release test suite. Co-authored-by: Cursor --- scripts/release/managed_host_packages.py | 13 +- .../release/test/download-retry-harness.py | 125 ++++++++++++++++++ .../managed-host-packages-download.test.mjs | 21 +++ 3 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 scripts/release/test/download-retry-harness.py create mode 100644 scripts/release/test/managed-host-packages-download.test.mjs diff --git a/scripts/release/managed_host_packages.py b/scripts/release/managed_host_packages.py index 6070332..6d337fc 100755 --- a/scripts/release/managed_host_packages.py +++ b/scripts/release/managed_host_packages.py @@ -12,6 +12,7 @@ import datetime as dt import gzip import hashlib +import http.client import json import lzma import os @@ -184,10 +185,12 @@ def download(url: str, output: pathlib.Path, max_bytes: int) -> None: opener = urllib.request.build_opener(StrictHTTPSRedirect()) request = urllib.request.Request(url, headers={"User-Agent": "nehemiah-release-builder/1"}) # snapshot.ubuntu.com load balancers flap with 5xx bursts that span - # minutes, so retry transient server and network failures with a capped - # backoff patient enough to bridge them. Policy failures raise SystemExit - # and are never retried, and every download is digest-verified afterward, - # so retries cannot alter the closure. + # minutes, so retry transient server and network failures — including + # bodies truncated mid-stream, which surface as http.client exceptions + # rather than OSError — with a capped backoff patient enough to bridge + # them. Policy failures raise SystemExit and are never retried, and every + # download is digest-verified afterward, so retries cannot alter the + # closure. last_error: Exception | None = None for attempt in range(8): if attempt: @@ -209,7 +212,7 @@ def download(url: str, output: pathlib.Path, max_bytes: int) -> None: if error.code < 500: fail(f"snapshot download failed: {error}") last_error = error - except (OSError, urllib.error.URLError) as error: + except (OSError, urllib.error.URLError, http.client.HTTPException) as error: output.unlink(missing_ok=True) last_error = error else: diff --git a/scripts/release/test/download-retry-harness.py b/scripts/release/test/download-retry-harness.py new file mode 100644 index 0000000..7f96f01 --- /dev/null +++ b/scripts/release/test/download-retry-harness.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Exercise the snapshot downloader's retry contract with a stubbed opener. + +Run by managed-host-packages-download.test.mjs; exits nonzero on any drift. +Covers the transient failures the release builder must survive — 5xx bursts +and bodies truncated mid-stream — plus the failures that must stay immediate +(client errors) and the fail-closed exhaustion path that must leave no +partial artifact behind. +""" + +from __future__ import annotations + +import http.client +import importlib.util +import io +import pathlib +import sys +import tempfile +import urllib.error + +MODULE_PATH = pathlib.Path(__file__).resolve().parents[1] / "managed_host_packages.py" + +spec = importlib.util.spec_from_file_location("managed_host_packages", MODULE_PATH) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +module.time.sleep = lambda seconds: None + + +class FakeResponse: + def __init__(self, blocks): + self.blocks = list(blocks) + + def read(self, size): + action = self.blocks.pop(0) + if isinstance(action, Exception): + raise action + return action + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class FakeOpener: + def __init__(self, script): + self.script = list(script) + self.calls = 0 + + def open(self, request, timeout=None): + self.calls += 1 + action = self.script.pop(0) + if isinstance(action, Exception): + raise action + return FakeResponse(action) + + +def run(script): + opener = FakeOpener(script) + module.urllib.request.build_opener = lambda *handlers: opener + with tempfile.TemporaryDirectory() as scratch: + output = pathlib.Path(scratch) / "artifact" + try: + module.download("https://snapshot.ubuntu.com/x", output, 1 << 20) + return "ok", opener.calls, output.read_bytes() + except SystemExit as error: + return "exit", opener.calls, (str(error), output.exists()) + + +def http_error(code): + return urllib.error.HTTPError("https://x", code, "err", {}, io.BytesIO()) + + +def truncated(): + # One partial block lands in the output file before the stream dies. + return [b"partial-", http.client.IncompleteRead(b"partial-")] + + +failures = [] + + +def expect(label, actual, expected): + if actual != expected: + failures.append(f"{label}: {actual!r} != {expected!r}") + + +# A truncated body is retried, the partial file is discarded, and the retry's +# complete payload is exactly what lands on disk. +expect( + "truncated body retried", + run([truncated(), [b"payload", b""]]), + ("ok", 2, b"payload"), +) + +# Persistent truncation exhausts the retry budget, fails closed, and leaves +# no partial artifact behind. +status, calls, (message, leftover) = run([truncated()] * 8) +expect( + "persistent truncation fails closed", + (status, calls, "snapshot download failed" in message, leftover), + ("exit", 8, True, False), +) + +# 5xx bursts are retried until the mirror recovers. +expect( + "5xx burst retried", + run([http_error(503), http_error(502), [b"payload", b""]]), + ("ok", 3, b"payload"), +) + +# Client errors are permanent and must fail on the first attempt. +status, calls, (message, leftover) = run([http_error(404)]) +expect( + "client error fails fast", + (status, calls, "404" in message, leftover), + ("exit", 1, True, False), +) + +if failures: + print("download retry contract drifted:", file=sys.stderr) + for failure in failures: + print(f" {failure}", file=sys.stderr) + raise SystemExit(1) +print("download retry contract holds") diff --git a/scripts/release/test/managed-host-packages-download.test.mjs b/scripts/release/test/managed-host-packages-download.test.mjs new file mode 100644 index 0000000..052a36b --- /dev/null +++ b/scripts/release/test/managed-host-packages-download.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); + +test("snapshot downloads retry truncated and 5xx failures without residue", () => { + const result = spawnSync( + "python3", + [path.join(testDirectory, "download-retry-harness.py")], + { encoding: "utf8" }, + ); + assert.equal( + result.status, + 0, + `download retry harness failed:\n${result.stdout}${result.stderr}`, + ); + assert.match(result.stdout, /download retry contract holds/); +}); From ba744a7e68e425bb43352dd69563c5853992401d Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 10:57:04 -0400 Subject: [PATCH 13/14] fix(release): refresh the Alpine index snapshot after upstream republish Alpine republished both v3.23 main indexes this morning, so every guest-image build failed its immutable index digest check. Refresh the two changed APKINDEX pins from the live repository (both community indexes are unchanged) and update the reviewed capturedAt in check.mjs and the release test, per the security-refresh ritual. Co-authored-by: Cursor --- scripts/release/check.mjs | 2 +- scripts/release/guest-images/policy.json | 10 +++++----- scripts/release/test/release.test.mjs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/release/check.mjs b/scripts/release/check.mjs index dd378de..7973fe7 100644 --- a/scripts/release/check.mjs +++ b/scripts/release/check.mjs @@ -590,7 +590,7 @@ for (const arch of ["amd64", "arm64"]) { invariant( parsedGuestPolicy.alpineRepositorySnapshot.release === "v3.23" && parsedGuestPolicy.alpineRepositorySnapshot.capturedAt === - "2026-08-11T05:45:11Z" && + "2026-08-11T14:55:44Z" && parsedGuestPolicy.alpineRepositorySnapshot.maxIndexAgeHours === 168, "guest APK indexes must match the reviewed security refresh", ); diff --git a/scripts/release/guest-images/policy.json b/scripts/release/guest-images/policy.json index e3cef89..1d7abad 100644 --- a/scripts/release/guest-images/policy.json +++ b/scripts/release/guest-images/policy.json @@ -3,15 +3,15 @@ "rootfsProfile": "signed-developer-ext4-v1", "alpineRepositorySnapshot": { "release": "v3.23", - "capturedAt": "2026-08-11T05:45:11Z", + "capturedAt": "2026-08-11T14:55:44Z", "maxIndexAgeHours": 168, "architectures": { "amd64": { "apkArchitecture": "x86_64", "main": { "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/main/x86_64/APKINDEX.tar.gz", - "sha256": "a1a2d3451af0489a8d7f16b253db1001d916ab58e60feaed081f605fb4ed0640", - "publishedAt": "2026-08-10T12:31:02Z" + "sha256": "ec8c6d9632082c73d807c4d076698de10a54be95c77c7929cc560ae7c285b5d9", + "publishedAt": "2026-08-11T09:03:28Z" }, "community": { "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/community/x86_64/APKINDEX.tar.gz", @@ -23,8 +23,8 @@ "apkArchitecture": "aarch64", "main": { "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/main/aarch64/APKINDEX.tar.gz", - "sha256": "b2d21b682f3642b1e23a79c4a75cec1255443c4aad4a9364236fd79a0e924025", - "publishedAt": "2026-08-10T12:18:59Z" + "sha256": "fd95fd6dd119eb786dc43592dbcbbf7cf979d8f0d6b3d85f180cc46fcfa9ccbc", + "publishedAt": "2026-08-11T09:01:08Z" }, "community": { "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/community/aarch64/APKINDEX.tar.gz", diff --git a/scripts/release/test/release.test.mjs b/scripts/release/test/release.test.mjs index 4f5ecf6..0f58eb0 100644 --- a/scripts/release/test/release.test.mjs +++ b/scripts/release/test/release.test.mjs @@ -159,7 +159,7 @@ test("checksum generation is sorted, deterministic, and verifies the exact matri assert.equal(policy.alpineRepositorySnapshot.release, "v3.23"); assert.equal( policy.alpineRepositorySnapshot.capturedAt, - "2026-08-11T05:45:11Z", + "2026-08-11T14:55:44Z", ); assert.equal(policy.npmRuntime.version, "11.19.0"); assert.equal(policy.pythonRuntime.pip.version, "26.2.1"); From fa49c542642b3d3bda3bb33fbc2c2e037146d9e4 Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 11 Aug 2026 11:14:40 -0400 Subject: [PATCH 14/14] fix(release): drain the retention reaper instead of assuming one batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device-auth suite freezes its clock at a calendar date, and once its revoked fixture families aged past the reaper's 24-hour window they sort ahead of the retention test's chain and fill the whole default family batch of 4, so a single reap pass no longer removed the chain and the new policy-enforcing workspace job started failing mid-day. Run the job to completion the way its schedule would — batched drains promise eventual removal, not first-pass removal — and assert the accumulated totals; verified against a fresh sequential Postgres run. Co-authored-by: Cursor --- .../device-retention-postgres.test.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/nehemiah/test/integration/device-retention-postgres.test.ts b/apps/nehemiah/test/integration/device-retention-postgres.test.ts index a00b0bb..2596c13 100644 --- a/apps/nehemiah/test/integration/device-retention-postgres.test.ts +++ b/apps/nehemiah/test/integration/device-retention-postgres.test.ts @@ -313,11 +313,28 @@ databaseDescribe('device authorization retained-row boundary', () => { expect(expiredResults[1]).toEqual({ status: 'fulfilled', value: false }); expect(expiredResults[2]).toEqual({ status: 'fulfilled', value: undefined }); - const report = await reapExpiredDeviceAuthorizations(database); - expect(report).toMatchObject({ skipped: false }); - expect(report.refreshTokens).toBeGreaterThanOrEqual(2); - expect(report.families).toBeGreaterThanOrEqual(1); - expect(report.authorizations).toBeGreaterThanOrEqual(2); + // The reaper drains oldest-first in bounded batches, and other suites' + // expired fixtures legitimately share this database and can sort ahead + // of this chain, so run the job to completion the way its schedule + // would instead of assuming one batch covers this family. + const totals = { accessTokens: 0, refreshTokens: 0, families: 0, authorizations: 0 }; + for (let pass = 0; pass < 20; pass += 1) { + const report = await reapExpiredDeviceAuthorizations(database); + expect(report).toMatchObject({ skipped: false }); + totals.accessTokens += report.accessTokens; + totals.refreshTokens += report.refreshTokens; + totals.families += report.families; + totals.authorizations += report.authorizations; + if ( + report.accessTokens + report.refreshTokens + report.families + report.authorizations === + 0 + ) { + break; + } + } + expect(totals.refreshTokens).toBeGreaterThanOrEqual(2); + expect(totals.families).toBeGreaterThanOrEqual(1); + expect(totals.authorizations).toBeGreaterThanOrEqual(2); const terminalRows = await database.query<{ count: number }>( `SELECT (SELECT count(*) FROM device_authorizations