diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf651b8..4440e3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,9 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: shellcheck shell: bash @@ -31,7 +33,9 @@ jobs: bats: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install and run bats run: | git clone --depth 1 --branch v1.10.0 https://github.com/bats-core/bats-core.git /tmp/bats diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index be1946a..197940c 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -23,13 +23,18 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 + # Nothing in this job consumes the persisted credential: + # claude-code-action takes its own token input and runs no git + # push/commit, so dropping it removes exposure without removing + # capability (zizmor: artipacked). + persist-credentials: false - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@5ccc3a35a6367cdb8e6fbd0728287467540ecfe2 # v1.0.219 with: # claude-code-action rejects non-User actors unless explicitly allowed. # The `if:` guard above still filters on @claude mention content; this diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 0000000..0bcb326 --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,130 @@ +# zizmor configuration for repos consuming the smartwatermelon workflow set. +# +# Copy this to the root of any repo that uses the caller stubs from this +# repo's README, alongside your pre-commit hook. Without it, zizmor's blanket +# hash-pin policy reports ~9 high findings against a byte-identical standard +# caller stub, and the only workaround is `SKIP=zizmor` on every commit. +# +# That workaround is the actual hazard this file exists to remove. Routinely +# skipping the security linter is what let anthropics/claude-code-action sit +# at v1.0.70 for 123 releases while carrying GHSA-8q5r-mmjf-575q (see #123). +# A linter people bypass by habit protects nothing. +# +# Every entry below is a *documented policy decision*, not a convenience +# mute. Each one is justified against the code. Findings that reflect a real +# gap are deliberately left visible — see the note on third-party actions. + +rules: + # --------------------------------------------------------------------- + # unpinned-uses + # --------------------------------------------------------------------- + # zizmor's default is a blanket hash-pin requirement: every `uses:` must + # name a commit SHA. That is correct for third-party actions and wrong for + # first-party reusable workflows, and this repo deliberately treats the two + # classes differently: + # + # Third-party actions -> SHA-pin, plus dependabot.yml to keep the + # (actions/checkout, ...) pins current. We do not control upstream, so + # a repointed tag would run arbitrary code on + # the next trigger. + # + # First-party reusable -> floating tag (@v3). We control this repo, + # workflows (this repo) its branch protection, and who moves the + # tag. Floating refs are what make coordinated + # fleet remediation possible at all. + # + # The second rule is not a relaxation — it is load-bearing. When + # GHSA-8q5r-mmjf-575q was patched here, the fix reached consumers by + # repointing one tag. The ~19 repos that had pinned an exact @v3.1.0 + # silently received nothing, because immutable tags cannot carry a fix + # published after they were cut. Hash-pinning a first-party ref has the + # same effect, permanently. + # + # `ref-pin` still requires *a* ref — `@main` or a bare repo reference is + # rejected. It only lifts the hash requirement. + unpinned-uses: + config: + policies: + # First-party: tag refs are the convention (see README "Versioning"). + smartwatermelon/github-workflows/*: ref-pin + # Everything else keeps the strict default. This line matters: it is + # what keeps genuine third-party findings visible. A blanket ignore + # here would also hide, e.g., `actions/checkout@v7` in a repo's own + # workflows — which is a real gap worth fixing, not policy. + "*": hash-pin + + # --------------------------------------------------------------------- + # excessive-permissions + # --------------------------------------------------------------------- + # The caller stubs declare workflow-level permissions because the reusable + # workflows require them. They are not aspirational or copy-pasted: + # + # contents: read - checkout and diff reading + # pull-requests: write - posting and minimizing review comments + # issues: write - the inline-comment API path + # id-token: write - OIDC exchange for the app token + # + # `dependabot-auto-merge.yml` is in the list for the same reason but needs + # a different pair — `contents: write` to merge and `pull-requests: write` + # to approve. Both are inherent to what that workflow does; a read-only + # auto-merger is a contradiction. Note it deliberately runs with no + # `actions/checkout` (enforced by this repo's `guard-no-checkout` job), so + # the write scopes never combine with executing PR-controlled code. + # + # Removing any of them does not narrow the blast radius; it produces a + # `startup_failure` before the job runs. GitHub also does not let a caller + # grant a called workflow more than the called workflow declares for + # itself, so the effective ceiling is set in the reusable workflow, not + # here. + # + # Scoped to the three standard caller filenames only. A repo's own + # workflows are still audited normally — if `validate.yml` or `release.yml` + # asks for more than it needs, that finding still fires. + # + # These match on FILENAME, not on which reusable workflow is called, so the + # coupling is by naming convention. Note especially that `claude.yml` is the + # caller for `claude-assistant.yml` — the names differ, which is easy to + # misread as an omission. A consumer who names a caller anything else (say + # `claude-assistant-caller.yml`) gets excessive-permissions findings with no + # explanation; the fix is to rename the caller to the standard filename, or + # to add the local name here. See #142 and the README's zizmor section. + excessive-permissions: + ignore: + - claude-blocking-review.yml # caller for claude-blocking-review.yml + - claude.yml # caller for claude-assistant.yml + - dependabot-auto-merge.yml # caller for dependabot-auto-merge.yml + - standards-check.yml # caller for standards-check.yml + + # A cooldown deliberately DELAYS applying action updates. This repo was + # burned by exactly that delay: claude-code-action sat at v1.0.70 for 123 + # releases and stayed vulnerable to GHSA-8q5r-mmjf-575q with no PR opened + # (see #123 and the note at the top of .github/dependabot.yml). Adding a + # cooldown here would re-introduce the lag that incident was about, so the + # absence of one is a decision, not an oversight. + # + # This file also serves as the fleet-wide CI fallback policy: + # standards-check.yml uses it for any consuming repo that has no zizmor.yml + # of its own, so this ignore applies fleet-wide by design, not only here. + dependabot-cooldown: + ignore: + - dependabot.yml + + # zizmor advises `$/...` for a same-repo reusable workflow call. GitHub and + # actionlint both reject that form — actionlint reports "not following the + # format owner/repo/path@ref nor ./path/to/workflow.yml" — so `./...` is the + # only syntax that actually runs. Verified against zizmor 1.30 / actionlint + # on 2026-09-08. + self-repository: + ignore: + - self-review.yml + - self-standards-check.yml + + # markdownlint-cli2 and yamllint have no lockfile to install from — they are + # single pinned CLI tools, not project dependencies. standards-check.yml + # pins an exact version of each (MARKDOWNLINT_CLI2_VERSION, + # YAMLLINT_VERSION), and the three binary downloads next to them are + # additionally SHA256-verified. A lockfile would add a second place for the + # version to drift without removing the install. + adhoc-packages: + ignore: + - standards-check.yml