Skip to content

SekoiaLab/egress-filter

 
 

Repository files navigation

Egress Filter

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.

The Problem

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 build

Without 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 build

The 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}

How It Works

The system operates at multiple layers to prevent bypass:

  1. 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.

  2. 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.

  3. PID lookup — The proxy queries the BPF map to find which process initiated each connection, then walks /proc to 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.

  4. 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.

  5. Policy enforcement — Each connection is checked against the allowlist. Non-matching connections are blocked (or logged in audit mode).

Bypass Prevention

  • IPv6 blocked at kernel level — BPF cgroup hooks reject all IPv6 to force traffic through the IPv4 proxy
  • Raw sockets blocked — BPF prevents SOCK_RAW and AF_PACKET to stop iptables bypass via crafted packets
  • Network namespace escape blockedkernel.unprivileged_userns_clone=0 prevents creating new network namespaces
  • sudo disabled by default — Prevents privileged escapes (flush iptables, create namespaces, etc.)

Quick Start

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 test

Developing a Policy

Start in audit mode to discover what your workflow needs:

- uses: gregclermont/egress-filter@v1
  with:
    audit: true   # Log only, don't block

After 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.yml

The 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 -u

Policy Syntax

Policies 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 passthrough

TLS Passthrough

Some 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.com

Passthrough 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.

Process Scope Constraints

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.

Built-in Defaults

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

Connection Log

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

Package Security (Socket.dev)

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.io

This is opt-in and fail-open:

  • Only enabled when socket-security: true is 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"]}

Inputs

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

Connection Log Upload

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.

sudo Behavior

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 sudo

The enable-sudo sub-action can re-enable it later if needed.

Requirements

  • GitHub-hosted Ubuntu runners only (ubuntu-latest, ubuntu-24.04)
  • Self-hosted runners are not supported
  • IPv6 is blocked (traffic forced through IPv4 proxy)

Limitations

  • action= requires JavaScript actions — Docker actions, composite actions, and run: steps don't have GITHUB_ACTION_REPOSITORY in their environment. Use image= for Docker containers, or step=/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 the passthrough keyword 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.

Acknowledgments

This project was inspired by:

License

MIT

About

No description or website provided.

Topics

Resources

Stars

Watchers

Forks

Languages

  • Python 91.1%
  • Shell 4.9%
  • JavaScript 2.6%
  • C 1.4%