JSTGTECH
← Back to blog

CVE-2025-30066: The GitHub Action Tag You Trusted Lied

4 min read

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.

Related posts