Network egress control for GitHub Actions workflows. Uses eBPF for kernel-level connection tracking and a transparent proxy to attribute every outbound connection to the process that made it.
CI/CD pipelines routinely execute third-party code: actions, build tools, and dependencies. A compromised component can exfiltrate secrets, inject malware into build artifacts, or establish command-and-control channels—all from within a trusted workflow.
Example attack scenario:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install # Dependency executes postinstall script
# Script phones home to attacker.com with $GITHUB_TOKEN
- run: npm run buildWithout egress controls, this exfiltration succeeds silently. The workflow logs show a successful build.
With Egress Filter:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: gregclermont/egress-filter@v1
with:
policy: |
github.com action=actions/checkout
*.npmjs.org
- uses: actions/checkout@v4
- run: npm install # postinstall script tries to reach attacker.com
# Connection blocked, logged, build fails
- run: npm run buildThe connection to attacker.com is blocked because it's not in the allowlist. The connection log shows:
{"ts":"2024-01-15T10:30:46.456Z","type":"https","dst_ip":"93.184.216.34","dst_port":443,"host":"attacker.com","policy":"deny","exe":"/usr/bin/node","src_port":54321,"pid":4521}The system operates at multiple layers to prevent bypass:
-
eBPF connection tracking — Kernel probes (
kprobe/tcp_connect,kprobe/udp_sendmsg) record the PID for every outbound connection in a BPF hash map before the connection leaves the machine. -
Transparent proxy — iptables redirects TCP traffic to mitmproxy (port 8080) and DNS to its DNS mode (port 8053). UDP packets pass through netfilter queue for DNS detection.
-
PID lookup — The proxy queries the BPF map to find which process initiated each connection, then walks
/procto extract the executable path, command line, cgroup, and GitHub Actions context (step, action repository). For Docker containers, it queries the Docker socket to resolve the container image name. -
Container TLS MITM — A runc wrapper (
src/runc_wrapper.py) intercepts container creation to inject the proxy's CA certificate into the container rootfs. It appends the cert to system CA bundles and sets environment variables (NODE_EXTRA_CA_CERTS,SSL_CERT_FILE, etc.), enabling full HTTP-level inspection (URL/path matching, method filtering, Socket.dev scanning) for container traffic. -
Policy enforcement — Each connection is checked against the allowlist. Non-matching connections are blocked (or logged in audit mode).
- IPv6 blocked at kernel level — BPF cgroup hooks reject all IPv6 to force traffic through the IPv4 proxy
- Raw sockets blocked — BPF prevents
SOCK_RAWandAF_PACKETto stop iptables bypass via crafted packets - Network namespace escape blocked —
kernel.unprivileged_userns_clone=0prevents creating new network namespaces - sudo disabled by default — Prevents privileged escapes (flush iptables, create namespaces, etc.)
Add the action as the first step in your job:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: gregclermont/egress-filter@v1
with:
policy: |
# Package registry
*.npmjs.org
# GitHub (scoped to checkout action)
github.com action=actions/checkout
- uses: actions/checkout@v4
- run: npm ci
- run: npm testStart in audit mode to discover what your workflow needs:
- uses: gregclermont/egress-filter@v1
with:
audit: true # Log only, don't blockAfter the workflow runs, download the connection log artifact and analyze it against your policy:
gh run download -n egress-connections <run-id>
uvx --from 'git+https://github.com/gregclermont/egress-filter' \
egress-filter analyze --log connections.jsonl .github/workflows/build.ymlThe CLI shows which connections would be blocked, with process context:
BLOCKED connections (would fail with this policy):
------------------------------------------------------------
https://registry.npmjs.org [exe=/usr/bin/node, step=build.__run_1]
tcp://evil.com:443 [exe=/usr/bin/node]
Summary: 12 allowed, 2 blocked (out of 14 unique connections)
Add -v to also see allowed connections and which rule matched them. Add rules for legitimate blocked connections, then remove audit: true to enforce.
For quick inspection without the CLI, use jq:
jq -r 'select(.policy=="deny") | "\(.dst_ip):\(.dst_port) \(.exe)"' connections.jsonl | sort -uPolicies use an allowlist model—connections not matching any rule are blocked. Each line specifies an allowed destination:
policy: |
# Hostname (port 443/tcp implied)
api.github.com
# Explicit port and protocol
8.8.8.8:53/udp
# CIDR range
10.0.0.0/8:*
# Wildcard subdomains
*.amazonaws.com
# Prefix wildcard
derp*.tailscale.com
# URL with path (GET/HEAD only by default)
https://api.github.com/repos/*/releases
# URL with method
POST https://api.github.com/repos/*/issues
# Placeholders for current repository (from GITHUB_REPOSITORY)
https://github.com/{owner}/{repo}/*
# TLS passthrough (skip MITM for cert pinning, etc.)
pinned.example.com passthroughSome services use certificate pinning or embedded trust stores that reject the MITM proxy's CA certificate. The passthrough keyword skips TLS interception for matching connections while still enforcing the allow policy:
policy: |
# Allow and MITM normally
github.com
# Allow and skip TLS interception
pinned.example.com passthrough
# Passthrough scoped to Docker containers
*.docker.io passthrough cgroup=@docker
# Header context for multiple passthrough rules
[passthrough cgroup=@docker]
registry.example.com
auth.example.comPassthrough is evaluated as a separate phase — a connection must first match an allow rule, then passthrough rules are checked independently. This means you need both an allow rule and a passthrough rule (or use the same hostname for both). Only hostname and wildcard rules support passthrough; IP, CIDR, URL, and DNS-only rules do not.
Rules can be restricted to specific processes:
| Constraint | Description | Example |
|---|---|---|
action= |
GitHub Action repository (JavaScript actions only) | github.com action=actions/checkout |
step= |
Job and step identifier | example.com step=build.__run_2 |
exe= |
Executable path | *.tailscale.com exe=/usr/bin/tailscaled |
arg= |
Match any command line argument | example.com arg=--config=/etc/app.conf |
arg[N]= |
Match specific argument by index | example.com arg[0]=node |
cgroup= |
Linux cgroup path (or @docker, @host) |
168.63.129.16 cgroup=/azure.slice/* |
image= |
Docker container image | registry.example.com image=node:* |
Multiple constraints combine with AND logic—all must match.
See docs/POLICY.md for full syntax documentation.
GitHub Actions infrastructure is allowed automatically:
127.0.0.53:53/udp— systemd-resolved (local DNS resolver)168.63.129.16:80|32526 cgroup=...walinuxagent.service— Azure wireserver (scoped to agent)results-receiver.actions.githubusercontent.com— Job result reporting
All connections are logged to a JSONL file, uploaded as the egress-connections artifact by default.
{"ts":"2024-01-15T10:30:45.123Z","type":"https","dst_ip":"104.16.23.35","dst_port":443,"url":"https://registry.npmjs.org/lodash","method":"GET","exe":"/usr/bin/node","cmdline":["node","/app/install.js"],"cgroup":"/actions_job/abc123","step":"build.__run_1","policy":"allow","src_port":54321,"pid":3847}
{"ts":"2024-01-15T10:30:46.456Z","type":"tcp","dst_ip":"93.184.216.34","dst_port":443,"exe":"/usr/bin/node","cmdline":["node","/tmp/malicious.js"],"cgroup":"/actions_job/abc123","policy":"deny","src_port":54322,"pid":3892}Fields:
| Field | Description | Present |
|---|---|---|
ts |
Timestamp (ISO 8601) | Always |
type |
Protocol: http, https, tcp, udp, dns |
Always |
dst_ip |
Destination IP address | Always |
dst_port |
Destination port | Always |
policy |
Policy match result: allow, deny |
When policy evaluated |
src_port |
Source port | When available |
pid |
Process ID | When available |
exe |
Executable path | When available |
cmdline |
Command line arguments (list) | When available |
cgroup |
Linux cgroup path | When available |
step |
GitHub step ({job}.{action_id}) |
When available |
action |
GitHub Action repository | JavaScript actions only |
image |
Docker container image | Docker containers only |
url |
Full URL | http, https (MITM) |
method |
HTTP method | http, https (MITM) |
host |
Hostname (SNI) | https (TLS-layer) |
name |
DNS query name | dns |
passthrough |
TLS passthrough (MITM skipped) | When passthrough rule matched |
error |
Connection error type | On failure (e.g., tls_client_rejected_ca) |
security_block |
Socket.dev blocked this package | When socket-security blocks |
purl |
Package URL (e.g., pkg:npm/evil@1.0) |
When socket-security blocks |
reasons |
Alert reasons (e.g., ["critical:malware"]) |
When socket-security blocks |
When socket-security: true is set, the proxy intercepts package downloads from npm, PyPI, and Cargo registries and checks them against Socket.dev's free API. Packages flagged with critical or high severity alerts (malware, protestware, etc.) are blocked with a 403 response.
- uses: gregclermont/egress-filter@v1
with:
socket-security: true
policy: |
*.npmjs.org
files.pythonhosted.org
crates.io
static.crates.ioThis is opt-in and fail-open:
- Only enabled when
socket-security: trueis set - API errors or timeouts never break your build
- Only checks URLs that your policy already allows
- Results are cached in-memory for the duration of the CI run
Blocked packages appear in the connection log with security_block: true and the package PURL:
{"ts":"...","type":"https","url":"https://registry.npmjs.org/evil-pkg/-/evil-pkg-1.0.0.tgz","policy":"deny","security_block":true,"purl":"pkg:npm/evil-pkg@1.0.0","reasons":["critical:malware"]}| Input | Description | Default |
|---|---|---|
policy |
Egress policy rules (one per line) | (none) |
audit |
Log connections without blocking | false |
allow-sudo |
Keep sudo enabled for runner user | false |
socket-security |
Check package downloads against Socket.dev | false |
upload-log |
Upload connection log as artifact | conditional |
By default, the connection log is only uploaded as an artifact when audit: true, when a connection was blocked, or when there are connection errors (e.g., TLS failures). This reduces artifact noise for successful runs while ensuring logs are available for debugging. Set upload-log: 'true' to always upload, or 'false' to never upload.
By default, sudo is disabled because root access can bypass egress controls (e.g., flush iptables rules, create network namespaces, kill the proxy). Set allow-sudo: true if your workflow requires it.
For workflows that only need sudo temporarily (e.g., Tailscale setup), you can disable it after:
- uses: gregclermont/egress-filter@v1
with:
allow-sudo: true
- uses: tailscale/github-action@v3 # Needs sudo for setup
- uses: gregclermont/egress-filter/disable-sudo@v1 # Lock down sudo
# Remaining steps run without sudoThe enable-sudo sub-action can re-enable it later if needed.
- GitHub-hosted Ubuntu runners only (
ubuntu-latest,ubuntu-24.04) - Self-hosted runners are not supported
- IPv6 is blocked (traffic forced through IPv4 proxy)
action=requires JavaScript actions — Docker actions, composite actions, andrun:steps don't haveGITHUB_ACTION_REPOSITORYin their environment. Useimage=for Docker containers, orstep=/exe=for other cases.- Container CA injection is best-effort — The runc wrapper injects the proxy CA cert into containers via system CA bundles and environment variables. Runtimes that don't use the system store or the injected env vars (e.g., Java without keytool import, certificate pinning) will see TLS failures. If a container image pre-sets CA-related env vars (e.g.,
NODE_EXTRA_CA_CERTS), the wrapper won't override them and will log a warning. Use thepassthroughkeyword to skip MITM for hosts that reject the proxy CA. - Detached daemons lose GitHub context — Background processes that daemonize lose their parent relationship to Runner.Worker. Use
exe=to scope their traffic. - No WebSocket inspection — WebSocket connections are logged but not inspected after the upgrade handshake.
This project was inspired by:
- step-security/harden-runner — Pioneered egress filtering for GitHub Actions with MITM proxying
- GitHubSecurityLab/actions-permissions — Demonstrated using mitmproxy for GitHub Actions traffic analysis
MIT