PRACTITIONER GUIDE | CI/CD SECURITY
Practitioner Guide13 min read

Self-Hosted GitHub Actions Runners: The Security Checklist to Run Before You Get Compromised

Why a machine you own, sitting behind your firewall, is often the softest target in your CI/CD pipeline, and the hardening steps that keep it from becoming an attacker's foothold

75 of 76
trivy-action version tags force-pushed to malicious commits in the March 2026 incident
10,000+
CI/CD workflows reportedly affected by the trivy-action tag compromise
1,000+
downstream SaaS environments reportedly impacted
12
hardening steps in this checklist, from runner ephemerality to OIDC credential brokering

SponsoredHorizon3.ai

Proactive Security for the AI Era

NodeZero continuously and autonomously pentests infrastructure, identity, cloud, and now web applications, chaining weaknesses across every domain the way real attackers do. Every finding ships with replayable proof showing exploitable business impact, not theoretical risk.

See NodeZero WebApp in action

A GitHub-hosted runner is a virtual machine that exists for the duration of one job and is destroyed the moment that job finishes. A self-hosted runner is a machine your team stood up, on your network, with your credentials cached, that stays alive and registered to accept the next job and the one after that. Teams choose self-hosted runners for good reasons: access to internal build caches, GPU hardware GitHub doesn't offer, private network resources GitHub-hosted runners can't reach, or avoiding per-minute billing on long-running builds.

That same persistence and network access is exactly what makes a self-hosted runner attractive to an attacker once any code running on it is malicious, whether that code arrived through a compromised third-party Action, a poisoned dependency, or a fork pull request your workflow trusted more than it should have.

This is a preventive hardening guide: the configuration decisions you make before a compromise, not the response steps you follow after one. If you've already found evidence of compromise on a runner, the forensic and remediation steps belong in GitHub Actions Supply Chain Incident: Forensic Response Checklist for Compromised CI/CD Pipelines. Read this one first, on a quiet Tuesday, not during an incident.

Why self-hosted runners are a distinct risk from GitHub-hosted runners

GitHub-hosted runners are ephemeral by design: a fresh VM is provisioned per job and torn down afterward, so anything an attacker plants dies with the job. Self-hosted runners break that assumption in three specific ways.

First, runner reuse across workflow runs. A self-hosted runner that isn't explicitly configured as ephemeral picks up the next job after finishing the current one, on the same filesystem, with the same installed tooling. Anything an attacker drops during one compromised run, a cron entry, a modified shell profile, a background process, can survive to influence or observe later runs, including runs from workflows and repositories that never intended to trust that code. Sysdig has documented attackers deliberately exploiting this: setting the environment variable RUNNER_TRACKING_ID to 0 or an empty string prevents GitHub's own cleanup job from terminating processes the attacker spawned, letting a planted process outlive the workflow that launched it. Attackers have also been observed registering the runner as a system service so it survives a reboot.

Second, network access to internal resources. The entire reason many teams run self-hosted infrastructure is to reach things GitHub-hosted runners can't: internal package registries, staging databases, VPC-only services, on-prem hardware. That access doesn't distinguish between your CI job and an attacker's payload running inside it. A compromised workflow step on a self-hosted runner can pivot to whatever that runner can reach, which is often a meaningfully larger blast radius than a GitHub-hosted VM that can only exfiltrate whatever secrets were explicitly injected into the job.

Third, credential exposure. Long-lived runners accumulate cached credentials, SSH keys, cloud provider tokens, Docker registry logins, that a fresh GitHub-hosted VM would never have. Attackers who gain code execution on a self-hosted runner have been observed harvesting far more than the current job's secrets: querying cloud instance metadata services, dumping runner process memory, and reading credential files left behind by prior jobs or by the humans who set up the machine.

GitHub's own documentation is direct about this: self-hosted runners should generally not be used for public repositories, because anyone can open a pull request or fork and, depending on workflow triggers, get code running on your infrastructure.

The trivy-action incident: what happened and why it matters

