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..69b00e2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,562 @@ +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 + # 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 + + - 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 + # 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 -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: + 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/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 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 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..83448f2 --- /dev/null +++ b/scripts/release/build.mjs @@ -0,0 +1,824 @@ +#!/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; +} + +// 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, + }); + 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", + // 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")}`, + ]); + 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, + dependencies: { + [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`, + { + encoding: "utf8", + mode: 0o644, + }, + ); + await vendorCliKeyring(packageDirectory); + 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", + ), + ); + // 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( + 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"), + ["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..7973fe7 --- /dev/null +++ b/scripts/release/check.mjs @@ -0,0 +1,897 @@ +#!/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, + "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, + "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-11T14:55:44Z" && + 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..1d7abad --- /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-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": "ec8c6d9632082c73d807c4d076698de10a54be95c77c7929cc560ae7c285b5d9", + "publishedAt": "2026-08-11T09:03:28Z" + }, + "community": { + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/community/x86_64/APKINDEX.tar.gz", + "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": "fd95fd6dd119eb786dc43592dbcbbf7cf979d8f0d6b3d85f180cc46fcfa9ccbc", + "publishedAt": "2026-08-11T09:01:08Z" + }, + "community": { + "url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/community/aarch64/APKINDEX.tar.gz", + "sha256": "5d748ab69d207c6ce056d5f90036b38dd5195b3c047df0df08b7ed01c7362d21", + "publishedAt": "2026-08-10T12:19:54Z" + } + } + } + }, + "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..5ef554a --- /dev/null +++ b/scripts/release/guest-images/prepare-vulnerability-scanner.sh @@ -0,0 +1,108 @@ +#!/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")" +# 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" \ + --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..2d4a46b --- /dev/null +++ b/scripts/release/lib.mjs @@ -0,0 +1,1221 @@ +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 && + 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 === + 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, + // 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, + }, + }, + ]; + }), + ); + 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..6d337fc --- /dev/null +++ b/scripts/release/managed_host_packages.py @@ -0,0 +1,799 @@ +#!/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 http.client +import json +import lzma +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zlib + + +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"}) + # snapshot.ubuntu.com load balancers flap with 5xx bursts that span + # 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: + time.sleep(min(30, 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, http.client.HTTPException) 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") + + +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 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: + 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/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/); +}); diff --git a/scripts/release/test/release.test.mjs b/scripts/release/test/release.test.mjs new file mode 100644 index 0000000..0f58eb0 --- /dev/null +++ b/scripts/release/test/release.test.mjs @@ -0,0 +1,572 @@ +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) }, +}; +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, + 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-11T14:55:44Z", + ); + 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`, +);