A compromised GitHub Action rewrote its tags: why we now pin every action to a SHA
In March 2025 the popular tj-actions/changed-files action was compromised and its version tags were repointed to code that printed CI secrets into workflow logs. Three of our repositories used it by tag. This is how we checked exposure, rotated secrets and moved to SHA pinning.
On March 15, 2025, security advisories reported that tj-actions/changed-files, an action used by tens of thousands of repositories to list files changed in a push or pull request, had been compromised. The attacker had updated many of the action's existing version tags to point at a malicious commit. Any workflow that referenced the action by tag, such as @v45 or @v44, ran the malicious code on its next run. The code read the runner process memory, extracted secrets, and printed them, double base64-encoded, into the workflow log. The issue was tracked as CVE-2025-30066.
Our first question was: are we affected? A search across the organisation answered it in a minute:
gh search code --owner example-org "tj-actions/changed-files" --json repository,path \
| jq -r '.[] | "\(.repository.nameWithOwner) \(.path)"'
example-org/docs-site .github/workflows/lint.yml
example-org/infra-modules .github/workflows/validate.yml
example-org/mobile-app .github/workflows/ci.yml
All three used @v45. All three had run during the compromise window.
Reproducing it
We did not need to reproduce the attack. We reviewed the runs from the window for the tell-tale output: a long base64 blob printed by the action step. Two repositories, both private, had such output in their logs. The third run had failed before reaching the step.
What we ruled out
- Public exposure of our logs. All three repositories are private, so the printed secrets were visible to people with read access to the repository, not to the internet. Public repositories were much worse off, because their logs are public.
- Exfiltration to an external server. The published analyses of the payload described printing to logs rather than sending data out, but we treated every secret available to those jobs as compromised regardless.
Root cause
Git tags are mutable. A reference like uses: tj-actions/changed-files@v45 means "whatever commit the tag v45 points to at run time". When the attacker gained write access to the action's repository, they could change what v45 meant for everyone, without any change in our repositories, and our workflows pulled the new code automatically. Our jobs also had more secrets in scope than the lint steps needed, because secrets were defined at the workflow level.
What is happening under the hood
GitHub resolves owner/repo@ref at the start of each run. A ref can be a branch, a tag, or a full commit SHA. Branches and tags can be moved by anyone with push access to the action's repository; a full 40-character commit SHA identifies exact content and cannot be changed to point at something else.
Third-party actions run in the same job as your steps, with access to the job's environment: secrets exposed to the job, the GITHUB_TOKEN, and the runner's filesystem and memory. An action that only lists changed files has the same access as your deploy step if they share a job.
Environment
| Component | Version / value |
|---|---|
| CI | GitHub Actions, GitHub-hosted runners |
| Affected action | tj-actions/changed-files@v45 (tag reference) |
| Repositories | 3 private repositories |
| Secrets in scope | npm publish token, Terraform Cloud token, Slack webhook, plus GITHUB_TOKEN |
Minimal reproduction
The risky pattern:
jobs:
lint:
runs-on: ubuntu-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # visible to every step, including third-party actions
steps:
- uses: actions/checkout@v4
- uses: tj-actions/changed-files@v45 # mutable tag
The fix
Immediate response.
- Removed the action from all three workflows the same morning, replacing it with a few lines of
git diff --name-onlyfor our simple use case. - Rotated every secret available to the affected jobs: npm token, Terraform Cloud token, Slack webhook.
GITHUB_TOKENexpires at the end of each job, so it did not need rotation. - Deleted the workflow run logs from the compromise window that contained the encoded output.
- Checked npm and Terraform Cloud audit logs for use of the old tokens from unknown sources. There was none.
Structural changes.
Pin every third-party action to a full commit SHA, with the version in a comment for humans and tools:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# copy the full SHA from the action's release page or `git ls-remote`, never from a blog post
Dependabot understands this format and opens pull requests that update both the SHA and the comment:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule: {interval: weekly}
Scope secrets to the steps that need them, not the whole job or workflow:
- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
And at the organisation level we enabled the setting to allow only specified actions, with an allowlist of verified creators and our own internal actions.
Alternatives and trade-offs
- Pin to tags of trusted publishers only. GitHub's own actions are lower risk, but the attack shows trust is not permanent. We pin those too.
- Fork actions into the organisation. Full control, and you take on maintenance and security updates.
- Replace small actions with a few lines of shell. Often the best option for simple tasks like listing changed files.
How to test the fix
We wrote a small check that fails CI if any uses: line references a third-party action without a 40-character SHA:
grep -RhoE 'uses:\s*[^ ]+@[^ ]+' .github/workflows \
| grep -vE '@[0-9a-f]{40}$' \
| grep -vE 'uses:\s*\./' \
&& { echo "unpinned actions found"; exit 1; } || echo "all actions pinned"
Confirming in production
- All 214 workflow files across the organisation were converted to SHA pins within two weeks, mostly by a script plus review.
- Dependabot opens grouped weekly pull requests for action updates; reviewers look at the changelog and the diff between SHAs before merging.
- No use of the rotated credentials was observed.
Regression guard
- The unpinned-actions check is a required status on all repositories.
- The organisation actions allowlist blocks new third-party actions until reviewed.
- A workflow linter flags secrets defined at workflow or job level when only one step uses them.
Security implications
This incident is entirely about security. The general lessons: references to third-party code must be immutable, every secret in scope of a compromised step must be treated as leaked, and limiting which steps can see which secrets is what decides the blast radius. SHA pinning does not protect against an action that was already malicious when you pinned it, so review what you pin and prefer few, well-maintained actions.
Prevention checklist
- Pin third-party actions to full commit SHAs, with version comments.
- Let Dependabot or Renovate propose updates, and review them.
- Expose secrets only to the steps that need them.
- Restrict allowed actions at the organisation level.
- Keep a runbook for "a dependency was compromised": find usage, rotate, clean logs, audit.
References
- NVD, CVE-2025-30066 — https://nvd.nist.gov/vuln/detail/CVE-2025-30066
- tj-actions/changed-files incident issue — https://github.com/tj-actions/changed-files/issues/2463
- GitHub, security hardening: using third-party actions — https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions
- Dependabot for GitHub Actions — https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
- CISA Known Exploited Vulnerabilities catalog — https://www.cisa.gov/known-exploited-vulnerabilities-catalog
Add a label
Embed this post
<iframe src="https://hibug.me/pin-github-actions-to-commit-sha-tag-hijack" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.