Egress guarantee — exactly what the firewall connects to
This document is the deliverable of issue #27. It is the list an operator uses to configure their own firewall, and the statement our zero-egress claim is allowed to make.
It is kept honest by tests, not by review: see Assertions.
The claim, worded exactly
Zero egress means no telemetry to us unless you turn it on, and no vendor meter on your traffic.
It does not mean “no outbound connections at all.” The gate fetches metadata from the registry you configure and scores from the scorer you configure, and the cacheless MVP adds one upstream fetch per pull. Overclaiming this is how a strong, checkable argument gets discredited by the first competent reader (D65).
The precise, checkable form is:
Every address the firewall dials comes from operator-supplied configuration. There is no host literal in the egress path, and in particular none of them is ours.
That is a stronger statement than it looks. Every comparable product studied (2026-07-26) needs the vendor’s cloud reachable on the request path to render a verdict — which is why they must fail open when it is unreachable. We can default fail-closed because there is nothing of ours to be unreachable.
The list
Every entry is a Config field. Nothing here is a default that points at us.
| Destination | Env var | When it is dialled | Required? |
|---|---|---|---|
| Upstream registry | FW_UPSTREAM |
Every metadata request; npm/Maven/OCI artifact bytes; under FW_ATTESTATION_POLICY, each npm release’s document and its attestations (/-/npm/v1/attestations/, read from this host even when the metadata names another) |
Yes |
| PyPI files host | FW_FILES_UPSTREAM |
PyPI artifact bytes (a different host from the index) | PyPI only |
| deps.dev | FW_DEPSDEV_BASE |
api-mode scoring, and repo verification (D33) |
Unless stub/local mode and FW_VERIFY_REPO=false |
| Scheduler | FW_SCANNER_URL |
local/async scoring only — to launch a scan |
local mode only |
| Approval service | FW_APPROVAL_URL |
L2 score lookups, approval verdicts, and the capacity/health heartbeat | Optional |
| Known-malware snapshot | FW_MALWARE_FEED_URL |
Hourly, in the background, never on the request path; once at startup only if no snapshot exists yet (#157). Carries the subscription key from FW_MALWARE_FEED_AUTH_FILE when one is set (D366), to this host only |
Optional, empty by default |
| DNS resolver | (host resolver) | To resolve any of the above that is named rather than addressed | If you use hostnames |
Not dialled, despite looking like it:
FW_LISTEN_ADDR— inbound.FW_PUBLIC_URL— never connected to. It is only woven into the artifact URLs we mint, so the client’s next request comes back to us.
Behind a corporate forward proxy, this whole list collapses to one destination. Set
HTTPS_PROXY (and NO_PROXY for anything that must stay direct) and every entry above is
reached through it: the container needs outbound access to the proxy and to nothing else.
The claim at the top of this file is unchanged — the proxy address is operator configuration
like every other address here, and nothing new is dialled. If that proxy terminates TLS with
a corporate CA, it also needs FW_UPSTREAM_CA_BUNDLE (docs/CONFIGURATION.md), or every
upstream handshake fails x509: certificate signed by unknown authority. Measured against a
real chained proxy by e2e/tls_class7_product_test.go.
The heartbeat is not telemetry to us. FW_APPROVAL_URL is your approval service, on
your network. If you do not set it, no heartbeat is emitted at all.
Usage data is the one report you can choose to send us, and it is off (#173, D416). The
approval service, not the firewall, can post a daily usage report to APPROVAL_USAGE_URL. It
is empty by default, and no address of ours is built in, so the claim above still holds: the
destination is operator configuration like everything here. The console’s Usage data page
(and GET /v1/usage) shows the exact report each level would send before anything is turned
on. TestAnUnconfiguredInstallSendsNoUsage asserts that the default sends nothing.
Destinations an operator can add elsewhere in the stack (all off by default)
The list above is the firewall’s. The approval service can also send data out, but only to destinations the operator names, and only when one is named. These are operator-chosen third-party egress: data leaves for a service the customer picked. That is a different class from vendor egress, which stays none, ever. Each is off unless its variable is set.
| Destination | Env var | What leaves |
|---|---|---|
| Chat webhook (Slack / Teams) | APPROVAL_CHAT_WEBHOOK |
On each enforced block, once per package, version and reason per hour: the package, version, reason, and the rule and source that blocked it (#87) |
| False-positive destination | APPROVAL_FP_WEBHOOK |
Each false-positive report filed from the console (#142) |
| Mail relay | APPROVAL_SMTP_* |
Capacity alert emails, through the operator’s own relay (D90) |
What an operator must allow through their own firewall
Allow outbound from the firewall container to:
- your registry (and, for PyPI, the files host),
api.deps.dev:443— only if you useapimode or leaveFW_VERIFY_REPOon,- your scheduler and approval service, if configured,
- your DNS resolver.
Deny everything else. The firewall is designed to run in exactly that posture, and the container-level test below runs it that way on every pipeline.
To run with no internet at all: FW_SCORECARD_MODE=local (or stub),
FW_VERIFY_REPO=false, and an upstream on your own network.
Denying your clients a route around the proxy (deployment hardening, D170)
Everything above is about our outbound connections. This section is the mirror image and the only part of this document about your clients’ outbound connections.
In the cooperative mode we ship today, a client reaches the gate because it was configured
to — an .npmrc, a settings.xml, a pip.conf. Nothing stops a developer or a CI job from
pointing at the public registry instead, and the gate never sees the pull. Blocking direct
registry egress at the network layer removes that choice: a client that cannot reach
registry.npmjs.org has to come through us.
⚠️ This is hardening, not a substitute for interception (D170). It makes bypass inconvenient; it does not make the gate unavoidable, and it must never be described as doing so. Interception is what closes the bypass properly, because it depends on neither the client’s cooperation nor on your enumerating every destination. TLS-interception mode is not part of the open-source edition.
Deny by default, allow the proxy — do not blocklist registries
Write the rule as default-deny egress plus an allowance for the gate and DNS, never as a list of registry hostnames to block. A blocklist is an open set and loses: PyPI serves artifacts from a different host than its index, container registries redirect blob downloads to a CDN, and npm and Maven front their traffic the same way. Miss one and the bypass is silent — the same shape as an extension allowlist, which this repository has already been bitten by twice.
Kubernetes — default-deny egress for a build namespace, allowing only DNS and the gate (requires a CNI that enforces NetworkPolicy, such as Calico or Cilium; without one the object is accepted and does nothing):
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: builds-egress-through-yellowjack
namespace: builds
spec:
podSelector: {} # every pod in the namespace
policyTypes: [Egress] # with Egress listed, anything not matched below is denied
egress:
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: yellowjack
podSelector:
matchLabels:
app: yellowjack-firewall
ports:
- { protocol: TCP, port: 8080 }
A build host, scoped to the account that runs builds — host rules are per host, so this is only as good as your coverage of the fleet:
iptables -A OUTPUT -m owner --uid-owner build -d "$GATE_IP" -p tcp --dport 8080 -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner build -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner build -j REJECT
What it does not guarantee
- It is a network control, not an identity one. Anything with its own path out escapes it: a laptop off the VPN, a self-hosted runner outside the policy, a container on host networking, a developer’s phone hotspot.
- It is only as complete as the boundary you drew. Default-deny fixes the open-set problem within the namespace or host you applied it to, and says nothing about the ones you did not.
- It does not police an allowed destination. Most estates must permit their source-code host; dependencies fetched straight from release URLs or a git reference are then outside the package-manager path the gate inspects, and the network rule cannot tell them apart.
- It does not survive a client that is configured to use us and then fetches elsewhere — a postinstall script with its own HTTP client, for example — unless that traffic is also inside the deny.
Each of those is closed by the client being unable to talk to anything unintercepted, which is the interception mode’s property, not this one’s (and that mode is not part of the open-source edition).
Assertions
The property is enforced by four tests, because it has four independent ways to break. Each carries a negative control — an egress check that has only ever printed green is worse than no check, since it converts an unknown into false confidence.
| Test | Layer | Catches |
|---|---|---|
TestFirewallDialsOnlyConfiguredDestinations |
in-process dialler | a connection to any address no Config field names |
TestEveryOutboundClientIsObservable |
source (AST) | an http.Client with no Transport, which uses http.DefaultTransport and is invisible to the layer above |
TestFirewallBinaryLinksOnlyStdlib |
dependency graph | a third-party package, which can dial without passing through our transport |
TestFirewallMakesNoUnconfiguredEgress (e2e) |
container | any name the process resolves, including from code that never touches our transport |
Both are gated already: the first three run in unit-tests (go test ./...), the fourth in
one of the three sharded e2e jobs.
The argument here used to be “
make e2e ECOSYSTEM=allapplies no-runfilter, so everything runs”. Since #140 each shard DOES apply one, so that reasoning no longer holds and the assurance comes from a different place:TestEveryE2ETestIsInExactlyOneShardderives every test in the package from the source and asserts the shards partition it, so a test cannot fall out of the gate unnoticed. Recorded rather than quietly reworded, because “no filter” was the whole of the old argument.
The two layers are complementary, not redundant
- The in-process check sees
host:portfor every dial throughpooledTransport. It cannot see DNS (resolution happens inside the dialer, before the connect it wraps), and it cannot see a dialer it does not own. - The container check sees every name resolved, by anything in the process. It cannot see egress to a hard-coded IP literal, which needs no lookup.
Each covers the other’s blind spot. Retiring either one silently reopens a spelling.
Known gaps
-
TLS-interception mode is not part of the open-source edition. This bullet, and the tests it names, apply to the build that includes it.
-
TLS-interception mode (npm, increment 7) is covered, and by construction. The interception listener (
FW_INTERCEPT_LISTEN) never dials the host a client names. It terminates TLS only for the hostname ofFW_UPSTREAM, refusesCONNECTto any other host with a 403 and no dial, and hands the intercepted request to the sameServeHTTPthe cooperative path uses — so its one upstream iscfg.UpstreamRegistry, which is already inegressDestinations(). No new destination, no new dialler. Asserted, not claimed:TestInterceptDialsOnlyTheConfiguredUpstreamobserves every dial the intercepted path makes through the same observer this document describes, and checks that a refusedCONNECTdialled nothing. D65’s extension of the guarantee to this mode is therefore met for the host set the mode terminates today — the registry host, plusFW_FILES_UPSTREAM’s host for PyPI (increment 8), both of them configured destinations already. OCI (increment 11, below) and Maven (increment 12) added no host, and any host ever added must already be a configured upstream.Under
FW_ECOSYSTEM=oci(increment 11) one more thing is relayed, and it is not a new destination: a registry that wants a token names a realm in its 401 challenge, and the intercepted client is pointed at/_tokenon the registry host instead, from where the exchange is relayed to the realm the registry actually named. That realm is already a host this binary dials for its own scoring (ociEcosystem.ociToken), so the set of hosts the gate talks to is unchanged, and the client is never handed a host the operator did not configure.TestInterceptedOciPullStaysOnTheRegistryHostasserts a CONNECT to the realm host is still refused.Under
FW_ECOSYSTEM=maven(increment 12) the listener does one thing less than relay: an intercepted client names Central’s repository path (/maven2, the path inFW_UPSTREAM) in front of every request, and the listener strips it so the gate sees the path it gates cooperatively. A request on the registry host outside that path is refused with no dial — the gate relays one repository and does not construct a URL for anything else — so the mode’s only upstream is stillcfg.UpstreamRegistry, path included.TestInterceptedRequestOutsideTheRepositoryIsRefusedUndialledasserts the refusal dialled nothing.(Earlier versions of this bullet recorded the mode as a deliberate gap while only the e2e measurement rig existed —
e2e/mitmproxy, a throwaway container the firewall cannot import. That rig is still the instrument for the failure classes; the product path above is what closed the gap.) -
The container leg does not use an
--internalnetwork, so it proves “nothing was resolved”, not “nothing could have left.” Port publishing and an internal network do not compose cleanly; the DNS observation was chosen because it records the attempt, whereas an internal network only makes attempts fail silently — and a silent failure is precisely how a best-effort telemetry call would behave.
Yellow Jack