diff --git a/.github/actions/create-release-tag/action.yml b/.github/actions/create-release-tag/action.yml index c540a3e0..a5bcfec6 100644 --- a/.github/actions/create-release-tag/action.yml +++ b/.github/actions/create-release-tag/action.yml @@ -62,11 +62,14 @@ runs: - name: Create and push tag shell: bash + env: + TAG: ${{ steps.next_tag.outputs.tag }} run: | - tag="${{ steps.next_tag.outputs.tag }}" - git tag -a "${tag}" -m "Release ${tag}" - git push origin "${tag}" + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" - name: Print created tag shell: bash - run: echo "Created tag ${{ steps.next_tag.outputs.tag }}" + env: + TAG: ${{ steps.next_tag.outputs.tag }} + run: echo "Created tag ${TAG}" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9a35f529..35c25625 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,7 @@ version: 2 + +# The 7-day cooldown skips freshly published (possibly compromised) releases; +# security updates are exempt. updates: - package-ecosystem: "gomod" directory: "/" @@ -6,6 +9,8 @@ updates: interval: "daily" time: "09:00" timezone: "Etc/UTC" + cooldown: + default-days: 7 open-pull-requests-limit: 10 labels: - "semver: patch" @@ -24,6 +29,8 @@ updates: interval: "daily" time: "09:00" timezone: "Etc/UTC" + cooldown: + default-days: 7 open-pull-requests-limit: 10 labels: - "semver: patch" @@ -42,6 +49,8 @@ updates: interval: "daily" time: "10:00" timezone: "Etc/UTC" + cooldown: + default-days: 7 open-pull-requests-limit: 10 labels: - "semver: patch" @@ -53,3 +62,31 @@ updates: update-types: - "minor" - "patch" + + # Bumps the hook revs, including the zizmor version zizmor.yml runs in CI. + - package-ecosystem: "pre-commit" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "10:00" + timezone: "Etc/UTC" + cooldown: + default-days: 7 + labels: + - "semver: patch" + - "docs: skip" + + # Bumps pre-commit itself, which zizmor.yml installs from this file. + - package-ecosystem: "pip" + directory: "/.github/tools" + schedule: + interval: "weekly" + day: "monday" + time: "10:00" + timezone: "Etc/UTC" + cooldown: + default-days: 7 + labels: + - "semver: patch" + - "docs: skip" diff --git a/.github/tools/requirements.txt b/.github/tools/requirements.txt new file mode 100644 index 00000000..23705400 --- /dev/null +++ b/.github/tools/requirements.txt @@ -0,0 +1 @@ +pre-commit==4.6.2 diff --git a/.github/workflows/automated-release.yml b/.github/workflows/automated-release.yml index eac43139..cd8f11b6 100644 --- a/.github/workflows/automated-release.yml +++ b/.github/workflows/automated-release.yml @@ -5,11 +5,9 @@ on: - cron: "0 10 * * 4" # Every Thursday at 10 AM UTC workflow_dispatch: +# Only the `ci` job needs write scopes, granted there for ci.yml's jobs. permissions: - contents: write - pull-requests: write - checks: write - security-events: write + contents: read concurrency: group: create-release-tag @@ -28,9 +26,9 @@ jobs: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: main fetch-depth: 0 - token: ${{ secrets.PRO_ACCESS_TOKEN }} - name: Fetch tags run: git fetch --tags --force @@ -66,9 +64,9 @@ jobs: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: main fetch-depth: 0 - token: ${{ secrets.PRO_ACCESS_TOKEN }} - name: Fetch tags run: git fetch --tags --force @@ -115,8 +113,14 @@ jobs: name: CI needs: check-changes if: needs.check-changes.outputs.has_changes == 'true' - uses: ./.github/workflows/ci.yml - secrets: inherit + uses: $/.github/workflows/ci.yml + # Must cover every scope ci.yml's jobs declare, even `release`, which is + # skipped here but still validated. + permissions: + contents: write + security-events: write # govulncheck SARIF upload + secrets: + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} create-tag: name: Create release tag @@ -124,7 +128,7 @@ jobs: if: needs.check-changes.outputs.has_changes == 'true' runs-on: ubuntu-latest steps: - - name: Checkout code + - name: Checkout code # zizmor: ignore[artipacked] the tag push needs it; this job uploads no artifacts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main @@ -135,6 +139,6 @@ jobs: run: git fetch --tags --force - name: Create release tag - uses: ./.github/actions/create-release-tag + uses: $/.github/actions/create-release-tag with: bump: ${{ needs.determine-bump.outputs.bump }} diff --git a/.github/workflows/check-release-label.yml b/.github/workflows/check-release-label.yml index 56b15bf5..121ca8e9 100644 --- a/.github/workflows/check-release-label.yml +++ b/.github/workflows/check-release-label.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, labeled, unlabeled, synchronize, reopened] +# The check reads labels off the event payload, so it needs no API access. +permissions: {} + jobs: check-label: name: Require release label diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78c74f64..6dfb8b33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,16 @@ on: pull_request: workflow_dispatch: workflow_call: - + # Replaces `secrets: inherit`. Only governs workflow_call runs, which skip + # `release`; a direct tag push still sees every repo secret. + secrets: + LOCALSTACK_AUTH_TOKEN: + required: false + +# A scope added to any job below must also be granted by automated-release.yml's +# `ci` job, or the weekly release fails at startup with no job logs. permissions: contents: read - pull-requests: write - checks: write # Cancel superseded runs on the same PR. Non-PR runs (push/tag/release) each # get a unique group via run_id, so cancel-in-progress can never touch them — @@ -30,8 +35,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - name: Set up Go + - name: Set up Go # zizmor: ignore[cache-poisoning] non-publishing job uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod @@ -54,6 +61,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # SARIF report for the Security tab; never fails the job (gate steps # below own pass/fail). repo-checkout: false everywhere: the action's @@ -114,8 +123,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - name: Set up Go + - name: Set up Go # zizmor: ignore[cache-poisoning] non-publishing job uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod @@ -163,6 +174,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Download generated cask uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 @@ -180,8 +193,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - name: Set up Go + - name: Set up Go # zizmor: ignore[cache-poisoning] non-publishing job uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod @@ -236,8 +251,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - name: Set up Go + - name: Set up Go # zizmor: ignore[cache-poisoning] non-publishing job uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod @@ -270,7 +287,7 @@ jobs: # (cdk_e2e_test.go) run on the Docker-capable Linux shards. They skip # automatically wherever cdk is absent (macOS/Windows). lstk requires # CDK >= 2.177.0; the latest release satisfies that. - - name: Install AWS CDK + - name: Install AWS CDK # zizmor: ignore[adhoc-packages] unpinned on purpose, see above if: matrix.os == 'ubuntu-latest' run: npm install -g aws-cdk @@ -323,9 +340,11 @@ jobs: timeout-minutes: 5 steps: - name: Verify integration matrix succeeded + env: + RESULT: ${{ needs.test-integration.result }} run: | - if [ "${{ needs.test-integration.result }}" != "success" ]; then - echo "test-integration matrix result: ${{ needs.test-integration.result }}" + if [ "${RESULT}" != "success" ]; then + echo "test-integration matrix result: ${RESULT}" exit 1 fi @@ -335,8 +354,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - name: Set up Node.js + - name: Set up Node.js # zizmor: ignore[cache-poisoning] non-publishing job uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "20" @@ -364,13 +385,15 @@ jobs: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 + # No cache in the publishing job, so a poisoned entry can't reach a release. - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache-dependency-path: go.sum + cache: false - name: Validate version tag run: | @@ -434,6 +457,7 @@ jobs: with: node-version: "20" registry-url: "https://registry.npmjs.org" + package-manager-cache: false # Build the platform packages and main wrapper package from the # GoReleaser output. This is the same tool the previous @@ -464,7 +488,7 @@ jobs: - name: Add bundled extensions to the npm platform packages run: scripts/bundled-extensions/add-bundled-to-npm.sh dist/npm bundled - - name: Publish to NPM + - name: Publish to NPM # zizmor: ignore[use-trusted-publishing] needs npm-side setup first run: | for dir in dist/npm/lstk-*/ dist/npm/lstk/; do npm publish "$dir" --access public diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml index c4c7f4fa..53493f3a 100644 --- a/.github/workflows/create-release-tag.yml +++ b/.github/workflows/create-release-tag.yml @@ -15,8 +15,9 @@ on: - patch - minor +# The tag push authenticates with PRO_ACCESS_TOKEN, not GITHUB_TOKEN. permissions: - contents: write + contents: read concurrency: group: create-release-tag @@ -29,7 +30,7 @@ jobs: if: github.repository == 'localstack/lstk' runs-on: ubuntu-latest steps: - - name: Checkout code + - name: Checkout code # zizmor: ignore[artipacked] the tag push needs it; this job uploads no artifacts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.release_ref }} @@ -39,7 +40,8 @@ jobs: - name: Fetch tags run: git fetch --tags --force + # $/ runs the action from this workflow's commit, not the checked-out release_ref. - name: Create release tag - uses: ./.github/actions/create-release-tag + uses: $/.github/actions/create-release-tag with: bump: ${{ inputs.bump }} diff --git a/.github/workflows/enforce-labels.yml b/.github/workflows/enforce-labels.yml index 5b25e612..756949c3 100644 --- a/.github/workflows/enforce-labels.yml +++ b/.github/workflows/enforce-labels.yml @@ -1,8 +1,15 @@ name: Enforce Labels on: + # Runs the base branch's copy, so a PR can't weaken its own label gate. + # Never check out or run PR code here. + # zizmor: ignore[dangerous-triggers] reads labels only, never checks out PR code pull_request_target: types: [labeled, unlabeled, opened, synchronize] +permissions: + # Enough while add_comment stays at its default of false. + pull-requests: read + jobs: docs: runs-on: ubuntu-latest diff --git a/.github/workflows/linear-release.yml b/.github/workflows/linear-release.yml index 1eeac5c1..6267be30 100644 --- a/.github/workflows/linear-release.yml +++ b/.github/workflows/linear-release.yml @@ -17,6 +17,7 @@ jobs: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 - name: Create Linear release diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index 83cbfeb1..6389a740 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -6,6 +6,9 @@ on: - cron: "0 0 * * *" workflow_dispatch: +# The called workflow uses the github-token secret, never GITHUB_TOKEN. +permissions: {} + jobs: sync-labels: uses: localstack/meta/.github/workflows/sync-labels.yml@83b4ff2ee4169d58eeb35bfa6dcddf9f35388212 # main @ 2026-04-22 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index a872d83b..a68c4752 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -34,6 +34,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # SARIF report for the Security tab covers all severities and never fails # the job (the gate below owns pass/fail). trivy-action ignores severity @@ -80,6 +82,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Run Trivy (all severities) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 diff --git a/.github/workflows/weekly-go-upgrade.yml b/.github/workflows/weekly-go-upgrade.yml index fed89e75..589356fb 100644 --- a/.github/workflows/weekly-go-upgrade.yml +++ b/.github/workflows/weekly-go-upgrade.yml @@ -5,9 +5,8 @@ on: - cron: "0 8 * * 1" workflow_dispatch: -permissions: - contents: write - pull-requests: write +# create-pull-request pushes and opens the PR with PRO_ACCESS_TOKEN. +permissions: {} jobs: upgrade-go: @@ -16,6 +15,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 00000000..8820f7a8 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,46 @@ +name: Zizmor Workflow Audit + +on: + pull_request: + push: + branches: + - main + schedule: + # Online audits check pins against upstream, which can change without a commit. + - cron: "30 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +# Cancel superseded PR runs only (mirrors ci.yml). +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + audit: + name: Audit workflows + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + # Pinned in a requirements file so Dependabot's pip ecosystem bumps it. + - name: Install pre-commit + run: pip install -r .github/tools/requirements.txt + + # Uses the hook's zizmor pin, so CI and local runs match. Fails on every + # finding; accepted ones carry an inline `# zizmor: ignore[...]`. + - name: Run zizmor + env: + GH_TOKEN: ${{ github.token }} + run: make lint-actions diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0f37bf7c..9d72d64c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,3 +3,8 @@ repos: rev: v8.30.1 hooks: - id: gitleaks + # Also what CI runs (zizmor.yml); Dependabot bumps this rev. + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.30.1 + hooks: + - id: zizmor diff --git a/CLAUDE.md b/CLAUDE.md index 9e6cbb2a..4ca5d307 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ After cloning, install the pre-commit hooks: pre-commit install ``` -This installs a [gitleaks](https://github.com/gitleaks/gitleaks) hook that scans staged files for secrets before each commit. Requires [pre-commit](https://pre-commit.com/#install). +This installs a [gitleaks](https://github.com/gitleaks/gitleaks) hook that scans staged files for secrets and a [zizmor](https://docs.zizmor.sh) hook that audits staged GitHub workflow files before each commit. Requires [pre-commit](https://pre-commit.com/#install). # Build and Test Commands @@ -19,6 +19,7 @@ make build # Compiles to bin/lstk make test # Run unit tests (cmd/ and internal/) via gotestsum make test-integration # Run integration tests (rebuilds bin/lstk via `build`, requires Docker) make lint # Run golangci-lint (version pinned via .tool-versions) +make lint-actions # Run zizmor on GitHub workflows (via the pre-commit hook) make govulncheck # Run govulncheck (reachability-based vuln scan) make mock-generate # Regenerate mocks (mockgen via go:generate) make clean # Remove build artifacts @@ -38,6 +39,7 @@ Notes: - Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for valid token tests. - `test/integration` is a **separate Go module** (own `go.mod`); `make lint` runs golangci-lint twice — repo root and `test/integration` — and fails if the installed golangci-lint version doesn't match `.tool-versions`. `golangci-lint run --fix` auto-fixes many findings. - `make govulncheck` also runs twice for the same reason (root + `test/integration`). It complements the dependency-version scan in `trivy.yml` with call-graph reachability analysis — it only flags known vulnerabilities in code actually called from the repo. It has no severity filter (most Go vulnerability reports carry no CVSS data), so it gates on reachability alone: any reachable known vulnerability fails the job. CI (`ci.yml`'s `govulncheck` job) runs it on every push/PR and uploads a SARIF report to the Security tab, same pattern as Trivy, but it is **not yet** in `release`'s `needs:` — it's a new check on a staged rollout and should be promoted to a hard release gate once it's proven false-positive-free. +- GitHub workflows, actions, `dependabot.yml` and `.pre-commit-config.yaml` are audited by [zizmor](https://docs.zizmor.sh) (`zizmor.yml`, runs the pre-commit hook), which fails on every finding. Accept a finding with `# zizmor: ignore[] ` on the step's `- name:` line (never the `uses:` line — Dependabot parses its trailing comment). Reproduce locally with `GH_TOKEN=$(gh auth token) make lint-actions`; zizmor older than 1.29 can't parse the `uses: $/...` refs. - Mocks are generated with mockgen (go.uber.org/mock) via per-file `//go:generate mockgen ...` directives (e.g. `internal/snapshot/remote.go`); adding a mock means adding a directive, then `make mock-generate`. - Set `CREATE_JUNIT_REPORT=1` to get a JUnit XML report from `make test` / `make test-integration`. diff --git a/Makefile b/Makefile index 7e299496..a84f3214 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ endif BUILD_DIR=bin export CGO_ENABLED=0 -.PHONY: build clean test test-integration test-scripts check-cask lint-cask lint govulncheck mock-generate otel +.PHONY: build clean test test-integration test-scripts check-cask lint-cask lint lint-actions govulncheck mock-generate otel # Always invoke `go build` and let Go's build cache handle incrementality; a # file target on bin/lstk would be skipped when the binary exists, even with @@ -47,6 +47,11 @@ lint: golangci-lint run --tests ./... (cd test/integration && golangci-lint run --tests ./...) +# zizmor audit of workflows, actions and dependabot/pre-commit config, at the +# hook's pinned version. Export GH_TOKEN to match CI's online audits. +lint-actions: + pre-commit run zizmor --all-files + govulncheck: go run golang.org/x/vuln/cmd/govulncheck@latest ./... (cd test/integration && go run golang.org/x/vuln/cmd/govulncheck@latest ./...)