Lesson 06 of 8 · Modules
Hardening the runner
CI runners execute code from many people, so treat them as hostile workloads: ephemeral runners, separation by trust level, no self-hosted runners for untrusted forks, no privileged builds where avoidable, restricted network egress and cloud permissions, and pinned, reviewed actions.
Your runners run strangers' code
Every CI job runs code: your repo's scripts, its dependencies, third-party actions, test fixtures. Any of these can be malicious or compromised. A runner is therefore a high-risk workload: it often has network access to internal systems, credentials, and a path to production.
A CI runner is a public workshop where anyone who hands in a plan gets to use the tools. You'd make sure each visitor gets a clean bench (ephemeral), keep visitors from walking into the vault (network and permissions), and never let a stranger's plan run in the room with the master keys (deploy runners separate from untrusted builds).
Isolation by design
| Control | Why |
|---|---|
| Ephemeral runners (ARC does this by default) | Nothing survives between jobs |
| Separate pools by trust: PR builds / main builds / deploy | A PR can't touch the runners that hold deploy credentials |
| Runner groups limited to selected repos | A random repo can't target your deploy runners |
| Dedicated nodes (taints, separate node group or cluster) | A container escape lands on a CI node, not next to production pods |
| No self-hosted runners for fork PRs of public repos | Anyone can open a PR |
Privileges inside the runner
- Avoid privileged containers (dind) where you can: use rootless BuildKit or Buildah, or the ARC kubernetes mode (lesson 03). If you must use dind, confine it to dedicated nodes and to pools that never run untrusted code.
- Run the runner as non-root, with a read-only root filesystem where the tooling allows it.
- Don't mount the host Docker socket into runner pods: that's root on the node.
Network and cloud access
- Egress control: NetworkPolicies (or a CNI with FQDN policies) that allow the source host, registries, package mirrors and needed APIs, and nothing else. Tools such as StepSecurity's harden-runner audit or block egress per job on GitHub-hosted runners.
- Block the cloud metadata service from runner pods, and give jobs credentials only through OIDC federation (lesson 04) or per-workload identity.
- Internal systems reachable from runners should still require authentication. Don't rely on "it's only reachable from CI".
Workflow-level hardening
permissions: {} # nothing by default
jobs:
build:
permissions:
contents: read
runs-on: arc-trusted
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: make test
- Pin actions to full commit SHAs, with a comment showing the version; let Renovate/Dependabot propose updates. Limit allowed actions in the organisation settings (GitHub-created, verified creators, an allow-list).
pull_request_targetandworkflow_runrun with secrets in the base repo's context: never check out and execute untrusted PR code in them.- Pass untrusted input through
env:(lesson 01) to avoid script injection. - Use CODEOWNERS on
.github/workflows/so workflow changes need review by the platform or security team.
Try it: attack your own CI (sandbox org)
- In a test repo, add a workflow that prints
${{ github.event.pull_request.title }}directly inrun:; open a PR titled"; echo INJECTED; #and see it execute. Fix it withenv:. - Set
permissions: {}at the top and add back only what each job needs; watch which steps fail until you do. - Pin every action in a workflow to a commit SHA (look up the SHA for the tag in each action's repo).
- On an ARC scale set, add a NetworkPolicy that allows only DNS, GitHub and your registry; run a job that tries
curl https://example.comand see it blocked. - List your runner groups and check which repos can use each one.
Going deeper: CI as a production system
- Threat-model the CI system like any production service: who can change workflows, which runners hold which credentials, how releases are signed (lesson 05).
- Collect runner logs and audit logs (organisation audit log, Kubernetes audit) centrally; CI compromises are investigated from these.
- Rebuild runner images regularly and scan them; they're part of your supply chain too.
- For the most sensitive builds (release signing), consider hermetic, isolated builders with no network beyond the artifact store.
Recap
- Runners execute untrusted code: ephemeral, separated by trust, on dedicated nodes, never self-hosted for fork PRs.
- Avoid privileged builds and host sockets; restrict egress and block metadata; use OIDC for credentials.
- In workflows: SHA-pinned actions,
permissions: {}by default, no untrusted code inpull_request_target, input viaenv:, CODEOWNERS on workflows.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.