cd /news/developer-tools/two-ways-to-reuse-a-privileged-ci-to… · home topics developer-tools article
[ARTICLE · art-65153] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer's static scanner, mcpscan, initially only caught one of two ways a privileged CI token can be reused in a GitHub Actions workflow_run trigger, missing the checkout reuse attack. The developer fixed the rule in v0.15.0 to detect both untrusted refs and untrusted artifacts independently of token permissions.

read6 min views1 publishedJul 19, 2026

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.

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

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

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:

workflow_run.head_sha

directly never got flagged at all, because the rule was only looking at artifact downloads.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

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

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.

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/*

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.

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

── more in #developer-tools 4 stories · sorted by recency
── more on @mcpscan 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/two-ways-to-reuse-a-…] indexed:0 read:6min 2026-07-19 ·