Safeguard
DevSecOps

Securing self-hosted GitHub Actions runners

Self-hosted GitHub Actions runners trade GitHub's ephemeral isolation for persistent infrastructure access — here's how real incidents like CVE-2025-30066 exploited that gap.

Priya Mehta
DevSecOps Engineer
7 min read

Self-hosted GitHub Actions runners give teams control over build hardware, network access, and toolchains that GitHub-hosted runners can't match — but that control comes with a tradeoff most teams don't price in until something breaks. A self-hosted runner is a persistent (or semi-persistent) machine with a network identity, often sitting inside a VPC with access to internal services, cloud credentials, and artifact registries. When a workflow on that runner executes untrusted code — a malicious pull request, a compromised third-party Action, a poisoned dependency — the attacker doesn't just get a sandboxed container that disappears in minutes. They get a foothold on infrastructure you own.

This isn't theoretical. In March 2025, a single compromised GitHub Action (tj-actions/changed-files, CVE-2025-30066) dumped CI/CD secrets in plaintext logs across an estimated 23,000 repositories before GitHub pulled the tag. Attacks like this land differently on self-hosted runners than on ephemeral, GitHub-managed ones, because the blast radius extends to whatever the runner can reach. Below is what actually causes runner compromises, how the March 2025 incident worked, and what controls stop lateral movement once a workflow goes bad.

What makes self-hosted runners riskier than GitHub-hosted runners?

Self-hosted runners are riskier because they're long-lived, network-connected, and often over-privileged, while GitHub-hosted runners are destroyed after every job. GitHub's own documentation states plainly that self-hosted runners "should never be used" on public repositories, because anyone who opens a pull request can potentially get a workflow to execute on your machine. A GitHub-hosted runner spins up a fresh VM per job and terminates it afterward — any compromise dies with the container. A self-hosted runner, by contrast, frequently persists across dozens or hundreds of jobs, retains disk state, keeps cached credentials in memory or on disk, and sits on a network segment with access to internal package registries, cloud metadata endpoints, and secrets managers. A 2022 CI/CD risk taxonomy from the security research community (the "Top 10 CI/CD Security Risks," CICD-SEC-4: Poisoned Pipeline Execution) specifically flags self-hosted runners as the highest-consequence variant of pipeline poisoning, because the attacker's code doesn't just run in a pipeline — it runs on infrastructure with a persistent identity.

How did the tj-actions/changed-files compromise actually work?

It worked by modifying a widely-used Action's code so that any workflow calling it would print CI secrets to public build logs. tj-actions/changed-files is a GitHub Action used by an estimated 23,000+ repositories to detect which files changed in a pull request. On March 14–15, 2025, an attacker gained write access (traced back to a compromised personal access token tied to a GitHub Actions bot) and modified the action's dist/index.js to fetch a Base64-encoded Python script from a remote memory-dumping tool, then printed decoded runner memory — including any exposed secrets, tokens, and environment variables — directly into the workflow's public log output for public repositories. The compromise also retroactively rewrote several historical version tags to point at the malicious commit, meaning even workflows pinned to what looked like a specific, previously-audited release were pulling poisoned code. GitHub Advisory Database tracked this as CVE-2025-30066, and a related upstream compromise of reviewdog/action-setup (CVE-2025-30154) was identified as the initial entry point. On a self-hosted runner, "secrets in logs" escalates further: if the runner's cloud instance profile or local credential cache is reachable during that job, the same code path that dumps environment variables can also reach IMDS endpoints or local SSH keys that never appear in the log at all.

Why is pull_request_target on a self-hosted runner a critical misconfiguration?

It's critical because that trigger runs with write-level GITHUB_TOKEN permissions and repo secrets while checking out attacker-controlled code from a fork. The standard pull_request trigger runs workflows against the base repository's code with a read-only, fork-scoped token by default — a malicious PR author can't touch your secrets even if their workflow file is malicious. pull_request_target, however, evaluates using the target repository's workflow file and token scope, then — if the workflow explicitly checks out github.event.pull_request.head.sha — executes the fork's code with that elevated access. This pattern was documented extensively by security researchers throughout 2021–2023 as the primary technique for hijacking self-hosted runners from public repositories: an external contributor opens a PR, the misconfigured workflow checks out their branch, and their code runs on your runner with your secrets and your network path. Combine this with a self-hosted runner attached to a public repo — which GitHub's docs explicitly warn against — and a single unreviewed pull request can lead to full runner compromise before a human ever looks at the diff.

How should teams isolate self-hosted runner infrastructure from production?

Teams should treat every self-hosted runner as a potential foothold and isolate it accordingly: dedicated VPC/subnet, no shared credentials with production, and ephemeral (single-job, then destroyed) lifecycle by default. GitHub's Actions Runner Controller (ARC) for Kubernetes supports ephemeral runners natively — each job gets a fresh pod that's torn down on completion, which eliminates cross-job credential leakage and cached-state persistence as an attack surface. For teams still running static VM-based runner pools, the minimum bar is: runner groups scoped per-repository or per-team (not one shared pool for the whole org), no direct network path from the runner subnet to production databases or internal admin panels, and cloud IAM roles scoped to only what that specific pipeline needs — never an org-wide deploy role attached to a build box that also runs third-party Actions. Set permissions: contents: read at the workflow level by default and only grant write scopes on the specific jobs that need them; this alone limits what a compromised dependency can do even if it executes.

What runner-level controls stop lateral movement after a workflow is compromised?

Egress filtering, ephemeral credentials, and Action pinning by commit SHA are the three controls that most directly limit what a compromised workflow can do once it's already running. Egress allowlisting (permitting outbound traffic only to known package registries and APIs) stops a memory-dumping payload like the one used against tj-actions from exfiltrating anything, because there's nowhere for the stolen data to go. Short-lived, OIDC-issued cloud credentials (instead of long-lived access keys stored as repo secrets) mean that even a fully dumped runner environment yields a token that expires in minutes, not a key that's valid until manually rotated — AWS, GCP, and Azure all support OIDC federation with GitHub Actions specifically for this reason. Pinning third-party Actions to a full commit SHA rather than a version tag (uses: org/action@a1b2c3... instead of @v4) closes the exact gap the March 2025 incident exploited, since tags can be moved by anyone with write access but a SHA reference cannot be silently repointed. Dependabot or equivalent tooling should also flag any Action pinned to a mutable tag as a finding, not a style preference.

How Safeguard Helps

Safeguard maps your CI/CD dependency graph — including third-party GitHub Actions, their transitive dependencies, and the runner infrastructure they execute on — so you can see which workflows actually reach production credentials before an incident forces you to find out. Reachability analysis distinguishes Actions that are merely present in a workflow file from ones whose code path is actually exercised with elevated permissions, cutting the alert volume security teams have to triage after incidents like the tj-actions compromise. Griffin, Safeguard's AI investigation agent, correlates newly disclosed CVEs and Action compromises against your live runner configuration and permission scopes, and can open an auto-fix PR that pins the affected Action to a known-safe commit SHA or drops unnecessary write permissions — often before your team has finished reading the advisory. Safeguard also generates and ingests SBOMs across your build pipeline so runner-level exposure is tracked as part of your broader supply chain inventory, not a separate spreadsheet.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.