How tj-actions/changed-files compromised 23,000+ repos - and how to prevent it

How tj-actions/changed-files compromised 23,000+ repos - and how to prevent it

In March 2025, a single compromised GitHub Action exposed secrets across 23,000+ repositories. Here's a technical breakdown of how the attack worked, what failed, and the two controls that would have stopped it.

Alex Jung · 12. März 2026 · 9 min Lesezeit
github-actions security supply-chain sha-pinning

How tj-actions/changed-files compromised 23,000+ repos - and how to prevent it


On March 14, 2025, security researchers discovered that tj-actions/changed-files - one of the most widely used GitHub Actions in the ecosystem - had been modified to dump CI secrets to public workflow logs. Over 23,000 repositories were using it. Every one of them was potentially exposed.

CVE-2025-30066 is not a sophisticated exploit. The attacker didn’t find a zero-day. They didn’t break any encryption. They simply pushed malicious code to a GitHub repository, and thousands of pipelines automatically executed it on their next run.

This is what a supply-chain attack looks like in practice.


What actually happened

tj-actions/changed-files is a GitHub Action that detects which files changed in a pull request or push. It’s the kind of utility action that teams add once and forget about - exactly the profile that makes it a high-value target.

The attack followed a classic supply-chain pattern:

Step 1: The attacker gained write access to the repository. How exactly is still disputed. The most likely vector was a compromised Personal Access Token (PAT) of a maintainer. Once the attacker had write access, they could modify the action’s code directly.

Step 2: They modified the action to exfiltrate secrets. The attacker added code to the action that iterated over all environment variables - which in GitHub Actions includes any secrets mapped into the workflow - and printed them to the workflow log.

# Simplified version of what the malicious code did
for env_var in $(printenv | grep -oP '^[^=]+'); do
    echo "::notice::$env_var=$(printenv $env_var)"
done

GitHub Actions automatically masks known secrets in logs. But the masking only applies to values that GitHub knows are secrets - values explicitly defined as repository or organization secrets. Custom tokens passed as environment variables without proper masking bypass this protection.

Step 3: They moved the tag. Here’s the part that made the attack so effective. The attacker didn’t create a new version. They moved the existing tag - v35, v44, and others - to point to their malicious commit. Repositories pinned to tj-actions/changed-files@v35 were now running the attacker’s code without any change on their end.

# This workflow was suddenly running malicious code
steps:
  - uses: tj-actions/changed-files@v35  # tag silently moved to malicious commit

Step 4: Every pipeline run exfiltrated secrets. Any repository that triggered a workflow after the tag was moved - a PR opened, a push made - ran the attacker’s code with full access to the workflow’s secrets.


Why 23,000+ repos were affected

The scale of this attack isn’t surprising once you understand how GitHub Actions versioning works - or rather, how most teams use it.

Tags are mutable. Unlike a commit SHA, a Git tag is just a pointer. Any repository maintainer (or attacker with write access) can move a tag to a different commit at any time. There is no warning, no notification, no integrity check. The tag v35 today is not guaranteed to be the same code as v35 tomorrow.

Most teams pin to tags. Browse any GitHub Actions workflow and you’ll see the same pattern:

uses: actions/checkout@v4
uses: tj-actions/changed-files@v44
uses: docker/build-push-action@v6

This feels like good practice - you’re not using @main, which is clearly dangerous. But a version tag gives you a false sense of stability. You’re trusting that the maintainer of every action you use will never have a compromised account.

Transitive exposure. Many affected repositories didn’t even use tj-actions/changed-files directly. They used composite actions or reusable workflows that depended on it internally. The attack surface is larger than most teams realize.


What would have stopped it

Two controls. Both are straightforward. Neither requires a security team or special tooling.

1. SHA-pinning

Instead of referencing a tag, pin the action to a specific commit SHA:

# Before - vulnerable to tag mutation
- uses: tj-actions/changed-files@v44