In late February and March 2026, attackers compromised aquasecurity/trivy-action, the official GitHub Action wrapping the widely used Trivy vulnerability scanner, and force-pushed 75 of its 76 version tags to point at malicious commits. A related repository, aquasecurity/setup-trivy, had 7 tags similarly poisoned. Because Git tags are mutable, workflows that pinned trivy-action to a tag like @v0.33.0 rather than a commit SHA silently started pulling attacker-controlled code the next time that job ran, with no visible change to the workflow file itself.

The root cause traced back further: attackers exploited a pull_request_target misconfiguration in the project's own workflows, a pattern where a workflow checks out and runs code from an incoming pull request while still carrying the privileged permissions and secrets of the target repository. That let an attacker's PR code execute with access to repository secrets, which is how a privileged personal access token was exfiltrated and used to force-push the malicious tags.

The payload itself was a credential harvester. It searched for SSH keys, cloud provider credentials, database credentials, Git and Docker configuration files, and Kubernetes tokens, and specifically went after Solana validator key pairs and cryptocurrency wallets. It also queried the AWS Instance Metadata Service and dumped runner process memory looking for anything else usable. Stolen data was exfiltrated to a typosquatted domain, and when that exfiltration path failed, the malware fell back to staging stolen data inside the victim's own GitHub account by pushing it to a public repository, using the victim's infrastructure to launder the theft. Reporting put the blast radius at more than 10,000 affected CI/CD workflows and over 1,000 downstream SaaS environments before the compromised tags were identified.

The reason this incident belongs in a self-hosted runner hardening guide rather than only a general supply chain one: on an ephemeral GitHub-hosted runner, this payload runs, harvests whatever secrets were injected into that one job, and dies when the VM is torn down. On a long-lived self-hosted runner with cached credentials, an installed cloud CLI already authenticated from a prior deployment job, and a route to internal systems, the same payload has a much larger set of things to steal and a much longer window in which to steal them. Remediation was to upgrade to safe versions (trivy 0.69.3, trivy-action 0.35.0 or later, setup-trivy 0.2.6 or later) and pin the action to a full commit SHA rather than a tag going forward.

Free daily briefing

Briefings like this, every morning before 9am.

Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.

Prerequisites before you start hardening

Before working through the checklist, get an accurate inventory. You cannot harden what you don't know exists.

List every self-hosted runner registered to your organization, including runners registered at the repository level that may not show up in an org-wide view. For each one, record what repositories and workflows can dispatch jobs to it, what network segments it can reach, what long-lived credentials are installed or cached on it, and whether it is currently configured as ephemeral (destroyed and re-provisioned per job) or persistent (stays alive across jobs indefinitely).

You will also need write access to organization-level and repository-level Actions settings (Settings > Actions > General), branch protection rules, and runner group configuration. If your organization uses a separate identity provider for cloud credentials (OIDC federation, a secrets manager), have those team's contacts ready, several of the steps below involve migrating from static secrets to short-lived, brokered credentials, which is rarely a solo change.

The hardening checklist

Work through these in order. The first several remove entire classes of risk; the later ones reduce blast radius for whatever gets through anyway.

1. Stop using self-hosted runners for public repositories

Anyone on the internet can open a pull request or fork a public repository. If a workflow on that repository can dispatch a job to a self-hosted runner, that stranger has a path to code execution on your infrastructure. Use GitHub-hosted runners for any public repository's CI. Reserve self-hosted runners for private repositories where the pool of people who can trigger a workflow run is known and access-controlled.

2. Make runners ephemeral by default

Configure runners to accept exactly one job and then de-register and tear down, rather than looping back to accept another. This is the single control that most directly closes the runner-reuse persistence path Sysdig documented: an attacker's planted process, service registration, or modified environment variable dies with the machine instead of surviving into the next job, regardless of which repository or team owns that next job.

3. Segment runner network access with deny-by-default rules

Do not place runners on a flat network with unrestricted egress. Apply firewall or security group rules that deny outbound access by default and explicitly allow only the destinations a given runner group actually needs (an internal package registry, a specific staging environment). Route any access to privileged internal systems through a narrow, audited control point rather than granting the runner direct network reachability.

4. Scope GITHUB_TOKEN to read-only by default

