Your CI/CD pipeline has more access than almost any human in your organisation. It reads source code, stores secrets, builds artefacts, and deploys to production. If an attacker compromises it, they own everything downstream. This lesson covers how to secure the path from commit to production.
The pipeline as a target
A typical CI/CD pipeline:
- Developer pushes code to a repository
- CI runner checks out the code
- CI runs tests, linting, security scans
- CI builds artefacts (Docker images, binaries, packages)
- CD deploys to staging, then production
At every step, the pipeline has access to:
- Source code (including private repositories)
- Secrets (database credentials, API keys, signing keys)
- Build infrastructure (runners, registries, cloud accounts)
- Production environments (deployment credentials)
Compromise any step and the attacker can inject code, steal secrets, or deploy a backdoor.
Threat model
Compromised dependency
A malicious package runs code during npm install or pip install in your CI pipeline. The install script reads environment variables (which often contain secrets) and exfiltrates them.
Malicious pull request
An external contributor opens a PR that modifies the CI config or build scripts. If your CI runs on untrusted PRs with access to secrets, the attacker extracts them.
Stolen CI credentials
An attacker obtains CI runner credentials (e.g., a GitHub Actions token, a Jenkins API key) and triggers builds with modified code or config.
Supply chain injection
An attacker compromises the build process itself — modifying the build script, replacing a compiler, or tampering with the build cache.
Securing runners
Use ephemeral runners
Each CI job should run on a fresh, clean environment that is destroyed after the job completes. This prevents:
- Secrets leaking between jobs
- Persistent malware on shared runners
- Cache poisoning
GitHub Actions hosted runners, GitLab SaaS runners, and containerised Jenkins agents are ephemeral by default. Self-hosted runners need explicit configuration to achieve this.
Restrict self-hosted runner access
If you must use self-hosted runners:
- Isolate them in a dedicated network segment
- Do not reuse runners across security boundaries (public repos vs. private repos)
- Run CI jobs in containers, not directly on the host
- Regularly rotate runner credentials
Limit runner permissions
CI runners should have the minimum cloud IAM permissions required:
# GitHub Actions — use OpenID Connect instead of long-lived keys
permissions:
id-token: write
contents: read
Use short-lived credentials via OIDC federation rather than storing long-lived cloud keys as secrets.
Protecting secrets in CI
Scoped secrets
Restrict which jobs and workflows can access which secrets:
- Per-environment secrets — production deployment secrets should not be available to test jobs
- Per-branch restrictions — only the
mainbranch should trigger deployments with production secrets - Per-job scoping — a linting job does not need database credentials
Pull request restrictions
Never expose secrets to pull request builds from forked repositories. On GitHub Actions:
# Secrets are not available to pull_request events from forks by default
# Do NOT use pull_request_target with checkout of PR code — this bypasses the restriction
on:
pull_request:
# Secrets NOT available for forked PRs ✓
The pull_request_target event runs in the context of the base branch and has access to secrets. If combined with checking out the PR's code, an attacker's code runs with your secrets. This is one of the most dangerous GitHub Actions anti-patterns.
Masked output
CI platforms can mask secret values in logs. Enable this for all secrets:
# GitHub Actions — secrets are masked automatically
# But if you derive a new value from a secret, mask it explicitly
echo "::add-mask::$DERIVED_VALUE"
Pipeline configuration as code
Protect the CI config
Your .github/workflows/, .gitlab-ci.yml, or Jenkinsfile is a high-value target. Modifications to these files should:
- Require code review approval
- Be protected by branch protection rules
- Trigger alerts when changed
Pin action versions
Do not use mutable tags for CI actions:
# BAD — the tag can be moved to point to different code
- uses: actions/checkout@v4
# GOOD — pinned to a specific commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Tags and branches can be force-pushed. Commit SHAs are immutable.
Limit workflow permissions
Follow least privilege for the GITHUB_TOKEN:
permissions:
contents: read
pull-requests: write
# Only the permissions this workflow actually needs
The default GITHUB_TOKEN often has more permissions than necessary. Restrict it explicitly.
Build integrity
Reproducible builds
The same source code should produce the same artefact every time. This makes it possible to verify that a deployed artefact was built from the expected source code.
Factors that break reproducibility:
- Timestamps embedded in artefacts
- Non-deterministic dependency resolution (use lock files)
- Build tools that introduce randomness
Build provenance
Record what was built, from which source, by which pipeline:
- SLSA (Supply Chain Levels for Software Artefacts) defines levels of build integrity, from basic provenance to hermetic builds
- Sigstore provides tools for signing and verifying artefacts
- in-toto provides a framework for verifying each step in the supply chain
At minimum, log: the source commit, the builder identity, the build timestamp, and the artefact hash.
Deployment gates
Require approval for production
Automated deployment to staging is fine. Deployment to production should include a human approval gate:
# GitHub Actions environment with required reviewers
environment:
name: production
url: https://myapp.com
Canary and progressive deployments
Deploy to a small percentage of production first. Monitor for errors, latency spikes, and security anomalies before rolling out to 100%.
Rollback capability
Ensure you can roll back any deployment within minutes. If a compromised build reaches production, the speed of your rollback determines the blast radius.
Summary
CI/CD pipelines are high-value targets because they have broad access to secrets, source code, and production infrastructure. Use ephemeral runners, scope secrets to specific environments and branches, never expose secrets to untrusted pull requests, pin action versions to commit SHAs, and restrict workflow permissions to the minimum needed. Record build provenance, require approval for production deployments, and ensure you can roll back quickly. Your pipeline should be as hardened as your production infrastructure.
