-
-
Notifications
You must be signed in to change notification settings - Fork 134
Description
A common CI pattern involves checking out a PR branch and then running package manager commands that resolve binaries from the checked-out files. This enables a supply chain hijack: the attacker modifies package.json (or similar) in their PR to redefine tool binaries, and the workflow executes the attacker's code with full workflow privileges.
Real-world example
A researcher found this in a production Claude Code PR review workflow:
- Workflow triggered on
issue_comment, checking out the PR branch - Workflow ran
npx tsx review-script.ts - NPM's resolution checks the local
package.jsonbinfield before looking at global packages - The attacker's PR added to
package.json:"bin": {"tsx": "malicious.sh"} npx tsxexecutedmalicious.shinstead of the realtsxpackage- The script exfiltrated
GITHUB_TOKENand every secret available to the workflow - The PAT had
contents:write— the attacker could push new workflows to the default branch, read all other secrets (Notion, Linear, NPM publishing keys), and establish persistence
This required no prompt injection — the hijack happens before any AI agent code runs. The attacker just opens a PR and leaves a comment.
The general pattern
This isn't limited to npx or AI agent workflows. Any command that resolves executables or scripts from the checked-out tree is vulnerable:
| Command pattern | Hijack mechanism |
|---|---|
npx <pkg> (no @version) |
package.json bin field shadows the real package |
bunx <pkg> (no @version) |
Same resolution as npx |
npm run <script> / yarn <script> / pnpm run <script> |
package.json scripts field |
pip install . / pip install -e . |
Executes setup.py or build backend from checkout |
make / make <target> |
Reads Makefile from checkout |
cargo build / cargo test |
Executes build.rs from checkout |
gem build / bundle exec |
Reads Gemfile / gemspec from checkout |
Proposed detection
The audit would implement audit_workflow and for each job, run a two-phase analysis:
Phase 1: Identify untrusted checkouts. An actions/checkout step where the ref points to PR code:
# Explicit PR ref patterns
ref: ${{ github.event.pull_request.head.ref }}
ref: ${{ github.event.pull_request.head.sha }}
ref: ${{ github.head_ref }}
ref: refs/pull/${{ github.event.number }}/mergeAlso: default actions/checkout (no explicit ref) on pull_request_target checks out the base branch which is safe, but any ref override on pull_request_target likely points to attacker code. On pull_request, the default checkout is the merge commit (which includes attacker code).
Phase 2: Scan subsequent run: steps for hijackable commands. After an untrusted checkout is identified, scan all later steps in the same job for unversioned package manager invocations. The key distinction is versioned vs. unversioned:
npx tsx@4.19.2 script.ts— safe, version-pinned, won't resolve from localpackage.jsonnpx tsx script.ts— vulnerable, resolves from localpackage.jsonbin field
Similarly for bunx, pipx, etc.
Severity
| Scenario | Severity | Confidence | Persona |
|---|---|---|---|
issue_comment/pull_request_target + PR checkout + unversioned npx/bunx |
High | High | Regular |
pull_request + checkout + unversioned npx/bunx |
Medium | High | Regular |
Untrusted checkout + npm run / pip install . |
Medium | Medium | Regular |
Untrusted checkout + make / cargo build / cargo test |
Low | Medium | Pedantic |
The lower tiers are Pedantic because checking out and building PR code is the entire purpose of most CI workflows. make and cargo build on a PR checkout is extremely common and intentional.
Remediation
- Pin package versions:
npx tsx@4.19.2instead ofnpx tsx - Use
npm ci --ignore-scriptswhen installing dependencies (see New audit: detect usage ofnpm install|ci ...without--ignore-scripts#1405) - Avoid checking out PR code in privileged contexts (
issue_comment,pull_request_target) - Use Docker containers or pre-built tool images instead of resolving tools from the checkout
- Set minimal
GITHUB_TOKENpermissions (permissions: read-allor explicit per-scope)
Open questions
- Naming: open to suggestions. Some options:
untrusted-checkout-exec,checkout-exec,pr-code-exec. - Scope of "hijackable commands": Should
make/cargo buildbe in scope? They're standard CI operations. Including them would need Pedantic persona at minimum to avoid being noisy. Maybe start with the NPM/pip patterns (highest signal-to-noise) and expand later. - Determining "untrusted" checkouts: How precisely can we detect this? On
pull_request, the default checkout includes attacker code in the merge commit. Should allpull_request+ checkout + exec be flagged? That might be very noisy. Starting withpull_request_targetandissue_comment(where checkout of PR code is clearly a mistake) and treatingpull_requestas lower severity seems reasonable. - Interaction with existing audits: This is adjacent to New audit: detect usage of
npm install|ci ...without--ignore-scripts#1405 (npm installwithout--ignore-scripts). The two could eventually share infrastructure for detecting package manager commands inrun:blocks.
Related
- New audit: detect usage of
npm install|ci ...without--ignore-scripts#1405 —npm install/npm ciwithout--ignore-scripts(adjacent concern, same risk category) - Audit idea: TOCTOU PR/branch checks #935 — TOCTOU; untrusted checkouts are susceptible to race conditions
- New audit:
agentic-actions— detect insecure AI coding agent configurations #1605 — The proposedagentic-actionsaudit — AI review agents commonly create the conditions for this attack (they check out PR code, run tools on it, and trigger onissue_comment) - Enhance
dangerous-triggers: flagissue_commentas a dangerous trigger #1606 — The proposeddangerous-triggersenhancement forissue_comment