Set the default GITHUB_TOKEN permission at the organization level to read-only, and grant write permissions (contents: write, packages: write, etc.) only in the specific workflow files that require them, scoped to the minimum they need. A workflow that only needs to read repository contents and post a status check should never hold a token that can push commits or publish packages.

5. Require approval for workflow runs from fork pull requests

In repository settings, require maintainer approval before workflows run on pull requests from first-time or outside contributors (Settings > Actions > General > Fork pull request workflows). This creates a human checkpoint before untrusted PR code gets anywhere near a runner with elevated access, and it is the specific control that would have interrupted the pull_request_target abuse pattern behind the trivy-action compromise's root cause.

6. Audit and eliminate risky pull_request_target usage

Search every workflow file for the pull_request_target trigger. For each match, confirm whether the job checks out the incoming PR's code. If it does, and the workflow also has access to secrets, that is the exact misconfiguration that let attackers exfiltrate a privileged token in the trivy-action incident. Refactor to separate the privileged, secret-bearing steps from any step that touches PR-supplied code, or switch the trigger to a pattern that doesn't carry target-repository privileges into PR-controlled execution.

7. Pin every third-party Action to a full commit SHA, not a tag

A tag reference like @v1 or @v0.33.0 is mutable; whoever controls the upstream repository (or whoever compromises it) can force-push that tag to point anywhere, silently, with no change visible in your own workflow file. A full 40-character commit SHA is immutable. This is the single control that would have stopped the trivy-action tag hijack from affecting any workflow that used it, regardless of anything else in this checklist.

8. Isolate runner groups by trust level and repository

Don't share one runner pool across every repository in the organization. Create separate runner groups mapped to specific repositories or trust tiers, so a compromised low-trust project's workflow cannot dispatch a job to the same runner pool your production deployment workflows use. A runner that can reach deployment credentials should never also be reachable by a workflow in an experimental or low-scrutiny repository.

9. Replace static cloud credentials with short-lived OIDC tokens

Where runners authenticate to AWS, GCP, or Azure, use GitHub's OIDC provider to issue short-lived, per-run credentials instead of storing long-lived static access keys as repository secrets or, worse, as environment variables cached on the runner itself. A stolen OIDC token expires in minutes; a stolen static key doesn't expire until someone notices and rotates it.

10. Treat shared state (caches, workspaces, Docker sockets) as hostile

Build caches, workspace directories, and mounted Docker sockets that persist or are shared across jobs are a lateral movement path even on an otherwise ephemeral runner. Clear or isolate caches per trust boundary, avoid mounting the host Docker socket into job containers unless the job genuinely needs it, and don't let one repository's cache be readable by another repository's job.

11. Ship runner logs and process telemetry off the host

A compromised runner is not a trustworthy source of its own audit trail. Forward host-level logs, process execution records, and network connection logs to a system the runner itself cannot write to or delete from, so an investigation isn't dependent on evidence the attacker had the opportunity to tamper with.

12. Maintain a runner inventory and remove stale registrations aggressively

Runners that were provisioned for a project that ended, a team that moved on, or a one-off migration and never de-registered are exactly the kind of forgotten infrastructure attackers look for, unpatched, unmonitored, and often still holding credentials nobody remembers exist. Review registered runners on a recurring schedule and de-register anything not tied to an active, owned use case.

Validating your hardening actually worked

Configuration changes are not validated until you've tried to break them. Run adversarial tests against your own setup rather than assuming the settings did what you intended.

Open a test pull request from a fork (or a branch simulating an untrusted contributor) and confirm the workflow either doesn't run against a self-hosted runner at all, or requires explicit maintainer approval before it does. If it runs without approval, your fork PR gate isn't actually enforced.

On an ephemeral runner, SSH into the underlying host (in a test environment) mid-job, plant a marker file or background process, and confirm it is gone after the job completes and the next job starts. If the marker survives, the runner isn't tearing down the way you think it is.

Attempt to reach an internal-only resource (a staging database, an internal API) from a job running on a runner that shouldn't have that network path, and confirm it's blocked. Deny-by-default rules that were configured but never tested against an actual attempted connection have a way of turning out to have an unnoticed allow rule left over from before the segmentation project.

