CI Token Reuse: Why My Security Rule Failed Half the Time
The goal was to flag a specific CI vulnerability: the workflow_run trigger reusing a privileged token on content an attacker controls. After revisiting GitHub's own documentation, I realized my implementation was too narrow.
The Danger of workflow_run
The core issue is that workflow_run fires after another workflow completes. It always runs from the default branch using the repo's normal token, regardless of what triggered the initial workflow. If that first workflow can be triggered by a fork's PR, you are essentially handing an untrusted event to a job with full trust.
There are two primary vectors for this exploit:
github.event.workflow_run.head_sha or .head_branch. The untrusted input is the commit itself.Both paths lead to the same result: attacker-influenced code executing with a token that likely has write access to the repository.
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 2Where I messed up the logic
In v0.14.0, I implemented rule MCP019 to only catch artifact downloads, and I made it conditional: it only fired if the workflow lacked a permissions: block.
I thought tying "no restrictions" to "artifact reuse" would reduce noise. Instead, it created two massive blind spots. First, direct SHA checkouts were ignored entirely. Second, any workflow with any permissions: block—even a loose one—was marked as "safe," even if it was still reusing untrusted artifacts. A narrower token scope doesn't magically make untrusted code safe.
Here is the exact fixture I used for testing:
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.shThe run: ./build-output/script.sh line is the critical failure; it executes whatever the fork's build step produced without validation.
The v0.15.0 Fix
To resolve this, I've split the logic into two independent rules in v0.15.0. MCP019 now catches both attack shapes regardless of whether a permissions: block exists, ensuring that the presence of a partial restriction doesn't mask a fundamental architectural flaw in the AI workflow or CI pipeline.
