On March 19, 2026, someone with stolen credentials force-pushed 76 of the 77 version tags on aquasecurity/trivy-action to point at a credential stealer. All 7 tags on aquasecurity/setup-trivy too. The only clean trivy-action tag was 0.35.0, because it was an immutable release.
If your workflow said uses: aquasecurity/trivy-action@0.34.2, you didn't change a line. Your repo looked exactly the same. The next run just executed different code, for about 12 hours (roughly 17:43 to 05:40 UTC per the advisory), and that code read secrets out of the runner's memory and shipped them off.
The part I keep coming back to is in Aqua's own advisory, under "You are affected if you used":
SHA pinning to a commit prior to 2025-04-09.
So some people who did the "right" thing and pinned trivy-action to a full SHA were still exposed, if their job ran during the ~4 hours setup-trivy was poisoned. Not because the SHA moved. Because the action they pinned called another action by tag.
A lot of small SaaS repos have a CI file that was copied from a README in an afternoon and never opened again. Often a handful of uses: lines, some on @v3 or @main, and a deploy job with a cloud key or an npm token in it. This post is the one-hour version of fixing that.
Three incidents, same shape
These are all public, with advisories you can read:
| When | Action | What moved | Window |
|---|---|---|---|
| Mar 14-15, 2025 | tj-actions/changed-files |
multiple version tags repointed to one malicious commit; secrets dumped into build logs | ~1 day; GitHub's advisory says it impacted "over 23,000 repositories" |
| Mar 3-10, 2026 | xygeni/xygeni-action |
the v5 tag moved to a commit from an unmerged pull request |
~6 days |
| Mar 19-20, 2026 |
aquasecurity/trivy-action + setup-trivy
|
76 of 77 tags, all 7 tags | ~12 h and ~4 h |
In none of these did the victim's repository change. That's the whole problem with a tag. @v5 is not a version, it's a pointer that gets looked up every time the job starts.
The xygeni one is worth a second look. Branch protection worked, the bad PRs were never merged. But the commit still existed in the repo's git objects, and moving a tag to it was enough. Merged or not doesn't matter to a tag.
Three ways a pin fails
People argue "tags vs SHAs" like it's one decision. It's really three separate failure modes:
1. The tag moves. @v4, @v4.2.2, @main. All mutable unless the publisher uses immutable releases. This is tj-actions, xygeni and trivy-action.
2. Your pin isn't transitive. You pin owner/action@<sha>. That action's own action.yml says uses: owner/other-thing@v1. You get whatever v1 is today. This is the trivy-action pre-April-2025 case: until a commit on 2025-04-09 ("Pin aquasecuriy/setup-trivy to hash instead of tag"), trivy-action's action.yaml called aquasecurity/setup-trivy@v0.2.2. It's right there in the repo history. GitHub's own docs say it plainly now: lockfiles cover transitive dependencies, "which SHA pinning does not."
3. The SHA is real but it's not from where you think. A full SHA can point at a commit that isn't on any branch or tag of the real repo. In the trivy binary part of the same incident, the attacker swapped an actions/checkout reference to what the advisory calls an "imposter commit". GitHub's docs have a one-liner on this: when you pick a SHA, "verify it is from the action's repository and not a repository fork." It's very easy to copy a SHA from a blog post or a bot PR and never check where it lives.
Here's the order I'd fix things in.
Step 1: list what you actually run (10 min)
From the repo root:
grep -rnE '^\s*-?\s*uses:\s*[^ ./$]' .github/workflows \
| grep -vE '@[0-9a-f]{40}([^0-9a-f]|$)'
This prints every uses: that points outside your repo and isn't pinned to a full 40-character SHA. It skips local ./ actions. It will also show docker:// steps and reusable workflows (jobs.<id>.uses: org/repo/.github/workflows/x.yml@main), which is good, you want to see those too.
On a test workflow it gave me lines like:
.github/workflows/ci.yml:10: - uses: actions/setup-node@v4
.github/workflows/ci.yml:12: - uses: docker/setup-buildx-action@v3
.github/workflows/ci.yml:14: - uses: github/codeql-action/init@v3
.github/workflows/ci.yml:16: uses: docker://alpine:3.20
.github/workflows/ci.yml:18: uses: octo-org/shared/.github/workflows/deploy.yml@main
Same idea as grepping your server for hardcoded renewal days before the Let's Encrypt change. You can't fix what you haven't listed.
Write the list down in three groups:
-
GitHub's own (
actions/*,github/*) - Big, well-known publishers (docker, aws, google, cloudflare, vercel...)
- Everything else. Small repos, one maintainer, something you found in a Marketplace search at 1am.
Step 2: delete before you pin (10 min)
The cheapest action to secure is the one you remove.
A lot of third-party actions in small repos do something like "post a comment", "get changed files", "set up a tool", "send a Slack message". Some of those are 3 to 10 lines of shell plus gh or curl, which runs with the same token anyway but without someone else's code in the middle. For example, a lot of what people use tj-actions/changed-files for is a git diff --name-only between two commits.
For the ones you keep, look at them once before you trust them. Open the repo: how many maintainers, how old, does it pin its own dependencies, does the action.yml call other actions by tag (that's failure mode 2 again). It's basically the same spelling, age and real-usage check as checking a package an AI coding agent wants to install. A typo in an action name runs someone else's code with your secrets, same as a typo in npm install.
Step 3: pin, and check the pin is honest (15 min)
To get the commit a tag points to right now:
git ls-remote https://github.com/actions/checkout \
'refs/tags/v4.2.2' 'refs/tags/v4.2.2^{}' | tail -n1 | cut -f1
The ^{} line matters. Some repos use annotated tags, and then the first SHA you see is the tag object, not the commit. tail -n1 takes the peeled commit when it exists. (Before you pin, check that the tag is the release you meant and not already compromised. Pinning a bad commit just makes it permanent.)
Then write it with the version as a comment so humans can read it:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Now the check most people skip. This script reads every @<sha> # <tag> line and asks GitHub what that tag points to today:
#!/usr/bin/env bash
# For every "uses: owner/repo@<full sha> # <tag>" line, ask GitHub
# what that tag points to right now and compare it with the pin.
grep -rhoE 'uses:\s*[^ ./$][^ @]*@[0-9a-f]{40}\s*#\s*\S+' .github/workflows |
while read -r _ ref _ tag; do
sha=${ref##*@}
repo=$(echo "${ref%@*}" | cut -d/ -f1-2)
now=$(git ls-remote "https://github.com/$repo" \
"refs/tags/$tag" "refs/tags/$tag^{}" | tail -n1 | cut -f1)
if [ "$now" = "$sha" ]; then
echo "ok $repo $tag"
else
echo "CHECK $repo $tag pinned=$sha tag-now=${now:-missing}"
fi
done
On my test file, with one deliberately wrong pin:
ok actions/checkout v4.2.2
CHECK actions/checkout v4.2.2 pinned=d632683dd7b4114ad314bca15554477dd762a938 tag-now=11bd71901bbe5b1630ceea73d27597364c9af683
ok pnpm/action-setup v3.0.0
ok github/codeql-action v3.29.0
A CHECK means one of two things, and both are worth five minutes: either your SHA was never that tag (copied wrong, or an imposter commit), or the tag moved after you pinned it. The second one is exactly the event you want to hear about. Your pin protected you, but it's also a signal that upstream had a bad day and you should go read their issues.
It only checks the format @sha # tag, it handles owner/repo/subdir actions like github/codeql-action/init, and it needs nothing but git and grep. You could run it weekly in a scheduled job. It does not look inside the actions for failure mode 2, that's what the next step is for.
One tradeoff that's easy to miss: GitHub's docs say Dependabot "will not create alerts for actions pinned to SHA values", alerts are only for actions using semantic versioning. So if you pin by SHA, turn on Dependabot version updates for github-actions so the pins don't rot. Since there's a default cooldown now (Dependabot waits 3 days after a release before proposing it, on github.com), a freshly poisoned release has some time to get caught before a bot PR shows up. That helps with bad new releases. It does nothing for a tag that gets repointed under you, only the pin does.
Step 4: or try the new lockfile (15 min)
On October 14, 2026, GitHub put workflow dependency lockfiles into public preview, including on the Free plan. It's basically package-lock.json for Actions. A CLI extension writes .github/workflows/actions.lock with the commit for every action your workflows use, including the transitive ones, plus the numeric owner and repo IDs. Once a workflow is onboarded, Actions runs the locked commit even if the tag moves, and every dependency of that workflow has to be in the lockfile.
gh extension install github/gh-actions-lock
gh actions-lock # scan .github/workflows, write actions.lock
gh actions-lock --verify # read-only check, good for a CI job
Your workflow keeps readable refs (it may narrow @v4 to @v4.2.2). GitHub's docs now say that for repos with a lockfile, SHA pinning in the workflow file is "no longer necessary". There's also a "Require lockfile" policy at repo/org level, and Dependabot regenerates the lockfile entry in the same PR when it bumps an onboarded workflow.
Things to know before you rely on it, straight from the README and release notes:
- It's pre-1.0 and in preview. v0.3.0 (Oct 22) changed behavior enough that some lockfiles that used to pass
--verifynow fail it. - Reusable workflows called from another repo (
jobs.<id>.uses) are not locked by your lockfile. - Container images are not locked. Anything without an
@sha256:digest is only a warning. - A lockfile change that moves a floating ref like
v4back to an older commit in its history isn't detected. So review lockfile diffs, or pin exact tags. - Since v0.3.0 it rejects full-SHA pins to fork-only (imposter) commits, every locked commit must be in the history of its recorded tag or branch. That covers failure mode 3.
If you have one repo and a few workflows, I'd honestly try this over hand-pinning. If you're not ready for a preview tool, steps 1 to 3 still get you most of the way.
Step 5: make a stolen run worth less (10 min)
Pinning is about which code runs. This step is about what that code can grab if something slips through anyway. The tj-actions and trivy payloads both went for secrets in the runner's memory. The xygeni one gave the attacker a remote shell on the runner for up to 180 seconds per run, which reaches the same things.
At the top of every workflow:
permissions:
contents: read
Then give extra scopes only to the job that needs them:
jobs:
comment:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
# steps: ...
Once you set any permission, every one you didn't list becomes none. permissions: {} turns them all off.
Why bother if new repos default to read-only? Because that default only applies to things created after February 2, 2023. GitHub's changelog was explicit: the change "will not impact any existing enterprises, organizations or repositories." If your repo or org is older than that, go to Settings → Actions → General → Workflow permissions and look. An older repo can still say "Read and write" there.
Two more:
- Keep prod secrets out of the PR workflow. Your test job doesn't need the production database URL or the live payment key. Put deploy secrets in an environment with required reviewers. Then only jobs that use that environment can read them, and only after someone clicks approve.
-
Prefer short-lived cloud creds. If you deploy to AWS/GCP/Azure, OIDC gives the job a token that expires, instead of a long-lived key sitting in repo secrets forever. On GitHub-hosted runners the
GITHUB_TOKENitself already dies with the job, max 6 hours.
Step 6: if you ran a bad version
All three advisories say some version of the same thing: treat every secret the pipeline could read as exposed, and rotate it. Not "probably fine". The trivy payload read the runner's memory, so masked secrets in the logs didn't matter.
The order I'd go in:
- Find the runs inside the advisory's window.
gh run list --created 2026-03-19..2026-03-20works, or just scroll the Actions tab. - Rotate whatever those jobs could reach. Cloud keys, deploy keys, package registry tokens, any API key in repo or org secrets.
- For trivy specifically, look for a repo named
tpcp-docsin your account or org. Per the advisory, the stealer creates it as a fallback upload spot when its normal exfiltration fails. - Check what got published from those runs (releases, packages, images) in case something was tampered.
Rotation is the slow part if you don't know where each key is used. Writing that list down before you need it is worth doing just for this.
The rules I'd actually adopt
At under 50 customers, with one or two people touching CI:
- Third-party actions: always locked (lockfile) or SHA-pinned, with the version comment. No exceptions for "it's popular".
- GitHub's own
actions/*: I'd pin them too. GitHub's "Require actions to be pinned to a full-length commit SHA" policy (added August 2025) treats them the same, and it makes the rule simple enough to enforce. - Fewer actions every quarter, not more.
-
permissions: contents: readat the top of every workflow, widen per job. - The pin check, weekly.
Total time is about an hour for a typical small repo. The trivy-action window was about twelve hours, starting on a Thursday evening UTC, and the real scan still ran after the stealer, so the job looked normal. Nobody watching their CI casually would have caught it.
I'm curious how other small teams handle this. Are you pinning SHAs by hand, trying the new gh actions-lock preview, or still on @v4? And if you already pin by SHA, have you ever opened the action.yml of what you pinned to see if it calls other actions by tag?
Top comments (0)