Most workflow files reference actions like actions/checkout@v4. That looks like a version, but it is a git tag, and whoever controls the repository can point a tag at any commit they like, including one that did not exist yesterday. Your pipeline then runs the new code with your secrets, on the next push, without a single line changing in your repository.
The fix is not glamorous and it does not need a new tool. It is an afternoon of pinning, a small config file and a couple of permission lines. Here is the order we would do it in.
Why tags are the weak point
A tag reference is resolved at run time. If an attacker gets write access to an action's repository, or to a maintainer's account, they can force-push a tag to a malicious commit and every workflow using that tag picks it up. This is exactly what happened in the tj-actions/changed-files compromise in March 2025, where retagged versions dumped CI secrets into build logs.
A full 40-character commit SHA is different. It names one exact tree of files. Nobody can make it point somewhere else. Short SHAs and branch names do not count: only the full hash is immutable.
Pin every third-party action
Find what you are using first. This lists every uses: line that is not already a full SHA, ignoring local actions:
grep -rn "uses:" .github/workflows \
| grep -v "./" \
| grep -Ev "@[0-9a-f]{40}"
You can resolve a tag by hand with git ls-remote, but a tool is faster and less error-prone. The open-source pinact rewrites tags to SHAs and keeps the version as a trailing comment, so humans can still read it:
pinact run
# before
- uses: actions/setup-node@v4
# after
- uses: actions/setup-node@<full-40-char-sha> # v4.1.0
Two edge cases. Docker-based steps using docker:// should be pinned by image digest, not tag. And reusable workflows called from other repositories need the same treatment as actions.
Let a bot keep the pins current
The usual objection to pinning is that you stop getting fixes. You do not, if Dependabot watches the github-actions ecosystem. It understands SHA pins with version comments and raises a pull request that updates both.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
Grouping keeps it to one pull request a week instead of a dozen. Review it like any other dependency change: read the diff of the action when the jump is large or the publisher is small.
Cut the GITHUB_TOKEN down
Pinning limits what code runs. Permissions limit what that code can do if something still goes wrong. Set the organisation or repository default to read-only, then declare permissions at the top of each workflow and widen them only on the job that needs it:
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
An empty permissions: {} is valid too, for jobs that only lint or test. While you are here, look for long-lived cloud keys in secrets and replace them with OIDC federation where your provider supports it.
The pull_request_target trap
pull_request_target runs in the context of the base repository, with access to secrets and a token that can write. It exists so that workflows can label or comment on pull requests from forks. The trap is checking out the fork's code and running it:
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install && npm test # runs attacker code with your secrets
The same goes for interpolating untrusted fields such as a pull request title straight into a run: script. Pass them through an environment variable instead, so the shell treats them as data.
Check yours in 10 minutes
- Run the grep above and count the unpinned references.
- Run
zizmor .github/workflows. It flags unpinned actions, excessive permissions, risky triggers and template injection. - Run
actionlintto catch the syntax mistakes pinning sometimes introduces. - Check the repository setting for default workflow permissions and set it to read.
- Add the Dependabot file and merge the first grouped update.
That is the afternoon. Once it is done, every change to what runs in your pipeline arrives as a reviewed pull request rather than a silent retag.
Where this sits on the line
In the tower (in development):