CI/CD & Software Supply Chain›06 · Hardening the runner
Learning Hub / Delivery & Infrastructure as Code / CI/CD & Software Supply Chain

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.

Advanced
Key wordsrunner isolationephemeral runnersfork pull requestspull_request_targetprivileged containersegress controlrunner groupspinning actionsGITHUB_TOKEN scope

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_target and workflow_run run 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)

  1. In a test repo, add a workflow that prints ${{ github.event.pull_request.title }} directly in run:; open a PR titled "; echo INJECTED; # and see it execute. Fix it with env:.
  2. Set permissions: {} at the top and add back only what each job needs; watch which steps fail until you do.
  3. Pin every action in a workflow to a commit SHA (look up the SHA for the tag in each action's repo).
  4. On an ARC scale set, add a NetworkPolicy that allows only DNS, GitHub and your registry; run a job that tries curl https://example.com and see it blocked.
  5. 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 in pull_request_target, input via env:, 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.