# After - pinned to an immutable commit
- uses: tj-actions/changed-files@a29e8b565651ce417abb5db7164b4a2ec8365a6a

A commit SHA is immutable. Once a commit exists in Git, its SHA cannot change without changing the content. If an attacker moves a tag, your workflow is unaffected - it’s still running the exact code you reviewed and approved.

This is the single most effective control against supply-chain attacks targeting GitHub Actions.

To find the SHA for any tag:

# Option 1: GitHub CLI
gh api repos/tj-actions/changed-files/git/refs/tags/v44 \
  --jq '.object.sha'

# Option 2: Git
git ls-remote https://github.com/tj-actions/changed-files refs/tags/v44

For composite actions (actions that call other actions internally), you also need to verify the transitive dependencies. That’s the less obvious part - and where most teams stop.

2. Minimal workflow permissions

GitHub Actions workflows run with certain default permissions on the GITHUB_TOKEN. Many teams leave these at their defaults, which are broader than necessary.

# Add this to every workflow
permissions:
  contents: read    # only what this workflow actually needs

Or at the repository level, set the default to read-only and grant specific permissions per workflow. This doesn’t prevent the exfiltration of custom secrets, but it limits what an attacker can do with the GITHUB_TOKEN once they have it - and stops a large class of secondary attacks (unauthorized pushes, PR manipulation, release creation).

3. Regular dependency audits

SHA-pinning solves the active attack problem. But it creates a new one: how do you know when there’s a legitimate security update that you should pull in?

The answer is automated tooling. Dependabot supports GitHub Actions:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      actions:
        patterns: ["*"]

With Dependabot and SHA-pinning in combination, you get the best of both worlds: immutable references for stability, automated PRs when legitimate updates are available.


How to check your own exposure

If you want to audit your repository right now, here’s what to look for:

Find all actions not pinned to a SHA:

# Scans all workflow files for actions pinned to tags instead of SHAs
grep -rh 'uses:' .github/workflows/ \
  | grep -v '#' \
  | grep -oP 'uses:\s*\K[^\s]+' \
  | grep -v '@[0-9a-f]\{40\}' \
  | sort -u

Any line that appears here is a potential supply-chain risk. The output shows you exactly which actions in your pipeline are referencing mutable tags.

Check your default workflow permissions:

Go to Settings → Actions → General → Workflow permissions. If it’s set to “Read and write permissions”, change it to “Read repository contents and packages permissions”.

Review which secrets are available in your workflows:

# List all secrets referenced in your workflows
grep -rh '\${{ secrets\.' .github/workflows/ \
  | grep -oP 'secrets\.\K[^\s}]+' \
  | sort -u

Cross-reference this against which workflows actually need each secret. Over-provisioned secrets are unnecessary risk.


The bigger picture

The tj-actions attack was discovered quickly and patched within hours. The attacker’s access was revoked, the malicious commits were removed, and GitHub published an advisory. In terms of incident response, it went about as well as it could have.

But the underlying problem isn’t fixed. Thousands of repositories in the wild are still pinning to tags. New GitHub Actions are being published every week. The ecosystem has no built-in mechanism to alert you when a tag you’re using has been moved to a different commit.

The attack on tj-actions/changed-files was not unique. There have been others. There will be more. The only reliable defense is not trusting mutable references - regardless of who maintains the action.

SHA-pinning is not a new idea. The CISA advisory on CVE-2025-30066 explicitly recommends it. OpenSSF’s Scorecard has been flagging unpinned actions for years. The tooling exists. The knowledge exists.

What’s missing is the habit.

Artikel teilen

// newsletter

Kein Artikel mehr verpassen.

Neue Artikel direkt in ihrem Postfach. Kein Spam, jederzeit abmeldbar.

Mit dem Abonnieren stimmen Sie der Datenschutzerklärung zu.

Kommentare

Lade Kommentare...

Kommentar schreiben