GitHub Actions Security in 2026: 76 Hijacked Tags and the Fixes That Work
I pin every third-party action in my workflows to a commit SHA, and I still read each GitHub Actions security advisory with a small knot in my stomach, because the attack that keeps working is not clever. Someone gets write access to a popular action, moves its version tags to a malicious commit, and every workflow that says @v4 runs the payload on the next push. It happened to tj-actions in 2025 and to trivy-action, issues-helper and semantic-release-action in 2026. This post covers the incidents, the two root causes behind nearly all of them (mutable tags and pull_request_target), the controls GitHub shipped in the last eighteen months, which of them actually stop these attacks, and what Actions costs now that the pricing changed.
What happened: GitHub Actions supply chain attacks, 2025 to 2026
The modern template is tj-actions/changed-files. On 14 and 15 March 2025, tags v1 through v45.0.7 were repointed to a commit that dumped runner memory, secrets included, into the public build log. CISA's alert tracks it as CVE-2025-30066 and notes it was probably enabled by an earlier compromise of reviewdog/action-setup (CVE-2025-30154). Reports put the number of potentially exposed repositories above 23,000, a count of users, not of confirmed leaks.
Since then the pattern has repeated with slight variations:
| Date | Target | What the attacker did | Source |
|---|---|---|---|
| 14 Mar 2025 | tj-actions/changed-files | Repointed tags v1 to v45.0.7, secrets printed to logs | CISA |
| Aug 2025 | Nx (s1ngularity) | PR-title injection in a pull_request_target workflow left on an old branch, then malicious npm releases |
The Hacker News |
| 2 Sep 2025 | GhostAction, 817 repos | Malicious workflow committed with stolen maintainer accounts, 3,325 secrets exfiltrated | GitGuardian |
| 19 Mar 2026 | aquasecurity/trivy-action | 76 of 77 tags plus all setup-trivy tags repointed, CVE-2026-33634, CVSS 9.4 | NVD |
| 11 May 2026 | TanStack | Fork PR poisoned the pnpm cache that the release job later restored, malicious npm packages | SafeDep |
| 18 May 2026 | actions-cool/issues-helper | All tags moved to an impostor commit, about 15,000 dependent repos | Socket |
| 24 Jun 2026 | codfish/semantic-release-action | Force-pushed commit, version tags v1 to v5 repointed, Bun payload | StepSecurity |
The September 2026 epilogue is the one that stuck with me. GitHub disabled issues-helper and its sibling maintain-one-comment the day after the May compromise. On 16 September both repositories became reachable again with their tags still pointing at the May payload, according to Socket, and workflows that had been failing at "Set up job" for four months started running the malware. Anyone pinned to a SHA from before 18 May was untouched. GitHub disabled them again a week later.
These incidents also connect. The trivy compromise continued a campaign that began in late February; the issues-helper payload sent secrets to a domain tied to the Mini Shai-Hulud npm campaign, and TanStack ended in malicious npm releases, the same worm economy I covered in the npm worms post. A CI runner is where publishing tokens live, so it is where package-registry worms go to breed.
Why mutable tags are the real GitHub Actions vulnerability
A uses: owner/action@v4 line is a pointer to a pointer. The tag is a Git ref that anyone with push rights can move, and the runner resolves it fresh on every job. You reviewed the code once; you execute whatever the tag points at today. A commit SHA is content-addressed, so @3d3c42e5... means the same tree forever (subject to one caveat below).
The caveat is the impostor commit. GitHub serves commits from anywhere in a repository's fork network under the parent's name, so a SHA that only exists in an attacker's fork can still resolve as actions/checkout@<sha>. zizmor's impostor-commit audit checks for exactly this, and Dependabot or Renovate-generated pins, which come from real tags, avoid it in practice.
Is pull_request_target still dangerous?
Yes, though less than it was a year ago. pull_request_target runs in the context of the base repository, with its secrets and a write-capable token, and fires on pull requests from forks. The trigger is safe only as long as the workflow never executes code from the PR. People break that rule by checking out the PR head "just to run the linter", or by interpolating ${{ github.event.pull_request.title }} into a shell script, which is how Nx was hit.
GitHub has chipped at this from three directions:
- From 8 December 2025,
pull_request_targetalways runs the workflow file from the default branch, regardless of the PR's base branch. That kills the Nx move, where a vulnerable workflow fixed on main still lived on an old branch. actions/checkoutv7, released on 18 June 2026, refuses to check out fork PR head or merge code underpull_request_targetand PR-triggeredworkflow_run. The block was backported to floating major tags (v2 to v6) on 20 July. The opt-out input isallow-unsafe-pr-checkout, deliberately ugly so reviewers notice it. Note the irony: if you pinned checkout to a SHA, you did not get the backport, and Dependabot has to bring it to you.- Since 26 June 2026, untrusted triggers get a read-only token for the default-branch cache. This is the TanStack fix: the fork PR could write a poisoned pnpm store that the trusted release job later restored. On 10 September GitHub added a
cache-modekey (read,write,write-only,none) so you can state intent explicitly, with a warning annotation if you grant write on a low-trust trigger.
None of these cover a run: step that fetches PR code with git or gh, or issue_comment workflows. The rule I apply is simple: if a workflow needs to run PR code, it uses pull_request, which gets no secrets from forks. If it needs secrets to comment on a PR, it never touches PR code.
How to pin GitHub Actions to a commit SHA, and enforce it
Pinning by hand works for five actions and fails for fifty. Three things make it sustainable.
First, enforcement. Since 15 August 2025 the allowed-actions policy at enterprise, organisation or repository level can require full-SHA pins, failing any workflow that uses a tag, and can block specific actions or versions with a ! prefix. When the next compromise lands, the block list lets an org admin stop one action everywhere in a minute instead of grepping hundreds of repos.
Second, transitive dependencies. A composite action you pinned can itself call other/action@v1, and your pin does nothing about it. GitHub's 2026 security roadmap, published in March, named this as the gap, and on 14 October 2026 workflow lockfiles entered public preview: .github/workflows/actions.lock, maintained with the gh actions-lock CLI extension, records the commit for every direct and transitive dependency, and Actions runs the recorded commit even if a tag moves. A "Require lockfile" policy is in preview alongside it. It is a preview, so I am trying it on non-critical repos first.
Third, updates. A pin you never update is a pin to known bugs. Dependabot and Renovate both bump SHA pins and keep the version comment in sync. zizmor's dependabot-cooldown audit nudges you to add a cooldown, which matters: several of the hijacks above were contained within a day or two, so a few days' delay before adopting a new release would have filtered them out.
Here is the shape I use for a PR check. Versions are the current releases as of this week; resolve SHAs yourself with git ls-remote --tags https://github.com/actions/checkout v7.0.1 rather than copying mine.
# .github/workflows/pr-check.yml
name: pr-check
on:
pull_request: # not pull_request_target: fork PRs get no secrets
permissions: {} # deny everything at workflow level
cache-mode: read # PR runs restore caches but never write them
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read # grant per job, only what it needs
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # no token left in .git/config
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
- run: npm ci && npm test
- name: Echo PR title safely
env:
TITLE: ${{ github.event.pull_request.title }} # pass via env, never inline
run: printf 'PR: %s\n' "$TITLE"
Immutable releases and artifact attestations: what they actually prove
Pinning protects consumers. If you publish an action, you can protect your users even if they never pin. Immutable releases, generally available since 28 October 2025, lock a release's assets and its tag once published: the tag cannot be moved or deleted while the release exists, and the name cannot be reused even if the repository is deleted and recreated. Turn it on at the org level and the tj-actions technique stops working for new releases. Existing releases stay mutable unless republished, and floating major tags like v4 are not releases, so they remain movable. That is why trivy-action's 76 tags were all vulnerable.
Artifact attestations answer a different question: was this binary or image built by this workflow, from this commit? GitHub says attestations on their own give SLSA v1.0 Build Level 2, and Level 3 if the build runs in an isolated reusable workflow. They do not say the code is safe; a compromised workflow will happily attest to malware. They are only useful if someone runs gh attestation verify and enforces a policy, which is also the gap I found when looking at the CRA reporting rules in the SBOM and Sigstore post.
How to audit GitHub Actions workflows with zizmor
zizmor is a static analyser for workflows with 41 audits, covering dangerous triggers, template injection, unpinned uses, cache poisoning, credential persistence (artipacked), excessive permissions, impostor commits and known-vulnerable actions. I wrote a deliberately bad workflow (pull_request_target, checkout of the PR head, tj-actions/changed-files@v45, PR title inlined into run:) and ran the current release against it:
$ uvx zizmor@1.31.1 --offline .
error[dangerous-triggers]: use of fundamentally insecure workflow trigger
3 | pull_request_target:
| ^^^^^^^^^^^^^^^^^^^ pull_request_target is almost always used insecurely
error[template-injection]: code injection via template expansion
12 | - run: echo "PR title is ${{ github.event.pull_request.title }}"
| --- this run block ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
error[unpinned-uses]: unpinned action reference
11 | - uses: tj-actions/changed-files@v45
warning[artipacked]: credential persistence through GitHub Actions artifacts
warning[excessive-permissions]: overly broad permissions
9 findings (3 suppressed, 2 unsafe fixes): 0 informational, 0 low, 2 medium, 4 high
That is trimmed, but the findings are real. Offline mode skips the audits that need the GitHub API (impostor commits, known-vulnerable actions); in CI, run it online through the official zizmorcore/zizmor-action with SARIF upload so findings land in code scanning. It catches the shape of every incident in the table above except the one you cannot see from YAML: a maintainer's stolen token.
What does GitHub Actions cost in 2026?
Security and cost meet in one place: the runners. GitHub cut hosted-runner prices by up to 39 percent on 1 January 2026. The same announcement proposed a $0.002 per-minute platform charge on self-hosted runner minutes from 1 March 2026; after a loud reaction GitHub postponed it "to re-evaluate our approach", and I have not found a revised date. Current rates from the pricing reference:
| Runner | Per minute (USD) |
|---|---|
| Linux 2-core x64 | $0.006 |
| Linux 2-core arm64 | $0.005 |
| Windows 2-core | $0.010 |
| macOS 3 or 4-core | $0.062 |
| Linux 4-core (larger) | $0.012 |
| Linux 8-core (larger) | $0.022 |
| Linux 16-core (larger) | $0.042 |
Jobs round up to the minute, and included minutes do not apply to larger runners. The security point: GitHub-hosted runners are ephemeral VMs, wiped after each job, which is a property a self-hosted fleet has to work to match. A persistent self-hosted runner on a public repo is an open invitation; if the platform fee comes back, I would still pay it before I would run non-ephemeral runners. If you want faster CI without bigger bills, my caching guide covers the cheap wins first.
FAQ
Should I pin GitHub Actions to a SHA or a version tag?
Pin to the full 40-character commit SHA with the version in a trailing comment, and let Dependabot or Renovate update it. Tags can be moved by anyone with push access to the action, which is how tj-actions, trivy-action and issues-helper were weaponised. First-party actions/* actions are lower risk, not zero risk.
Is pull_request_target safe to use?
It is safe only if the workflow never runs code from the pull request, never checks it out, and never interpolates PR fields into shell commands. Since December 2025 it always runs the default branch's workflow, and checkout v7 refuses fork PR code by default. For running tests on PRs, use pull_request instead.
Do immutable releases protect me as a consumer?
Only for actions whose maintainers enabled them, and only when you reference an immutable release tag rather than a floating major tag like v4. A SHA pin protects you regardless of what the maintainer does. Immutable releases are the publisher's half of the fix.
What should I do if an action I use was compromised?
Check workflow logs for runs during the exposure window, treat every secret the job could read as leaked, and rotate them. Then pin to a SHA verified to predate the compromise, or remove the action. The NVD entry for trivy also suggests looking for unexpected repositories (it names tpcp-docs) created in your organisation as evidence of exfiltration.
This quarter I would do four things, in order: turn on the org SHA-pinning policy and fix what breaks, set permissions: {} as the default in every workflow, run zizmor in CI and burn down the high-severity findings, and enable immutable releases on anything I publish. Lockfiles go on one repo as a trial. None of this is new discipline; it is dependency management applied to the one place where the dependencies hold your publishing keys, and with agents now opening pull requests at volume (see the CI/CD and agents post) the workflows triggered by those PRs deserve the same scrutiny as the code.
Related: The Worms Learned to Use Your AI Agent: A Year of npm Supply-Chain Attacks, CI/CD in 2026: Agents Opened 1M PRs in 5 Months, Bots Write 1 in 5 Reviews, and Most Agent PRs Get No Human Look and CRA Reporting Went Live on 11 September: 24 Hours to ENISA, 3.08 Billion Rekor Entries, 17% of PyPI Attested, 92% SBOM False Positives.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
What Is DevSecOps in 2026? 87% Run Exploitable Code, 4% Pin Actions
DevSecOps explained for 2026: shift-left vs shift-everywhere, what SAST, DAST, SCA, secrets and IaC scanning each catch, SLSA 1.2's new Source Track, and what Datadog, GitGuardian, GitLab, Sonatype and Veracode data say about AI-generated code.
11 minOct 5, 2026CRA Reporting Went Live on 11 September: 24 Hours to ENISA, 3.08 Billion Rekor Entries, 17% of PyPI Attested, 92% SBOM False Positives
Supply-chain compliance in 2026: what the Cyber Resilience Act's Article 14 duty requires since 11 September 2026 (24-hour early warning, 72-hour notification, ENISA's Single Reporting Platform), what waits until 11 December 2027, how the open-source steward role works, where SBOMs stand (CISA's 2026 minimum elements, CycloneDX 1.7, a 92 percent false-positive rate in a 2,414-repo study), how far provenance has got (SLSA 1.2, 20 percent of PyPI uploads via trusted publishing, Rekor at 3.08 billion entries), the US retreat from mandates, and the pipeline I would run.
13 minOct 20, 2026AI Agent Failures in Production: 5 Incidents, 14 Failure Modes (2026)
What actually goes wrong when AI agents run in production: the Replit, Amazon Kiro, PocketOS and PromptPwnd incidents, the MAST taxonomy of 14 failure modes from 1,642 traces, cost loops, permission mistakes, and the guardrails that stopped them.
12 min