DEV Community

Kiell Tampubolon
Kiell Tampubolon

Posted on

Two ways to reuse a privileged CI token (and my rule only caught one)

I shipped a security rule this week, looked at it again the next morning, and realized it only caught half the bug it was supposed to catch.

This is mcpscan, a static scanner I maintain that checks MCP configs and, since a few releases ago, GitHub Actions workflows too. The new rule was supposed to close a well documented CI vulnerability class: a workflow_run trigger reusing a privileged token against something an attacker controls. I wrote it, tested it, shipped it as v0.14.0. Then I read GitHub's own writeup on the pattern again and noticed my rule only checked one of the two ways this actually happens.

Here's the bug, the fix, and why the "missing permissions" half of it turned out to be the harder rule to design.

Why workflow_run is dangerous in the first place

Most CI security advice about forks boils down to: don't trust code from a PR with your repo's secrets. pull_request_target gets flagged for this constantly, and rightly so.

workflow_run is the quieter version of the same problem. It fires after another workflow finishes, and it always runs from your default branch with your repo's normal token, regardless of what triggered the workflow it's watching. If that first workflow can be triggered by a fork's PR (a CI/build workflow usually can), you've got an untrusted event handed to a job that runs with full trust.

There are two separate ways to actually abuse that setup:

Attack shape What it does Where the untrusted input lives
Checkout reuse Checks out github.event.workflow_run.head_sha or .head_branch directly The commit itself
Artifact reuse Downloads an artifact the triggering run produced, then runs or trusts it A build output, not source code

Same root cause, same privileged token, two different things get executed with it.

 fork PR
   |
   v
 [ build.yml ]  <-- runs on pull_request, produces an artifact
   |
   | workflow_run fires (base branch, full token)
   v
 [ post-build.yml ]
   |
   +-- downloads the artifact and runs it        <- shape 1
   |
   +-- checks out workflow_run.head_sha directly  <- shape 2
Enter fullscreen mode Exit fullscreen mode

Both routes end at the same place: attacker-influenced content executing with a token that has write access to your repo.

What I actually shipped first

My first pass at this, MCP019 in v0.14.0, only covered the artifact download shape, and I made it fire conditionally: only if the workflow also had no permissions: block restricting the token.

That reasoning felt fine in isolation. No restriction plus artifact reuse looked like the higher signal case. What it actually did was create two blind spots at once:

  • A workflow that checks out workflow_run.head_sha directly never got flagged at all, because the rule was only looking at artifact downloads.
  • A workflow that reused an artifact but did have some permissions: block, even a loosely scoped one, also passed clean, because the rule treated the presence of any restriction as "handled."

Untrusted code running inside a privileged job isn't made safe by a narrower token scope. The token still has to do something for the exploit to matter, sure, but tying the two conditions together meant the rule only fired in the narrowest possible overlap of both problems.

Here's the actual fixture I use to test it:

name: post-build
on:
  workflow_run:
    workflows: [Build]
    types: [completed]
jobs:
  post:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - name: Run whatever the triggering build produced
        run: ./build-output/script.sh
Enter fullscreen mode Exit fullscreen mode

That last line is the whole problem. Whatever the fork's build step produced, this now executes it, no questions asked.

The fix: split it into two rules that don't depend on each other

v0.15.0 does two things differently.

MCP019 now catches both attack shapes on its own terms, independent of whether a permissions: block exists anywhere. Untrusted ref or untrusted artifact, either one, flowing out of a workflow_run trigger is the finding. Token scope is a separate concern.

That separate concern became its own rule, MCP020: a workflow with no explicit permissions: key anywhere, at all, regardless of trigger.

That second one turned out to be the harder design problem, and it's worth explaining why.

The hardest rule to write is the one that checks for absence

Most static analysis rules look for something bad: a call, a pattern, a string. MCP020 looks for something missing, and that inverts the whole design process.

A workflow with no permissions: block isn't a bug by default. Plenty of workflows genuinely need zero elevated access and should just leave the block out entirely. This project's own CI workflow is one of them: it runs tests, nothing else, and correctly has no permissions: key. If MCP020 fired on absence alone, that workflow would be a false positive on day one, and so would a huge share of real world CI configs.

So "missing" can't be the finding by itself. It has to be missing and it matters, which means the rule needs a second, independent signal: does this workflow actually do anything that writes to GitHub?

Signal checked Examples
Known write-only actions softprops/action-gh-release, actions/create-release, peter-evans/create-pull-request
Raw git write git push
Write-shaped gh CLI calls gh release create, gh pr merge, gh issue close
Direct API write verbs curl or api calls to api.github.com with POST/PUT/PATCH/DELETE

Only when one of those shows up and there's no permissions: block anywhere does it fire. A plain lint-and-test job stays quiet no matter what. A release workflow with no restriction at all does not.

name: release
on:
  push:
    tags: ["v*"]
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: softprops/action-gh-release@v2
        with:
          files: dist/*
Enter fullscreen mode Exit fullscreen mode

No permissions: block, and a write action sitting right there. That's the shape MCP020 is built for.

It's still not complete. actions/github-script calling a write API from inside its own JS callback won't get caught, since that needs parsing the script body, not scanning YAML lines. Noted as a known gap rather than pretending the heuristic covers more than it does.

The actual lesson

None of this was a hard vulnerability to understand. GitHub's own documentation describes both attack shapes clearly, and the fix for each is well known.

The mistake was in rule design, not threat modeling: tying two independent root causes to one firing condition, because in the specific example I tested first, they happened to overlap. Once I looked at the general case instead of the one fixture, the overlap fell apart in both directions at once.

If you're writing a detection rule and it needs two conditions to fire, it's worth asking whether those two conditions are actually testing the same thing, or whether you've quietly merged two separate checks and given yourself a blind spot on each one individually.

mcpscan is open source, MIT licensed, zero runtime dependencies: github.com/glatinone/mcpscan

Top comments (0)