Skip to main content

How Bullfrog controls egress

Bullfrog decides, packet by packet, whether a job's outbound traffic is allowed. Understanding what it inspects explains most of its behavior: why the step has to run first, why DNS is treated specially, and why a blocked connection fails immediately instead of timing out.

The agent sits in the kernel's packet path

The action installs an agent on the runner and loads a set of nftables rules. Those rules hook the kernel's input, output, and forward paths and hand selected packets to the agent for a verdict. Nothing is proxied and no traffic is decrypted: the agent sees packet headers, returns accept or drop, and the kernel does the rest.

Only two kinds of packet reach the agent:

  • Every DNS packet, in both directions, including packets belonging to an already-established connection.
  • The first packet of every new connection.

Packets on a connection that is already established and isn't DNS are accepted by a fast-path rule without consulting the agent. Once the agent has allowed a connection, it stops looking at that connection's data.

DNS is where domain allowlists are enforced

A packet header carries an address, not a hostname. For allowed-domains to mean anything, Bullfrog has to watch name resolution.

That's why the rules queue DNS responses as well as queries, even on established connections. When a job resolves an allowlisted domain, the agent reads the answer and learns which addresses that name currently points to. A later connection to one of those addresses is recognized as a connection to the allowlisted domain.

Two consequences follow directly:

  • dns-policy: allowed-domains-only is the default and refuses to resolve names outside the allowlist. A blocked lookup fails before any connection is attempted.
  • Allowlisting an address is not the same as allowlisting a name. A job that connects to api.example.com still needs a DNS answer, so allowed-ips alone won't get it there under the default DNS policy. See Reach an allowlisted IP address by hostname.

Watching DNS is also what makes dns-policy: any a real weakening rather than a convenience toggle. A hostname is attacker-controlled data going out over the network. A job that can resolve arbitrary names can encode a secret into a series of lookups against a resolver it controls, and never open a connection Bullfrog would judge.

A denied connection is reset, not dropped silently

When the agent refuses a connection, it injects a TCP reset back to the client. The step fails at once with a connection error instead of hanging until a timeout.

The nftables rules carry a narrow exemption to let those resets out, matched on a random per-run tag, an exact 40-byte length, and the RST flag with SYN clear. Nothing carrying a payload and nothing that could open a connection fits that shape.

The action only covers what comes after it

The rules load when the action's step runs. Connections made earlier in the job happen before any of this exists, so they're neither recorded nor filtered. That includes connections made by actions/checkout and by any action that downloads a toolchain.

Put the Bullfrog step first. There are cases where you deliberately don't, such as fetching the addresses you're about to allowlist, or running one wide-open step you don't want to open the allowlist for. That's a real trade-off, and it means exactly what it says: those steps are unfiltered.

The same reasoning applies across jobs. Each job gets its own runner, its own agent, and its own ruleset, so protecting a workflow means adding the step to every job in it.

Containers are covered; container jobs are not

Containers a step starts are covered. Their IPv4 egress goes through the DOCKER-USER chain and their IPv6 egress through the forward chain, and both queue DNS and new connections the same way.

A job with a container: key is different: the job's own steps run inside a container that GitHub starts before the workflow's first step, so the action never gets to install anything on the host. Container jobs aren't supported. Run the container from a step instead.

See also