Skip to content

This tool is not affiliated with, endorsed by or sponsored by GitHub, Inc. or Microsoft Corporation. GitHub and GitHub Actions are trademarks of GitHub, Inc. Other names are trademarks of their respective owners.

Self-hosted runner security: GitHub Actions forensics

Rogue or compromised GitHub self-hosted runners: the audit log events to check, host evidence in _diag, and how to scope and rebuild after an incident.

Published on 5 min read

TL;DR. Self-hosted runners are a GitHub risk that sits inside your network. There are two scenarios. A rogue runner: an attacker registers their own machine (org.register_self_hosted_runner / repo.register_self_hosted_runner) and receives your jobs and their secrets. A compromised runner: untrusted code runs on your persistent host and stays there. The audit log tells you when runners and groups changed. The host's _diag/Runner_* and Worker_* logs tell you what ran. Remove unknown runners, rebuild any host that ran untrusted jobs, and move to ephemeral runners in restricted groups.

GitHub-hosted runners are fresh virtual machines that are discarded after each job. Self-hosted runners are whatever you give them: a VM in your VPC, a pod in your cluster or a Mac mini under a desk. They often have network access to internal systems and cloud metadata endpoints, which is exactly why attackers like them.

Why runners matter to an attacker

GitHub's secure use reference is direct about it. Self-hosted runners have no guarantee of running on clean ephemeral machines and "can be persistently compromised by untrusted code in a workflow". They should almost never be used for public repositories. On private repositories, anyone who can open a pull request from a fork may be able to run code on them.

ScenarioWhat the attacker getsATT&CK
Rogue runner registered to the org or a repoJobs routed to their machine, with checked-out code, secrets and GITHUB_TOKENT1072 Software Deployment Tools
Untrusted workflow on a persistent runnerCode execution inside your network, persistence between jobs, access to later jobs' secretsT1677 Poisoned Pipeline Execution
Runner group widenedSensitive runners (with prod network access) now available to more repositoriesn/a, a configuration change

What the audit log records

The organization audit log events cover the runner lifecycle:

ActionMeaning
org.register_self_hosted_runner, repo.register_self_hosted_runnerA runner was registered
org.configure_self_hosted_jit_runner, repo.configure_self_hosted_jit_runnerA just-in-time (single-job) runner was configured through the API
org.remove_self_hosted_runner, repo.remove_self_hosted_runnerA runner was removed
org.self_hosted_runner_online / _offline, repo.…The runner application started or stopped
org.self_hosted_runner_updatedThe runner application updated itself
org.runner_group_created, org.runner_group_updated, org.runner_group_runners_added, org.runner_group_visiblity_updatedRunner groups and their access changed
org.update_repo_self_hosted_runners_policyWhich repositories may create repository-level runners changed

The analyzer raises self-hosted-runner-registered (medium) for org., repo. and enterprise.register_self_hosted_runner. The other actions above are not rules. They show up in the event table and are worth filtering on (runner in the search box) around any registration you cannot explain.

Reading a registration

Registering a runner requires a registration token that expires after one hour, obtained by someone with admin rights on the repository or organization, or with an equivalent token. So when a registration is suspicious, ask:

  • Who is the event's actor? Check that account for other findings (token from a new country, recent owner grant).
  • From where? A registration from an IP that never appears in your CI infrastructure is a red flag.
  • What name and labels? Rogue runners often reuse labels like self-hosted, linux and x64 so that existing workflows with runs-on: [self-hosted, linux] pick them up without anyone editing a YAML file.
  • Which group? A runner in the default group of an organization is available to every repository that the group allows.

What only the host can tell you

The audit log does not show which jobs ran on which runner. For that you need the runner host:

  • _diag/Runner_*.log: the runner application log. One file per start, named with a UTC timestamp. It shows registration, connections and job assignments.
  • _diag/Worker_*.log: one detailed log per job the runner processed, including the repository and workflow.
  • The work directory (_work/): checked-out repositories, tool caches and leftovers from past jobs. Attackers use it to drop files that later jobs execute.
  • Standard host telemetry: process creation, outbound connections, new cron jobs or services, SSH keys, and access to cloud instance metadata.

For ephemeral runners, GitHub warns that the runner logs must be forwarded to external storage, otherwise they disappear with the machine. If you run runners on Kubernetes with Actions Runner Controller, the pod logs, the Kubernetes audit log and the node's container runtime are part of the evidence. The sibling Kubernetes Forensics tool covers that side.

Scoping a runner incident

  1. List every runner (organization, repositories, enterprise) and compare with your inventory. Unknown names or IPs go straight to removal, after you record their details.
  2. Correlate registrations with the actor's other activity in the analyzer: Entities → Runners, then pivot on the actor and IP.
  3. Find the jobs. For a rogue runner, use the audit log's workflows.completed_workflow_run events for workflows that target self-hosted labels in the window, plus the job logs themselves: the "Set up job" step prints the runner name and machine name. For a compromised host, use Worker_* logs.
  4. Assume secrets used by those jobs are exposed. Rotate them, including the repository's deploy credentials and any cloud role the runner's machine identity could assume.
  5. Rebuild any persistent runner that ran untrusted code. Do not clean it, reinstall it.

Hardening

  • Use ephemeral runners (./config.sh --ephemeral) or just-in-time runners. GitHub only assigns one job to them, which limits persistence between jobs. See the self-hosted runners reference.
  • Put sensitive runners in runner groups restricted to specific repositories and workflows. See managing access with groups.
  • Prevent repository-level runners unless there is a reason. Organization owners can restrict which repositories may create them.
  • Never attach self-hosted runners to public repositories.
  • Give runner hosts least-privilege cloud identities and egress filtering. Many runner compromises become cloud incidents through the instance's role.
  • Alert on every registration. It is a rare event in a stable organization.

Related articles

How GitHub Actions secrets leak despite *** masking: poisoned workflows, new branches, encoded output and third-party actions. The evidence to collect and read.
A fictional GitHub supply chain attack investigated end to end: stolen PAT, 38 repos cloned, AWS keys printed by a workflow, branch protection removed.
AWS, Google Cloud or Azure keys leaked from GitHub Actions or a repo: contain the key, trace its use in the cloud audit logs, then replace keys with OIDC.

This tool is not affiliated with, endorsed by or sponsored by GitHub, Inc. or Microsoft Corporation. GitHub and GitHub Actions are trademarks of GitHub, Inc. Other names are trademarks of their respective owners.