Skip to content
back to blog

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

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

InputPayload shapeBeforeAfter
urlhttps://x.test; touch pwnedshell ran itone literal argv entry
urlhttps://x.test/$(touch pwned)shell ran itone literal argv entry
user-agenta" ; touch pwned ; echo "shell ran itone literal argv entry
timeout1; touch pwnedshell ran itone literal argv entry
reportjson; touch pwnedshell ran itone 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.

zizmoractionlintCodeQL
${{ inputs.* }} in run:yes, 14 findingsnonot on action.yml, as far as I found
eval on user-controlled textnonono
npx pkg@latestnonono
a -- before the positionalnonono
a pin that depends on a publish step that never rannonono
a v1 tag pointing at Februarynonono

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:

  1. Search action.yml for ${{ inside any run: block. Every hit is an injection unless the value is a constant you control.
  2. Search for eval. Delete it. If you think you need it, you need an array.
  3. Put a -- before any positional that comes from an input, and check your CLI's parser honours it.
  4. If the action calls npx something@latest, pin it, and then confirm the pipeline that publishes something has actually published at least once by looking at the registry, not at your GitHub releases page.
  5. If you have a floating v1 tag, find out what commit it points at today.