Securing CI/CD Pipelines: Controlling Who Triggers What

CI/CD Security
Securing CI/CD Pipelines: Controlling Who Triggers What

The Problem No One Fixes Until It’s Too Late

Most teams treat their CI/CD pipeline as a build tool. Attackers treat it as a privileged execution environment — one that already has your cloud credentials, your registries, and your deployment keys baked in.

Three attack surfaces keep showing up in post-mortems for public and open-source repositories:

  1. Fork pull requests that inherit trusted pipeline permissions
  2. Overly broad GITHUB_TOKEN or job-level permission scopes
  3. OIDC role assumptions with claims too wide to be meaningful

None of these require a zero-day. They just require a malicious PR — or even an honest contributor who makes a mistake.


Fork PRs: Untrusted Code in a Trusted Context

When a contributor forks your repo and opens a PR, GitHub Actions has two event types available: pull_request and pull_request_target.

The difference is critical.

  • pull_request runs the workflow from the fork’s branch, in a restricted context with no access to secrets. Safe for most CI tasks.
  • pull_request_target runs the workflow from the base branch, with full access to secrets and environment variables — even when triggered by a fork.

pull_request_target exists for legitimate reasons: it lets you post comments back to a PR, or run deployment previews that need deploy keys. But if you check out the PR’s code inside a pull_request_target workflow without thinking, you’ve handed an arbitrary contributor root-equivalent access to your pipeline.

The Dangerous Pattern

on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # ← checks out fork code
      - run: ./scripts/build.sh  # ← runs untrusted code with full secret access

This is the classic pull_request_target footgun. The solution is to separate concerns: use pull_request for jobs that need to run untrusted code, and use pull_request_target only for jobs that post results back — never mixing secret access with fork code execution.

The Safe Split Pattern

Run the potentially dangerous work in an isolated job triggered by pull_request (no secrets), then use a second workflow triggered by workflow_run to do the privileged posting or deployment — only after the first workflow passes.

# workflow 1: runs on fork code, no secrets
on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make test
# workflow 2: triggered after workflow 1, runs on base branch with secrets
on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]

jobs:
  report:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - name: Post coverage comment
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            // post comment using artifact from prior run

GITHUB_TOKEN Permissions: Least Privilege at the Workflow Level

By default, GITHUB_TOKEN is issued with write permissions across most GitHub resource types. That’s a wide blast radius for what should be a scoped build token.

Set a restrictive default at the top of every workflow, then grant only what each specific job needs:

permissions: read-all  # deny everything by default

jobs:
  release:
    permissions:
      contents: write       # only this job can publish releases
      id-token: write       # only this job fetches OIDC tokens
      packages: write       # only this job pushes to GHCR
    steps:
      - ...

The permissions: read-all top-level block is your global default. Individual jobs can escalate from there — but no job gets write access it didn’t ask for.

For repos where you never need write access from CI (read-only audit pipelines, security scans, docs previews), go further:

permissions: {}

This disables the token entirely, forcing any downstream steps to use explicit credentials.


OIDC Token Scoping: Don’t Trust the Repo Name Alone

OIDC federation is the right way to authenticate CI to AWS, GCP, or Azure — no long-lived secrets to rotate or leak. But the security guarantee is only as tight as the trust policy on the receiving side.

A common misconfiguration: the IAM role’s trust policy only validates the repository name, not the branch or workflow context.

Too Broad (AWS Example)

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*"
    }
  }
}

This allows any workflow in myorg/myrepo — including one triggered by a fork PR — to assume the role.

Tighter: Lock to Specific Refs and Workflows

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main",
      "token.actions.githubusercontent.com:workflow": "Deploy to Production"
    }
  }
}

By adding the workflow claim, you ensure only a specific named workflow — not an attacker-supplied one — can assume the role. Combine this with the environment claim when using GitHub Environments:

"token.actions.githubusercontent.com:environment": "production"

This means even a compromised workflow on main that isn’t inside the production environment block can’t get the production role.


Environment Protection Rules: Your Last Gate Before Deployment

GitHub Environments let you require approvals before any job targeting that environment runs. This is your backstop for production deployments.

Key settings to configure:

  • Required reviewers — at least one human must approve before secrets are exposed
  • Deployment branches — restrict to main or protected branches only
  • Wait timer — add a mandatory delay window for emergency cancellation
jobs:
  deploy:
    environment: production   # triggers approval gate + scoped secrets
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        env:
          DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}  # only available after approval
        run: ./deploy.sh

Environment-scoped secrets are only injected after a reviewer approves — they never appear in PR preview runs or fork-triggered workflows.


GitLab CI: Protected Branches and Variable Scoping

GitLab’s equivalent controls work at the variable and runner level.

  • Protected variables — only exposed on protected branches and tags. Fork MR pipelines don’t qualify.
  • Protected runners — high-privilege runners that access production credentials are tagged and locked to protected refs only.
  • rules: with if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH — scope deployment jobs explicitly, preventing any feature branch or MR from triggering a production job.
deploy-prod:
  rules:
    - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
  environment:
    name: production
  script:
    - ./deploy.sh

The $CI_PIPELINE_SOURCE == "push" check excludes merge request pipelines from triggering this job, even if someone targets main.


A Quick Access-Control Checklist

  • [ ] All workflows have permissions: read-all or permissions: {} at the top level
  • [ ] No workflow uses pull_request_target and checks out fork code in the same job
  • [ ] OIDC trust policies validate workflow, ref, and environment claims — not just repo
  • [ ] Production secrets live in environment-scoped stores, behind required reviewers
  • [ ] Protected runners (GitLab) or self-hosted runner groups (GitHub) restrict which refs can use privileged hardware
  • [ ] CODEOWNERS covers .github/workflows/ so workflow changes require senior review

Adding CODEOWNERS protection to the workflow directory is often overlooked — it ensures that any change to the pipeline itself requires approval from a specific team, not just any contributor with merge rights.


The Underlying Principle

Every CI/CD system is a privilege-escalation machine by design: it takes code and turns it into running infrastructure. The controls above aren’t optional hardening — they’re the boundary between “code review” and “production access.”

Start with the permissions block and the fork PR split. Those two changes close the most common attack paths with no external tooling required. Then layer in OIDC claim hardening and environment gates as your deployment surface grows.

Sources