Exhibit A
What happened, and the short answer
At 16:57 UTC on 14 March 2025, one push moved every version tag of tj-actions/changed-files to the same malicious commit, and every workflow that named the action by a tag ran it on its next trigger. GitHub's advisory counts over 23,000 repositories using the action and puts the exposure window at 14 to 15 March 2025. The payload read secrets out of the runner's memory and printed them, encoded, into the build log, which anyone can read on a public repository.
The short answer, worked out exhibit by exhibit below: a tag reference ran the payload; a full commit SHA pin on the action itself would not have; and the new workflow dependency lockfile (.github/workflows/actions.lock), generally available since 28 October 2026 on the Free, Team and Enterprise Cloud plans, also covers the actions your actions call, which is where this attack actually started. None of them would have made a malicious commit safe once you had locked it on purpose.
Sources: GHSA-mrrh-fwg8-r2c3 (CVE-2025-30066); GitHub changelog, 28 October 2026.
Exhibit B
Chain of custody: who held the tag, and when
The tj-actions tags were not the first thing the attacker moved. Unit 42's reconstruction, updated on 2 April 2025, traces it back four months and two organisations.
| Time (UTC) | Who held it | What changed |
|---|---|---|
| 2024-11-28 09:45 | spotbugs maintainer | Adds their own personal access token (PAT) to a spotbugs/sonar-findbugs workflow. On 6 December 2024 a pull_request_target workflow leaks it to the attacker. |
| 2025-03-11 10:52 | attacker, with the spotbugs maintainer's PAT | Pushes a workflow to spotbugs/spotbugs that leaks the repository's secrets, a reviewdog maintainer's PAT among them, and deletes the branch a second later. |
| 2025-03-11 18:42 | attacker, with a reviewdog maintainer's PAT | Pushes the v1 tag of reviewdog/action-setup to b833eecd, a commit that came from a fork. |
| 2025-03-11 20:31 | same | Force-pushes v1 back to a clean commit. The bad tag lived about two hours. |
| in that window | tj-actions CI | tj-actions/changed-files' own CI runs tj-actions/eslint-changed-files, which uses reviewdog/action-setup@v1, and leaks the PAT of the tj-bot-actions account. |
| 2025-03-14 16:57 | attacker, with the tj-bot PAT | Repoints the tags of tj-actions/changed-files to 0e58ed8. NVD lists v1 through v45.0.7 as affected. |
| 2025-03-14 | StepSecurity | Detects an unexpected outbound call to gist.githubusercontent.com and reports it. |
Two details matter for the replay. The first hijack used a tag inside someone else's action, two hops away from the workflows that leaked. And the commit it pointed at did not live in the action's own history; it came from a fork.
Sources: Unit 42 threat assessment (updated 2 April 2025); Wiz, reviewdog/action-setup (CVE-2025-30154).
Exhibit C
The replay: three moves against five defenses
Each move below is one thing the attacker did. Under it, every defense gets a verdict for a consumer workflow on the day of the attack.
-
Move 1: a moved tag two hops down
reviewdog/action-setup@v1 is repointed to a fork commit, and runs inside an action that another action uses.
- Tag,
@v1Runs itGitHub resolves the tag at run time, so the new commit runs. - SHA pin on your own
uses:linesDependsPins the action you name. If that action calls another by a tag, the inner tag still floats. - Require SHA pinning policyStops itFails any workflow that uses an action not pinned to a full SHA, and the check reaches inside pinned actions: Semgrep's org-wide rollout lists "transitively unpinned actions" among what it breaks. The job fails instead of running the moved tag, and keeps failing until that action's maintainer pins too.
- Immutable releasesDependsTags of new immutable releases cannot be moved, if the maintainer switched it on. It is their switch, not yours.
- actions.lockStops itPins transitive dependencies too, and rejects a commit that exists only in a fork or is not reachable from the recorded tag.
- Tag,
-
Move 2: every version tag repointed at once
tj-actions/changed-files tags, full versions such as v44.5.1 included, now point at
0e58ed8.- Tag,
@v45or@v44.5.1Runs itA full version tag is just as movable as a major one. - SHA pin on your own
uses:linesStops itA pinned commit cannot change without a SHA-1 collision, so yesterday's code runs. - Require SHA pinning policyStops itSame as a pin, enforced across the organisation so nobody slips a tag in.
- Immutable releasesDependsOnly if these releases had been published as immutable. The feature went GA in October 2025, after the attack.
- actions.lockStops itThe locked commit runs. On the next
gh actions-lockrun, a moved full version tag is reported as an error until you relock on purpose.
- Tag,
-
Move 3: the payload runs
Code you already run dumps the runner's memory and prints the secrets to the log.
- Every pin and lockRuns itLocking freezes code, it does not judge it. GitHub's own changelog: lockfiles "don't determine whether a dependency is safe".
- Least privilegeDependsWhat the secure use guide asks for: a read-only default
GITHUB_TOKEN, raised per job. It limits what a leaked token can do, which is the move that let this attack hop from one organisation to the next.
Sources: GitHub Docs, secure use reference and workflow dependency lockfiles; GitHub changelog, 15 August 2025 and 28 October 2025; Semgrep, 12 August 2026.
Exhibit D
Bag it: the commands for a repository
Dependabot first, because GitHub recommends enabling it before you lock, and because it then bumps the workflow and its lockfile entry in the same pull request.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Then the lockfile, from the repository root:
gh extension install github/gh-actions-lock
gh actions-lock # all workflows
gh actions-lock .github/workflows/ci.yml # or one at a time
Read the whole diff before you commit. The extension also rewrites references to the exact version it resolved, so @v3 can become @v3.1.0 with the same commit. If your organisation allows only listed actions, entries pinned to a tag such as actions/checkout@v6 stop matching once a workflow is onboarded; use a wildcard or the full SHA.
No lockfile yet? A plain SHA pin still beats a tag. Write the version in a comment so a reviewer can read it:
- uses: actions/checkout@<full 40-character commit SHA> # v6.x.y
Sources: GitHub Docs, locking action dependencies and keeping actions up to date with Dependabot.
Exhibit E
Reading a lockfile change in review
The lockfile records, for each dependency, the ref it came from, the commit that will run, and the numeric owner_id and repo_id of the source repository. That gives a reviewer three things to check:
- A changed
commitis a version bump. Read it like one. - A changed
owner_idorrepo_idmeans the repository behind the name is not the one you locked. Stop and investigate. - A
ref-movedwarning on a branch or major tag means upstream moved. Look at their history before you rungh actions-lock --relock, and keep--accept-movedout of automation.
Two gaps stay open. A lockfile does not pin reusable workflows you call with jobs.<id>.uses: pin those by full SHA, or rely on the lockfile in the repository that defines them. And a uses: ./path local action runs whatever is checked out, so the extension cannot lock it. If that local action uses other actions, gh actions-lock stops with a local-action error and writes nothing until you pass --migrate-local-actions, which rewrites ./ to a $/ reference that can be locked. GitHub recommends that over --ignore-local-actions, which keeps the ./ path and leaves the actions inside it without lockfile entries.
Source: GitHub Docs, workflow dependency lockfiles.
Exhibit F
Questions from the evidence desk
Do I still need SHA pins if I adopt actions.lock?
Not in onboarded workflows. GitHub says SHA pinning there is no longer necessary, and the lockfile satisfies the "require full-length commit SHA" policy for them.
Which GitHub plans have lockfiles?
General availability covers Free, Team, Enterprise Cloud and Enterprise Cloud with data residency, per the 28 October 2026 changelog.
Can admins force every repository to use one?
Yes. Enterprise, organisation and repository admins can turn on a "Require lockfile" policy.
Would a lockfile have helped a repository already running the bad commit?
No. If you locked 0e58ed8 during the window, it would keep running. Rotate any secret a workflow could read in that window, as the advisory asks.
Sources: GitHub Docs and changelog, read on 3 November 2026.
Sources
- GitHub Advisory GHSA-mrrh-fwg8-r2c3, CVE-2025-30066
- Unit 42, GitHub Actions supply chain attack, updated 2 April 2025
- Wiz, reviewdog/action-setup supply chain attack (CVE-2025-30154)
- GitHub Docs, secure use reference
- GitHub Docs, workflow dependency lockfiles
- GitHub Docs, locking action dependencies in a repository
- GitHub changelog, workflow lockfiles generally available, 28 October 2026
- GitHub changelog, blocking and SHA pinning policy, 15 August 2025
- GitHub changelog, immutable releases generally available, 28 October 2025
- GitHub Docs, keeping your actions up to date with Dependabot
- Semgrep, SHA pinning for GitHub Actions org-wide, 12 August 2026



