Skip to main content

Bypass risks and limits

Bullfrog enforces egress from inside the runner, and the runner is a machine your job's own steps control. This page describes what a step can do to get around the filtering, and what you set to prevent it.

Two things drive the list. Most gaps trace back to root on the runner, which enable-sudo: false closes. The rest follow from where the filtering happens: on addresses, which is coarser than a domain allowlist implies.

Root is the whole game

Bullfrog's rules live in the kernel's nftables ruleset, and its agent is an ordinary process. A step running as root can flush the ruleset, kill the agent, or open a raw socket and put packets on the wire beneath the rules that would have judged them.

Raw IP layer traffic is the clearest case. A program with CAP_NET_RAW constructs its own packets, and the queue rules that hand new connections to the agent are matched on connection tracking state the raw path doesn't establish in the usual way.

On a GitHub-hosted runner the runner account has passwordless sudo, so by default every step in your job can become root. That's the default because plenty of workflows need it. It also means that with enable-sudo: true, Bullfrog's guarantee is against accidents and unaudited dependencies, not against a step that is actively trying to get out.

Setting enable-sudo: false withholds root from the job's steps. If you're running in block mode against untrusted code, set it.

Docker access is root

Closing off sudo isn't enough on its own, because the runner account is also in the docker group and the Docker daemon runs as root. Any account that can talk to the Docker socket can ask the daemon to do root things on its behalf. docker run --privileged -v /:/host is enough to rewrite the sudoers file and put sudo back.

So when enable-sudo: false is set, Bullfrog also filters the Docker API and refuses requests that would hand a container control of the host:

  • --privileged
  • Bind mounts of host paths, including volumes backed by a host path
  • --cap-add, --device, --security-opt
  • Host namespaces: --net=host, --pid=host, --ipc=host, --uts=host
  • --volumes-from, non-default runtimes, volume plugins, swarm
  • macvlan and ipvlan networks, --sysctl, overridden masked or read-only paths
  • Builds and docker exec requesting any of the above

Ordinary container work is unaffected: docker build, docker run, image pulls, named volumes, user-defined bridge networks, published ports, resource limits, and dropped capabilities all behave as usual. Container egress is filtered like any other egress.

A refused request fails with a message naming what was refused and why:

docker: Error response from daemon: bullfrog: --privileged is not allowed while sudo is disabled: it is root on the host

If a job genuinely needs one of these, keep enable-sudo: true for that job, or move the privileged work into a separate job that doesn't run Bullfrog.

Steps before the action are unfiltered

The nftables rules exist only after the action's step has run. Anything a job did before that point was neither recorded nor filtered. This is a property of how the action installs itself, not something a setting changes. See How Bullfrog controls egress.

A wildcard is as trustworthy as its weakest subdomain

*.example.com allows every name under example.com, forever, including names that don't exist yet. If any one of them is attacker-controlled, whether through a subdomain takeover, a compromised service, or an open registration, the job has an egress path you approved. Allowlist exact hostnames where you can.

Domains sharing an IP address share your allowlist entry

Bullfrog enforces at the network and transport layers, L3 and L4: what it accepts or drops is a connection to an address and a port. Domain allowlisting is built on top of that. The agent watches DNS resolution, learns which addresses an allowlisted name currently points to, and permits connections to those addresses.

That indirection is the gap. Once an address is permitted, it's permitted for every domain that resolves to it, not only the one you allowlisted.

Shared infrastructure makes this ordinary rather than exotic. A CDN, a cloud load balancer, or a shared host serves many unrelated domains from one address and routes each request to the right origin by the name the client asks for: the SNI field in the TLS handshake, and the Host header in the request.

So suppose you allowlist trusted.example.com, which sits behind a large CDN. An attacker puts exfil.example.net behind the same CDN, where it resolves to the same address. A step in your job then:

  1. Resolves trusted.example.com. Bullfrog allows the lookup and learns the address.
  2. Opens a connection to that address. Bullfrog allows it, because the address belongs to an allowlisted domain.
  3. Sends the request naming exfil.example.net.

The CDN routes on the name in the request, so it reaches the attacker's origin. Bullfrog saw a connection to an allowlisted address, allowed it, and recorded it as authorized.

Your visibility has the same limit. The Domain column reports the name Bullfrog watched resolve to that address, which isn't necessarily the name the request asked for, so nothing in the job summary distinguishes this from a legitimate request.

Closing this requires inspecting the request at the application layer (L7) and matching the name it carries against the allowlist. That work is in progress.

Until it ships, treat an allowlisted domain on shared infrastructure as an allowlist entry for every other domain at the same address.

What Bullfrog does not see

  • Connection contents. Once the agent allows a connection, the fast-path rule accepts the rest of it without inspection. Bullfrog tells you a step talked to an allowlisted address; it doesn't tell you what it sent, or which domain the request named.
  • Anything outside the runner. Bullfrog judges egress from the job. It has nothing to say about what your workflow's secrets are used for elsewhere, or about a compromised action that only reads.
  • Unsupported platforms. Only the runners in Supported runners are supported. A job elsewhere is unprotected.

See also