On March 14, 2025, the tj-actions/changed-files GitHub Action — a workflow step used by more than 23,000 repositories to detect which files changed in a pull request — started dumping CI runner memory and printing base64-double-encoded secrets straight into public build logs. Attackers had compromised the maintainer's tooling and rewritten the existing version tags, including v46 and earlier releases, to point at malicious commits, so every workflow that referenced the action by tag pulled the backdoored code automatically on its next run. The malicious commit went in around 16:57 UTC on March 14, 2025, disguised as a routine Dependabot update, and the exposure ran roughly 15 hours until GitHub pulled the compromised repository on March 15 and a fix shipped in v46.0.1. The incident is tracked as CVE-2025-30066 (GitHub Advisory GHSA-mrrh-fwg8-r2c3), sits alongside a related compromise of reviewdog/action-setup@v1 (CVE-2025-30154), and prompted a CISA alert on March 18, 2025. The root cause wasn't a bug in anyone's application code — it was a trust decision made months or years earlier: pinning a third-party action to a mutable tag instead of an immutable commit. This post walks through the mechanics of how secrets actually leak in GitHub Actions, and the concrete configuration changes — OIDC federation, SHA pinning, environment protections — that remove the failure modes the 2025 incidents exploited.
How did a single compromised Action expose secrets across 23,000 repositories?
It happened because Git tags in GitHub Actions references are mutable by default, and most workflows trust them anyway. A reference like uses: tj-actions/changed-files@v46 doesn't pin to a specific commit — it resolves to whatever commit the tag v46 currently points at, at the moment the workflow runs. When attackers gained write access to the action's repository, they force-moved existing tags to a malicious commit that ran a payload dumping the runner's process memory (where secrets.* values are typically materialized as environment variables) and printed it to the job log, base64-encoded twice to slip past GitHub's log-masking filter. Because the log was public on public repositories, anyone could read it. No credential of the downstream repositories was itself breached — the compromise happened upstream, in a dependency, and every consumer inherited it silently the next time CI ran.
Why doesn't GitHub's built-in log masking stop this?
GitHub's log redaction only performs exact-string matching against values it already knows are secrets — every value registered as a repository, environment, or organization secret gets substituted with *** if it appears verbatim in stdout or stderr. That protects against the common case of a script accidentally echoing $API_KEY, but it has two hard limits attackers routinely exploit. First, any transformation of the secret defeats it: base64-encoding, reversing, splitting across lines, or concatenating with other strings all produce a byte sequence GitHub never registered, so nothing gets masked — which is exactly the double-base64 technique used in the tj-actions compromise. Second, masking only covers GitHub Actions' own log stream; it does nothing if a compromised action exfiltrates the secret via an outbound HTTP request instead of printing it. Log masking is a safety net for accidental leaks, not a control against a malicious or compromised action deliberately extracting credentials.
What does OIDC federation actually remove from the attack surface?
OpenID Connect (OIDC) federation lets a GitHub Actions workflow request a short-lived, per-run credential from a cloud provider instead of reading a long-lived static key out of repository secrets. With the id-token: write permission set on a job, GitHub mints a signed OIDC token unique to that specific workflow run, and a trust policy configured on the AWS, Azure, or GCP side exchanges it for temporary credentials scoped to that run — typically valid for under an hour. This removes an entire category of exposure: there is no standing AWS access key sitting in repository secrets for a compromised action to read, memory-dump, or exfiltrate, because no such long-lived key exists. If a workflow run is compromised mid-execution, the attacker gets credentials that expire before they're useful for anything beyond that run, and the trust policy can further restrict which repository, branch, or environment is allowed to assume the role at all.
What should replace tag-pinning for third-party Actions?
Pin every third-party action reference to a full 40-character commit SHA, not a version tag or branch name — for example uses: tj-actions/changed-files@<full-sha> instead of @v46. A commit SHA is immutable in Git; unlike a tag, it cannot be silently repointed by anyone with write access to the upstream repository, which is precisely the mechanism the March 2025 compromise relied on. This single change would have prevented every downstream repository using SHA pinning from pulling the malicious tj-actions/changed-files update, regardless of what happened to the tag. Dependabot and Renovate both support version-update pull requests against SHA-pinned actions, so pinning doesn't mean giving up patch automation — it means every update is a reviewable diff instead of a silent retarget. GitHub's own security hardening guidance for Actions has recommended SHA pinning for third-party actions for years; the 2025 incidents are the clearest evidence yet of why the recommendation exists.
What other guardrails limit the blast radius if a secret does leak?
Least-privilege GITHUB_TOKEN permissions, environment-scoped secrets with required reviewers, and avoiding pull_request_target with untrusted checkout round out the defense. Setting permissions: contents: read at the workflow level (rather than inheriting the broad default) means a compromised step can't push tags, open releases, or modify repository settings even if it fully controls the job. Environment protection rules let you gate access to production secrets behind a required human approval, so a secret scoped to a production environment is never even injected into a run triggered by an untrusted pull request. And pull_request_target workflows — which run with the base repository's secrets available — should never check out and execute a fork's code, a pattern that has caused multiple real secret-exfiltration incidents independent of the tj-actions case.
What's the recovery path if a secret has already leaked into a build log?
Treat any secret that touched a CI log, even briefly, as compromised and rotate it — masking or deleting the log entry after the fact doesn't undo exposure if the log was public or cached. Safeguard's secrets scanning ingests CI build log tails alongside source code and Git history, and verifies issuer-specific findings live — for example confirming an AWS key is real via sts:GetCallerIdentity or a GitHub PAT via the /user endpoint — so a leaked credential is flagged as critical and verified rather than sitting as an unconfirmed pattern match. From there the same remediation playbook applies regardless of where the secret leaked from: revoke via the issuer's API where supported, rotate through a connected secrets manager, and purge it from Git history if it was committed rather than just logged. The tj-actions incident is a reminder that this playbook needs to cover CI logs specifically, not just source code — a secret that never touches a commit can still end up public.