Egress Control for Untrusted CI Workloads: eBPF/cgroup Filtering, DNS vs IP Allow-lists, SNI Limits and DNS Tunnelling
Last verified2026-10 Also see: Build platform security (caches, BuildKit secrets, SSH into jobs) · Sandbox isolation deep dive (gVisor vs Firecracker vs Kata, per-VM nftables egress) · GitHub & CI/CD security (workflows, runners, OIDC) · DNS deep dive (resolution, tunnelling, rebinding) · tcpdump, BPF and eBPF · TLS (SNI, ECH)
Start here: the basics in plain words
A CI job is a program you did not write, running on a machine you own, with network access. Egress is everything that program sends out: package downloads, test traffic, and, when the job is malicious, your secrets. Sandboxing (see the sandbox page) decides whether the job can break out of its box. Egress control decides what the job can do from inside the box, and in practice most real CI compromises never leave the box: they simply phone home with the credentials the job was handed.
Outbound network traffic from a job. The thing you are trying to limit.
A short list of destinations a job may reach (its package registry, its own cloud). Everything else is denied. The opposite of a block-list, which can never be complete.
A kernel object that groups processes so you can meter and limit them together. Every container, systemd service and CI job step lives in one. Version 2 is a single tree.
Small programs the kernel runs safely at defined hook points. A program attached to a cgroup can say yes or no to every connection made by every process in that group.
The hostname a TLS client writes, in plaintext, at the start of a connection so a shared server knows which certificate to use. Firewalls read it to learn where traffic is going.
A TLS 1.3 extension (RFC 9849, 2026) that encrypts the SNI. Firewalls that relied on reading it go blind.
Hiding data inside DNS queries and answers. Works through firewalls because almost everyone allows DNS.
A server that makes outbound connections on behalf of jobs, so policy and logging live in one place.
A fixed set of public IP addresses that one tenant's traffic leaves from, so the tenant's own firewalls can trust those addresses and nobody else's abuse gets them blocked.
Think of a job as a hotel guest. The sandbox is the room lock. Egress control is the switchboard: the guest can call room service and the front desk, but every outside call goes through an operator who checks the number, logs it, and refuses calls to the competitor across the street.
There are five places you can stop an outbound packet, from closest to the program to furthest away. The closer the control, the more context it has (which process, which job); the further away, the harder it is for the job to tamper with it:
job process internet
│ ▲
│ connect() │
┌──▼──────────────┐ ① cgroup eBPF: per-job yes/no at connect() │
│ kernel socket │ (knows the job, no packet exists yet) │
└──┬──────────────┘ │
┌──▼──────────────┐ ② netns / nftables / tc on the job's veth │
│ job network ns │ or tap device (knows the VM, sees packets) │
└──┬──────────────┘ │
┌──▼──────────────┐ ③ host firewall: drop metadata, RFC 1918, │
│ host kernel │ other tenants; allow only the proxy │
└──┬──────────────┘ │
┌──▼──────────────┐ ④ egress proxy: hostname allow-list, SNI, │
│ proxy (Go) │ DNS pinning, per-job logs │
└──┬──────────────┘ │
┌──▼──────────────┐ ⑤ NAT with a dedicated IP pool per tenant ───┘
│ egress gateway │
└─────────────────┘Memory hook"name, not number; outside, not inside." Policy should be written in hostnames (that's what dependencies are) but enforced on IPs the firewall resolved, and the enforcement point must sit where the job can't reach it: outside the VM, or in a cgroup hook the job lacks the capability to detach.
Threat Model: What Leaves a CI Job and Why
What is this?A list of the outbound flows an attacker wants from a CI job, so you can design controls that break each one instead of "blocking the internet" and then opening it again when the first build fails.
Why it mattersInterviewers use egress to test whether you can secure a pipeline without stopping it from building. Every honest build needs the network: it downloads packages, pulls base images and pushes artefacts. A control that cannot tell a dependency download from an exfiltration is a control someone will turn off.
How it worksThree flows cover nearly every real incident:
-
1. Exfiltrate what the job was givenattacksecrets, tokens, source
- A compromised dependency or action reads environment variables, cloud tokens or the OIDC token and POSTs them to a server the attacker owns
- Needs: one outbound connection to one arbitrary hostname
-
2. Steal what the host hasattackcloud metadata, internal services
- From an ephemeral runner, reach 169.254.169.254 (cloud metadata), the Kubernetes API, a package mirror with write access, another tenant's cache
- Needs: a route to an internal or link-local address
-
3. Call home and waitattackcommand and control
- A miner or a backdoor polls an attacker server for instructions, often over DNS or HTTPS to a domain that looks like a CDN
- Needs: any repeatable outbound channel, including DNS
-
Controls, one per flowdefenddefault-deny + name allow-list; deny internal ranges regardless of DNS; resolver confinement + telemetry
📰Real incident — Codecov Bash Uploader (2021). Codecov's uploader is a shell script thousands of CI pipelines fetched and ran on every build. From 31 January to 1 April 2021 an attacker, who had obtained a credential from a flaw in how Codecov built its Docker images, periodically altered the hosted script by adding a single line: a
curlthat sent the job's environment variables to a server the attacker controlled. The pieces: a script fetched over the network, executed with every secret the job had in its environment, on runners that could reach any host on the internet. What went wrong: nothing in the pipeline distinguished "upload coverage to codecov.io" from "upload the environment to an unknown IP". Why it was bad: the environment of a CI job is a bundle of API keys, cloud credentials and signing tokens, and hundreds of customer networks were affected; a customer only noticed because the script's checksum no longer matched the one on the download page. Lesson: a job with a hostname allow-list ofcodecov.ioplus its registry would have had its exfiltration blocked and logged on the first run; and verify the integrity of anything youcurl | bash.
📰Real incident — Shai-Hulud npm worm (2025). In September 2025 a self-replicating payload spread through hundreds of npm packages. When installed inside a CI job or developer machine it harvested npm tokens, GitHub tokens and cloud credentials (it also queried the cloud metadata endpoints), then exfiltrated them two ways: by pushing a GitHub Actions workflow into every repository the stolen GitHub token could reach, which sent that repository's secrets to a single
webhook.siteURL, and by creating a public repository containing the dump. It used the stolen npm token to republish itself into the victim's own packages. The pieces:npm installruns package lifecycle scripts, so any dependency can run code; CI jobs hold tokens for other systems. What went wrong: the job could reachwebhook.site, the metadata service and the npm and GitHub APIs with write scope. Why it was bad: each infected maintainer became a new publisher of the worm. Lesson: deny-by-default egress blocks thewebhook.sitechannel and the metadata lookup outright; the GitHub and npm API channels are legitimate destinations, which is why egress control is paired with short-lived, narrowly scoped tokens (see OIDC in CI) rather than used alone.
🎯On the job. When a team says "we allow-list egress", ask three questions. Where is it enforced, and can root inside the job turn it off? Is the list written in hostnames, and who resolved them? What happens to DNS: does a query for
evil.exampleleave the building? If the answers are "iptables inside the container", "IP ranges we copied from a doc" and "we never thought about DNS", the control stops a careless script, not an attacker.
Security angle
The tj-actions/changed-files compromise (CVE-2025-30066, March 2025) exfiltrated secrets by printing them into public workflow logs, with no network at all. Egress control does not help there; secret masking, short-lived tokens and pinned actions do. Know which control covers which flow.
Shai-Hulud used api.github.com and registry.npmjs.org, both of which every build legitimately needs. Egress reduces blast radius; token scope and lifetime decide what is lost through the holes you must leave open.
A runner that lives five minutes can still fetch cloud credentials from metadata and use them for months. Block the metadata address from job networks and require IMDSv2 with a hop limit of 1 on the host.
Kernel-Level Enforcement: cgroups v2 and eBPF
What is this?Attaching a small eBPF program to the cgroup that contains a job, so the kernel asks that program before letting any process in the job open a connection or send a packet.
Why it mattersIt is the only enforcement point that knows which job is talking, at the moment of connect(), before a single packet exists, and it is identical on an AMD EPYC and an AmpereOne node because it is the same kernel code. iptables inside the container can be flushed by root inside the container; a cgroup program attached by the host cannot be removed by a process that lacks the capability to touch BPF.
How it works
The pieces you need to knowcgroups v2 is one tree under /sys/fs/cgroup. Each container, systemd scope or Kubernetes pod is a directory in that tree, and every process belongs to exactly one directory. eBPF programs can be attached to a directory, and they apply to every process in it and in its sub-directories. The kernel offers several hook points for network programs on a cgroup:
Runs when a process calls connect() (and for UDP, on the first send). The program sees the destination address and port and can deny it or rewrite it. No packet exists yet. Program type CGROUP_SOCK_ADDR, since Linux 4.17.
Same idea for unconnected UDP sends, which is how most DNS lookups happen. Lets you force every DNS query to your own resolver.
Runs on every outgoing packet from the cgroup, after routing. Sees the IP header; returning 0 drops the packet. Since Linux 4.10. Good for counting and for a last-line IP deny-list.
Runs on TCP events (connection established, retransmit, state change). Not for blocking; for telemetry and per-job connection accounting.
Attach to an interface, not a cgroup. They see packets from everyone on that interface, so they are for host-wide rules on a veth or tap, not per-job policy.
-
1. Platform creates the job's cgroupos/sys/fs/cgroup/jobs/job-812
- The runner process and everything it starts (shells, BuildKit, test binaries) land inside it
-
2. Host attaches the policy programdefendbefore the job's first instruction runs
connect4,connect6,sendmsg4,sendmsg6on job-812, flags BPF_F_ALLOW_MULTI so a parent policy still applies- The allow-list lives in a BPF map the host updates; the job cannot open the map
-
3. Job code calls connect(169.254.169.254:80)attacka dependency probing for cloud credentials
- The kernel runs the program first; it returns 0; connect() fails with EPERM; nothing was sent
-
4. Job code calls connect(140.82.112.3:443)networkapi.github.com, resolved by the pinned resolver
- Program finds the IP in the map (inserted when the resolver answered); returns 1; packet flows on to the host firewall and proxy
-
5. Every verdict is written to a ring bufferdefendjob ID, destination, decision, comm
- The platform's telemetry agent streams it to the job's audit record
The decision in the program is deliberately tiny: a map lookup keyed by destination IP and port, plus hard-coded denies for link-local and private ranges that no DNS answer may override. Written in C and loaded from Go with the cilium/ebpf library, the core of a connect4 program looks like this (defensive code, it only ever says no):
SEC("cgroup/connect4")
int job_connect4(struct bpf_sock_addr *ctx) {
__u32 dst = bpf_ntohl(ctx->user_ip4);
if ((dst & 0xffff0000) == 0xa9fe0000) return 0; // 169.254.0.0/16 ← metadata, always denied
if ((dst & 0xff000000) == 0x0a000000) return 0; // 10.0.0.0/8 ← internal, always denied
struct allow_key k = { .ip = ctx->user_ip4, .port = ctx->user_port };
if (bpf_map_lookup_elem(&allowed, &k)) return 1; // pinned by the resolver ← the allow-list
log_deny(ctx); // ring buffer entry, read by the host agent
return 0; // EPERM to the caller
}In practiceChecking what is attached where, from the host:
$ bpftool cgroup tree /sys/fs/cgroup/jobs
CgroupPath
ID AttachType AttachFlags Name
/sys/fs/cgroup/jobs
812 cgroup_inet4_connect multi platform_egress ← platform-wide floor, applies to every job
/sys/fs/cgroup/jobs/job-812
901 cgroup_inet4_connect multi job_connect4 ← this job's allow-list
902 cgroup_inet6_connect multi job_connect6 ← don't forget IPv6
903 cgroup_udp4_sendmsg multi job_sendmsg4 ← DNS goes only to the pinned resolver
$ cat /sys/fs/cgroup/jobs/job-812/cgroup.procs | head -3
48213 ← runner
48260 ← bash step
48301 ← buildkitdAnd what the job sees when its dependency probes the metadata service:
$ curl -m 2 http://169.254.169.254/latest/meta-data/
curl: (7) Failed to connect to 169.254.169.254 port 80: Operation not permitted ← EPERM from connect(), no packet sent🎯On the job. Review the attach step, not the program. Is the program attached before the job's first command? Is it on the job's own cgroup (not on a shared parent only, or every tenant shares one list)? Does the job's user lack
CAP_BPF,CAP_SYS_ADMINandCAP_NET_ADMIN, so it cannot detach or replace it? Is there aconnect6twin, and does the host have IPv6 at all? Most "the eBPF firewall was bypassed" stories are one of these four.
Security angle
If the job escapes to a process outside its cgroup (a container breakout, or hostPID plus a ptrace into the kubelet), the policy no longer applies. Pair with the sandbox page: cgroup eBPF is layer ① of five, not the whole design.
A pod with CAP_SYS_ADMIN or CAP_BPF can attach its own programs or detach yours; hostNetwork: true moves the pod into the host's network namespace, past the per-pod veth where layer ② lives. Admission policy must forbid both for job pods.
connect()The socket-address hooks fire for connect() and sendmsg(), not for raw or packet sockets. Deny CAP_NET_RAW to jobs, and keep the cgroup_skb egress program and host nftables as the backstop that sees actual packets.
BPF itself is a privileged subsystem with a history of verifier bugs; only the host loads programs, and kernel.unprivileged_bpf_disabled=1 stays set. The job never gets to talk to BPF, it is only subject to it.
Sandboxed Runner Networking: gVisor Netstack vs Apple Virtualization
What is this?Where the network stack lives changes where your enforcement point can live. Three runner shapes are common on a mixed fleet: ordinary runc containers, gVisor sandboxes on Linux (EPYC, AmpereOne), and macOS virtual machines made with Apple's Virtualization framework (vz) on Apple Silicon.
Why it mattersA control that works for runc (a cgroup hook on the job's sockets) silently does nothing for a gVisor sandbox, and does not exist at all on macOS. Interviewers who run heterogeneous fleets ask this precisely because candidates assume Linux everywhere.
How it works
- runc — The job's processes create sockets in the host kernel. Cgroup hooks see every
connect(). The pod's veth carries its packets, so nftables and tc on the host side of the veth see them too. Both layers ① and ② are available. - gVisor — The application's TCP/IP runs inside the Sentry, gVisor's user-space kernel written in Go (its stack is called netstack). The Sentry cannot open host sockets; it is given a single
AF_PACKETsocket on a veth and writes raw Ethernet frames to it. Consequences: aconnect4hook on the Sentry's cgroup sees no connections, because the job'sconnect()never reaches the host kernel. Enforcement moves to the packets: nftables or tc on the host end of the veth, or acgroup_skbprogram, which does see the frames the Sentry emits. Host TCP/IP bugs are unreachable from the job, which is a security win, but--network=host(gVisor's "hostinet" mode) hands sockets back to the host kernel and gives that up. - Apple vz on macOS — The guest's network device is attached one of three ways.
VZNATNetworkDeviceAttachmentshares the host's network through Apple's NAT, convenient but the host sees only translated traffic and filtering it withpfis awkward.VZBridgedNetworkDeviceAttachmentputs the guest on the LAN, the worst option for untrusted code.VZFileHandleNetworkDeviceAttachmenthands the virtual NIC to a socket your own host process owns, so every Ethernet frame the guest sends passes through your code first: you can run a user-space stack (netstack again, in Go) that applies the same hostname allow-list and proxying as the Linux fleet. The Apple Silicon licence limits a Mac to two macOS guests, so density is low, but the enforcement story is the cleanest of the three because the "firewall" is a process the guest cannot see.
Hook at connect() (cgroup eBPF) and at the veth (nftables/tc). Job can see and, if privileged, fight the controls.
Hook at the veth only; connect() happens in Go, not the host kernel. Job cannot see the host at all.
Hook in the host process that owns the socket. Guest sees a plain Ethernet link and nothing else.
Host NAT does the work; policy is coarse (pf on the host) and attribution is by guest IP.
In practiceThe gVisor runtime flags that decide this, in containerd's runsc config:
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc.options]
TypeUrl = "io.containerd.runsc.v1.options"
ConfigPath = "/etc/containerd/runsc.toml"
# /etc/containerd/runsc.toml
[runsc_config]
network = "sandbox" ← netstack in the Sentry (default); "host" would bypass it
platform = "systrap"And a host-side rule set that treats the veth as the enforcement point, because the cgroup hook cannot see gVisor's connections:
table inet jobs {
chain fwd {
type filter hook forward priority 0; policy drop;
iifname "veth-job812" ip daddr 169.254.0.0/16 counter drop ← metadata
iifname "veth-job812" ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } counter drop
iifname "veth-job812" ip daddr 10.255.0.10 udp dport 53 accept ← pinned resolver only
iifname "veth-job812" ip daddr 10.255.0.11 tcp dport 3128 accept ← egress proxy only
}
}🎯On the job. On a gVisor node, run
bpftool cgroup treeand look for aconnect4program on the sandbox's cgroup with a non-zero run count. A zero count on a busy job is not "the job made no connections"; it means the hook is in the wrong layer. Move the policy to the veth or the proxy.
Security angle
netstack means host-kernel network bugs are out of reach, but netstack's own parser is now the attack surface for crafted inbound packets; the sandbox page covers that trade.
With Apple's NAT, outbound flows from two guests on one Mac share the host's address; per-job attribution needs the file-handle attachment or a per-guest proxy credential.
A user-space stack that filters on the first packet of a flow must reassemble or drop fragments; otherwise the SNI can be split across fragments to dodge the check.
DNS vs IP Allow-lists
What is this?Whether your policy says "allow 140.82.112.0/20" or "allow api.github.com", and, if the latter, who turns the name into an address.
Why it mattersDependencies are named by hostname and served by content delivery networks (CDNs) whose addresses change hourly. GitHub release assets come from Azure blob storage, npm from Cloudflare, PyPI from Fastly; the IP sets are large, shared with thousands of other customers, and published only as whole provider ranges. An IP allow-list is either stale (builds break) or so wide that it allows the attacker's site on the same CDN.
How it works
-
Why CIDR lists failattackthree ways
- Stale: the CDN rotates addresses; the build fails at 03:00 and someone adds 0.0.0.0/0
- Wide: allowing all of a CDN's ranges allows every tenant of that CDN, including the attacker's
- Blind: the log says "connected to 104.18.3.7"; nobody knows what that was
-
Resolve-and-pindnsthe firewall learns IPs from the DNS answers it saw
- Jobs may only use the platform resolver; that resolver (or a DNS proxy in front of it) inspects every answer
- If the queried name is on the allow-list, each returned IP is inserted into the enforcement map for the answer's TTL (with a floor, so a 0-second TTL doesn't flap)
- If the name is not allowed, the query is answered NXDOMAIN locally and never leaves
-
Hard floors that DNS can never liftdefendmetadata, RFC 1918, other tenants, the host
- Checked before the map, so an allowed name resolving to 10.0.0.5 is still denied
-
Dedicated egress IP poolservera fixed public IP set per tenant, after the proxy
- Customers put your pool in their firewalls; one tenant's scraping doesn't get the others blocked
This is how Cilium's toFQDNs policy works in Kubernetes (its DNS proxy records answers for names that match and programs the allowed IPs into the datapath), and how GitHub's hosted-runner egress firewall expands its default wildcard entries from the /meta API rather than letting users write patterns.
In practiceA Cilium policy for a job pod: the toFQDNs rule needs a matching DNS rule, or the proxy never sees the answers it must learn from.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata: { name: job-812-egress, namespace: ci-jobs }
spec:
endpointSelector: { matchLabels: { job: "812" } }
egress:
- toEndpoints:
- matchLabels: { k8s:io.kubernetes.pod.namespace: kube-system, k8s-app: kube-dns }
toPorts:
- ports: [{ port: "53", protocol: UDP }]
rules: { dns: [{ matchPattern: "*" }] } ← DNS is inspected here; answers feed the allow-list
- toFQDNs:
- matchName: "registry.npmjs.org"
- matchName: "api.github.com"
- matchPattern: "*.githubusercontent.com" ← one label, so evil.githubusercontent.com would match: keep patterns tight
toPorts: [{ ports: [{ port: "443", protocol: TCP }] }]What the datapath holds after the job's first npm ci, from the Cilium agent:
$ cilium fqdn cache list | grep npmjs
ci-jobs/job-812 registry.npmjs.org. 104.16.1.35,104.16.0.35 TTL 300 ← pinned from the answer the proxy saw📰Real incident — DNS rebinding used against local services (2022). Tailscale's Windows client exposed a local control API on a TCP port with no check of the HTTP
Hostheader (TS-2022-004, CVE-2022-41924). A web page could point a domain it controlled at the victim's own machine after the browser had resolved it once, then issue requests to that local API, including changing the coordination server to an attacker's. The pieces: a service that trusted "requests arrive from localhost" as proof of who sent them; DNS answers that can change between the check and the use. What went wrong: identity was inferred from an address rather than checked. Why it matters here: an IP allow-list is the same mistake in reverse. If an allowed name resolves to an internal address, a pinning firewall will happily allow the internal address, which is why the private and link-local floors are enforced before any DNS-derived entry. Lesson: never wildcard a name that strangers can register under (*.github.io,*.cloudfront.net,*.ngrok.app), and never let a DNS answer open an internal range.
🎯On the job. Pull the allow-list and the resolver config. Can a job reach
8.8.8.8:53, or any host on 853 (DNS over TLS), or a public DNS-over-HTTPS resolver on 443? If so the job can resolve names the firewall never saw, connect to the IP, and the pinning layer is bypassed; only the proxy layer can save you then. The fix issendmsg4to force DNS to one address, plus a proxy that is the only route out.
Security angle
An attacker's authoritative server can answer with a 0-second TTL and a different address each time; the pin must use a minimum TTL and remove entries when it expires, and connections already open may legitimately outlive the pin (Cilium has an idle-connection grace period for exactly this).
Pinning cdn.example.com to 104.18.3.7 also "allows" every other site on that Cloudflare address at the IP layer. Only a proxy that checks the hostname (next section) closes that; the IP pin is the floor, not the ceiling.
If a tenant's customers allow-list your pool, a job that escapes to another tenant's pool gets their trust. Pools map to tenants in the NAT layer, keyed by source network, which the job cannot change.
L7 Inspection and the SNI Boundary
What is this?Running the job's TLS connections through a proxy that reads the destination hostname and enforces the allow-list on it, and understanding the three ways that hostname can lie.
Why it mattersThe hostname is the policy language. IP pinning gets you "an address that some allowed name once resolved to"; the proxy gets you "this specific connection is to api.github.com". But the two common ways of learning the hostname, the HTTP CONNECT line and the TLS SNI, are both claims the client makes, and the newest TLS encrypts one of them.
How it works
Two proxy shapes exist. An explicit proxy is configured in the job (HTTPS_PROXY=http://10.255.0.11:3128); clients send CONNECT api.github.com:443 and the proxy opens that connection. A transparent proxy receives the job's TCP connections via nftables redirection (tproxy) without the job knowing, peeks at the first bytes, reads the SNI from the TLS ClientHello, and decides. Jobs ignore proxy variables (Go binaries compiled without HTTP_PROXY support, hard-coded clients), so production systems do both: explicit for clients that cooperate, transparent as the catch-all, and the host firewall allows nothing else.
-
Lie 1: SNI says one thing, the IP says anotherattackSNI spoofing
- The client connects to the attacker's IP but writes
api.github.comin the SNI; a proxy that allows by SNI and forwards to the client's chosen IP has been fooled - Fix: the proxy ignores the client's destination IP and dials the SNI name itself, through the pinned resolver
- The client connects to the attacker's IP but writes
-
Lie 2: SNI names an allowed CDN site, Host header names another tenantattackdomain fronting
- Inside the encrypted stream the HTTP Host header picks a different site on the same CDN; the proxy cannot see it
- Google and Amazon blocked this on their CDNs in 2018, Microsoft in 2022, but any provider that routes by Host rather than by SNI remains frontable
- Fix: allow specific hostnames, never whole CDN wildcards; terminate TLS where the risk warrants it
-
Lie 3: there is no SNI to readattackEncrypted Client Hello (RFC 9849, March 2026)
- The real ClientHello, with the real SNI and ALPN, is encrypted to a key the client fetched from the server's DNS HTTPS record; the outer ClientHello carries only a shared "public name" such as the CDN's
- The proxy sees
cloudflare-ech.comfor thousands of different sites - Options: strip ECH keys from DNS HTTPS answers at the pinned resolver (clients then fall back to plain SNI); require the explicit CONNECT proxy, whose hostname is outside TLS; or terminate TLS
-
TLS termination (man in the middle by design)defendwhat GitHub's hosted-runner firewall does
- The proxy presents its own certificate, minted per run from a per-run CA the job is told to trust, decrypts, enforces on the real hostname and path, re-encrypts to the destination
- Costs: certificate pinning and mutual TLS inside jobs break; the proxy now holds plaintext and a signing key; roughly 15–20% longer runs in GitHub's preview
In practiceThe heart of a transparent proxy in Go: peek the ClientHello, decide on the SNI, dial the name, splice. The standard library parses the hello for you if you hand it a connection that replays the bytes you peeked.
func handle(c net.Conn) {
peek := &replayConn{Conn: c} // records bytes so the real server still sees a full ClientHello
var sni string
tls.Server(peek, &tls.Config{GetConfigForClient: func(h *tls.ClientHelloInfo) (*tls.Config, error) {
sni = h.ServerName; return nil, errStop // we only wanted the name
}}).Handshake()
if !policy.Allows(jobOf(c), sni, 443) { // job identity from the source address → cgroup/VM map
audit(jobOf(c), sni, "deny"); c.Close(); return
}
up, err := dialPinned(sni, 443) // ← resolve the NAME ourselves; never trust the client's dst IP
if err != nil { c.Close(); return }
audit(jobOf(c), sni, "allow")
splice(peek.Replay(), up) // hand the original bytes on, then copy both ways
}The deny line in the proxy log, which is what an investigator will search for:
2026-10-24T09:14:02Z job=812 tenant=acme src=10.42.7.3 sni=webhook.site port=443 decision=deny rule=default-deny comm=node ← one line per connection, with the job🎯On the job. Three checks for any "SNI firewall". Does it dial the SNI name or the client's IP? Does its allow-list contain any bare CDN wildcard (
*.cloudfront.net,*.azureedge.net,*.githubusercontent.comis borderline)? What does it do with a ClientHello that carries theencrypted_client_helloextension: pass, drop, or terminate? "Pass" means the policy is advisory for any site behind an ECH-enabled CDN.
Security angle
A terminating proxy is a legitimate interception device. Its CA key must be per run or per tenant, short-lived, and never shared across tenants, or one tenant's job that steals it can impersonate every site to every other tenant.
ECH also hides ALPN, and QUIC (HTTP/3) moves TLS into UDP 443. Block UDP 443 from jobs so clients fall back to TCP where your proxy lives.
Fuzzed ClientHellos, slowloris connections and oversized records hit your Go code. Timeouts, size caps and a per-job connection budget are part of the design, not hardening after the fact.
DNS Tunnelling: Mechanics and Defences
What is this?Encoding data in DNS queries so that an attacker's name server receives it, and receiving instructions back in DNS answers. It works through a default-deny egress firewall because the firewall allows DNS to the internal resolver, and the resolver talks to the internet.
Why it mattersThis is the channel that survives everything above if you forget the resolver. A job whose only open port is 53 to 10.255.0.10 can still move a stolen OIDC token off the machine in a few hundred queries.
How it works
The pieces you need to knowA recursive resolver that does not know a name asks the authoritative server for that zone, wherever on the internet it is. So any name under a domain the attacker owns is, in effect, a message delivered to the attacker's server by your resolver, and the answer is a message back. The firewall allowed "DNS to the internal resolver"; it never saw the second hop. Tools such as iodine and dnscat2 package this into a general-purpose channel; from a defender's seat the details of the encoding matter far less than the shape of the traffic, which is distinctive: many queries, to one parent domain, with long labels that look like random text, often for record types a build never uses (TXT, NULL, CNAME) and with a high rate of "no such name" answers.
-
Job queries many names under one attacker-owned zoneattackthrough the platform resolver
- Each query carries a little data outward; each answer carries a little inward
-
Where the resolver is the holednsit forwards anything it doesn't know
- An open recursive resolver turns "DNS allowed" into "arbitrary outbound channel allowed"
-
Resolver confinementdefendthe single most effective control
- The job's resolver is a filtering forwarder: it answers queries for allow-listed names by recursing upstream and answers everything else NXDOMAIN locally, so the attacker's zone is never contacted
- This is the same allow-list the proxy uses, so it costs nothing extra to maintain
-
Rate and shape limitsdefendfor the names you must allow
- Per-job query budget (a build does hundreds of lookups, not tens of thousands), label-length and entropy scoring, counting distinct subdomains per parent domain, alerting on TXT/NULL from jobs
-
Telemetrydefendlog every query with the job ID
- In log-only mode you cannot block, but you can still see the one job that made 40,000 queries to one domain
In practiceAn Unbound configuration for the per-job resolver that only knows the allow-list, and the query log line an investigator would look for:
server:
interface: 10.255.0.10
access-control: 10.42.0.0/16 allow ← job networks only
local-zone: "." always_nxdomain ← default: every name does not exist
local-zone: "npmjs.org." transparent ← allow-listed zones are recursed upstream
local-zone: "github.com." transparent
local-zone: "githubusercontent.com." transparent
log-queries: yes
ratelimit: 200 ← per zone, queries per second[1761297812] unbound[412:0] info: job=812 10.42.7.3 x7k2m9q4p1z8v3c6b5n0.c2.example. TXT IN ← long random label, TXT, unknown zone: NXDOMAIN locally, alert
[1761297812] unbound[412:0] info: job=812 10.42.7.3 registry.npmjs.org. A IN ← normal📰Real incident — Shai-Hulud, the channel that failed (2025). The worm's primary exfiltration endpoint was a free
webhook.siteURL. Under the free plan that endpoint could receive only 100 callbacks before the platform deactivated it for excessive activity, so most victims' secrets never arrived there, although they were still exposed in workflow logs and in the public repository the worm created. The lesson is not that attackers are sloppy; it is that exfiltration channels have capacity and shape, and that a defender who meters the channels a job is allowed (how many connections, how many queries, to how many distinct names) catches even a channel they did not think to block.
🎯On the job. Point
dig +short TXT test.$(date +%s).exampleat the job resolver from inside a job. If it returns an answer or a SERVFAIL after a delay, the resolver recursed to the internet for an unknown zone and the tunnel is open. The correct result is an immediate NXDOMAIN and a log line with the job ID.
Security angle
It talks to the internet on behalf of every tenant. Rate-limit per job, cap the number of distinct names a job may resolve, and run it outside the job's reach (it is layer ④, not inside the sandbox).
A client that ships its own DoH resolver bypasses yours over port 443. The proxy's hostname allow-list is what stops it: public resolvers are not on the list.
Queries under an allowed zone (<random>.github.com) are forwarded; they just reach GitHub's name servers, not the attacker's. The residual risk is an attacker who controls a name server for an allowed zone, which is why wildcards over shared-hosting domains are dangerous here too.
BuildKit and Multi-Stage Pipeline Confinement
What is this?Making the container-image build itself, which runs arbitrary RUN instructions from a tenant's Dockerfile, obey the same egress rules as the rest of the job, without losing remote cache.
Why it mattersRUN is a shell with the network; a Dockerfile is a program. If the build daemon is more privileged than the job (it often is), a RUN step is the easiest place to reach things the job shouldn't.
How it works
--network=none at the right scopeBuildKit lets you set the network mode for RUN instructions: per instruction (RUN --network=none ...) or for the whole build (docker buildx build --network=none). Fetching with ADD from a URL, importing a remote cache (--cache-from) and pulling base layers are done by the daemon, not inside the RUN sandbox, so a build whose RUN steps are offline can still hit the cache and the registry.
Stage one, with the network, runs the package manager against an allow-listed mirror; stage two, with --network=none, compiles and tests. A test that needs the internet is a test that smuggles a channel.
--allow network.host (the network.host entitlement) gives a RUN step the host's network namespace. The daemon must not be started with --allow-insecure-entitlement network.host, or the flag is one Dockerfile line away.
Rootless buildkitd inside the job's sandbox and cgroup, never a shared privileged daemon over a socket. Then the daemon's network is the job's network and the cgroup and veth rules already apply to it.
Cache mounts (--mount=type=cache) and remote caches keyed only by content let one tenant's step read another's artefacts if keys collide across tenants. Partition cache storage per tenant and include the tenant in the key (see Build platform security).
Cross-compiling on EPYC for AmpereOne (or emulating with QEMU) changes nothing about egress: the emulated process is still a process in the same cgroup. What changes is time, so per-job connection budgets must be per unit of work, not per second, or emulated builds trip them.
In practiceA Dockerfile that fetches online and builds offline, and the daemon flags that keep it honest:
# syntax=docker/dockerfile:1.7
FROM golang:1.23 AS deps
ENV GOPROXY=https://goproxy.internal.example ← allow-listed mirror; direct module fetches are denied by the proxy
COPY go.mod go.sum ./
RUN go mod download ← the only step that needs the network
FROM deps AS build
COPY . .
RUN --network=none go build -trimpath -o /out/app ./cmd/app ← compile and test with no network at all
RUN --network=none go test ./...$ buildkitd --oci-worker-net=cni --rootless \
--oci-worker-no-process-sandbox=false ← keep the process sandbox
# note what is absent: no --allow-insecure-entitlement network.host, so `--allow network.host` is refused
$ buildctl build --frontend dockerfile.v0 --local context=. --local dockerfile=. \
--import-cache type=registry,ref=registry.internal.example/acme/app:cache ← cache import is done by the daemon, RUN stays offline
--allow network.host
error: entitlement network.host is not allowed ← the daemon refuses🎯On the job.
grep -rn "network.host\|security.insecure" .buildkit/ buildkitd.tomlon the runner image, and look at howbuildkitdis started. If a tenant can reach a daemon socket that belongs to another process, the daemon's privileges, not the job's, define the blast radius.
Security angle
network=none is a namespace, not a cgroupIt removes the interfaces; it does not stop a RUN step from talking to the daemon over its gRPC socket if that socket is mounted. Keep the daemon's socket out of the build sandbox.
--mount=type=secret keeps a token out of layers; it does not stop a RUN step that has the network from sending it. Use secrets only in offline steps where possible.
The # syntax= line fetches an image that then interprets your Dockerfile. Pin it by digest and keep it on the allow-listed registry.
Production Architecture and In-Practice Telemetry
What is this?How the layers combine on a real fleet: Linux pods under gVisor on EPYC and AmpereOne racks, macOS guests on Apple Silicon, GitHub Actions and Buildkite jobs arriving with single-use tokens.
Why it mattersThe system-design question is rarely "name a control"; it is "put them in order, say where each runs, and say what you'd see when one fails".
How it works
-
1. Job arrives with a single-use identityserverGitHub JIT runner config or Buildkite agent token, plus a signed OIDC token for the job
- The platform records tenant, repo, ref and job ID; everything after is tagged with them
-
2. PlacementosLinux: pod with RuntimeClass gVisor in its own cgroup and veth; macOS: vz guest with a file-handle NIC owned by the host agent
- Policy for the job is compiled from the repo's egress file (log or enforce) plus the platform floor
-
3. Attach before the first instructiondefendcgroup programs (runc) or veth/host-agent rules (gVisor, vz)
- Floors: metadata, RFC 1918, other job networks, the host, UDP 443. Only the resolver and the proxy are reachable
-
4. Resolver answers only allow-listed namesdnspins IPs for TTL, logs every query with the job ID
-
5. Proxy enforces hostnamesnetworkexplicit CONNECT and transparent SNI; dials names itself; terminates TLS only where the tenant opted in
-
6. NAT from the tenant's dedicated IP poolservercustomers trust those addresses
-
7. Telemetrydefendevery verdict (connect deny, DNS NXDOMAIN, proxy allow/deny) streams to the job's audit record and the run summary
- Enforce mode fails closed; the job sees EPERM or a proxy 403 and the summary says which binary and which rule
Who owns the policy matters as much as where it runs. GitHub's hosted-runner firewall, in technical preview during 2026, reads .github/egress-firewall.yaml from the branch being built, runs outside the runner VM, offers a log mode and an enforce mode, and terminates TLS with a per-run certificate. That is the shape to copy for self-hosted platforms: policy as code in the tenant's repository, enforcement in infrastructure the job cannot reach, log mode first so the allow-list is built from observed traffic rather than guesses.
In practiceA tenant policy file and the run summary the job's author sees when a dependency misbehaves:
# .github/egress-firewall.yaml (job needs permissions: contents: read, or the file is silently not loaded)
mode: enforce
allow:
- api.github.com
- registry.npmjs.org
- goproxy.internal.exampleEgress firewall summary — job 812 (enforce)
allowed registry.npmjs.org:443 142 connections
allowed api.github.com:443 6 connections
DENIED webhook.site:443 3 attempts binary=node rule=default-deny ← this is your incident
DENIED 169.254.169.254:80 1 attempt binary=node rule=platform-floor
DNS NXDOMAIN (not allow-listed) 2,114 queries parent=c2.example ← and this🎯On the job. Before switching a tenant from log to enforce, diff a week of log-mode destinations against the proposed allow-list and look for three things: cloud SDK endpoints that only appear on some runs, anything on UDP 443, and names with more than a handful of distinct subdomains. Those are, respectively, the builds you are about to break, the QUIC fallback you need to force, and the jobs to investigate before you give them an allow-list at all.
Security angle
A silent EPERM looks like a flaky network and trains developers to retry; the deny must surface in the job's own output with the rule that fired.
Metadata, private ranges and other tenants' networks stay denied even when a tenant sets mode: log; log mode is about their destinations, not yours.
A fork's PR can edit .github/egress-firewall.yaml. Read it from the base branch for pull_request events, and treat the pull_request_target footgun (see GitHub security) as part of the egress design.
Interview Questions
cgroup_skb and sock_ops?The job's processes all live in one cgroup v2 directory, and the kernel lets you attach small verified programs to that directory which run for every process inside it. The useful one for blocking is the socket-address hook, connect4 and connect6 and the UDP sendmsg twins, which runs at connect() time with the destination address and can return "no" so the caller gets EPERM and no packet is ever built. cgroup_skb egress runs later, per packet, after routing, and can drop a packet but only sees IP headers; it is the backstop for raw sockets. sock_ops runs on TCP events like connection established or retransmit and cannot block anything; it is for telemetry and per-job accounting. The catch is that these hooks only see sockets created in the host kernel, so under gVisor, where the job's TCP/IP runs in Go inside the Sentry, you must enforce on the veth or in the proxy instead.
Dependencies live on CDNs whose addresses are large, shared and rotate, so a CIDR list is either stale and breaks builds or so wide it allows every other tenant on that CDN, and the logs only show numbers. A DNS-based design keeps the policy in hostnames and has the firewall's own resolver learn the addresses: it inspects each answer for an allowed name and pins those IPs into the enforcement map for the TTL, with a minimum so zero-TTL answers don't flap, and answers everything else NXDOMAIN locally. That is how Cilium's toFQDNs works. The two bypasses are a job resolving names elsewhere, over DoH or a public resolver, and connecting to an IP the firewall never saw, which you stop by forcing all DNS to the pinned resolver and allowing nothing but the proxy out; and an allowed name resolving to an internal address, which you stop with hard floors for link-local and private ranges that no DNS answer can lift.
SNI is a plaintext claim the client makes, so a proxy must dial the SNI name itself rather than forward to the client's chosen IP, otherwise the client writes an allowed name and connects anywhere. Domain fronting is the second lie: the SNI names an allowed site on a CDN while the encrypted Host header names another tenant of the same CDN; the big providers blocked it in 2018 and 2022 but the fix on your side is specific hostnames, never CDN wildcards. The third is ECH, standardised as RFC 9849 in 2026, which encrypts the real SNI to a key published in the site's DNS HTTPS record and shows the proxy only a shared public name. Your choices are to strip ECH keys from DNS answers at your resolver so clients fall back, to require an explicit CONNECT proxy where the hostname is outside TLS, or to terminate TLS with a per-run certificate the way GitHub's hosted-runner firewall does, accepting that pinning and mutual TLS inside jobs break and that the proxy now holds plaintext.
The firewall allows DNS to the internal resolver, and the resolver forwards any name it does not know to the authoritative server for that zone, so names under an attacker-owned domain are messages delivered by your own resolver and the answers are messages back. The shape is distinctive, many queries to one parent domain with long random-looking labels and unusual record types, and heuristics on length, entropy, distinct-subdomain count and rate will catch tunnels against allowed zones. But the control that actually closes it is resolver confinement: the job's resolver only recurses for allow-listed zones and answers everything else NXDOMAIN locally, so the attacker's name server is never contacted. Pair it with blocking DNS over HTTPS by keeping public resolvers off the proxy's hostname list, and log every query with the job ID so the one job that made forty thousand lookups is visible even in log-only mode.
Under runc the job's sockets are host kernel sockets, so cgroup connect hooks and nftables on the veth both work. Under gVisor the job's TCP/IP runs inside the Sentry in Go, and the Sentry writes raw Ethernet frames to one AF_PACKET socket on a veth; the job's connect() never reaches the host kernel, so a cgroup connect hook sees nothing and you enforce on the veth, with tc or nftables, or in the proxy. The benefit is that host network-stack bugs are unreachable, unless someone sets --network=host. On macOS with Apple's Virtualization framework there is no cgroup at all; the NAT attachment hides the guest behind the host, but the file-handle attachment hands the virtual NIC to a socket your host agent owns, so every frame passes through your Go code and you can run the same netstack and hostname policy as on Linux. Same policy, three enforcement points.
Each job gets a network identity the proxy can trust, its source address on a per-job veth or VM, mapped at placement time to the tenant, repo and job ID along with the compiled allow-list from the repo's policy file plus the platform floor. The proxy listens two ways: explicit, where clients send CONNECT and the hostname is in the request line, and transparent via nftables redirection, where it peeks the ClientHello, which Go's standard TLS library will parse for you if you replay the bytes. In both cases it resolves the name itself through the pinned resolver, never trusts the client's destination IP, checks the floor before the list, and splices the connection with timeouts, size caps and a per-job connection budget. Every verdict is one log line with the job ID. Policies are loaded per job and dropped when the job ends, so the proxy's state is a map from source network to policy, and a job can only ever see its own.
RUN without losing remote cache hits?BuildKit sets the network mode for RUN instructions, per instruction with RUN --network=none or for the whole build with --network=none, while base-image pulls, ADD from URLs and --cache-from imports are done by the daemon outside the RUN sandbox, so the cache still works when the steps are offline. The pattern is a fetch stage that talks only to an allow-listed mirror followed by compile and test stages with no network at all. The daemon must be rootless, inside the job's cgroup and sandbox, and started without --allow-insecure-entitlement network.host, otherwise --allow network.host is one Dockerfile line away. Emulated or cross-compiled arm64 builds on EPYC change timing, not the policy, so connection budgets should be per unit of work rather than per second.
Because the destinations you must allow are exactly the ones attackers use. Shai-Hulud exfiltrated through api.github.com and republished itself through registry.npmjs.org, both on every build's allow-list; egress control blocked its webhook.site channel and its metadata lookup but could not block the legitimate APIs. What limits damage through the holes you leave open is the token: a job-scoped OIDC token that expires in minutes and is exchanged for narrowly scoped cloud credentials, instead of a long-lived secret in the environment. Egress control shrinks the number of doors, identity shrinks what walks out of the doors that stay open, and the audit record of both tells you afterwards which job did it.
The run summary, because a good design makes the deny visible with the binary and the rule that fired. In practice three causes cover most reports: a cloud SDK that only calls a regional endpoint on some runs, which log mode would have shown over a week; a client using QUIC on UDP 443 that the proxy cannot see, which you fix by blocking UDP 443 so it falls back to TCP; and a tool with certificate pinning or mutual TLS that breaks under TLS termination, where the answer is an explicit CONNECT path without termination for that hostname. If none of those match and the destination is an unfamiliar name with many random subdomains, it is not a false positive, it is the finding.