Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXLI · Runner SecurityThreatModel

The runner threat model — who attacks, how, and with what access

Advanced⏱ ~22 mingit

What you'll learn

  • Identify the four attacker profiles the runner must defend against and the access each one starts with
  • Map the attack tree from initial access to production compromise step by step
  • Distinguish the runner host boundary from the production boundary and the cluster boundary
  • Use STRIDE-style categories to classify the controls that defend each boundary

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

A CI runner sits between two trust levels: the untrusted bytes in a commit and the trusted systems the job must reach. Every control on the runner is a control on that boundary. The threat model names who attacks, what access they start with, and what they can reach. Writing it down before designing the controls is the difference between defending the runner and defending the runner as imagined.

The four attacker profiles

The runner must defend against four distinct attacker classes, each with a different starting access level:

flowchart TB
    subgraph A1["Profile 1: External contributor"]
        X1["No forge account\nopens PR from fork"]
    end
    subgraph A2["Profile 2: Compromised maintainer"]
        X2["Valid forge account\ncredential or session stolen"]
    end
    subgraph A3["Profile 3: Malicious dependency"]
        X3["Code injected via\ntyposquat or hijack"]
    end
    subgraph A4["Profile 4: Malicious action"]
        X4["Code injected via\nthird-party action update"]
    end
    X1 --> R["Runner host"]
    X2 --> R
    X3 --> R
    X4 --> R
  • External contributor (fork PR). No forge account; opens a pull request from a fork. The PR runs CI on the receiving side; the workflow is the receiving repo’s, but the code that runs (a script: block, a test step) is the contributor’s. Default protections: read-only $GITHUB_TOKEN, no secrets, first-time contributor approval.
  • Compromised maintainer. A real forge account, authenticated legitimately, but the attacker holds the credential. The attacker can push to branches, modify workflow files, change secrets. The runner cannot tell the maintainer from the attacker; the boundary is the credential, not the runner.
  • Malicious dependency. A legitimate-looking package whose installer hook runs as the workflow author. The attacker does not touch the workflow file; npm install, pip install, terraform init are all installer hooks that run arbitrary code.
  • Malicious action. A third-party action pinned by tag rather than by digest. The attacker compromises the publisher, pushes a new version under the same tag, and every workflow using the action runs the new code.

The four profiles share one property: the attacker’s code runs on the runner. Once the code runs, it is in the same position regardless of how it got there.

The attack tree

The attack tree from initial access to production compromise is four steps, and each step has a corresponding defence:

flowchart LR
    S1["Step 1:\nInitial access\n(PR, action, dep)"] --> S2["Step 2:\nCode execution\n(on runner)"]
    S2 --> S3["Step 3:\nRunner host\ncompromise"]
    S3 --> S4["Step 4:\nLateral movement\nto production"]
  • Step 1 — initial access. Defence at the trigger layer: pull_request_target versus pull_request (XLI-02), action pinning by digest, dependency review.
  • Step 2 — code execution. Defence at the execution layer: ephemeral runners, scoped secrets, network egress controls, no privileged containers.
  • Step 3 — host compromise. Defence at the host layer: non-root user, no Docker socket (XLI-04), no privileged container (XLI-05).
  • Step 4 — lateral movement. Defence at the identity layer: OIDC short-lived credentials (XLI-03), no long-lived cloud keys, network egress allowlist, cluster access via a separate identity (XLI-06).

Each step is an amplification of access. Step 1 grants a script; Step 2 grants the runner filesystem; Step 3 grants the host kernel and network; Step 4 grants production. The goal is to make each step harder than the previous.

STRIDE on the runner

STRIDE — Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege — is a useful way to classify controls:

flowchart TB
    subgraph STRIDE["STRIDE on the runner"]
        SP["Spoofing\nsigned commits, OIDC tokens"]
        T["Tampering\ndigest pinning, immutable refs"]
        R["Repudiation\naudit logs, run provenance"]
        I["Information disclosure\nsecret masking, scoped tokens"]
        D["Denial of service\nrate limits, ephemeral runners"]
        E["Elevation of privilege\nnon-root, no privileged"]
    end
  • Spoofing is defended by signed commits and OIDC tokens. An unsigned SHA is spoofable; an OIDC token is bound to the workflow run and is not.
  • Tampering is defended by digest pinning (image by SHA, action by SHA, dependency by lockfile). Mutable references are tampering vectors.
  • Repudiation is defended by the forge’s audit log. The runner is not the audit log; the forge’s logs are.
  • Information disclosure is defended by per-job secret scoping. The defence is that the step does not have access to the secret it does not need.
  • Denial of service is defended by ephemeral runners and rate limits.
  • Elevation of privilege is defended by non-root users, no privileged containers, no Docker socket.

Three boundaries

The runner is not one boundary; it sits on three:

flowchart LR
    subgraph B1["Boundary 1:\nUntrusted commit -> Runner"]
        C1["PR code"] -->|executes as| R1["Runner step"]
    end
    subgraph B2["Boundary 2:\nRunner -> Runner host"]
        R1 -->|runs on| H["Runner host\n(kernel, mounts, network)"]
    end
    subgraph B3["Boundary 3:\nRunner host -> Production"]
        H -->|reaches via| P["Cloud APIs\nk8s API\ninternal services"]
    end
  • Boundary 1: commit to runner. The workflow file is the contract; the commit is the payload. Defence: workflow- level (triggers, secrets, actions).
  • Boundary 2: runner to host. The step is the contract; the host is the resource. Defence: runner configuration (rootless, no privileged, scoped filesystem).
  • Boundary 3: host to production. The runner’s credentials are the contract; the production API is the resource. Defence: identity (OIDC, short-lived tokens) and network (egress allowlist, cluster network policies).

A control on Boundary 1 does not defend Boundary 2. A control on Boundary 2 does not defend Boundary 3. The defences are layered; each layer assumes the layer above has failed.

Production discipline

  1. Write the threat model down. A threat model in someone’s head is not a threat model; it is a guess.
  2. Treat each boundary independently. A control on Boundary 1 does not defend Boundary 2 or 3.
  3. Revisit the threat model when the runner changes. Moving from hosted to self-hosted, adding the Docker socket, or storing a long-lived credential all change the model.
  4. Treat STRIDE as a checklist, not a substitute. STRIDE is a coverage check on a threat model that already exists; it is not a model on its own.

Cross-course references

  • Git, CI/CD & GitOps — Part XL-02 (Self-hosted runners) covers the runner classes that change the threat model.
  • Git, CI/CD & GitOps — Part XXXVIII-02 (Control plane isolation) covers the control plane the runner is part of.
  • Docker for Production Sysadmins — Part XXXV (DockerSecurity) covers the container-side boundaries.
  • Kubernetes for Production Sysadmins — Part LVI (DefenseInDepth) covers the cluster-side defences.

Quiz

Knowledge check · 4 questions

  1. Q1. Which of the four attacker profiles runs code on the runner without ever touching the workflow file in the target repository?

  2. Q2. A control on Boundary 2 (runner to host) also defends Boundary 3 (host to production), so a single layered control is enough.

  3. Q3. List the four attacker profiles that can run code on a CI runner, and name the initial access vector for each.

  4. Q4. Map the defences of a self-hosted runner pool to the attack-tree steps and identify which boundary each defence operates on.

    Team T runs Terraform CI on self-hosted runners: persistent hosts, /var/run/docker.sock mounted for build steps, AWS access key in ~/.aws/credentials for apply steps, and private VPC connectivity to the production EKS cluster. Identify the boundaries each current configuration defends (or fails to defend).

Passing score: 75%. Answers are checked in this browser.