Skip to content

Harden the GitHub Actions workflows - #856

Open
2ndkauboy wants to merge 2 commits into
v3from
ci/harden-workflows
Open

Harden the GitHub Actions workflows#856
2ndkauboy wants to merge 2 commits into
v3from
ci/harden-workflows

Conversation

@2ndkauboy

Copy link
Copy Markdown
Member

Prompted by Sebastian Bergmann's Hardening GitHub Actions Workflows, which walks through the pass he did over PHPUnit's workflows with zizmor.

Measured against that article, most of the work was already done here: every uses: is a commit SHA with a version comment, Dependabot advances those pins, there is no pull_request_target, no workflow_run and no issue_comment anywhere, and the release workflows already pass event data through env: rather than splicing it into shell. This closes the two measures the repository had not adopted at all, plus the injection sites that were left.

zizmor goes from 30 findings to 0 (1 high, 25 medium, 1 low, 1 informational → none).

persist-credentials: false on every checkout

Not set anywhere before, so all twelve checkouts left a usable GITHUB_TOKEN in .git/config for the rest of the job — including the jobs that then run composer install and npm install, which is to say execute third-party code with a credential sitting on disk next to it.

Nothing depended on it: no workflow pushes with git, and every step that calls the API already sets GH_TOKEN itself.

Least-privilege permissions:

Seven workflows declared none and therefore ran at whatever the repository default is. The two that matter most are wordpress-plugin-deploy.yml and wordpress-plugin-asset-update.yml, which hand real wordpress.org SVN credentials to a third-party action.

  • contents: read for the four test/static-analysis workflows and the two wordpress.org ones — they authenticate against SVN with their own credentials and never touch the GitHub API.
  • permissions: {} in spam-detection-comparison.yml, whose single job already declares its own.

Event data through the environment

An expression is expanded before the shell ever sees the line, so what it expands to runs as code rather than being read as an argument.

The one that mattered is composer install ${{ inputs.composer-flags }} in .github/actions/setup-php/action.yml — zizmor's only high-severity finding, and every PHP job in the repository goes through it. Deliberately $COMPOSER_FLAGS unquoted: the flags are several words and have to split into several arguments, and word splitting is all that ever happens to an environment variable.

Smaller ones in wordpress-plugin-check.yml (github.event.repository.name, now bound once as a job-level PLUGIN_SLUG), attach-snapshot.yml and spam-detection-comparison.yml.

A Workflow hardening job

So the next regression does not need a blog post to be noticed. Plain pipx run zizmor rather than zizmorcore/zizmor-action: one fewer third-party action in the workflow that argues for fewer of them, and the action's SARIF upload would want security-events: write for a report that reads just as well in the log. The read-only job token is passed only to lift zizmor out of offline mode — the check it enables is whether a pinned hash is really a commit in the action's own repository, which is how an impostor pin gets caught.

The path filter widens accordingly, from this workflow and .github/actions/setup-php/** to all of .github/workflows/** and .github/actions/**.

Deliberately not here

  • dependabot-cooldown on all three ecosystems — a real question, but one about dependency policy rather than workflows. Suppressed inline with the reasoning written down, and tracked in Consider a Dependabot cooldown for Composer, npm and GitHub Actions updates #855.
  • The 10up SVN actions stay. The article's "replace third-party actions with gh" advice does not transfer: there is no built-in equivalent for the wordpress.org SVN dance. They are SHA-pinned and Dependabot moves them, which is the part that was actually load-bearing.

Verification

zizmor --persona=regular .github/ reports zero findings, online and offline. The release-time workflows cannot be exercised from a pull request; their diffs are the same two mechanical changes as everywhere else, and each API-calling step still sets GH_TOKEN explicitly.

Three measures from the same review, all of them things the workflows here did
not do at all.

Every `actions/checkout` now sets `persist-credentials: false`. By default the
action leaves a usable `GITHUB_TOKEN` in `.git/config` for the rest of the job,
including the jobs that go on to run `composer install` and `npm install` — that
is, to execute third-party code with a credential sitting on disk beside it. No
workflow pushes with git, and the steps that do call the API pass `GH_TOKEN`
themselves, so nothing depended on the persisted copy.

Seven workflows had no `permissions:` block and therefore ran at whatever the
repository default is. They now declare what they need: `contents: read` for the
test and static-analysis workflows and for the two that publish to wordpress.org
(those authenticate against SVN with their own credentials and never touch the
GitHub API), and an empty block in `spam-detection-comparison.yml`, whose only
job already declares its own.

The remaining template expressions inside `run:` blocks now travel through the
environment instead. An expression is expanded before the shell ever sees the
line, so whatever it expands to is executed as code rather than read as an
argument. The one that mattered is `composer install ${{ inputs.composer-flags }}`
in `.github/actions/setup-php/action.yml`, which every PHP job in the repository
runs through; `wordpress-plugin-check.yml`, `attach-snapshot.yml` and
`spam-detection-comparison.yml` had smaller ones.
The previous commit fixed thirty findings that nobody had a way of seeing. This
adds the thing that would have seen them, so the next one does not need a blog
post to be noticed: a `Workflow hardening` job in the static-analysis workflow
running zizmor over `.github/`.

The plain CLI rather than `zizmorcore/zizmor-action` — one fewer third-party
action in the workflow that argues for fewer of them, and the action's SARIF
upload would want `security-events: write` for a report that reads just as well
in the log. It gets the read-only job token only so the online checks work; the
one that matters is whether a pinned hash is really a commit in the action's own
repository, which is how an impostor pin gets caught.

The path filter widens from this one workflow and the shared PHP setup to all of
`.github/workflows/**` and `.github/actions/**`, since the new job has an opinion
about every file there.

That leaves `dependabot-cooldown` on all three ecosystems in `dependabot.yml`.
It is a real question — a cooldown trades a few days of update latency for
immunity to a release that is hijacked and then yanked — but it is a question
about dependency policy rather than about workflows, and it is answered in #855.
Suppressed here with the reasoning written down, so the job reports green on
everything that this pull request is actually about.
@2ndkauboy 2ndkauboy added this to the 3.0.0-beta.3 milestone Aug 25, 2026
@2ndkauboy 2ndkauboy added the v3 This issue is for the new version (v3) of the plugin label Aug 25, 2026
@github-actions

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v3 This issue is for the new version (v3) of the plugin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant