All posts
Unproven Execution

Cordyceps: Unproven Execution at the Pull-Request Edge

Novee Security mapped a fork-PR anti-pattern across 300+ open-source repos; Microsoft, Google, Apache, Cloudflare, and PSF all confirmed impact

Securityv0 Intelligence Team OWASP: ASI05 sv0 finding: unproven_execution
cordyceps github-actions pull-request-target unproven-execution ci-cd-supply-chain asi05

The Incident

On 2026-06-23, penetration-testing firm Novee Security published Cordyceps — a class of GitHub Actions weaknesses exploitable by anyone with a free GitHub account. Researcher Elad Meged scanned roughly 30,000 high-impact repositories: 654 were flagged in a single sweep, and more than 300 were confirmed fully exploitable. The named victims spanned the stack. In Microsoft’s Azure Sentinel, a pull-request comment ran anonymous code on Microsoft CI and exfiltrated a non-expiring GitHub App key whose write scope reached the security content shipped to customer Sentinel workspaces. Google’s AI Agent Development Kit samples repository was confirmed affected. Apache Doris carried two independent zero-click paths — one exfiltrated hardcoded CI credentials via a PR comment, the other stole a token with write across actions, contents, and packages. A specially crafted branch name in a Cloudflare Workers SDK fork triggered arbitrary command execution on Cloudflare’s CI. In Python’s Black formatter, any pull request could lift a bot token that enabled PR-approval forgery and downstream Docker-image poisoning. Microsoft, Google, Cloudflare, Apache, and the Python Software Foundation each confirmed impact and patched their workflows after coordinated disclosure. No CVEs were assigned — Novee classified Cordyceps as a structural workflow-composition pattern, not a single product flaw.

Five days before the disclosure, on 2026-06-18, GitHub shipped actions/checkout v7 — a platform-level default change that refuses to check out the head of an unreviewed fork pull request inside pull_request_target and workflow_run (when triggered by pull_request*) workflows. Backport of the enforcement to all supported major versions is scheduled for 2026-07-16. An explicit opt-out exists — allow-unsafe-pr-checkout: true — documented as a deliberate security decision rather than a convenience knob.

MITRE ATT&CK coverage: T1195.002 (Compromise Software Supply Chain), T1078.004 (Valid Accounts: Cloud Accounts), T1552.001 (Credentials In Files).

The Authority Path That Failed

The identity that carried execution authority at the moment of failure is the base-repository’s GitHub Actions runner, executing in the privileged context conferred by pull_request_target — or, in some Cordyceps variants, workflow_run chained off a pull_request event. The scope it held is the base repo’s GITHUB_TOKEN, every secret the workflow referenced, every environment binding, and — in the named victims — federated cloud credentials: a non-expiring GitHub App private key with write access to customer-deployed Sentinel content; broad write scopes on actions, contents, and packages for Apache Doris; publish and bot tokens for Black. The scope it exercised was execution of attacker-controlled code from the head of an untrusted fork PR — via actions/checkout of github.event.pull_request.head.sha, a branch-name expansion interpolated into a shell step, or a PR-comment body fed into a runner command.

The trust anchor that failed first is the maintainer’s reading of pull_request_target. The trigger was introduced so workflows could label, comment, and gate fork PRs from a privileged context; the named workflows compounded that privilege with an explicit checkout of fork HEAD and then ran build, test, or lint steps that load attacker-controlled files. The held-vs-exercised gap is statically detectable in the workflow YAML before any pull request is filed — the triple of pull_request_target (or workflow_run off pull_request*) plus fork-HEAD checkout plus a step that reads or executes repo files is the entire prerequisite. Cordyceps’ contribution is the scale at which that triple appears: 300+ confirmed-exploitable instances, including AI-agent infrastructure published by Google and security content shipped by Microsoft to its customers.

SecurityV0 Perspective

This is unproven_execution. Each runner identity was wired up for a narrow job — labeling fork PRs, posting status, gating reviews — and the workflow definition handed it broad code-execution surface the deploying maintainer never explicitly authorized. The NHI fallout — the non-expiring GitHub App key in the Sentinel case, the publish tokens in Black’s, the write-scoped token in Apache Doris’s — is the consequence, not the root failure: the runner exercised authority outside its sanctioned scope, and a stolen credential is what happened next.

The evidence pack SecurityV0 would produce for an organization with CI/CD: an inventory of every workflow triggered by pull_request_target (or by workflow_run chained off a pull_request* event); the explicit secrets, environment bindings, and federated credentials each runner can reach; whether each workflow performs an actions/checkout of fork HEAD or any equivalent reference; and a mapped diff between the operator’s stated authorization (the comment block “label fork PRs from external contributors”) and the runner’s actual capability (the code-execution surface attached to that label step). The pre-incident question the pack answers is which of our runner identities can execute attacker code today, and against which secret scope. The post-incident question is of the credentials each runner could reach, which were minted before our last actions/checkout upgrade, and how many of them are still valid.

What To Do

  • Audit every workflow on pull_request_target and workflow_run for an explicit fork-HEAD checkout. The triple of that trigger, an actions/checkout of github.event.pull_request.head.sha (or head.ref), and a step that reads or executes repository files is the Cordyceps prerequisite. Switch to pull_request for any workflow that actually needs to run fork code, or split the privileged operation into a separate run.
  • Upgrade actions/checkout to v7 across your org and review every allow-unsafe-pr-checkout: true before 2026-07-16. The platform’s new default refuses unsafe fork-HEAD checkouts; the opt-out flag is the named escape hatch. Treat any instance of it as a security exception requiring documented reasoning, not a default to silently inherit when the backport lands.
  • Move privileged operations out of fork-HEAD execution paths. Labeling, commenting, and merge gating do not need to checkout the fork. Run them in a workflow_run workflow that only consumes the prior run’s outputs, never the prior run’s code.
  • Inventory the federated credentials reachable from each runner identity and shrink them to the smallest viable scope. Non-expiring GitHub App private keys with cross-customer write, Workload Identity Federation to broad cloud roles, and publish/bot tokens are the credentials Cordyceps exfiltrated in named cases. Bind each to a single workflow path; replace long-lived keys with short-lived OIDC where the platform supports it.
  • Wire static detection of the anti-pattern into CI lint. A workflow combining pull_request_target (or workflow_run off pull_request*) with fork-HEAD checkout and a code-loading step is detectable in YAML alone. Make that combination a failing pre-commit check rather than a code-review judgment call — it does not need a PR to be filed to find.

Sources