
A line like uses: tj-actions/changed-files@v45 looks like a version number. It isn't one. It is a tag, a movable label in someone else's repository, and whoever can write to that repository can point the label at different code whenever they like. Your next build runs that code with your secrets in reach.
On 14 March 2025 that is what happened to tj-actions/changed-files, a small helper that tells a workflow which files a commit touched. It was used in more than 23,000 repositories. This is the chain that led there, what the planted code did, and the settings that break each link. None of them cost money.

The chain, hop by hop
Palo Alto's Unit 42 traced the attack back through three open-source projects. Times are UTC.
Hop 1: SpotBugs, November and December 2024
• 28 November 2024: a maintainer of SpotBugs, a Java bug-finding tool, adds a personal access token (PAT) to a workflow in spotbugs/sonar-findbugs.
• 6 December: someone opens a pull request against that repository. The workflow used the pull_request_target trigger, which runs with the repository's secrets even when the change comes from a stranger's fork, and it built the stranger's code. The pull request changed a build script (mvnw) so that the build sent out the token.
Then nothing visible happened for three months.
Hop 2: reviewdog, 11 March 2025
• The stolen token is used to invite a new account into spotbugs/spotbugs. That account pushes a workflow that collects every secret available to it, encrypts them and saves them as a build artifact to download.
• One of those secrets is a token belonging to a maintainer of reviewdog, a code-review helper with its own set of actions.
• 18:42: the v1 tag of reviewdog/action-setup is pointed at a malicious commit. 20:31: it is pointed back. Two hours, then cleaned up.
Hop 3: tj-actions
A sister action, tj-actions/eslint-changed-files, used reviewdog/action-setup@v1, and the changed-files repository ran it in its own workflows with a PAT for the project's bot account. Wiz's reading is that those two hours were enough for that PAT to leak. It could write to the repository.
The real target, then everyone
• 12 and 13 March: throwaway accounts fork several Coinbase repositories and experiment with malicious changed-files commits.
• 14 March, 15:10: a workflow in coinbase/agentkit runs one of those commits and leaks a token with write access. 16:37: a Coinbase maintainer removes the workflow. Coinbase says nothing further was damaged.
• 16:57, twenty minutes later: every version tag in tj-actions/changed-files is replaced to point at one malicious commit.
Unit 42's view is that the attack was aimed at Coinbase from the start, and that the mass change came after the precise attempt failed.
What the planted code did
The commit was made to look like a routine dependency update from the Renovate bot. It added a small encoded script to the action. When a workflow ran, the script:
1. downloaded a Python file from a public GitHub gist;
2. ran it with sudo, which GitHub's hosted Linux runners allow without a password;
3. found the Runner.Worker process, the part of the runner that holds the job's secrets, and read its memory;
4. picked out the values marked as secrets, base64-encoded them twice, and printed the result to the build log.
The double encoding was there to get past GitHub's log masking, which replaces known secret values with *** but can't recognise them once transformed. StepSecurity found no sign that the secrets were sent anywhere else. They didn't need to be: the build logs of a public repository are readable by anyone. The attacker, and anyone else who understood what had happened, could collect them from the Actions tab.
How it was caught
Not by code review, and not by the tag change, which sends no notification to anyone. StepSecurity runs a monitoring agent on build runners that learns which outside hosts each workflow normally talks to. Around 16:00 on 14 March it flagged a workflow contacting gist.githubusercontent.com, a host that had never been in that job's history.
• 14 March, about 20:00: several public repositories confirmed to have secrets in their logs.
• 15 March, 14:00: GitHub takes the action down. Every workflow that used it now fails, which is loud but safe.
• 15 March, 22:00: the repository is back with the malicious commit gone.
• 18 March: CISA publishes an alert. The flaw is CVE-2025-30066, the reviewdog one CVE-2025-30154, and both go into its catalog of known exploited vulnerabilities. The fixed release is v46.0.1.
Why 218 and not 23,000
Endor Labs went through public repositories afterwards:
• 5,416 repositories it could find referenced the action in a workflow.
• 614 of them ran that workflow in the 24 hours the bad commit was live.
• 218 printed secrets.
Most of what leaked was the built-in GITHUB_TOKEN, which stops working when the job ends. A few dozen were worse: long-lived credentials for Docker Hub, npm and AWS. Those stay valid until somebody rotates them, and a stolen npm publishing token is how the next project in a chain like this gets poisoned.
The count says nothing about private repositories. Their logs weren't public, but the code still ran. Anyone who could read those logs (every collaborator, and any tool that archives them) could read the secrets.
The damage was small because the window was a day and most tokens were short-lived. Neither of those was a decision anyone made about this attack.
Five settings that break the chain
1. Pin actions to a commit, not a tag
A full commit SHA can't be moved. A workflow pinned to one kept running the old, clean code all through 14 and 15 March.
• Keep the version in a comment. Dependabot and Renovate both read it and open a pull request with the new SHA when a release comes out.
• Don't merge those pull requests the same hour. This malicious commit lived for a day; a waiting period of a few days before taking any new release would have skipped it. Dependabot has a cooldown setting for this, covered in our note on the 2026 pipeline changes.
• Since August 2025 an organisation or repository can require it: in the Actions policy settings, turn on the option that fails any workflow using an action not pinned to a full SHA. The same policy now accepts a block list.
• A pin covers the action you name. If that action calls another by tag inside itself, as eslint-changed-files did with reviewdog, you still inherit the tag. Prefer actions that pin their own dependencies, and fewer actions overall.
2. Give the job's token only what it needs
The Coinbase token that leaked could write to the repository. A job that only lints or tests needs to read code and nothing else. Set the default to read-only for the whole organisation in Settings, Actions, Workflow permissions, and grant more per job.
3. No long-lived personal tokens in workflows
Every hop in this chain was a PAT: a person's token, valid for months, with that person's reach across several projects. Replace them.
• For cloud deploys, use OpenID Connect. The job asks AWS, Azure or Google Cloud for credentials that last minutes and are tied to one repository and branch.
• For publishing packages, use the registry's trusted publishing, which works the same way.
• Where a token is unavoidable, make it a fine-grained one for a single repository with an expiry date, and keep it in an environment that needs approval, so a lint job can't read it.
4. Don't build strangers' code with your secrets
The first token leaked through pull_request_target. That trigger exists so a workflow can label or comment on pull requests from forks, and for that it has the repository's secrets. It turns dangerous the moment the workflow also checks out and runs the fork's code. Use plain pull_request for anything that builds or tests. If you must use the other one, never check out the pull request's head in it.
5. Watch where the runner connects
This attack was caught by one unexpected outbound connection. A build job has a short, stable list of hosts it needs: the package registry, your cloud, GitHub. An agent that allows or at least logs only those turns "fetch a script from somewhere new" into an alert. StepSecurity's Harden-Runner is free for public repositories, and GitHub has been previewing its own runner firewall.
If you used it that week
1. Search your organisation for tj-actions/changed-files and for reviewdog/action-setup, including inside other actions you use.
2. List the workflow runs between 12 and 15 March 2025 (and 11 March, 18:42 to 20:31, for reviewdog).
3. In those logs, look at the changed-files step for a long base64 string that shouldn't be there.
4. Rotate every secret those jobs could read, whether or not you find the string. Logs get deleted; the secret may have been read first.
5. Delete the affected logs in public repositories.
A leaked deploy credential is most often used quietly, to change what a site serves to its visitors. A content security policy with reporting is one place such a change shows up, and osec CSP Reports collects those reports for a small site without a DNS change.
The lesson
Nobody attacked changed-files because of what it does. It was attacked because of where it runs: inside thousands of builds, next to their secrets, trusted by a tag. The XZ Utils backdoor took two years of patience to reach the same position. This took one forgotten token and a trigger used the wrong way. A pipeline is production, and what it's allowed to run deserves the same care as what you deploy.
osec.one makes small security tools for sites that don't have a security team: passwordless sign-in, login rules, scans and reports, each added with a pasted snippet.
See the toolsSources: StepSecurity: Harden-Runner detection, tj-actions/changed-files action is compromised; Palo Alto Networks Unit 42: GitHub Actions supply chain attack, a targeted attack on Coinbase expanded to the widespread tj-actions/changed-files incident; Wiz: New GitHub Action supply chain attack, reviewdog/action-setup; Endor Labs: Blast radius of the tj-actions/changed-files supply chain attack; CISA: Supply chain compromise of third-party GitHub Action, CVE-2025-30066; The Hacker News: SpotBugs access token theft identified as root cause; GitHub changelog: Actions policy now supports blocking and SHA pinning actions (15 August 2025).