tj-actions (2025)¶
The Lesson: Every action in your CI pipeline runs in the same environment as your secrets. "Low-privilege" doesn't exist — if a step can see the runner's environment, it can read everything in it. The boring utility action you've never audited is exactly as dangerous as the scanner you scrutinize.
The Utility Action Trap
Nobody worries about the action that tells them which files changed. It's not running your code. It's not touching your API. It's checking diffs. Until it's also printing your AWS keys to a log that anyone on the internet can read. The attack doesn't need to be clever. It just needs to run.
What Happened¶
In March 2025, tj-actions/changed-files — a GitHub Action used by more than 23,000 repositories to detect which files changed in a pull request or push — was modified to include code that printed the runner's environment variables to workflow log output.1
GitHub Actions inject secrets into the runner environment. Any step in a job that has access to ${{ secrets.AWS_ACCESS_KEY_ID }} stores that value as an environment variable accessible to every subsequent step. The malicious modification didn't need elevated permissions, a separate exfiltration server, or any particularly clever technique. It called the equivalent of printenv.
For public repositories, GitHub Actions workflow logs are publicly visible. Every AWS key, npm token, Docker Hub credential, and GitHub Personal Access Token that passed through a workflow using changed-files was printed to a page anyone could load in a browser.
The Chain¶
The initial compromise wasn't a direct attack on the tj-actions repository — it began upstream. The reviewdog/action-setup action, a utility for installing code review tooling, was compromised first. Because tj-actions/changed-files used reviewdog/action-setup in its own CI workflows, the attacker gained code execution in the tj-actions build environment. From there, they pushed malicious changes directly to the action's source.2
This is transitive trust in its most direct form. The changed-files maintainer hadn't made a security mistake. They'd used a tool in their CI pipeline. That tool was compromised. The attacker inherited the maintainer's access without touching the maintainer's credentials.
What Was Exposed¶
Any secret in scope for a workflow step running changed-files was potentially printed to the logs:
- AWS access keys and IAM credentials
- npm and Docker Hub publishing tokens
- GitHub Personal Access Tokens
- Kubernetes service account tokens
- API keys for third-party services
- SSH private keys passed as secrets
Private repositories were less exposed — logs require authentication to view — but any person or automation with read access to those logs could have harvested credentials during the exposure window. For public repositories, the window was wide open.
The Response¶
CVE-2025-30066 was assigned. CISA issued an advisory recommending organizations audit their workflows for use of the compromised action and immediately rotate any secrets that may have been exposed.3
The malicious commits were reverted and removed. Repositories that had pinned changed-files to a specific commit SHA were unaffected — an SHA is immutable, and the tag rewrite didn't touch their pinned reference. Repositories pinned to a tag silently ran malicious code the moment the tag was updated.
Why It Mattered¶
"Low-privilege" GitHub Actions don't exist. The mental model that says "this action just checks diffs, it doesn't need secrets" misunderstands how CI runners work. Secrets are in the environment. If you pass AWS_ACCESS_KEY_ID to any step in a job, every action in that job can read it — whether or not it's supposed to. The fix isn't trusting the right actions. It's restricting secret scope per-job and auditing every action, not just the obvious ones.
Tag mutability is the root of the problem. Pinning to tj-actions/changed-files@v45 means pinning to whatever commit v45 points to at the moment the workflow runs. When the attacker updated the tag, every repository using it silently ran malicious code with zero changes to their own files. The repository owners did nothing wrong. Their workflow files were unchanged. Their pipelines ran attacker code anyway.
# Vulnerable — tag can be silently rewritten by the action's maintainer
# (or anyone who compromises the maintainer)
- uses: tj-actions/changed-files@v45
# Protected — this SHA cannot be changed by anyone
- uses: tj-actions/changed-files@a29e8b447113d3c43c5b5e57af7e45c46a4e7568
Utility actions are attack surface. changed-files, setup-node, cache, upload-artifact — these feel like infrastructure, not code. They're boring. They're in every workflow. Nobody reads their changelogs. That invisibility is exactly what makes them valuable targets. The compromised action doesn't need to do anything sophisticated. It just needs to be trusted and ubiquitous.
Transitive trust runs through your CI, not just your packages. You check your npm dependencies. You scan your container images. Do you audit the GitHub Actions used in the CI workflows of the actions you depend on? The reviewdog compromise reached changed-files through exactly that path. The dependency graph extends into your build pipeline's own build pipeline.
The Broader Pattern¶
A year later, the Trivy/Cisco compromise executed the same attack at higher stakes. The differences are of degree, not kind:
| tj-actions (2025) | Trivy/Cisco (2026) | |
|---|---|---|
| Vector | Compromised upstream action → tag update | Stolen PAT + force-pushed tags |
| Target | Utility file-diff action | Security scanner (high-privilege by design) |
| Exposure | Secrets printed to workflow logs | Full credential extraction, exfil to C2, persistence |
| Scope | 23,000+ repositories | Global CI pipelines + Cisco dev environment |
| Detection | Anomalous log output noticed by users | StepSecurity network monitoring |
The 2025 attack was quieter. It worked by accident of visibility — secrets in logs — rather than active exfiltration. But the mechanism was identical: compromise a trusted action, inherit trust from 23,000 repositories. A year later, the same mechanism extracted Cisco's source trees.
If the 2025 attack prompted your organization to audit GitHub Actions for SHA pinning, the 2026 attack was a near-miss. If it didn't, the 2026 attack wasn't a surprise.
The One That Should Have Been Obvious
The xz utils attack took two years of social engineering to execute. The SolarWinds attack required compromising a build system used by 18,000 organizations. The tj-actions attack required changing a script to print the environment.
Nobody's writing a conference talk about that. It's not interesting. It's not clever. But it hit 23,000 repositories in a single afternoon, because utility actions run in the same environment as your secrets, and nobody reads the changed-files changelog.
Audit the boring ones. Especially the boring ones.
Timeline¶
| Date | Event |
|---|---|
| Mar 14, 2025 | reviewdog/action-setup compromised; attacker gains foothold in tj-actions CI |
| Mar 14–15, 2025 | Malicious code pushed to tj-actions/changed-files; tags updated to point to compromised commits |
| Mar 15, 2025 | Anomalous log output reported by users; incident identified and contained |
| Mar 15, 2025 | CVE-2025-30066 assigned; CISA advisory issued recommending secret rotation |
-
CVE-2025-30066. National Vulnerability Database. March 2025. https://nvd.nist.gov/vuln/detail/CVE-2025-30066 ↩
-
"tj-actions/changed-files GitHub Action Compromised — Supply Chain Attack Details." StepSecurity. March 2025. ↩
-
"CISA Advisory: tj-actions/changed-files GitHub Action Compromise." CISA. March 2025. ↩