Safeguard
Supply Chain Attacks

Reconstructing the tj-actions/changed-files compromise

CVE-2025-30066 hit CISA's KEV list within 3 days: 23,000+ repos ran a poisoned GitHub Action that dumped CI secrets straight into public build logs.

Safeguard Research Team
Research
6 min read

On March 14, 2025, a GitHub Action used in an estimated 23,000 repositories started leaking secrets in plain sight. tj-actions/changed-files — a popular utility that reports which files changed in a pull request — had every tag from v1 through v45.0.7 silently repointed to a malicious commit. The injected code dumped CI runner memory into workflow logs, exposing AWS keys, npm tokens, and GitHub PATs to anyone who could view a public build log. NVD tracked the flaw as CVE-2025-30066, CWE-506 (Embedded Malicious Code), with a CVSS 3.1 base score of 8.6 and a confidentiality-only impact vector (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N). CISA added it to the Known Exploited Vulnerabilities catalog just three days later, on March 18, 2025, with a federal remediation deadline of April 8 — a fast escalation that signals active, not theoretical, exploitation. The maintainers pulled the compromised tags and shipped a clean v46 within hours, but the incident became the reference case for a risk that had been sitting in plain sight for years: floating version tags on third-party GitHub Actions are mutable, and mutable means attacker-controllable. This post reconstructs what happened and what actually stops it from happening to your pipeline.

What made the tj-actions tags attacker-controllable in the first place?

GitHub Actions references such as uses: tj-actions/changed-files@v45 resolve a tag name to whatever commit it currently points at, not to a fixed piece of code. Git tags are mutable by design — anyone with push access (or a stolen credential with push access) can force-move an existing tag to a new commit, and every workflow referencing that tag silently picks up the new code on its next run. NVD's record for CVE-2025-30066 confirms the scope: "the tags v1 through v45.0.7 were affected," meaning the attacker didn't need to compromise one release — repointing the tags retroactively compromised every version number users thought they'd already pinned to. The widely reported root cause was a compromised access token used to push the malicious commit and force-update the tags, the classic supply-chain failure mode for any action referenced by name or version rather than by immutable content hash. A @v45 reference offers no cryptographic guarantee about what code actually runs.

Why did the payload leak secrets specifically, rather than steal them directly?

Per NVD, the vulnerability "allows remote attackers to discover secrets by reading actions logs" — the injected code executed inside the runner, read process memory, and printed the contents to stdout, which GitHub Actions captures and stores as a workflow log. On public repositories, those logs are visible to anyone, including unauthenticated visitors, without any additional access step. This is a materially different exfiltration path than a reverse shell or outbound network callback: no alerting rule built around "unexpected outbound connection" would have caught it, because nothing left the runner through the network — it left through GitHub's own logging pipeline, a channel security teams don't typically monitor for secret-shaped strings. Any workflow that echoed environment variables, ran the action with default verbosity, or simply let the poisoned binary execute was exposed the moment the run completed and the log became readable.

Was this an isolated incident or part of a pattern?

It was one of two closely timed 2025 GitHub Actions compromises reported around the same window, alongside a related compromise of reviewdog-adjacent actions that fed into the same tj-actions dependency chain — reinforcing that one compromised upstream credential can cascade through actions that depend on each other. The pattern itself is not new: dependency confusion, typosquatted npm packages, and compromised maintainer accounts on PyPI have produced comparable blast radii for years. What's specific to GitHub Actions is that the entire ecosystem's default reference style — a tag or branch name — is exactly the attack surface that made this possible. Any marketplace action referenced by @v1, @main, or @latest carries the same theoretical exposure; tj-actions became the case study because of its popularity, not because of anything unique in its code.

What actually stops this class of attack?

Pin every third-party action to a full 40-character commit SHA, not a tag: uses: tj-actions/changed-files@<full-sha> instead of @v45. A commit SHA is content-addressed — it cannot be silently repointed the way a tag can, so even if an attacker compromises the repository and force-moves every tag, your workflow keeps running the exact commit you audited. GitHub's own security hardening guidance for Actions recommends this as the primary mitigation, and tools like Dependabot and Renovate can keep SHA-pinned actions current by opening a PR with the new hash and version comment on every release, so pinning doesn't mean freezing. Pair it with least-privilege GITHUB_TOKEN permissions (set permissions: read-all or narrower at the workflow level instead of the broad default), and treat any secret that could plausibly touch a third-party action as one to rotate immediately if that action is ever flagged as compromised — log exposure isn't reversible.

How should teams audit for exposure after an incident like this?

Search historical workflow run logs for the affected action's execution windows and specifically for strings that resemble secrets — a compromised action doesn't need to run today to have already leaked a key months ago into a log you never deleted. GitGuardian and Wiz both published post-incident guidance urging teams to treat every secret ever touched by an affected workflow run as compromised and rotate it, rather than assuming an unnoticed leak means no leak occurred. Building an inventory of every third-party action referenced across your organization's repositories — including transitive actions that your own actions call — is the only way to know your exposure surface before the next KEV entry, rather than after.

How Safeguard helps

Safeguard's CI/CD integration generates a CycloneDX SBOM on every build and enforces policy gates before deployment, which gives a security team a queryable record of what dependencies — including build-time tooling — were present in a given pipeline run when an incident like CVE-2025-30066 breaks. That inventory is what turns "were we affected?" into a query instead of a week of grepping through workflow YAML across every repository. Safeguard's package firewall applies the same install-time scrutiny to npm and PyPI dependencies pulled during CI runs, blocking known-malicious and typosquatted packages before they execute — the same failure mode, one step removed from GitHub Actions itself. Pinning Actions references to immutable commit SHAs remains a manual hardening step teams should apply directly in their workflow files; combined with SBOM visibility and dependency-firewall enforcement, it closes both the identity of what ran and the trustworthiness of what it pulled in.

Never miss an update

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