Pipeline-as-Code Security: Preventing CI Configuration Tampering

Pipeline-as-Code Security: Preventing CI Configuration Tampering

Problem

CI/CD pipeline definitions live alongside application code in Git. Whoever can modify .github/workflows/, .gitlab-ci.yml, or Jenkinsfile controls what runs in the pipeline, with the pipeline’s credentials. A developer who adds a single line to a workflow file can exfiltrate every secret available to that pipeline. A compromised account that pushes a modified CI config gets code execution in your build environment on the next trigger.

Most organizations protect application code with code review, but treat CI configuration changes as routine infrastructure updates that receive less scrutiny. This creates a gap: the CI config has broader access than any application code (it holds deployment credentials, registry tokens, and cloud provider roles), yet it receives weaker review controls.

The problem compounds in organizations with many repositories. Without centralized governance, each repository defines its own pipeline with no consistency in secret handling, permission scoping, or security controls. A single misconfigured pipeline in one repository can compromise credentials shared across the organization.

Threat Model

  • Adversary: Insider with repository write access, compromised developer account, or attacker who gains access through a stolen personal access token.
  • Objective: Modify CI configuration to exfiltrate secrets, inject code into build artifacts, or establish persistent access through a backdoored pipeline.
  • Blast radius: All secrets accessible to the pipeline. For organization-level secrets, a single compromised pipeline can expose credentials used across every repository.

Configuration

Branch Protection for CI Config Files

Protect pipeline definitions with the same rigor as production infrastructure code.

GitHub repository settings:

Settings -> Branches -> Branch protection rules -> main

[x] Require a pull request before merging
    [x] Required number of approvals: 2
    [x] Dismiss stale pull request approvals when new commits are pushed
    [x] Require review from Code Owners
[x] Require status checks to pass before merging
    [x] Require branches to be up to date before merging
    Status checks: "ci-config-validation", "security-scan"
[x] Require signed commits
[x] Do not allow bypassing the above settings
    (Even administrators must follow these rules)

CODEOWNERS for Pipeline Files

Require security team review for any change to CI configuration:

# .github/CODEOWNERS

# All workflow files require security team review
.github/workflows/ @your-org/security-team
.github/dependabot.yml @your-org/security-team
.github/CODEOWNERS @your-org/security-team

# GitLab CI configuration
.gitlab-ci.yml @your-org/security-team
.gitlab/ @your-org/security-team

# Jenkins pipeline files
Jenkinsfile @your-org/security-team
jenkins/ @your-org/security-team

# Docker build files (control what gets built)
Dockerfile @your-org/security-team
docker-compose*.yml @your-org/security-team
.dockerignore @your-org/security-team

GitLab Protected CI Configuration

GitLab provides built-in protection for CI configuration through protected files and compliance pipelines:

# .gitlab-ci.yml - include centrally managed compliance template
include:
  - project: 'your-org/ci-templates'
    ref: 'main'
    file: '/templates/security-baseline.yml'

# The compliance template runs security checks that
# individual repositories cannot disable or modify.

stages:
  - build
  - test
  - security  # Defined in compliance template
  - deploy

build:
  stage: build
  script:
    - make build

The compliance template, managed by the security team:

# ci-templates/templates/security-baseline.yml
# This file is in a protected repository. Only security team can modify.

secret-scan:
  stage: security
  image: zricethezav/gitleaks:latest
  script:
    - gitleaks detect --source=. --verbose --fail
  rules:
    - when: always  # Cannot be skipped by downstream projects

sast-scan:
  stage: security
  image: returntocorp/semgrep:latest
  script:
    - semgrep scan --config=auto --error
  rules:
    - when: always

ci-config-audit:
  stage: security
  script:
    - |
      # Verify the CI config hasn't been modified to skip security stages
      if ! grep -q "include:" .gitlab-ci.yml; then
        echo "ERROR: CI config must include the compliance template"
        exit 1
      fi
  rules:
    - when: always

Immutable Pipeline Definitions with External Templates

Move pipeline logic to a centrally managed, protected repository. Individual repositories reference templates but cannot modify them:

# .github/workflows/build.yml - in the application repository
name: Build
on: [push, pull_request]

jobs:
  build:
    # Use a reusable workflow from the protected ci-templates repository
    uses: your-org/ci-templates/.github/workflows/build-and-scan.yml@v2.1.0
    with:
      language: python
      python-version: "3.12"
    secrets: inherit

The reusable workflow in the protected repository:

# your-org/ci-templates/.github/workflows/build-and-scan.yml
name: Build and Scan (Reusable)
on:
  workflow_call:
    inputs:
      language:
        required: true
        type: string
      python-version:
        required: false
        type: string
        default: "3.12"

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Build
        run: make build

      - name: Run tests
        run: make test

  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

      - name: Secret scan
        uses: trufflesecurity/trufflehog@v3
        with:
          extra_args: --only-verified

      - name: SAST scan
        run: semgrep scan --config=auto --error

      # These security steps cannot be removed or modified
      # by the consuming repository.