Grep every workflow file in the organization for uses: referencing a tag or branch rather than a full commit SHA, and for the pull_request_target trigger combined with a checkout of the PR head. Both are mechanically detectable and don't require waiting for an incident to find.

Check that GITHUB_TOKEN permissions declared in workflow YAML match what the job's steps actually need, a workflow declaring contents: write because an earlier version of the job needed it, when the current version doesn't, is a common drift pattern that manual review catches and automated scanning often misses.

Common mistakes teams make

Treating 'self-hosted' as a one-time infrastructure decision rather than an ongoing configuration surface. Runners get set up once, pass an initial review, and then never get revisited as workflows, permissions, and repository access evolve around them.

Configuring ephemeral runners at the infrastructure level but leaving the underlying VM image or container with cached credentials baked in, which defeats the point: the process lifecycle is ephemeral, but the credential exposure isn't.

Assuming pinning to a tag like @v4 is sufficient because it looks like a version number. It is not immutable, and the trivy-action incident is a direct demonstration of what happens when that assumption is wrong at scale.

Granting a single runner group broad network access because it's convenient for the one workflow that needs it, then reusing that same runner group for unrelated, lower-trust workflows because provisioning a second group felt like unnecessary overhead.

Enabling fork PR approval requirements for the organization's flagship repositories while leaving smaller, less-visible internal tools on default settings, exactly the kind of repository an attacker looks for because it's less likely to be reviewed as carefully but often shares the same runner infrastructure or credential scope as the higher-profile projects.

Relying entirely on GitHub's audit log for detection without shipping any host-level telemetry off the runner itself, which leaves you blind to exactly the kind of persistence mechanism (a background process, a modified environment variable) that doesn't necessarily generate a GitHub-side audit event at all.

Security tradeoffs: ephemeral runners vs. persistent pools

Ephemeral runners are not free. Provisioning a fresh runner environment per job adds spin-up latency, typically the time to boot a VM or container and re-download tooling and dependencies that a persistent runner would already have cached, which lengthens CI wall-clock time and increases compute cost, since you're paying to rebuild the same environment repeatedly rather than reusing a warm one. For organizations running frequent, short jobs, that overhead compounds across hundreds of daily workflow runs.

Persistent runner pools avoid that cost: warm caches, pre-installed dependencies, and faster job start times. The tradeoff is that persistence is precisely the property that lets a compromise on one job's code carry forward into the next job's execution environment, which is the mechanism behind both the RUNNER_TRACKING_ID persistence technique and the broader argument for treating self-hosted runners as exposed systems rather than disposable build plumbing.

A reasonable middle ground many teams land on: ephemeral runners for anything that executes code from outside a small, trusted set of maintainers (any workflow that can be triggered by a pull request, a fork, or an external contributor), and persistent runner pools reserved for internal, tightly access-controlled workflows where the trigger surface is fully known and the cost of ephemeral spin-up doesn't justify itself. That split concentrates the compute cost of ephemeral runners where the compromise risk is actually elevated, rather than paying it uniformly across every job in the organization.

The bottom line

Self-hosted GitHub Actions runners are not inherently less secure than GitHub-hosted ones, but they remove a set of default protections (ephemeral lifecycle, network isolation, no cached credentials) that GitHub-hosted infrastructure provides automatically, and most of the hardening work in this checklist exists to put those protections back deliberately. The trivy-action incident didn't require a self-hosted runner to succeed, mutable tag pinning and a pull_request_target misconfiguration are risks on any runner type, but the same payload lands with a materially larger blast radius on a persistent, internally-networked self-hosted machine than on a GitHub-hosted VM that's destroyed within minutes. Work through the checklist before an incident forces the question. If you're reading this after finding evidence a runner is already compromised, stop here and move to the forensic response steps instead.

Frequently asked questions

What is the difference between a self-hosted and a GitHub-hosted runner from a security standpoint?

A GitHub-hosted runner is a fresh virtual machine provisioned for a single job and destroyed afterward, so anything an attacker plants on it is gone when the job ends. A self-hosted runner is provisioned and maintained by your own team, often stays registered and accepts multiple jobs over time unless explicitly configured as ephemeral, and frequently has network access to internal resources and cached credentials that a GitHub-hosted VM never accumulates. That persistence and access are what turn a compromised workflow into a foothold rather than a contained, self-erasing incident.

