17.1 CI/CD Security Principles¶
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 10 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.
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.
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:
# Use protected variables for sensitive credentials
variables:
DEPLOY_KEY:
value: "" # Actual value in CI/CD settings
protected: true # Only on protected branches
masked: true # Hidden in logs
# 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:18
options: --user 1001 # Non-root
steps:
- run: npm ci && npm run build
# GitLab CI: Container isolation
build:
image: node:18
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
# Compromised cache detected by hash mismatch
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:
SLSA (Supply-chain Levels for Software Artifacts) defines provenance requirements:
{
"_type": "https://in-toto.io/Statement/v0.1",
"subject": [
{
"name": "artifact.tar.gz",
"digest": {"sha256": "abc123..."}
}
],
"predicateType": "https://slsa.dev/provenance/v0.2",
"predicate": {
"builder": {"id": "https://github.com/Attestations/GitHubHostedActions@v1"},
"buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1",
"invocation": {
"configSource": {
"uri": "git+https://github.com/org/repo",
"digest": {"sha1": "abc123..."},
"entryPoint": ".github/workflows/release.yml"
}
},
"materials": [
{"uri": "git+https://github.com/org/repo", "digest": {"sha1": "..."}}
]
}
}
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@v3
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@v3
# 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 }}
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.