{"slug": "two-ways-to-reuse-a-privileged-ci-token-and-my-rule-only-caught-one", "title": "Two ways to reuse a privileged CI token (and my rule only caught one)", "summary": "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.", "body_md": "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.\n\nThis 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`\n\ntrigger 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.\n\nHere's the bug, the fix, and why the \"missing permissions\" half of it turned out to be the harder rule to design.\n\nMost CI security advice about forks boils down to: don't trust code from a PR with your repo's secrets. `pull_request_target`\n\ngets flagged for this constantly, and rightly so.\n\n`workflow_run`\n\nis 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.\n\nThere are two separate ways to actually abuse that setup:\n\n| Attack shape | What it does | Where the untrusted input lives |\n|---|---|---|\n| Checkout reuse | Checks out `github.event.workflow_run.head_sha` or `.head_branch` directly |\nThe commit itself |\n| Artifact reuse | Downloads an artifact the triggering run produced, then runs or trusts it | A build output, not source code |\n\nSame root cause, same privileged token, two different things get executed with it.\n\n```\n fork PR\n   |\n   v\n [ build.yml ]  <-- runs on pull_request, produces an artifact\n   |\n   | workflow_run fires (base branch, full token)\n   v\n [ post-build.yml ]\n   |\n   +-- downloads the artifact and runs it        <- shape 1\n   |\n   +-- checks out workflow_run.head_sha directly  <- shape 2\n```\n\nBoth routes end at the same place: attacker-influenced content executing with a token that has write access to your repo.\n\nMy 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:`\n\nblock restricting the token.\n\nThat 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:\n\n`workflow_run.head_sha`\n\ndirectly never got flagged at all, because the rule was only looking at artifact downloads.`permissions:`\n\nblock, 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.\n\nHere's the actual fixture I use to test it:\n\n```\nname: post-build\non:\n  workflow_run:\n    workflows: [Build]\n    types: [completed]\njobs:\n  post:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/download-artifact@v4\n        with:\n          name: build-output\n          run-id: ${{ github.event.workflow_run.id }}\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n      - name: Run whatever the triggering build produced\n        run: ./build-output/script.sh\n```\n\nThat last line is the whole problem. Whatever the fork's build step produced, this now executes it, no questions asked.\n\nv0.15.0 does two things differently.\n\nMCP019 now catches both attack shapes on its own terms, independent of whether a `permissions:`\n\nblock exists anywhere. Untrusted ref or untrusted artifact, either one, flowing out of a `workflow_run`\n\ntrigger is the finding. Token scope is a separate concern.\n\nThat separate concern became its own rule, MCP020: a workflow with no explicit `permissions:`\n\nkey anywhere, at all, regardless of trigger.\n\nThat second one turned out to be the harder design problem, and it's worth explaining why.\n\nMost 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.\n\nA workflow with no `permissions:`\n\nblock 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:`\n\nkey. 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.\n\nSo \"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?\n\n| Signal checked | Examples |\n|---|---|\n| Known write-only actions |\n`softprops/action-gh-release` , `actions/create-release` , `peter-evans/create-pull-request`\n|\n| Raw git write | `git push` |\nWrite-shaped `gh` CLI calls |\n`gh release create` , `gh pr merge` , `gh issue close`\n|\n| Direct API write verbs |\n`curl` or `api` calls to `api.github.com` with POST/PUT/PATCH/DELETE |\n\nOnly when one of those shows up *and* there's no `permissions:`\n\nblock 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.\n\n```\nname: release\non:\n  push:\n    tags: [\"v*\"]\njobs:\n  release:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: softprops/action-gh-release@v2\n        with:\n          files: dist/*\n```\n\nNo `permissions:`\n\nblock, and a write action sitting right there. That's the shape MCP020 is built for.\n\nIt's still not complete. `actions/github-script`\n\ncalling 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.\n\nNone 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.\n\nThe 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.\n\nIf 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.\n\nmcpscan is open source, MIT licensed, zero runtime dependencies: [github.com/glatinone/mcpscan](https://github.com/glatinone/mcpscan)", "url": "https://wpnews.pro/news/two-ways-to-reuse-a-privileged-ci-token-and-my-rule-only-caught-one", "canonical_source": "https://dev.to/kielltampubolon/two-ways-to-reuse-a-privileged-ci-token-and-my-rule-only-caught-one-l1p", "published_at": "2026-07-19 08:43:28+00:00", "updated_at": "2026-07-19 09:00:55.355732+00:00", "lang": "en", "topics": ["developer-tools", "ai-safety"], "entities": ["mcpscan", "GitHub Actions"], "alternates": {"html": "https://wpnews.pro/news/two-ways-to-reuse-a-privileged-ci-token-and-my-rule-only-caught-one", "markdown": "https://wpnews.pro/news/two-ways-to-reuse-a-privileged-ci-token-and-my-rule-only-caught-one.md", "text": "https://wpnews.pro/news/two-ways-to-reuse-a-privileged-ci-token-and-my-rule-only-caught-one.txt", "jsonld": "https://wpnews.pro/news/two-ways-to-reuse-a-privileged-ci-token-and-my-rule-only-caught-one.jsonld"}}