CVE-2025-30066: The GitHub Action Tag You Trusted Lied
Every workflow file with a line like uses: tj-actions/changed-files@v45
is making a promise to itself that a tag won’t change out from under it.
In March 2025 that promise broke for real: attackers rewrote the tags on
tj-actions/changed-files, a GitHub Action used in over 23,000 repositories,
to point at a malicious commit that dumped CI/CD secrets straight into
public workflow logs — tracked as CVE-2025-30066 (CISA). It’s one of the cleanest real-world illustrations of why “pin a version”
and “pin a commit” are not the same security control, and it’s directly
relevant if your CI — like this site’s — runs on GitHub Actions.
Root cause
tj-actions/changed-files is a popular action that reports which files
changed in a PR or push, and plenty of pipelines gate steps on its output.
On March 14, 2025, its maintainers discovered that a large number of the
project’s version tags had been silently repointed to a commit that never
went through review: it injected a Node.js payload that scanned GitHub
Runner memory for credentials — cloud access keys, PATs, npm tokens, private
RSA keys — and printed them, base64-encoded, into the workflow run log
(Wiz).
The entry point traces back further, and it’s the more interesting part of
the story. Wiz’s investigation found the compromise was likely cascading:
three days earlier, on March 11, attackers had already hijacked the v1
tag of a different action, reviewdog/action-setup, and pointed it at
malicious code of their own (CVE-2025-30154) (StepSecurity). tj-actions/eslint-changed-files happened to depend on
reviewdog/action-setup, and the maintainers’ own bot ran with a GitHub
personal access token — a token that Wiz believes was harvested through
that same compromised dependency and then used to push the malicious commit
and rewrite the changed-files tags (Wiz). One stolen credential in one small action rippled into a second,
much more widely used one within days.
The structural flaw both incidents share is that a tag like @v1 or @v45
is just a mutable pointer, not a content hash. GitHub lets any tag be force-
pushed to a new commit at any time by anyone with write access — there’s no
cryptographic binding between the ref your workflow trusts and the code
that actually runs when the runner checks it out. Reviewing an action once
and pinning @v4 doesn’t protect you from a maintainer account (or its
automation token) getting popped six months later.
Blast radius
The initial estimate — every one of the 23,000+ repos using
tj-actions/changed-files — made headlines fast, but the real exposure
window was narrower and still meaningfully bad. The malicious code was live
between roughly March 12, 00:00 UTC and March 15, 12:00 UTC; anyone who ran
the action against a public repo during that window had whatever secrets
were in scope for the job printed straight into a log anyone could read,
before GitHub could pull them down (GitHub Advisory Database). On the upstream reviewdog side, deeper analysis found the confirmed
secret-leak count was smaller than the initial panic suggested — around 218
repositories actually had secrets exposed in logs, not tens of thousands
(BleepingComputer) — but “smaller than feared” still means real cloud credentials and
tokens sitting in public logs for anyone who scraped them in time. GitHub
temporarily pulled the tj-actions/changed-files repository entirely while
the malicious commit was reverted and tags restored, which is its own
signal of how seriously the platform treated it.
Remediation
The fix that actually closes this hole is boring and mechanical: pin every
third-party Action to a full 40-character commit SHA, not a tag —
uses: tj-actions/changed-files@a1b2c3... instead of @v45. A SHA can’t
be silently repointed the way a tag can, so a compromised maintainer token
can rewrite tags all day and your pinned workflow still runs the commit you
reviewed. Pinning by SHA does mean you lose automatic updates, which is why
you pair it with Dependabot or Renovate — both can open PRs that
bump the pinned SHA (with the new tag as a comment for readability) so
upgrades stay a reviewed diff instead of an invisible tag move. On the org
side, GitHub lets you restrict which Actions are allowed to run at all
(allow-list specific actions or require them to be from verified creators),
which is worth turning on for anything beyond a personal repo. If you ran
an affected action during the exposure window, don’t stop at patching: pull
the workflow run logs, search them for anything that looks like a leaked
credential, and rotate every secret that was in scope for those jobs —
assume exposure rather than hoping the log got pulled in time.
The bigger lesson
A third-party GitHub Action is a dependency with write access to your CI
environment and, by extension, whatever your CI can reach — cloud
credentials, deploy keys, package registry tokens. Marketplace popularity
and a green checkmark on the README aren’t a security review; they’re a
popularity contest. This site’s own CI runs entirely on GitHub Actions, and
the practical takeaway is the same one that applies to any npm install:
pin to something immutable, let a bot handle the update diffs, and treat
every action in your workflow file as code you’re choosing to run with your
secrets — because that’s exactly what it is.
Join the discussion
Comments for this post live on social — reply to the thread.
Related posts
Cloud roundup: macOS Screen Sharing bug now under attack
A patched macOS Screen Sharing flaw is being exploited to plant crypto miners, a Windows Defender bypass has no fix yet, and EC2 gets built-in app health checks.
Cloud roundup: S3 finally names the policy that denied you
AWS S3 access-denied errors now name the exact policy ARN, Client VPN gets a scriptable CLI, and OpenAI ships authorized offensive-security models on Bedrock.
The Capital One breach: an SSRF bug into 100M records
A misconfigured WAF and an SSRF bug let an attacker reach the AWS metadata service and steal role credentials — exposing 100M+ Capital One records.