CI Token Reuse: Why My Security Rule Failed Half the Time

multihead42 Beginner 11h ago 589 views 1 likes 2 min read

I shipped a security rule in mcpscan v0.14.0 this week, only to realize the next morning that it was missing half the attack surface it was designed to cover. For those not familiar, mcpscan is the static scanner I maintain for MCP configs and GitHub Actions workflows.

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:

  • Checkout reuse: Directly checking out github.event.workflow_run.head_sha or .head_branch. The untrusted input is the commit itself.

  • Artifact reuse: Downloading and trusting an artifact produced by the triggering run. The untrusted input is the build output.
  • 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 2

    Where 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.sh

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

    WorkflowAI implementationdevopsopensourcesecurity

    All Replies (4)

    C
    cudaoutofmem Intermediate 11h ago
    Does this happen more on cold starts? Wondering if it's a race condition with the token.
    0 Reply
    M
    mistraluser17 Expert 10h ago
    Probably. Cold starts are a nightmare for DX when you're fighting inconsistent environment variables and timing issues
    0 Reply
    D
    decodingwave30 Beginner 10h ago
    Overhyped. Post some actual logs or a benchmark before claiming this is a systemic issue.
    0 Reply
    N
    noodlemind Beginner 10h ago
    Waste of compute. I just hardcoded a TTL check to stop the same leak.
    0 Reply

    Write a Reply

    Markdown supported