Skip to content

New audit: detect execution of hijackable commands after checkout of untrusted code #1607

@dguido

Description

@dguido

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:

  1. Workflow triggered on issue_comment, checking out the PR branch
  2. Workflow ran npx tsx review-script.ts
  3. NPM's resolution checks the local package.json bin field before looking at global packages
  4. The attacker's PR added to package.json: "bin": {"tsx": "malicious.sh"}
  5. npx tsx executed malicious.sh instead of the real tsx package
  6. The script exfiltrated GITHUB_TOKEN and every secret available to the workflow
  7. 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 }}/merge

Also: 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.tssafe, version-pinned, won't resolve from local package.json
  • npx tsx script.tsvulnerable, resolves from local package.json bin 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.2 instead of npx tsx
  • Use npm ci --ignore-scripts when installing dependencies (see New audit: detect usage of npm 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_TOKEN permissions (permissions: read-all or 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 build be 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 all pull_request + checkout + exec be flagged? That might be very noisy. Starting with pull_request_target and issue_comment (where checkout of PR code is clearly a mistake) and treating pull_request as 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 install without --ignore-scripts). The two could eventually share infrastructure for detecting package manager commands in run: blocks.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions