On March 14, 2025, a popular GitHub Action used in over 23,000 repositories was quietly compromised. The malicious code dumped the CI runner's memory to build logs, exposing AWS keys, GitHub tokens, npm tokens, and RSA private keys to anyone who could read the workflow output.
What happened
The action was tj-actions/changed-files, one of the most widely used GitHub Actions for detecting which files changed in a pull request. Thousands of teams use it to optimize CI runs: skip the build if only docs changed, run the test suite only for the affected service.
Between March 10 and March 14, 2025, an attacker gained write access to the repository. They modified existing version tags: v44, v45, and earlier tags all started pointing to the malicious commit. Developers who referenced the action by version number got the compromised code without knowing it.
The injected payload did one thing: it iterated over every environment variable available to the runner, base64-encoded the values, and printed them to the workflow log. GitHub Actions logs are readable by anyone with repository access. For public repos, they are readable by anyone on the internet.
CISA issued an advisory. Palo Alto's Unit 42 linked the initial access to a targeted attack on Coinbase, using a separate compromised action (reviewdog/action-setup@v1, CVE-2025-30154) as the stepping stone. The threat actor got into Coinbase's agentkit repository, then pivoted to the broader attack on tj-actions.
The root cause: version tags are mutable
Here is the thing about uses: tj-actions/changed-files@v44 in a workflow file: that version tag can be changed. Git tags are just pointers to commits. If the maintainer (or an attacker who has taken over the repo) moves the tag to point to a different commit, everyone referencing that tag gets the new code on their next run. No warning. No diff. No review.
The only reference that cannot be changed is a full commit SHA. A commit hash like this:
# Vulnerable: tag can be moved to malicious commit
- uses: tj-actions/changed-files@v45
# Safe: commit SHA is immutable
- uses: tj-actions/changed-files@d6babd6899969df1a11d14c368283ea4436bca78That SHA will always point to exactly that commit. If someone tries to change it, you will get a different hash and the action will fail. Pinning by SHA is not convenient. But it is the only way to guarantee what code is running.
How to check your own pipelines
If your project uses GitHub Actions, do this now:
# Find all workflow files and grep for 'uses:' lines not pinned to SHA
grep -r "uses:" .github/workflows/ | grep -v "@[0-9a-f]{40}"Any line that shows a version tag (@v1, @v2.1.0,@main, @latest) is a mutable reference. Each of those is a point of compromise if the action maintainer account gets hijacked.
To find the SHA for an action:
# Get the current SHA for a tag using the GitHub API
curl -s https://api.github.com/repos/tj-actions/changed-files/git/refs/tags/v46.0.1 | jq '.object.sha'What else to lock down
Use OIDC instead of long-lived secrets
If your workflow deploys to AWS or Azure, and you are storing cloud credentials as GitHub Secrets, those credentials exist persistently. If they are ever exposed via a log dump or a compromised action, an attacker has them until you rotate them.
OIDC (OpenID Connect) federation is the correct solution. You configure a trust relationship between GitHub and your cloud provider. Your workflow requests a short-lived token at runtime, uses it for the deployment, and it expires. No long-lived credentials stored anywhere.
# AWS OIDC setup in workflow
permissions:
id-token: write
contents: read
steps:
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4 # pin to SHA in production
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy
aws-region: us-east-1Restrict secret access by environment
GitHub Environments let you gate secret access behind manual approvals or branch restrictions. A secret in the "production" environment can only be accessed by workflows running on the main branch after an approval. Pull request workflows from forks cannot access it.
Audit what secrets are in your repositories
Run a scan. GitHub's secret scanning is enabled by default on public repos. For private repos you need the Advanced Security license, but you can also run Gitleaks or TruffleHog locally.
# Scan your repo history for secrets with gitleaks
docker run --rm -v "$(pwd):/path" zricethezav/gitleaks:latest detect --source /path --report-format json --report-path /path/gitleaks-report.jsonUse a tool to track third-party actions
step-security/harden-runner is a free GitHub Action that monitors outbound network traffic from your CI runner and alerts you to unexpected behavior. It would have flagged the tj-actions compromise at runtime. It also generates an allowed-domains policy you can enforce.
The patch and the timeline
The tj-actions incident was patched in v46.0.1 within hours of disclosure. But the broader lesson is not about this specific action. It is about every third-party action in your pipeline. Any of them can be compromised the same way. MITRE and Splunk both had vulnerable action references in their own public repositories. If it can happen there, it can happen anywhere.
The correct posture: pin every third-party action to a SHA. Audit the list of actions your pipelines use. Remove any you do not need. Review new ones before adding them.
$ pin --your-actions
If your pipeline has mutable action references or long-lived credentials and you want them sorted out, I can review and fix the workflow files.
$ ./fix-pipeline.sh →