PS Product SecurityKnowledge Base

Self-Hosted Runners Security Review Pack

Intro: A self-hosted runner is a controlled remote code execution surface. The repository decides the job; the runner host pays the price. This page turns runner review into a repeatable Product Security assessment for GitHub Actions and GitLab CI.

What this page includes

  • why self-hosted runners are high-value review targets;
  • GitHub and GitLab specific hardening guidance;
  • ephemeral versus persistent tradeoffs;
  • network, identity, executor, and secret review questions;
  • practical config snippets and review checklists.

Why self-hosted runners matter

When a workflow or pipeline job runs on a self-hosted runner, repository-defined code can often:

  • read runner-local files or caches;
  • access cloud credentials or internal networks;
  • reuse artifacts from prior jobs;
  • attempt persistence on the host;
  • pivot into nearby systems.

What is different from hosted runners

Hosted model

  • ephemeral execution is the default service pattern;
  • the platform vendor owns patching and base image lifecycle;
  • blast radius tends to be more contained.

Self-hosted model

  • you own host hardening and updates;
  • you own job isolation and cleanup;
  • you own secrets exposure boundaries;
  • you own network segmentation;
  • you own the consequences of persistence.

Core design principles

Principle Practical meaning
ephemeral first assume each runner should process one job or a very narrow trust set
no public trust mixing never let untrusted pull-request code share a persistent privileged runner
separate by trust tier build, test, release, and deploy should not all land on the same fleet
narrow network reach most runners do not need production or internal admin-plane access
short-lived credentials prefer OIDC / workload identity or tightly scoped short-lived tokens
observable cleanup logs, cleanup, and evidence must survive the runner instance

GitHub Actions review guidance

Current strongest pattern

Use ephemeral self-hosted runners where possible.

Registration example โ€” ephemeral runner

./config.sh \
  --url https://github.com/example-org \
  --token "$RUNNER_TOKEN" \
  --ephemeral \
  --labels linux,x64,build,isolated

When disabling auto-update

./config.sh \
  --url https://github.com/example-org \
  --token "$RUNNER_TOKEN" \
  --ephemeral \
  --disableupdate

GitHub runner hardening checklist

  • prefer ephemeral runners over persistent ones;
  • do not use self-hosted runners for public repos unless containment is unusually strong;
  • keep runners out of production networks unless the job type absolutely requires it;
  • use OIDC to cloud where possible instead of static long-lived secrets;
  • keep environment secrets behind review gates where supported;
  • forward runner logs externally before runner destruction;
  • route jobs by labels and groups, not by hope.

Example GitHub Actions workflow routed to a dedicated runner group

name: release
on:
  push:
    tags:
      - 'v*'
permissions:
  id-token: write
  contents: read
jobs:
  deploy:
    runs-on: [self-hosted, linux, x64, prod-deploy]
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-prod-deploy
          aws-region: us-east-1
      - run: ./scripts/deploy.sh

GitLab self-managed runner review guidance

Executor choices

Shell executor

Use only for highly trusted jobs on tightly controlled hosts.

Docker executor

Usually safer than shell when not privileged, with careful image and volume policy.

Kubernetes executor

Good for ephemeral isolation when namespaces, service accounts, node placement, and egress are handled correctly.

VM-based isolation

Best for highest-trust or privilege-heavy jobs when ephemeral rebuild is possible.

GitLab runner config.toml โ€” safer Docker baseline

concurrent = 4
check_interval = 0

[[runners]]
  name = "ci-build-isolated"
  url = "https://gitlab.example.com"
  token = "REDACTED"
  executor = "docker"
  [runners.docker]
    image = "alpine:3.20"
    privileged = false
    pull_policy = "always"
    disable_cache = true
    volumes = ["/cache"]
    shm_size = 0

GitLab runner config.toml โ€” privileged exception pattern

[[runners]]
  name = "image-builder-privileged"
  url = "https://gitlab.example.com"
  token = "REDACTED"
  executor = "docker"
  [runners.docker]
    image = "docker:27"
    privileged = true
    pull_policy = "always"

If you must do this:

  • isolate the runner onto dedicated ephemeral nodes or VMs;
  • route only protected jobs to it;
  • do not reuse it for general CI.

GitLab Kubernetes executor example

[[runners]]
  name = "k8s-release"
  url = "https://gitlab.example.com"
  token = "REDACTED"
  executor = "kubernetes"
  [runners.kubernetes]
    image = "alpine:3.20"
    namespace = "gitlab-runners-release"
    service_account = "gitlab-runner-release"
    privileged = false
    pull_policy = "always"
    cpu_limit = "1000m"
    memory_limit = "1Gi"

Network segmentation review

For both GitHub and GitLab, ask:

  • can the runner reach cloud instance metadata endpoints?
  • can it reach production databases or cluster control planes?
  • can low-trust jobs reach internal package mirrors or secret stores?
  • can one runner talk laterally to another runner subnet?

AWS IMDS blocking example with iptables

sudo iptables -A OUTPUT -d 169.254.169.254 -j REJECT

Secrets and identity

Better patterns

  • GitHub Actions OIDC to AWS/Azure/GCP;
  • GitLab OIDC / workload federation where available;
  • environment-scoped secrets only for trusted refs;
  • separate deploy identity from build identity.

Worse patterns

  • long-lived cloud admin keys stored on the runner host;
  • broad vault tokens reused for all jobs;
  • single shared SSH key for deployment from every runner.

Persistence and cleanup

Post-job cleanup example

#!/usr/bin/env bash
set -euo pipefail
rm -rf "$RUNNER_WORKDIR/_work"/*
docker system prune -af || true

Cleanup scripts do not replace ephemeral design. They only reduce residue when persistence exists.

Top 10 runner security issues

Issue Why it matters Fix
shared persistent runners cross-job contamination ephemeral or trust-tiered isolation
shell executor on mixed-trust repos host takeover risk use container or VM isolation
privileged Docker by default breakout and host compromise isolate privileged jobs to dedicated ephemeral fleet
no metadata blocking cloud credential theft block IMDS / metadata access where unnecessary
static cloud keys on runner secret theft OIDC or short-lived federated credentials
broad internal network access easy lateral movement runner subnet isolation and egress allowlists
stale runner versions feature and security drift automatic image refresh or disciplined update process
no external logs weak investigations forward logs before destruction
fork PRs on self-hosted runners trivial hostile code execution path keep untrusted PRs on hosted or separate low-trust infrastructure
shared caches across trust zones artifact poisoning and data leakage separate cache scope and cleanup aggressively

Review checklist

Governance

  • who can attach repos to this runner fleet?
  • who can edit workflow files that route jobs here?
  • are protected refs and environments actually enforced?

Host security

  • is the runner host hardened and patched?
  • are admin tools, SSH keys, or cloud CLIs present when they are not needed?
  • are OS logs and runner logs forwarded externally?

Executor

  • shell, Docker, Kubernetes, or VM?
  • is privileged mode enabled?
  • are images pinned and pull policies sane?

Network

  • which subnets, metadata endpoints, registries, clusters, and databases can jobs reach?
  • can the fleet talk laterally?

Secrets

  • how do jobs obtain cloud access?
  • are secrets withheld from low-trust refs?
  • is there evidence of secret rotation?