>samit_hota
Back to research
OFFENSIVE SECURITY

Pipeline Poisoning: Auditing and Hardening GitHub Actions CI/CD Workflows

Samit Hota·
#github-actions#devsecops#cicd-security

CI/CD pipelines are frequently architected under the flawed assumption that pull request boundaries double as security perimeters. In practice, automated workflows often execute untrusted code with elevated repository privileges, turning your continuous integration infrastructure into an attacker’s primary vector for initial access and credential theft.

When security teams audit repositories, they typically inspect static source code for traditional software vulnerabilities while overlooking the workflow definition files in .github/workflows/. These YAML files define code execution triggers, secret access policies, and authentication tokens that govern your deployment pipeline.

The Root Cause: Context Confusion in pull_request_target

The most widespread architectural flaw in GitHub Actions stems from mismanaging the execution context of untrusted pull requests. By default, the standard pull_request event triggers a workflow in the context of the fork’s code, but strips access to repository secrets and provides a read-only GITHUB_TOKEN. This prevents external contributors from executing arbitrary code that exfiltrates repository secrets.

To bypass this restriction—often to run integration tests requiring API keys or to post automated comments back to fork PRs—developers frequently swap the trigger to pull_request_target.

The pull_request_target trigger runs in the context of the base repository rather than the fork. It grants access to repository secrets and assigns a read/write GITHUB_TOKEN. Security boundaries break down when a workflow using pull_request_target explicitly checks out the head code submitted by the pull request.

Consider this common vulnerable pattern:

on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout pull request code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - name: Install dependencies
        run: npm install

      - name: Run test suite
        run: npm test

By explicitly setting ref: ${{ github.event.pull_request.head.sha }}, the workflow pulls untrusted code from an external fork and executes it inside a environment that holds full access to your repository’s secrets and a read/write repository token.

Analyzing Token Scope Escalation and Secret Exposure

When untrusted code executes in an unsafe context, the primary target is the environment’s temporary credentials and persistent secrets.

During workflow execution, GitHub automatically injects an installation access token as an environment variable (GITHUB_TOKEN). If the repository settings have not explicitly modified default token permissions to read-only, this token may possess write capabilities across multiple scopes, including repository contents, packages, deployments, and pull requests.

Any script executed during the build process—such as npm install executing a lifecycle hook or npm test running an arbitrary test runner—can inspect environment variables:

  • Environment variables defined at the job or step level containing static secrets (e.g., AWS_ACCESS_KEY_ID, DATABASE_URL).
  • The running process environment where token files and transient runtime variables reside.

If an untrusted PR modifies a build script or test file, it can instruct the runner to extract these variables or use the elevated GITHUB_TOKEN to push malicious commits directly back to unprotected branches, release assets, or package registries.

Step 1: Enforcing Least-Privilege Workflow Permissions

To eliminate token abuse vectors, you must enforce a default-deny permission model across all workflows. GitHub Actions allows explicit declaration of token scopes at both the workflow level and individual job level.

First, configure your repository default settings under Settings > Actions > General > Workflow permissions to select Read repository contents and packages permissions.

Second, explicitly declare minimal permissions in every workflow file using the permissions key. Set a restrictive baseline at the workflow top-level, and grant explicit permissions only to specific jobs that require them:

name: CI Build

on:
  pull_request:
    branches: [ main ]

# Top-level default: deny all access
permissions: {}

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read # Explicitly allow only reading source code
    steps:
      - uses: actions/checkout@v4
      - name: Run Linter
        run: npm run lint

  comment-status:
    needs: lint-and-test
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write # Restricted strictly to writing PR comments
    steps:
      - name: Comment PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: 'Automated validation passed successfully.'
            })

If you require elevated permissions to process untrusted PRs, separate the workflow into two decoupled stages:

  1. An untrusted pull_request workflow that builds and tests the code without access to secrets or write tokens, uploading build artifacts upon completion.
  2. A downstream workflow_run pipeline triggered only after the primary workflow finishes, which runs securely in the main branch context to process the uploaded artifacts and comment or deploy.

Step 2: Eliminating Static Secrets with OIDC Federation

Storing long-lived cloud credentials (AWS_SECRET_ACCESS_KEY, service account keys) in repository secrets presents a permanent vulnerability surface. If a workflow is compromised, those credentials remain valid until manually revoked.

Replace long-lived secrets with OpenID Connect (OIDC) federation. OIDC allows your GitHub Actions runner to request short-lived, auto-expiring tokens directly from your cloud provider (AWS, GCP, Azure) based on verified identity claims.

To configure AWS OIDC authentication, create an IAM Role with an AssumeRole policy restricted to your specific repository and branch context:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

In your workflow, grant id-token: write permission and authenticate dynamically without storing static cloud keys:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write # Required to request the OIDC JWT
    steps:
      - uses: actions/checkout@v4
      
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
          aws-region: us-east-1

      - name: Deploy Infrastructure
        run: aws s3 sync ./dist s3://my-application-bucket

Step 3: Immutability via Commit SHA Action Pinning

Referencing third-party actions using mutable git tags (such as @v4 or @v4.1.0) exposes your pipeline to supply-chain compromise. If an upstream action maintainer’s account is compromised, an attacker can re-tag a malicious commit to a previously trusted release version.

To guarantee pipeline immutability, pin all third-party actions to their full 40-character commit SHA, placing the human-readable tag in a comment for maintainability:

steps:
  # Vulnerable to tag mutation:
  # - uses: actions/checkout@v4

  # Hardened immutable reference:
  - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Automate SHA updates by configuring Dependabot in .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Dependabot automatically monitors upstream releases and submits pull requests that update both the commit SHA and the inline tag comment, keeping your build immutably pinned without introducing administrative overhead.

Want a second set of eyes on your security posture?

Let's talk about where your real exposure is.

Book an advisory call