Detecting Unauthorized CI Config Changes

Monitor audit logs for modifications to pipeline files:

# .github/workflows/audit-ci-changes.yml
name: Audit CI Config Changes
on:
  push:
    paths:
      - ".github/workflows/**"
      - "Dockerfile"
      - ".gitlab-ci.yml"

jobs:
  audit:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
        with:
          fetch-depth: 2

      - name: Detect CI config changes
        run: |
          CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD)
          CI_FILES=$(echo "$CHANGED_FILES" | grep -E '\.github/workflows/|Dockerfile|\.gitlab-ci\.yml|Jenkinsfile')

          if [ -n "$CI_FILES" ]; then
            echo "CI configuration files modified in this commit:"
            echo "$CI_FILES"
            echo ""
            echo "Diff of CI config changes:"
            for f in $CI_FILES; do
              echo "=== $f ==="
              git diff HEAD~1 HEAD -- "$f"
            done

            # Send alert to security team
            curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
              -H "Content-Type: application/json" \
              -d "{
                \"text\": \"CI config modified in ${{ github.repository }} by ${{ github.actor }}\",
                \"blocks\": [
                  {
                    \"type\": \"section\",
                    \"text\": {
                      \"type\": \"mrkdwn\",
                      \"text\": \"*CI Config Change Detected*\nRepo: ${{ github.repository }}\nAuthor: ${{ github.actor }}\nCommit: ${{ github.sha }}\nFiles: $(echo $CI_FILES | tr '\n' ', ')\"
                    }
                  }
                ]
              }"
          fi

Preventing Fork-Based Pipeline Manipulation

For public repositories, prevent forks from running modified workflows with full secret access:

# Restrict secret access for pull requests from forks
name: Build
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  build:
    runs-on: ubuntu-latest
    # Do not expose secrets to fork PRs
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
      - run: make build
      - run: make test
      # No deployment steps, no secret usage in PR builds

Expected Behaviour

  • All CI config file changes require at least 2 reviewers, including CODEOWNERS (security team)
  • Security scanning stages are defined in a protected template repository that application teams cannot modify
  • Audit alerts fire within minutes of any CI config file change
  • Fork-based PRs do not have access to repository secrets
  • Reusable workflows are pinned by tag to a version-controlled template repository
  • Branch protection prevents direct pushes to main, including administrator bypasses

Trade-offs

Control Impact Risk Mitigation
CODEOWNERS for CI files Security team reviews every pipeline change Bottleneck for rapid CI iteration Security team commits to 24-hour review SLA. Pre-approved patterns for common changes.
Immutable external templates Application teams lose flexibility to customize pipelines Teams work around restrictions by adding pre/post steps Allow controlled extension points in templates (e.g., additional test commands) while locking security stages.
Audit alerting on CI changes Alert fatigue if CI configs change frequently Real attacks hidden in noise of legitimate changes Filter alerts: only alert on changes to security-sensitive steps (secret usage, permissions, deploy stages).
Signed commits required Developers must configure GPG/SSH signing Setup friction for new developers; key management overhead Provide onboarding documentation. Use SSH signing (simpler than GPG).

Failure Modes

Failure Symptom Detection Recovery
CODEOWNERS bypassed via admin override CI config merged without security review Audit log shows merge without required review; CODEOWNERS bypass alert Revert the merge. Investigate why admin bypass was used. Disable admin bypass in branch protection.
Template repository compromised All downstream pipelines run modified security checks Template repository audit log shows unauthorized changes; downstream pipelines behave differently Revert template changes. Rotate all secrets used by downstream pipelines. Review template repository access.
Audit webhook fails silently CI config changes are not reported to security team Periodic reconciliation job compares current CI configs against approved baseline Fix webhook. Run manual audit of recent CI config changes.
Reusable workflow pinned to compromised tag Modified workflow runs across all consuming repositories Template repository shows force-pushed tag; consuming repos run unexpected steps Pin reusable workflows by SHA instead of tag. Rotate any exposed secrets.

When to Consider a Managed Alternative

Enforcing pipeline governance across 50+ repositories requires tooling beyond branch protection and CODEOWNERS. GitHub Enterprise provides advanced audit logging, required workflows (organization-level enforcement), and secret scanning push protection. Grafana Cloud (#108) and Axiom (#112) provide audit log aggregation for centralized monitoring of CI config changes across all repositories. For organizations on GitLab, Ultimate tier adds compliance frameworks and pipeline execution policies that enforce security stages across all projects.

Premium content pack: Pipeline governance templates. Includes reusable GitHub Actions workflows for build/test/deploy with embedded security scanning, CODEOWNERS templates, branch protection configuration scripts, and audit alerting workflows for Slack and PagerDuty.