CI/CD has access to source code, secrets, build infra, production. Compromise it, attackers own everything downstream.
Threat model
- Compromised dependency: Malicious package runs during install, exfiltrates env vars
- Malicious PR: Attacker modifies CI config, extracts secrets
- Stolen CI credentials: Attacker triggers builds with modified code
- Supply chain injection: Modified build script, poisoned cache
Runners
Ephemeral: Fresh environment per job, destroyed after. Prevents secrets leaking between jobs, persistent malware.
Self-hosted restrictions: Isolate in dedicated network, run jobs in containers, rotate credentials.
Minimal permissions:
permissions:
id-token: write # OIDC instead of long-lived keys
contents: read
Secrets
Scoped: Per-environment, per-branch, per-job. Lint job doesn't need DB creds.
PR restrictions: Never expose secrets to forked PR builds. pull_request_target with PR checkout = attacker's code runs with your secrets.
Pipeline config
Protect CI config: Require review for .github/workflows/ changes.
Pin action versions:
# BAD - tag can be moved
- uses: actions/checkout@v4
# GOOD - immutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
Build integrity
Reproducible builds: Same source = same artefact. Use lock files.
Provenance: Record source commit, builder identity, timestamp, artefact hash.
Deployment
Require approval for prod. Canary deployments. Ensure rollback capability.
The takeaway
Ephemeral runners. Scope secrets. Never expose to forked PRs. Pin versions to SHAs. Require prod approval. Your pipeline should be as hardened as production.
