GitHub Actions is the weakest link

A deep dive into how GitHub Actions' default features and configurations have become the primary vector for major open-source supply chain attacks over the past 18 months.
Pick almost any open source supply chain incident from the past eighteen months and trace it back, and you end up reading a .github/workflows YAML file. Ultralytics shipping a crypto miner to PyPI, the nx packages that turned thousands of developer machines into credential harvesters, tj-actions leaking secrets from 23,000 repositories, Trivy getting compromised twice in three weeks, elementary-data publishing a malicious wheel ten minutes after a stranger left a GitHub comment. Different headline payloads, different victims, and in each case a GitHub Actions feature behaving exactly as documented.
I wrote in December about the narrow problem of Actions being a package manager with no lockfile, no integrity hashes and no transitive visibility, and that the uses: line is a dependency declaration that the runner re-resolves on every execution against mutable git tags. That argument still stands and has since been demonstrated rather thoroughly in production, but it’s only one face of a larger problem.
The whole product is a collection of features that are each convenient on their own and very easy to assemble into something dangerous, and the workflows building and publishing most of the world’s open source run on a platform whose defaults were chosen for a private-repo enterprise CI tool and never really rethought for anonymous forks and drive-by pull requests.
The incidents
The earliest link in the recent chain is spotbugs in November 2024, which had a workflow on the pull_request_target trigger that checked out and built code from an untrusted fork. That trigger exists so that workflows can do things like label PRs from forks, and to make that work it runs in the context of the base repository with full secret access and a write-scoped token.
Combining it with a checkout of the fork’s head.sha hands an attacker code execution inside your trust boundary, which is what happened: a malicious PR lifted a maintainer’s PAT, that PAT had access to reviewdog, and four months later the same actor used it to seed the tj-actions/changed-files compromise. GitHub’s own documentation has warned about this combination since 2021 and still ships the trigger with no guardrail beyond a paragraph in the docs.
A month after spotbugs, Ultralytics was hit through the same trigger with a different second stage. The fork PR couldn’t reach the publishing credentials directly, so instead it poisoned a GitHub Actions cache entry, and when the legitimate release workflow later restored that cache it executed the payload while building wheels. Two versions of ultralytics reached PyPI with a miner inside.
The cache is keyed by branch and shared down to children, the pull_request_target job runs as the default branch, and nothing in the UI or the API tells you that an entry was written by a job processing untrusted input.
The tj-actions incident in March 2025 is the one most people have heard of because CISA put out an advisory and because the original target turned out to be Coinbase. With the PAT harvested from spotbugs the attacker pushed a malicious commit to reviewdog/action-setup and moved the v1 tag to point at it. tj-actions/eslint-changed-files referenced reviewdog by tag, tj-actions/changed-files referenced that, and 23,000 downstream repositories referenced changed-files by tag. Every one of them ran a memory scraper that dumped runner secrets into public build logs.
The platform feature at fault is that action versions are git refs in someone else’s repository, force-pushable by anyone with write access to that repository, and consumed by default through a moving tag rather than a content hash.
Unpinned tags are by far the most common finding in any scan of public workflows, but I suspect a good chunk of that risk could be closed inside the action loader without anyone editing their YAML. GitHub stores a repository and all its forks in one shared object pool, and the runner resolves uses: owner/action@<ref> against anything in that pool, so a SHA that only exists in a stranger’s fork, never reviewed and never on an upstream branch, is fetchable through the parent’s namespace as if the maintainers had put it there. Chainguard documented this as “imposter commits” back in 2022.
The malicious tj-actions commit was a dangling object that didn’t belong to any branch in the repository, and the runner executed it anyway because a tag pointed at it. Having the loader verify that a resolved SHA is reachable from a branch in the canonical repo, rather than just present somewhere in the fork network, would make tag hijacking need a real push to a real branch and would make a SHA pin actually mean the code had been in the upstream at some point.
August brought s1ngularity, where the nx build system’s repository had a pull_request_target workflow that interpolated the pull request title into a shell step. The $ template syntax expands before the shell sees the script, so a PR titled with a command substitution becomes code, and because of the trigger that code ran with an npm publishing token in scope.
The malicious nx releases that followed went looking for AI coding assistant credentials on developer machines and used them to enumerate and exfiltrate private repositories, which is how a single unsanitised string in a CI workflow ended up with over five thousand private repos briefly made public.
By 2026 attackers had stopped finding these one at a time and started running campaigns. The prt-scan operation spent six weeks across March and April opening hundreds of pull requests against repositories with pull_request_target misconfigurations, rotating through throwaway accounts and using generated, language-appropriate diffs to look like plausible contributions until the workflow fired.
Around the same time Trivy’s action repository was compromised through, again, a pull_request_target workflow, which an attacker found in late February. Aqua cleaned up, but the credential rotation wasn’t atomic, and three weeks later the same actor used tokens harvested in the first round to force-push 76 of 77 historical version tags so that even users pinned to an old “known good” @0.x.y ran the credential stealer.
Then last week a GitHub account two days old left a comment on an old elementary-data pull request. The repository had a workflow listening on issue_comment that echoed $ into bash, the comment body closed the echo string and curled a stager, and because the workflow had no permissions: block the stager got a write-scoped GITHUB_TOKEN by default. It pushed a commit with a forged github-actions[bot] author, dispatched the existing release workflow, and put a credential-stealing wheel on PyPI and a matching image on GHCR within ten minutes, without any maintainer accepting a PR or clicking a button or being awake.
Common factors
I don’t think any of the maintainers above were doing anything unusual, for what it’s worth. These workflows look like the examples in GitHub’s docs and like thousands of other repos.
Laying the incidents out side by side, the same GitHub Actions features keep recurring: pull_request_target and issue_comment triggers that run untrusted-event workflows with full secrets, $ expansion that does textual substitution into shell scripts with no quoting, a GITHUB_TOKEN that defaults to write on any repo created before February 2023, action versions that are mutable git refs, and a cache that crosses trust boundaries silently.
None of these are bugs in the strict sense, and as far as I can tell none of them are going away. A few have grown warnings in the documentation, and pull_request_target got a behavioural tweak last November so it always reads the workflow file from the default branch, but the change that would have stopped most of the list above, which is simply not handing write tokens and secrets to workflows triggered by people who’ve never been near
Source: Hacker News
