Should self-hosted runners ever be used on public repositories?

Generally no. Any public repository can receive pull requests and forks from anyone, and depending on workflow triggers, that gives an unknown outside party a potential path to code execution on your infrastructure. GitHub's own security guidance recommends using GitHub-hosted runners for public repository CI and reserving self-hosted runners for private repositories where the set of people who can trigger a workflow run is known and access-controlled.

What made the March 2026 trivy-action incident possible, and how would pinning to a SHA have prevented it?

Attackers force-pushed 75 of 76 version tags in aquasecurity/trivy-action to point at malicious commits containing a credential-harvesting payload. Any workflow that referenced the action by a mutable tag, such as @v0.33.0, silently started pulling the attacker's code the next time that job ran, with no visible change to the workflow file itself. A full 40-character commit SHA reference is immutable and cannot be redirected this way, so workflows pinned to a SHA rather than a tag were unaffected regardless of the tag hijack.

What is an ephemeral runner and why does it matter for security?

An ephemeral runner is configured to accept exactly one workflow job and then de-register and tear down, rather than remaining alive to accept subsequent jobs. This matters because persistence is the specific property attackers have been observed exploiting on self-hosted infrastructure, for example, setting the RUNNER_TRACKING_ID environment variable to prevent GitHub's cleanup process from terminating an attacker-spawned process, letting it survive into later workflow runs. An ephemeral runner removes that survival path because the entire machine or container is destroyed regardless of what was planted on it.

How is this hardening checklist different from a post-compromise incident response checklist?

This checklist covers configuration decisions made before any compromise occurs: making runners ephemeral, segmenting network access, scoping GITHUB_TOKEN permissions, and requiring approval gates for fork pull requests. A forensic incident response checklist, such as the one at /blog/github-actions-supply-chain-incident-response-checklist, covers the steps taken after a compromise is already discovered: containing active access, auditing what was exfiltrated, rotating exposed credentials, and rebuilding trust in affected artifacts. Preventive hardening reduces how often you need the forensic checklist and how much damage occurs when you do.

What is the practical cost of switching from persistent self-hosted runners to ephemeral ones?

Ephemeral runners add spin-up latency because each job starts from a clean environment rather than a warm one with cached dependencies, which increases both CI wall-clock time and compute cost when jobs run frequently. Many teams manage this tradeoff by reserving ephemeral runners for workflows exposed to any untrusted or external trigger, such as fork pull requests, while keeping persistent runner pools for tightly access-controlled internal workflows where the trigger surface is fully known.

Sources & references

  1. Sysdig: How Threat Actors Are Using Self-Hosted GitHub Actions Runners as Backdoors
  2. DeepFrame: Self-Hosted GitHub Actions Runner Security Checklist
  3. The Hacker News: Trivy Security Scanner GitHub Actions Breached, 75 Tags Hijacked to Steal CI/CD Secrets
  4. Endor Labs: What We Can Learn About GitHub Actions Security from the Trivy Breach
  5. GitHub Docs: Security Hardening for GitHub Actions
  6. Decryption Digest: GitHub Actions Supply Chain Incident Response Checklist

Free resources

25
Free download

Critical CVE Reference Card 2025–2026

25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.

No spam. Unsubscribe anytime.

Free download

Ransomware Incident Response Playbook

Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.

No spam. Unsubscribe anytime.

Free newsletter

Get threat intel before your inbox does.

50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.

Unsubscribe anytime. We never sell your data.

Eric Bang
Author

Founder & Cybersecurity Evangelist, Decryption Digest

Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.

Giveaway: InfoSec World 2026 All Access Pass ($3,895 value)

Details →
Daily Briefing

Subscribe to enter the giveaway

Every subscriber is automatically entered. You also get daily threat intel every morning: zero-days, ransomware, and nation-state campaigns. Free. No spam.

Already subscribed? You're already entered.

Giveaway

Win a $3,895 InfoSec World 2026 pass.