My own GitHub Action had a script injection
11 min read
#security #github-actions #supply-chain #lessons
This line sat in the action.yml of a tool I maintain from 16 February 2026 until 7 September 2026:
eval npx vercel-seo-audit@latest $args$args was built from the action's inputs, spliced straight into the script with ${{ inputs.url }} and friends. So anyone who wired a pull request title, a branch name or a workflow_dispatch field into the url input of my action was handing that string to a shell on their own runner. Twice, in fact. Once when GitHub expanded the template into the script, and once more when eval re-parsed the result.
The tool is vercel-seo-audit, a CLI that checks a Next.js site for the usual SEO mistakes. The action is a thin wrapper around it, listed on the Marketplace. I wrote both. Nobody else put the bug there.
What it looked like from the outside
Here is the run block as it shipped, trimmed to the parts that matter:
run: |
args="${{ inputs.url }}"
if [ -n "${{ inputs.user-agent }}" ]; then
args="$args --user-agent \"${{ inputs.user-agent }}\""
fi
if [ -n "${{ inputs.pages }}" ]; then
args="$args --pages ${{ inputs.pages }}"
fi
eval npx vercel-seo-audit@latest $argsSeven inputs reached the script this way. Three of them (pages, report, timeout) landed in $args with no quotes at all. url had quotes around it, which does nothing against $(...), and user-agent had escaped quotes, which a " in the value closes.
To measure it rather than argue about it, I wrote a harness that extracts the run block, substitutes the inputs the way the Actions expression evaluator does, puts a fake npx on PATH that records its argv, and runs the script with the exact invocation GitHub uses for shell: bash in a composite action (bash --noprofile --norc -eo pipefail). Then 23 payloads, each trying to create a canary file.
| Input | Payload shape | Before | After |
|---|---|---|---|
url | https://x.test; touch pwned | shell ran it | one literal argv entry |
url | https://x.test/$(touch pwned) | shell ran it | one literal argv entry |
user-agent | a" ; touch pwned ; echo " | shell ran it | one literal argv entry |
timeout | 1; touch pwned | shell ran it | one literal argv entry |
report | json; touch pwned | shell ran it | one literal argv entry |
Five of the five command-injection payloads fired against the old file, six rows in the matrix since the $(...) case has a backtick twin. Zero against the new one. The other 17 payloads were word splitting, globbing, tilde expansion and values the CLI simply absorbed, which the old block also got wrong but which stop short of running a command, plus a few that only mattered after the first fix, which I will get to.
How I found it
Not from a report. I spent 5 September going through my own public repositories the way a stranger with an hour would, looking for the thing they would find first. ${{ inputs.* }} inside a run: block is the first thing anyone finds. GitHub Security Lab wrote it up as untrusted input in August 2021, and GitHub's own script injection page teaches the same fix. I had read both. I still wrote the line, because the day I wrote it I was thinking about the CLI and treating the wrapper as plumbing.
The obvious objection is that zizmor would have flagged the template expansion in a second, and that is true. It would not have flagged the eval, the flag-shaped url, the version pin or the tag, and neither would anything else I know of. The linters get their own section at the end.
The blast radius is small. As of 8 September 2026 a GitHub code search for uses: JosephDoUrden/vercel-seo-audit returns nothing. The CLI has two small projects depending on it; the action, as far as I can tell, has no users. So this is a post about a bug that hurt nobody. It is still worth writing up, because the fix had a second act I did not see coming, and because the same line is sitting in a lot of other small actions right now.
The fix, and what the harness caught in the fix
The shape of the fix is the one from GitHub's guide. Inputs go into env:, the script reads them as variables, and the command is built as a bash array so every value stays one argument whatever it contains:
env:
INPUT_URL: ${{ inputs.url }}
INPUT_USER_AGENT: ${{ inputs.user-agent }}
run: |
args=()
if [ -n "$INPUT_USER_AGENT" ]; then
args+=(--user-agent "$INPUT_USER_AGENT")
fi
target=()
if [ -n "$INPUT_URL" ]; then
target=(-- "$INPUT_URL")
fi
npx vercel-seo-audit@2.5.0 "${args[@]}" "${target[@]}"No eval. No ${{ }} inside the script. That closed the five payloads. Then the harness, pointed at the first version of the fix, found two things I would not have thought about.
The first is the --. With the url as a bare positional, a value starting with a dash reaches the CLI's argument parser as a flag. Most flags fail harmlessly. But the CLI also reads a url from .seoauditrc.json if the repository has one, which is a documented feature, and then an injected --diff=../../../../etc/passwd runs the audit against the configured site and tries to parse the named file as a previous report. It fails, and the error message quotes the start of the file it could not parse. A url of --diff=.env prints the start of .env. Putting -- before the positional ends option parsing, so a dash-url is a url. I checked that commander honours it before relying on it.
The second is smaller. The old script wrote report-path=report.json to the step outputs whenever the report input was set, whether or not the CLI had written a file. A downstream step that trusts the output then reads a file that is not there, or worse, a stale one from an earlier job. Now the path is only reported if the file exists.
The tests live in the repository as src/action.test.ts. Sixteen cases. Ten of them fail against the old action.yml, which is the number I wanted to see before I trusted the other six. The whole change is pull request #117.
The pin that broke the action
@latest had to go as well. A wrapper that resolves its own package at run time is not reproducible for the person using it, and it means the day my npm account is compromised, every workflow that uses the action runs whatever got published. So the npx line now names an exact version, and my release tooling (release-please) bumps that line on every release, the same way it already bumps the version string in the CLI source.
That is the correct design and it has a consequence I had not written down: the action now only works if the version it names exists on npm. The action depends on my publish step succeeding. Fine, as long as the publish step works.
The fix merged on 7 September at 10:46 UTC. release-please opened the 2.5.1 release, I merged it at 10:48, the GitHub release and tag went up, the action's npx line now said @2.5.1, and the publish job failed with a 404 from npm. Not "version exists", not "invalid package", a 404 on the PUT, which is what npm returns when it will not accept the token. The last time this workflow had published anything was 2.5.0, on 7 March, from the same workflow. Between March and September there was no release, and somewhere in those six months the token stopped being accepted. Nothing exercised it, so nothing failed, until the first commit that actually depended on it.
For about ten minutes the action pointed at a version of my package that did not exist. Anyone running it would have watched npx fail. With zero users that cost nothing. With users it would have been a broken action shipped in the same commit that fixed a security bug, which is a bad look for exactly the wrong reason.
The repair was to stop using a token at all. npm's trusted publishing lets a GitHub Actions workflow authenticate with an OIDC token from the job itself, tied to the repository and the workflow file, with provenance attached automatically. That is pull request #119. I deleted the secret, ran the workflow by hand, and 2.5.1 landed on npm at 10:57.
The lesson I took from that is not "use OIDC", though you should. It is that a version pin inside an action is a promise your release pipeline has to keep, and a pipeline that last ran six months ago is not one you know can keep it. Cut a release, or at least run the publish job, before you make anything depend on it.
The tag nobody moved
One more thing the audit turned up, related and embarrassing. The README told people to use JosephDoUrden/vercel-seo-audit@v1. The v1 tag was created on 16 February, the day the action was added, and pointed at version 0.5.0. It was never moved. So anyone who copied the README example was running the February action.yml, with the same eval line, regardless of what I had released since. The README now pins the exact release tag and release-please updates that line too. The v1 tag has been moved by hand, with a signed tag, and the honest answer is that a floating major tag on a one-person project needs a script that moves it on every release or it should not exist.
What the linters would have said
Afterwards I ran zizmor and actionlint over the old file, and read CodeQL's query help, to see which part each would have caught.
zizmor 1.30.0 reports 14 template-injection findings on the old action.yml, one per ${{ inputs.* }} inside the run block, plus one unpinned-uses for actions/setup-node@v6. On the fixed file it reports nothing. It does not mention the eval, and it did not flag npx vercel-seo-audit@latest.
actionlint 1.7.12 does not read action.yml at all. Point it at the file and it answers with eight syntax errors, the first being "jobs" section is missing in workflow. Since shellcheck runs through actionlint, shellcheck never saw the script either.
CodeQL's Actions queries worked on workflow files only until May 2026, when actions/unpinned-tag started reading action.yml. As far as I can tell from the query help, the injection query still does not.
| zizmor | actionlint | CodeQL | |
|---|---|---|---|
${{ inputs.* }} in run: | yes, 14 findings | no | not on action.yml, as far as I found |
eval on user-controlled text | no | no | no |
npx pkg@latest | no | no | no |
a -- before the positional | no | no | no |
| a pin that depends on a publish step that never ran | no | no | no |
a v1 tag pointing at February | no | no | no |
So one tool catches the first row, which is the row everyone already knows about. The eval sat in a file two of the three never open. The rest are not lint problems. They needed someone to try to use the thing, and then to try to ship it.
Check your own action
If you maintain a composite action, this takes five minutes:
- Search
action.ymlfor${{inside anyrun:block. Every hit is an injection unless the value is a constant you control. - Search for
eval. Delete it. If you think you need it, you need an array. - Put a
--before any positional that comes from an input, and check your CLI's parser honours it. - If the action calls
npx something@latest, pin it, and then confirm the pipeline that publishessomethinghas actually published at least once by looking at the registry, not at your GitHub releases page. - If you have a floating
v1tag, find out what commit it points at today.