Incident Analysis

tj-actions/changed-files Compromise: What Happened

A March 2025 GitHub Action compromise rewrote every tagged version to leak secrets. Here is the timeline, attack chain, and what repos need to change.

Shadab Khan
Security Engineer
7 min read

On March 14, 2025, tj-actions/changed-files, a GitHub Action used by roughly 23,000 public repositories, was compromised. The attacker rewrote existing version tags (v1 through v45, plus several fixed-version tags) to point at a malicious commit. For roughly 24 hours, any CI run that pinned this action by tag executed code that dumped runner memory, extracted secrets, and printed them to the public Actions log.

This was not a typosquat. It was a supply chain attack against a legitimate, widely-trusted dependency, using the weakest link in GitHub's reference model: mutable tags.

How did a single commit poison every version tag?

The attacker gained write access to the repository and force-updated every version tag to point to a new commit containing malicious code.

Git tags are mutable by default. A tag like v45 is not a cryptographic identifier; it is a pointer that anyone with push access can move. When a workflow pins uses: tj-actions/changed-files@v45, GitHub resolves that tag at runtime and checks out whatever commit it currently points to. The attacker pushed a malicious commit, then reassigned every major version tag to that commit. Every pipeline that pinned by tag (which is the default pattern the GitHub Marketplace encourages) executed the malicious code on the next run.

The compromised access appears to have come from a downstream dependency chain: a maintainer's personal access token used for automation in a related tj-actions repository was exposed, then leveraged to push to changed-files. Public writeups link this to a prior compromise of reviewdog/action-setup, which some tj-actions maintainers used.

What did the malicious payload do?

The payload was a short Python script that dumped the runner's process memory and printed any matching secrets to stdout.

The script walked /proc/*/maps, read readable memory regions, grepped for patterns matching common secret formats (AWS keys, GCP tokens, GitHub tokens, npm tokens, Stripe keys, generic high-entropy strings), base64-encoded the matches, and wrote them into the job log with a distinctive marker. Because public repositories make Action logs publicly accessible, every secret the workflow had access to became world-readable. Private repositories were not directly logged-out, but the secrets were still captured and could be exfiltrated through other means.

The severity multiplier is the runner context: GitHub Actions runners inject secrets into environment variables and memory of the orchestrator process. Dumping memory is functionally equivalent to dumping the full set of secrets available to that workflow, including federated tokens exchanged via OIDC, temporary cloud credentials, and repository-scoped GITHUB_TOKENs with write permissions.

Which repositories were actually compromised?

Repositories that pinned by tag and ran workflows during the exposure window were compromised. Repositories that pinned by commit SHA were not.

GitHub's recommended practice (though not the default in most tutorials) is to pin Actions to a full commit SHA: uses: tj-actions/changed-files@a284dc1814e3fbfcfc4f1b6dcb8cc31ed50ad6b3. A SHA is content-addressable and cannot be rewritten. Repositories that followed this discipline saw no change in behavior because the SHA they pinned was not the malicious one.

An estimated 1% to 5% of repositories using the action had SHA pinning. The remainder were exposed, though only those that actually executed a CI run during the window leaked live secrets. For public repos, the exposure is permanent because job logs are archived and may have been scraped before GitHub's retroactive redaction.

How was the compromise detected?

A security researcher noticed the unexpected log output in a public repository's CI run and reported it within hours.

The detection path was manual. There was no automated signature, no CVE, no feed update. Someone saw an Actions log with base64 blobs and recognized the pattern. GitHub responded quickly: the malicious commit was removed, the tags were rewritten back to their pre-attack state, and the compromised account was locked. Public guidance to rotate all secrets used in affected workflows went out within 48 hours.

The detection gap is structural. There is no cryptographic signal in the GitHub Actions ecosystem that tells you "this tag moved" in real time. Organizations running fleets of repositories cannot, by default, be notified when a tag they depend on has been rewritten to a different SHA. Some SCA tools poll for tag drift, but coverage is inconsistent.

What should teams do now?

Three immediate actions, then three structural ones.

Immediately: rotate any secret that was accessible to a workflow using tj-actions/changed-files between March 14 and March 15, 2025. This includes GitHub tokens, cloud credentials, and any third-party API tokens exposed through secrets.*. Audit recent Git commits, package registry uploads, and infrastructure changes made using those credentials for any sign of attacker activity. Search public archives for leaked secret patterns specific to your organization.

Structurally: pin every GitHub Action to a full commit SHA, not a tag. Use Dependabot or Renovate to keep SHA pins current with automated pull requests, so you get upgrades without tag-rewriting exposure. Consider running Actions in a hardened runner environment with egress controls and secret-scope minimization, so a compromised Action cannot dump arbitrary secrets. For high-value pipelines, use OIDC federation with short-lived credentials instead of long-lived API keys stored as secrets; even if the OIDC token is captured, its blast radius is minutes.

Lastly, maintain a watch-list of Actions you depend on and monitor for tag drift. Any unplanned tag rewrite on a critical dependency should generate a page, not an email.

Why does the GitHub Actions trust model keep breaking?

The fundamental issue is that Actions is a package ecosystem that does not enforce immutable references by default, and the security guidance has never caught up.

In npm, the registry guarantees that a published version is immutable; unpublish is tightly controlled and leaves a gap. In Maven Central, artifacts cannot be overwritten after publication. In Cargo, crates are write-once. GitHub Actions, by contrast, pulls source directly from a Git repository every time a workflow runs. Git tags are mutable by design. Even branches can be force-pushed. The ecosystem relies on users pinning by SHA, but the documentation, tutorials, and Marketplace UI all encourage tag-based pinning because it reads cleanly and auto-updates within a major version.

A structural fix would be a registry layer between Git repos and workflow runs, where Actions are versioned as immutable artifacts and the workflow references that artifact by hash. This would require a meaningful re-architecture, but it would bring Actions in line with the trust model of other package ecosystems.

Until that happens, the defense is discipline. Treat every uses: reference as a supply chain dependency with the same rigor you apply to npm install. Review changes via pull request. Pin by SHA. Run Dependabot to track upstream without blindly accepting it. And build internal tooling to monitor tag movement across the Actions your organization depends on, because no external service is going to tell you when it happens.

How Safeguard.sh Helps

Safeguard.sh's reachability analysis would have flagged tj-actions/changed-files as a high-blast-radius CI dependency based on the secrets it can observe, and the 60-80% noise reduction from reachability means this specific alert surfaces ahead of the long tail of low-impact SCA findings. Griffin AI continuously evaluates behavioral diffs between action versions and tag rewrites, so the unexpected memory-dumping behavior would have been detected independent of a CVE. The SBOM pipeline captures GitHub Actions as first-class dependencies at 100-level depth, and TPRM workflows pin critical automation suppliers under review gates where any tag movement triggers a manual approval. Combined with container self-healing for affected build artifacts, teams using Safeguard.sh would have caught the regression before the poisoned Action ran against production secrets.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.