Safeguard
Concepts

What is Egress Filtering in CI

Egress filtering in CI restricts where build jobs can send traffic, so a compromised dependency can't exfiltrate your secrets. Here's how to roll it out without breaking builds.

Tomas Lindgren
Platform Engineer
7 min read

Egress filtering in CI is the practice of restricting which network destinations a build job is allowed to reach, so that a compromised dependency, build script, or CI plugin cannot exfiltrate secrets or fetch second-stage payloads. Build runners are a strange blind spot: we firewall production aggressively, then hand a CI job the deploy keys, cloud credentials, and signing certificates for that same production — and let it open a TCP connection to literally anywhere on the internet. Every major CI supply chain incident of the last few years has had an outbound network call as its load-bearing step. Cut the call, and most of these attacks collapse into a local nuisance.

The attacks it actually stops

Egress filtering is not theoretical hardening. Map it against real incidents:

  • Codecov, April 2021. A modified bash uploader script ran inside thousands of CI pipelines and exfiltrated environment variables — including credentials — via a single curl to an attacker IP. An allowlist containing only codecov.io and your package registries would have blocked the exfil outright.
  • Dependency confusion, 2021 onward. Alex Birsan's proof-of-concept packages phoned home with hostname and user data from inside corporate build environments via DNS and HTTPS callbacks. The install succeeded because of a registry misconfiguration; the impact happened because the build could reach an arbitrary collection server. (Background: dependency confusion attacks explained.)
  • Malicious install scripts. npm postinstall hooks and setup.py payloads routinely POST ~/.npmrc, ~/.aws/credentials, and environment dumps to attacker infrastructure the moment npm install or pip install runs. The package looks fine on disk; the network call is the crime.

The pattern is identical every time: untrusted code executes in CI (which you can only partially prevent), then reaches out (which you can prevent almost entirely).

Audit mode first: learn what your builds actually talk to

Nobody knows their builds' real traffic profile, and turning on enforcement blind is how you break every pipeline in the company on a Tuesday. Start by observing.

On GitHub-hosted runners, StepSecurity's harden-runner action does this as a one-liner:

steps:
  - uses: step-security/harden-runner@v2
    with:
      egress-policy: audit

After a week of builds you get a per-job inventory of contacted domains — typically registry.npmjs.org, pypi.org, files.pythonhosted.org, github.com, objects.githubusercontent.com, a cloud metadata endpoint, and then three or four things nobody can explain. Those unexplained entries are the reason you're doing this.

On self-hosted infrastructure, get the same data from VPC flow logs, a transparent Squid proxy in logging mode, or tcpdump on the runner subnet. Aggregate by destination and by job; the job dimension matters because a sensible policy is per-pipeline, not global.

Enforcement options, from easiest to strictest

1. Runner-level agent. harden-runner with egress-policy: block and an explicit allowed-endpoints list enforces at the job level with eBPF, and also flags file tampering during the build. Lowest friction if you're on GitHub Actions.

2. Proxy with an allowlist. Route runner traffic through Squid or Envoy, allow only named domains, and export HTTP_PROXY/HTTPS_PROXY into jobs. Works across any CI system. The catch: tools that ignore proxy variables (some Go tooling, raw curl in scripts) fail in ways that look like flaky DNS, so pair it with a default-deny firewall rule to force everything through the proxy rather than silently around it.

3. Network policy on Kubernetes runners. If your runners are pods (GitLab Runner on k8s, Actions Runner Controller), a Cilium or Calico NetworkPolicy gives you default-deny egress with FQDN-based allows:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
spec:
  endpointSelector:
    matchLabels:
      app: ci-runner
  egress:
    - toFQDNs:
        - matchName: "registry.npmjs.org"
        - matchName: "pypi.org"
        - matchName: "files.pythonhosted.org"
    - toEndpoints:
        - matchLabels:
            k8s:io.kubernetes.pod.namespace: kube-system
            k8s-app: kube-dns

4. Internal registry as the only route. The strictest posture: builds can reach the artifact proxy (Artifactory, Nexus, or a caching mirror) and nothing else. All packages arrive pre-vetted through one quarantine point. This is where SCA policy enforcement composes nicely — the proxy is a natural place to block known-malicious packages before they ever reach a runner.

Whichever layer you choose, block the cloud metadata endpoint (169.254.169.254) from build steps unless a job explicitly needs workload identity. Credential theft via metadata service is the classic pivot from "code execution in CI" to "cloud account compromise."

What a realistic allowlist looks like

For a typical Node/Python monorepo, the steady-state list is shorter than people expect — usually 8 to 15 entries:

PurposeDestinations
Sourcegithub.com, objects.githubusercontent.com
npmregistry.npmjs.org
Pythonpypi.org, files.pythonhosted.org
Containersregistry-1.docker.io, auth.docker.io, production.cloudflare.docker.com
Your infrainternal registry, artifact storage, deploy target APIs
Telemetry you chosee.g. codecov.io, sentry.io

Two honest caveats. First, allowing github.com means an attacker can still exfiltrate to a Gist or a throwaway repo — domain allowlists reduce the attack surface, they don't eliminate it. Second, DNS itself is a channel; if you don't restrict resolvers, data leaves in TXT lookups. Pin resolvers and log queries. Neither caveat is a reason to skip the control; blocking 95 percent of exfil paths changes attacker economics substantially.

Rollout without riots

  1. Audit mode on everything for two weeks; build the per-pipeline inventory.
  2. Enforce on one low-traffic pipeline; fix the breakage (it's almost always a forgotten mirror or an OS package repo).
  3. Enforce on pipelines that handle secrets or publish artifacts — these first, because they're the ones the Codecov pattern targets.
  4. Make the allowlist a reviewed file in the repo, not a ticket to the platform team. Engineers will tolerate a control they can amend via PR.
  5. Alert on denials rather than silently dropping, at least initially. A blocked connection from npm install is either a missing allowlist entry or your best detection signal of the year — you want eyes on it either way.

Teams using Safeguard typically wire the denial events into the same feed as dependency alerts, since "new package" plus "new outbound destination" in the same build is about as high-signal as supply chain detection gets. If you want a broader curriculum on locking down pipelines, the Academy track on CI/CD security covers egress alongside runner isolation and secrets hygiene.

Frequently asked questions

Does egress filtering slow down builds?

Not meaningfully. A proxy or eBPF agent adds microseconds per connection; if anything, pairing the allowlist with a caching registry mirror makes dependency installs faster and more reliable than hitting public registries directly.

What about builds that legitimately need broad internet access?

Isolate them. Integration tests that hit third-party APIs can run in a separate job with a wider policy and, critically, no credentials or publishing rights. The job that holds signing keys and deploy tokens should have the narrowest network of anything you operate.

Is egress filtering enough to stop malicious dependencies?

No — it contains their blast radius. A malicious package can still corrupt build output or plant a backdoor in your artifact, which egress rules don't address. You still need dependency scanning and install-script controls; egress filtering is the layer that turns "our secrets were stolen" into "a connection was denied and we got an alert."

GitHub-hosted runners versus self-hosted — where is this easier?

Hosted runners: use harden-runner (or your CI vendor's equivalent) since you don't control the network. Self-hosted: you own the VPC, so enforce with security groups, a proxy, or Kubernetes network policy — more work, stronger guarantees, and the policy survives even if a job disables its own instrumentation.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.