GitHub Actions Security in 2026: 76 Hijacked Tags and the Fixes That Work

Oct 20, 2026 · 13 min · Ajay Kumar

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).

GitHub Actions: attacks (above) and GitHub's defences (below), 2025 to 2026 Mar 2025 tj-actions/changed-files Mar 2026 trivy-action, 76 tags Aug 2025 Nx s1ngularity (pwn request) May 2026 issues-helper (back Sep) Sep 2025 GhostAction May 2026 TanStack cache poisoning Jun 2026 semantic-release-action Mar 2025Sep 2025Mar 2026Sep 2026 Aug 2025 SHA pinning policy Dec 2025 pull_request_target uses default branch Oct 2025 immutable releases Sep 2026 cache-mode Jun 2026 checkout v7 blocks fork PR checkout Oct 2026 lockfiles Jun 2026 read-only cache for untrusted triggers Dots mark dates on a linear scale. Terracotta: compromises. Sage: GitHub platform or actions/checkout changes.
Incident dates from CISA, NVD, GitGuardian, SafeDep, Socket and StepSecurity; defence dates from the GitHub changelog entries linked in this post. Positions are roughly 30 units per month.

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_target always 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/checkout v7, released on 18 June 2026, refuses to check out fork PR head or merge code under pull_request_target and PR-triggered workflow_run. The block was backported to floating major tags (v2 to v6) on 20 July. The opt-out input is allow-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-mode key (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