GitHub is building a native egress firewall for its hosted runners that keeps enforcing even if an attacker gains root inside the runner VM. If you run self-hosted runners, read that sentence again: the firewall lives outside the VM, on infrastructure only GitHub controls. Your ARC runner pods in your own cluster get none of it — and 2026's supply-chain attacks have made that gap the most exploited seam in CI/CD.
The short version of this post is a five-step blueprint for closing it yourself:
- Run ephemeral ARC runners — one job per runner, then destroy it.
- Default-deny all egress from the runner namespace with a baseline policy.
- Allowlist by DNS name, not IP, with Cilium
toFQDNsrules pinned per runner pod. - Monitor before you enforce — learn real traffic first, then flip to blocking, mirroring GitHub's own Monitor-then-Enforce rollout.
- SHA-pin every third-party action so a force-pushed tag can't smuggle the exfiltration code in to begin with.
The rest of this post is the evidence behind each step: what GitHub is actually shipping, why self-hosted runners are the exposed flank, the concrete YAML to replicate the guarantee, and the honest limits of the DIY version.
What GitHub is actually building
GitHub's 2026 Actions security roadmap, published on the GitHub Blog by Principal Product Security Engineer Greg Ose, treats CI/CD as critical infrastructure. It has two headline additions: an Actions Data Stream for visibility (execution telemetry delivered to S3 or Azure Event Hub) and a native egress firewall for control. The firewall is the important one for this post, and its design has three properties worth cloning:
- Enforcement outside the runner VM. The firewall operates at Layer 7, outside the virtual machine the job runs in. GitHub states it plainly: it remains immutable even if an attacker gains root access inside the runner environment. A compromised workflow step can own the box and still can't reconfigure the thing deciding what leaves the box.
- A precise policy surface. Organizations define allowed domains and IP ranges, permitted HTTP methods, and TLS and protocol requirements — not just "port 443 open."
- Monitor, then enforce. Two complementary capabilities ship together: Monitor audits every outbound request, correlated to the workflow run, job, step, and initiating command, so teams can build allowlists from real traffic; Enforce blocks anything not explicitly permitted. GitHub's public preview target is six to nine months out, and the safe adoption path — observe first, restrict second — is the part self-hosted operators should steal first, because it costs nothing.
Note the scope line in the announcement: this is a firewall for GitHub-hosted runners. Nothing in the roadmap extends the guarantee to machines you operate. That is by design — GitHub can't enforce outside a VM it doesn't own — but it leaves every self-hosted fleet holding the exact risk the firewall was built to kill.
Why self-hosted runners are the exposed flank
Every major CI supply-chain incident of the last eighteen months ends the same way: malicious code runs inside a runner, and secrets leave over plain outbound HTTPS. The runner is the beachhead; egress is the heist. Three precedents, with numbers:
tj-actions/changed-files, March 2025 (CVE-2025-30066). Attackers compromised the action and retroactively repointed its version tags at a malicious commit that dumped runner-process memory — access keys, personal access tokens, npm tokens, private RSA keys — into build logs. CISA added it to the Known Exploited Vulnerabilities catalog, and the GitHub Advisory Database counts over 23,000 affected repositories. Mutable tags meant thousands of pipelines pulled the backdoor with no code change on their side.
aquasecurity/trivy-action, March 2026 (CVE-2026-33634). The escalation. On March 19, 2026, a threat actor force-pushed 76 of 77 version tags in trivy-action (plus all 7 tags in setup-trivy) to credential-stealing commits and published a malicious Trivy v0.69.4 release. The exposure window was roughly twelve hours; the payload exfiltrated CI secrets through GitHub release assets. CISA added it to KEV a week later, and by August the incident was tied to malicious LiteLLM releases that may have exposed over 2,100 organizations. The irony wrote itself: the scanner action became the infostealer.
Shai-Hulud, September 2025 onward. The self-replicating npm worm stole credentials and exfiltrated them to attacker repositories, then kept evolving: the November 2025 "2.0" wave hit 738 packages and 25,000-plus repositories, and — the detail that matters here — began creating malicious GitHub workflows wired to self-hosted runners, turning victims' own CI infrastructure into persistence. The August 2026 wave compromised the keyv caching library and spread to 400-plus packages within hours. The worm doesn't just abuse runners; it registers them.
The through-line: none of these attacks needed inbound access, a kernel exploit, or anything exotic. They needed code execution in a runner and an open outbound connection. GitHub's firewall deletes the second half of that equation for hosted runners. Self-hosted runners still offer both halves to anyone who can land a malicious step.
The blueprint: five steps to a self-hosted egress firewall
This is the expanded version of the TL;DR up top, aimed at the common self-hosted shape: Actions Runner Controller (ARC) scheduling ephemeral runner pods in a Kubernetes cluster you own.
Step 1: Ephemeral runners — one job, then destroy
Persistence is what turns a compromised runner into a backdoor. ARC's scale-set mode already supports ephemeral runners: each runner takes exactly one job and the pod is destroyed afterward. Shai-Hulud 2.0's rogue-runner trick depends on runners that stick around to run attacker workflows later; a fleet where no runner survives its job has nowhere to persist.
This is containment, not prevention — the malicious step still runs once — which is why every step below still matters. But it bounds every compromise to a single job's lifetime and a single job's secrets.
Step 2: Default-deny egress from the runner namespace
Before allowlisting anything, establish the deny floor. A vanilla NetworkPolicy selecting the runner pods with an empty egress rule denies all outbound traffic (DNS included, so the next step must explicitly re-allow it):
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: runner-default-deny-egress
namespace: arc-runners
spec:
podSelector:
matchLabels:
app.kubernetes.io/part-of: gha-runner-scale-set
policyTypes:
- Egress
egress: []This one object already breaks the exfiltration half of every attack in the previous section — along with, admittedly, every legitimate fetch your builds do. That is expected: the deny floor exists so that everything you open afterward is a deliberate, reviewable exception.
Vanilla NetworkPolicy has a hard ceiling, though: except for ipBlock CIDRs, it cannot express "allow this DNS name." GitHub's registry endpoints, package mirrors, and artifact storage all sit behind DNS that resolves to large, shifting IP sets — thousands of CIDR blocks that no hand-maintained ipBlock list will track. An IP-only allowlist either rots into breakage or balloons into meaninglessness. That is why step 3 needs a CNI that understands DNS.
Step 3: DNS-aware allowlist with Cilium toFQDNs
Cilium's CiliumNetworkPolicy supports toFQDNs egress rules: allow traffic to a DNS name, and the Cilium agent continuously re-resolves it and programs the resulting IPs into the policy. This is the closest in-cluster equivalent to GitHub's domain-allowlist firewall. A starting sketch for ARC runner pods:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: runner-egress-allowlist
namespace: arc-runners
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/part-of: gha-runner-scale-set
egress:
- toEndpoints:
- matchLabels:
k8s-app: kube-dns
io.kubernetes.pod.namespace: kube-system
toPorts:
- ports:
- port: "53"
protocol: ANY
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: github.com
- matchPattern: "*.githubusercontent.com"
- matchName: api.github.com
- matchName: ghcr.io
- matchPattern: "*.pkg.github.com"
- matchName: registry.npmjs.org
- matchName: pypi.org
- matchPattern: "*.pythonhosted.org"
toPorts:
- ports:
- port: "443"
protocol: TCPTwo things to notice. First, the DNS rule comes before the FQDN rules: toFQDNs enforcement requires the pods' DNS to flow through (and be visible to) Cilium's DNS proxy, so the kube-dns allow is load-bearing, not boilerplate. Second, this allowlist is deliberately minimal — GitHub control plane, container registry, two language mirrors. Your builds will need more (artifact storage, your own registries, SaaS deploy targets), and that is exactly what step 4 is for: every addition should come from observed traffic, not guesswork.
If you are not on Cilium, the honest alternatives are a forward proxy sidecar (Squid or similar, dual-homed so the runner pod has no direct route out) or a per-node egress gateway — both strictly more machinery than a CNI-native rule. Vanilla NetworkPolicy alone cannot do this job.
Step 4: Monitor before you enforce
GitHub's roadmap ships Monitor and Enforce as a pair for a reason: an allowlist written from guesses will break Monday's deploy. Replicate the sequence:
- Audit mode first. Ship the deny floor with logging (Cilium's Hubble gives per-flow visibility with pod and DNS context; the open-source Hubble UI is enough to start). Let a full week of real pipelines run — including the monthly release job everyone forgets — and collect the actual destination set.
- Build the allowlist from the logs. Every domain your legitimate jobs touched becomes a
toFQDNsentry. Everything else stays denied. Correlate surprises back to the workflow and step that made them, the way GitHub's Monitor correlates to run, job, step, and command. - Flip to enforce. Only once the allowlist covers real traffic do you start dropping. Keep the audit trail running afterward — a denied-egress alert on a runner pod is now a high-signal compromise indicator, because legitimate traffic was already whitelisted.
This ordering is the difference between a firewall and an outage generator. The teams that skip straight to enforce spend the rollout adding panicked exceptions; the teams that monitor first spend it learning what their CI actually does.
Step 5: SHA-pin every third-party action
The firewall bounds what malicious code can reach, but the tj-actions and trivy-action incidents share a cheaper lesson: both payloads arrived through mutable tags. Repositories that had pinned tj-actions/changed-files to a commit SHA were immune — the moved tag simply never resolved to the malicious commit. Same story in March 2026: SHA-pinned trivy-action references sailed through the twelve-hour window untouched.
So the last step costs nothing and closes the delivery vector: pin every third-party action to a full commit SHA (with a comment noting the human-readable version), and let Dependabot or Renovate propose SHA bumps as reviewable PRs. Static analysis tools built for Actions workflows can flag unpinned references in CI itself. The firewall assumes breach; pinning makes the breach harder to deliver.
What the DIY version can't match
An honest blueprint names its ceiling. Three gaps remain between per-pod Cilium rules and GitHub's native firewall:
- Trust boundary. GitHub enforces outside the runner VM, so root inside the runner cannot touch the policy. Cilium enforces on the node, in the kernel's eBPF datapath — outside the pod, but on a machine your cluster shares. A container escape that reaches the host with enough privilege could tamper with the agent enforcing the rules. Ephemeral runners (step 1) shrink this window to one job, but the boundary is strictly weaker than a hypervisor-level firewall.
- Layer 7 depth. GitHub's firewall promises HTTP-method and TLS-aware policy. Cilium can do L7 HTTP policy with an Envoy proxy, but wiring per-runner L7 rules (allowed methods per destination, TLS version floors) is a second project on top of the DNS allowlist above — worth doing for high-value fleets, not a day-one default.
- Managed correlation. GitHub's Monitor ties every flow to run, job, step, and command out of the box. Hubble gives you pod-and-DNS-level flows; joining those to "which workflow step made this request" is integration work against the Actions API that you own.
None of these is a reason to skip the blueprint — default-deny plus a DNS allowlist still deletes the entire exfiltration class these attacks depend on. They are reasons to keep watching the upstream roadmap: if GitHub ever productizes the firewall for ARC or documents the policy schema, adopting the real thing beats maintaining the clone.
Runners are production now
GitHub's roadmap says it outright: CI/CD is critical infrastructure, and runners should be observable, controllable systems rather than disposable black boxes. The hosted side of that future is funded and scheduled. The self-hosted side is five steps of YAML, logging, and pinning discipline — unglamorous work that would have neutered every headline CI attack of the last eighteen months at the egress boundary.
Start with ephemeral runners and audit-mode logging this week. The firewall you build from what you observe will be the first control in your CI that assumes the workflow is already compromised — which, after tj-actions, trivy-action, and Shai-Hulud, is the only safe assumption left.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.



