From 1806fad5ab8801e60266842a8ea9cab853851697 Mon Sep 17 00:00:00 2001 From: John Carmack Date: Fri, 26 Jun 2026 22:13:02 -0700 Subject: [PATCH] Harden CI/CD supply chain: pin actions, scope perms, verify martin [skip release] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit's one High. Pins the deploy pipeline against supply-chain drift: - SHA-pin every GitHub Action across all workflows (version kept as a trailing comment), so a moved tag can't swap action code under us. Add Dependabot (github-actions, weekly) to bump the pins. - deploy.yml: replace the workflow-level union of permissions with a contents:read floor + per-job escalation — only infra/web get the OIDC id-token, and only web gets deployments:write. The changes (paths-filter) job no longer receives a token it never used. - Pin cargo-lambda (==1.9.1) in both the ci cdk job and the deploy infra job. - Pin the martin release (martin-v1.11.0) and verify its tarball sha256 before packaging — download to a file, check, then extract (was a curl|tar of "latest" with no integrity check). Override via MARTIN_RELEASE + MARTIN_SHA256. README: document the supply-chain posture in the IaC section. Out of scope (a local admin op, not CI): tightening the CDK bootstrap cfn-exec role / OIDC AssumeRole boundary — tracked separately. --- .github/dependabot.yml | 14 +++++++++ .github/workflows/auto-release.yml | 2 +- .github/workflows/ci.yml | 28 +++++++++--------- .github/workflows/deploy.yml | 46 +++++++++++++++++++----------- .github/workflows/release.yml | 2 +- README.md | 2 ++ scripts/build-martin-lambda.sh | 45 +++++++++++++++++++++++------ 7 files changed, 98 insertions(+), 41 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d177f99 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# Keep the SHA-pinned GitHub Actions current. Dependabot reads the version +# comment beside each pin (e.g. `# v6`) and opens PRs that bump the pin to a +# newer release's commit SHA — the counterpart to pinning, so the pins don't +# rot. Scoped to actions for now (npm in web/ + cdk/ and cargo in crates/ are +# left out to keep PR volume down); the martin release binary isn't trackable +# here and is bumped by hand in scripts/build-martin-lambda.sh. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: ci diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 381c5d2..30525fe 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -28,7 +28,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - name: Tag + release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7870d9c..29c4d90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,11 @@ jobs: web: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: 10 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: lts/* cache: pnpm @@ -33,11 +33,11 @@ jobs: rust: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: crates - run: cargo fmt --manifest-path crates/Cargo.toml --all -- --check @@ -45,10 +45,10 @@ jobs: # web/src/generated/{weather,geojson}.ts are generated (from # contract.rs and the typed-geojson crate) and biome-formatted # ('just build types'); rerun the pipeline and fail on drift - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: 10 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: lts/* cache: pnpm @@ -63,23 +63,23 @@ jobs: cdk: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # synth compiles weather-ingest via cargo-lambda-cdk and stages the # prebuilt martin zip, so this needs the Rust toolchain, cargo-lambda, # and the martin binary — but no AWS creds: the stack is env-agnostic # (no fromLookup; ARNs and the hosted-zone id are pinned). - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: crates - name: Install cargo-lambda - run: pip install cargo-lambda + run: pip install cargo-lambda==1.9.1 - name: Build martin lambda zip run: scripts/build-martin-lambda.sh build/martin-lambda.zip - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: 10 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: lts/* cache: pnpm diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d1b720c..91539b1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,12 +17,11 @@ on: - '.github/workflows/deploy.yml' workflow_dispatch: -# Union of what the two halves need: OIDC for AWS, contents for checkout, -# deployments to record the production publish. +# Least privilege at the workflow level — just enough for checkout. Each job +# escalates only what it needs (OIDC on infra/web, deployments on web), so the +# `changes` job never receives an OIDC id-token. permissions: - id-token: write contents: read - deployments: write # Serialize every deploy (both halves share the group) so two quick merges can't # interleave a later infra deploy with an earlier web publish. @@ -31,12 +30,15 @@ concurrency: deploy jobs: changes: runs-on: ubuntu-latest + # Just reads the repo to diff paths — no OIDC, no write. + permissions: + contents: read outputs: web: ${{ steps.filter.outputs.web }} infra: ${{ steps.filter.outputs.infra }} steps: - - uses: actions/checkout@v6 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 id: filter with: filters: | @@ -55,17 +57,21 @@ jobs: needs: changes if: needs.changes.outputs.infra == 'true' runs-on: ubuntu-latest + # OIDC to assume the deploy role + read to checkout. No deployments here. + permissions: + id-token: write + contents: read # No AWS keys: assumes stormdeck-github-deploy via OIDC (trust-scoped to # main on this repo). cdk uses the admin bootstrap cfn-exec role. steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: crates - name: Install cargo-lambda - run: pip install cargo-lambda + run: pip install cargo-lambda==1.9.1 # weather-ingest is compiled by cargo-lambda-cdk during cdk deploy (synth), # using the cargo-lambda + toolchain above; only the prebuilt martin binary @@ -73,16 +79,16 @@ jobs: - name: Build martin lambda zip run: scripts/build-martin-lambda.sh build/martin-lambda.zip - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: 10 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: lts/* cache: pnpm cache-dependency-path: cdk/pnpm-lock.yaml - - uses: aws-actions/configure-aws-credentials@v6 + - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6 with: role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} aws-region: us-east-2 @@ -130,9 +136,15 @@ jobs: needs.infra.result != 'failure' && needs.infra.result != 'cancelled' runs-on: ubuntu-latest + # OIDC to assume the deploy role + read to checkout + deployments to record + # the production publish via the API. + permissions: + id-token: write + contents: read + deployments: write steps: # Full history + tags so the version stamp resolves (next step). - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 # Bake in the version this commit ships as: its exact tag if it has one, @@ -143,10 +155,10 @@ jobs: v=$(git describe --tags --exact-match HEAD 2>/dev/null || scripts/next-version.sh patch) echo "VITE_APP_VERSION=$v" >>"$GITHUB_ENV" echo "version: $v" - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: 10 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: lts/* cache: pnpm @@ -159,7 +171,7 @@ jobs: # API_BASE resolves to '' in production builds (web/src/config.ts) — # same origin as the tiles and weather feeds, so all fetches relative. BASE_PATH: / - - uses: aws-actions/configure-aws-credentials@v6 + - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6 with: role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} aws-region: us-east-2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebe4111..378c7db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - name: Create GitHub Release diff --git a/README.md b/README.md index ea270f4..d58d0df 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ just dev # martin :3030 + vite :5173 (local data, overrides the defau CDK → CloudFormation: state lives in the account, and pushes to `main` deploy through the repo-pinned OIDC role (the `StormdeckGithubOidc` stack from step 3). `just cdk synth` works offline, and the `profile=` / `region=` variables (`.just/common.just`) thread through every infra recipe (`cdk bootstrap`, `cdk deploy`, `cdk outputs`, `tiles upload`, `weather prime`, …). Module justfiles live in their home folders, so e.g. `just deploy` from inside `cdk/` works too. +Supply chain: every GitHub Action is pinned to a commit SHA (Dependabot bumps them weekly), `cargo-lambda` is version-pinned, and the prebuilt martin binary is fetched from a pinned release and checksum-verified before it's packaged into the Lambda. The deploy workflow grants permissions per job, so only the AWS-touching jobs (`infra`, `web`) ever receive an OIDC token. + One piece lives outside CloudFormation: the stormdeck.live certificate was requested once via the ACM CLI in us-east-1 (CloudFront only takes certs from there) and is pinned by ARN in the stack. Its DNS validation records *are* stack-managed, so renewals stay hands-off. Mind the CAA gotcha: ACM follows CAA policy through CNAMEs, so a record pointing at a host with restrictive CAA (github.io, say) blocks issuance for that name. ## Configuration diff --git a/scripts/build-martin-lambda.sh b/scripts/build-martin-lambda.sh index 62311c9..de87edc 100755 --- a/scripts/build-martin-lambda.sh +++ b/scripts/build-martin-lambda.sh @@ -2,25 +2,54 @@ # Package the prebuilt martin release binary as an AWS Lambda zip # (provided.al2023 / arm64). Martin has native Lambda support: when # AWS_LAMBDA_RUNTIME_API is set it serves Lambda events instead of HTTP. -# Usage: build-martin-lambda.sh [out.zip] (MARTIN_RELEASE=v1.2.0 to pin) +# Usage: build-martin-lambda.sh [out.zip] +# Downloads a PINNED martin release and verifies its sha256 before baking it +# into the Lambda — "latest" is never used (supply-chain: freeze the exact +# bytes). To bump, update PINNED_RELEASE + PINNED_SHA256 together (get the +# checksum from `shasum -a 256` of the release tarball), or override both +# MARTIN_RELEASE and MARTIN_SHA256 for a one-off. Dependabot can't track +# release binaries, so this is a deliberate manual bump. set -euo pipefail -OUT="${1:-build/martin-lambda.zip}" -RELEASE="${MARTIN_RELEASE:-latest}" +# Pinned martin release + the sha256 of its aarch64 musl tarball. +PINNED_RELEASE="martin-v1.11.0" +PINNED_SHA256="350692798cbcda7d307adadcd9b16653bfe679fc0d2974ef25e70465255b7bed" -if [ "$RELEASE" = "latest" ]; then - URL="https://github.com/maplibre/martin/releases/latest/download/martin-aarch64-unknown-linux-musl.tar.gz" -else - URL="https://github.com/maplibre/martin/releases/download/${RELEASE}/martin-aarch64-unknown-linux-musl.tar.gz" +OUT="${1:-build/martin-lambda.zip}" +RELEASE="${MARTIN_RELEASE:-$PINNED_RELEASE}" +# The pinned checksum applies only to the pinned release; any override must bring +# its own (MARTIN_SHA256) — we refuse to run an unverified binary. +EXPECTED_SHA256="${MARTIN_SHA256:-}" +if [ -z "$EXPECTED_SHA256" ] && [ "$RELEASE" = "$PINNED_RELEASE" ]; then + EXPECTED_SHA256="$PINNED_SHA256" +fi +if [ -z "$EXPECTED_SHA256" ]; then + echo "error: no sha256 for martin release '$RELEASE' — set MARTIN_SHA256" >&2 + exit 1 fi +URL="https://github.com/maplibre/martin/releases/download/${RELEASE}/martin-aarch64-unknown-linux-musl.tar.gz" + mkdir -p "$(dirname "$OUT")" ABS_OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")" WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT +TARBALL="$WORK/martin.tar.gz" echo "==> downloading $URL" -curl -fL --progress-bar "$URL" | tar -xz -C "$WORK" martin +curl -fL --progress-bar "$URL" -o "$TARBALL" + +# Verify before trusting the contents. shasum -a 256 is on macOS and the CI +# ubuntu runner alike (avoids sha256sum, which macOS lacks). +actual=$(shasum -a 256 "$TARBALL" | cut -d' ' -f1) +if [ "$actual" != "$EXPECTED_SHA256" ]; then + echo "::error::martin tarball sha256 mismatch for $RELEASE" >&2 + echo " expected $EXPECTED_SHA256" >&2 + echo " actual $actual" >&2 + exit 1 +fi +echo "==> sha256 verified ($actual)" +tar -xzf "$TARBALL" -C "$WORK" martin # TILE_SOURCES is a space-separated list of s3:// urls set on the function # by the stack (unquoted on purpose so it word-splits into one positional