# Buildcage for Docker

[](https://github.com/buildcage/docker)
[](https://github.com/marketplace/actions/buildcage-for-docker)




A `docker build` runs your `RUN` steps, and every dependency they fetch, with unrestricted network
access. Buildcage puts that behind an allowlist: each `RUN` step can reach only the destinations you
name, and anything else is blocked and reported.
- Your Dockerfile doesn't change, BuildKit isn't patched, and nothing Buildcage does is left in the
image layers.
- Run once in [`audit`](#operation-modes) mode and the report writes the allowlist for you, ready to
paste back into the workflow.
- A rule can name an HTTP method and a URL, inside TLS as well, so a build can fetch a package from
a registry without being able to publish one to it.
- It all runs inside your GitHub Actions job, with no agent and no external service.
See [buildcage.github.io](https://buildcage.github.io/) for what it does and why. To isolate a
workflow `run:` step rather than a Docker build, use
[Buildcage for `run:` Steps](https://github.com/buildcage/isolated-run).
## Contents
- [Requirements](#requirements)
- [Usage](#usage)
- [Engines](#engines)
- [Inputs](#inputs)
- [Report action](#report-action)
- [How it works](#how-it-works)
- [CA trust and compatibility](#ca-trust-and-compatibility)
- [Scope](#scope)
- [Limitations](#limitations)
- [FAQ](#faq)
- [GitHub's native egress firewall](#githubs-native-egress-firewall)
- [Documentation](#documentation)
## Requirements
The builder is a container on the runner itself, so this action needs a Linux runner with a working
Docker installation:
- **GitHub-hosted**
- `ubuntu-latest`, the versioned `ubuntu-*` images, and their `-arm` variants
- Lightweight images such as `ubuntu-slim` are not supported: they ship a Docker client with no
daemon
- **Self-hosted**
- Docker Engine 25.0 or later, with Compose v2.20.2 or later
- A host using cgroup v2
- For the `inspect` engine, a Docker data root that can hold an overlayfs upper directory: not
itself on overlayfs (Docker-in-Docker needs a volume for `/var/lib/docker`) and not on XFS
formatted with `ftype=0`
A runner that falls short fails while the builder starts, before any `RUN` step runs.
## Usage
Buildcage starts a BuildKit builder in your job. Point Docker Buildx at it as a remote driver and
build as usual. Run once in [`audit`](#operation-modes) mode to collect what the build reaches, then
switch to `restrict`. The examples below use the default `inspect` engine; [Engines](#engines)
covers the choice between the two.
### 1. Find out what the build reaches
```yaml
- name: Start Buildcage in audit mode
uses: buildcage/docker@fb8ec1accd272b4a9c38483989af89dbb23161a6 # v4.1.0
with:
proxy_mode: audit # Log every destination, block nothing
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver: remote
endpoint: docker-container://buildcage
- name: Build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
- name: Show Buildcage report
if: always()
uses: buildcage/docker/report@fb8ec1accd272b4a9c38483989af89dbb23161a6 # v4.1.0
```
The [report action](#report-action) writes every destination the build contacted to the Job Summary:
Its **Switch to restrict mode** section holds the allowlist, already written out from what the build
actually did. A request whose method, host or path a rule would read as a wildcard is listed under
it rather than written in, since copying it would permit more than was sent.
### 2. Enforce the allowlist
Paste that allowlist into the setup step and switch the mode:
```yaml
- name: Start Buildcage in restrict mode
uses: buildcage/docker@fb8ec1accd272b4a9c38483989af89dbb23161a6 # v4.1.0
with:
proxy_mode: restrict
allowed_url_rules: |
GET http://deb.debian.org/**
GET https://registry.npmjs.org/**
POST https://registry.npmjs.org/-/npm/v1/security/advisories/bulk
```
Each rule names the methods it permits, so this one lets npm fetch packages without letting it
publish any: a `POST` to the same host is blocked, as is every host not listed.
A blocked connection fails the job at the report step, so a build that starts reaching somewhere new
doesn't pass unnoticed.
### Example workflows
Each pair builds the same Dockerfile with and without rules:
`inspect` on an apt and npm build ([audit](.github/workflows/example-inspect-audit.yml) ·
[restrict](.github/workflows/example-inspect-restrict.yml)), `universal` on a Maven build
([audit](.github/workflows/example-universal-audit.yml) ·
[restrict](.github/workflows/example-universal-restrict.yml)).
## Engines
`proxy_engine` sets how closely a build's traffic is read. `inspect` is the default; `universal` has
to be set explicitly.
`inspect` terminates TLS and re-signs it with a CA generated for each builder container. Rules
match on method and URL, so `GET|HEAD https://registry.npmjs.org/**` allows a fetch while blocking a
publish on the same host, and the report names every request with its URL. The build has to trust
that CA: Buildcage adds it to the system store, to the CA-trust variables, to a JVM already in the
base image, and to Chromium's NSS database, so most toolchains need nothing extra (see [CA trust and
compatibility](#ca-trust-and-compatibility)).
`universal` reads only the SNI. Rules match on host and port, so `registry.npmjs.org:443` is the
most one can say, the report shows host and port, and the build's own TLS is left untouched.
Start with `inspect`. Reach for `universal` when a host's certificate cannot be re-signed, such as a
tool that pins one or ships a trust store Buildcage cannot inject into: either for the whole build,
or for that host alone with an `allowed_tls_rules` passthrough.
Both intercept at the network level, so a tool that ignores `HTTP_PROXY` is covered either way, and
both apply to `RUN` steps. What buildkitd fetches for itself stays outside: see
[Limitations](#limitations).
## Inputs
Every input is optional, and the ones below are the rules you write by hand. The full list, with
defaults and the engines each input applies to, is in
[Reference](./docs/reference.md#setup-action-inputs), and the grammar those rules are written in is
in [Rule syntax](./docs/reference.md#rule-syntax).
The builder is named `buildcage` unless `builder_name` says otherwise, and the Buildx `endpoint` has
to match whatever it is named. The setup action's `builder_name` output holds the name it used.
The inputs can also live in a YAML file next to the Dockerfile, named by `config_file`. See
[Config file](./docs/reference.md#config-file).
### Operation modes
`proxy_mode: audit` logs every destination the build reaches and blocks nothing. `restrict`, the
default, allows only what the rules match and blocks and logs everything else. Start with `audit`
when you first adopt Buildcage or when a dependency changes, and keep `restrict` for everyday
builds.
If you forget a domain the build needs, `restrict` blocks it and the report step fails with the
destination named, which is why it is worth running `audit` first.
`audit` also lets a connection made straight to an address through. Under `universal` that includes
cloud metadata (`169.254.169.254`) and the runner's own addresses; `inspect` still blocks those
unless a rule names the address as its host.
### Rules for the `inspect` engine
`allowed_url_rules` is the one to reach for. Each line is a method list, a space, and a URL pattern.
`*` stays inside one domain label or path segment, `**` crosses dots and slashes, and a rule with no
path allows any path on that host:
```yaml
allowed_url_rules: |
# apt
GET http://deb.debian.org/**
# npm: fetch packages, and the audit endpoint it posts to
GET https://registry.npmjs.org/**
POST https://registry.npmjs.org/-/npm/v1/security/advisories/bulk
# pip: one registry, two domains
GET https://pypi.org/simple/**
GET https://files.pythonhosted.org/packages/**
# a private registry is an ordinary host
GET|HEAD https://registry.internal.example.com:8443/**
```
`allowed_tls_rules` passes a TLS destination through undecrypted, judged on its SNI and port. It is
for TLS that isn't HTTPS, and for the hosts an `inspect` build must not decrypt:
```yaml
allowed_tls_rules: |
db.example.com:5432
repo.maven.apache.org:443
```
`allowed_ip_rules` covers connections made straight to an address, which never go through DNS. A
rule may be an address, a CIDR block or a wildcard, on either engine:
```yaml
allowed_ip_rules: |
192.168.1.10:443
10.0.0.0/8:443
```
### Rules for the `universal` engine
`universal` never decrypts, so rules name a host and a port. `allowed_https_rules` and
`allowed_http_rules` split by scheme, and `allowed_ip_rules` works as it does under `inspect`:
```yaml
allowed_https_rules: |
# npm and maven
registry.npmjs.org:443
repo.maven.apache.org:443
*.internal.example.com:443 # everything on the internal network
allowed_http_rules: |
deb.debian.org:80
allowed_ip_rules: |
192.168.1.10:443
```
A `#` starts a comment at the start of a line or after whitespace, so a group of rules can carry a
heading or a rule can carry its reason. It works the same in every rule input.
### Destinations you expect to stay blocked
A noisy dependency, or a domain you are deliberately keeping off the allowlist to confirm it stays
blocked, belongs in `known_blocked_rules`. Those rows are marked **Expected** in the report and stop
failing the job, and the destination stays unreachable:
```yaml
known_blocked_rules: |
telemetry.example.com
```
A name blocked at resolution, before any connection, has no port. A bare `telemetry.example.com` (or
`telemetry.example.com:*`) covers it; `telemetry.example.com:443` does not, since no port was involved.
One rule per line, and on `inspect` a line can also be a URL rule (a method and a URL, the
`allowed_url_rules` syntax) to acknowledge a single endpoint on a host whose other traffic is
allowed, such as a telemetry POST to an API you otherwise use:
```yaml
known_blocked_rules: |
POST https://api.example.com/telemetry
```
Any other blocked request to that host still fails the step. See
[Blocked rules](./docs/reference.md#blocked-rules-known_blocked_rules) for the full syntax.
## Report action
`buildcage/docker/report` reads the builder's communication log and writes the Job Summary. Add it
with `if: always()` so a failing build still reports:
```yaml
- name: Show Buildcage report
if: always()
uses: buildcage/docker/report@fb8ec1accd272b4a9c38483989af89dbb23161a6 # v4.1.0
```
An alert under the report's title counts what needs a look: requests blocked, connections that
failed and requests nothing acted on. It is a CAUTION when a request was blocked unexpectedly or the
report is incomplete and a WARNING otherwise. With nothing to count, or only requests blocked as
expected, there is no alert. Whatever was blocked is listed under **Blocked Hosts** with the reason,
marked 🚫, beside any connection that failed, marked ⚠️, and, under `inspect`, **Communication
details** names the URL of every request, allowed or blocked, with credential query parameters
replaced (see [Credentials in a URL](docs/security.md#credentials-in-a-url)). In `audit` mode the
summary also holds the **Switch to restrict mode** allowlist.
In `restrict` mode the step fails when a blocked connection is found, and with it the workflow. Pass
`fail_on_blocked: false` to report without failing, or list what you expect to stay blocked in the
setup action's `known_blocked_rules`. In `audit` mode nothing fails the step. The action's inputs
are in [Reference](./docs/reference.md#report-action-inputs).
### Traffic artifact
`upload_traffic_artifact: true` uploads the whole timeline as a `traffic.json`, one row per request
and per name lookup, with the method, URL, status, size and the address it resolved to. It is
uploaded even when the build fails. Under `universal` it omits the method, URL, status and resolved
address that only `inspect` sees. The fields are listed in
[Reference](./docs/reference.md#traffic-artifact).
## How it works
BuildKit itself is unpatched. Buildcage starts it with a network configuration that puts every `RUN`
step on its own network, and wraps its runtime so the build CA is mounted into a step for as long as
that step runs. Name lookups and traffic from there reach the resolver and the proxy in the builder
container, at the network level, so a tool that ignores the proxy environment variables is covered as
well. The figure is the `inspect` engine; `universal` follows the same path without terminating TLS,
and so needs no CA.
Buildx needs `driver: remote` because the builder is a second BuildKit, but multi-stage builds,
caching and the image that comes out are unaffected, and nothing Buildcage does reaches the LLB or a
cache key.
[Security Details](./docs/security.md) has the architecture of each engine, with a diagram of what
runs where and what it decides.
## CA trust and compatibility
`proxy_engine: inspect` terminates TLS and re-signs it with a CA generated for each builder
container, whose private key never leaves that container
([details](docs/security.md#the-ca-and-its-private-key)), so the build has to trust that CA. As each
`RUN` step starts, Buildcage points the variables the common toolchains read at a store that holds
it: `NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and `PIP_CERT`.
`CURL_CA_BUNDLE` is set only in a step with no system CA store of its own, since curl reads that
store already. A variable the base image or the Dockerfile already set is appended to rather than
redirected. `GIT_SSL_CAINFO`, `npm_config_cafile`, `AWS_CA_BUNDLE`, `CARGO_HTTP_CAINFO` and
`BUNDLE_SSL_CA_CERT` are appended to the same way when set, and otherwise left unset. Neither the
CA nor the variables are left in the image layers, except a copy a step hides where it cannot be
read (see [Limitations](#limitations)). The CA is also left in the
distribution's own anchor directory, so a step that installs `ca-certificates` partway through keeps
trusting it once `update-ca-certificates` has rebuilt the bundle from scratch. On SUSE, GnuTLS reads
`/var/lib/ca-certificates/pem` instead of the bundle, so the CA goes into that directory for the
step as well. A JVM already in the base image reads none of those variables and only its own
keystore, so the CA is added there too, to `$JAVA_HOME/lib/security/cacerts` and that of the first
`java` on `PATH`, in whichever shape it ships (JKS or PKCS#12), for the step and taken back out
before the layer is committed, letting `mvn`, `gradle` and `java` reach the proxy without
`proxy_engine: universal`. Chromium, including the
`chrome-headless-shell` that Puppeteer, Playwright and Remotion download, reads only its compiled-in
root store and the NSS database in `$HOME`, so for each step that database's `pkcs11.txt` gains a
read-only slot on a database holding only the CA, taken back out before the layer is committed. The
database itself stays the step's own, with whatever the step writes to it.
The full table, with what each variable points at when the step has a system CA store and when it
has none, is in [Reference](./docs/reference.md#ca-trust-variables). What this cannot cover is in
[Limitations](#limitations), below.
## Scope
Buildcage controls _where_ your build can connect, not _what code_ it runs. A malicious package
delivered through an allowed domain still runs. Treat it as one layer in a defense-in-depth
strategy, a last line of defense so that if something slips through your other measures, at least it
can't call home.
An allowlist also cannot stop anything leaving through a service you had to allow anyway. That is a
structural limit. What it does stop is traffic to a destination that is not on the list, and
infrastructure an attacker set up is normally not on it, because the build has no reason to reach
it. That is also the hardest kind of leak to find afterwards.
An allowlist generated from an audit run already blocks every destination the audit did not record.
Whether to go further depends on what the build has access to:
[Hardening](./docs/security.md#hardening) is what to look at when it holds credentials, personal
data, or source you do not publish. For the full threat model, see
[Security Details](./docs/security.md).
## Limitations
### What isn't covered
- buildkitd's own traffic is outside the allowlist. `FROM`, `ADD `, git contexts and the
frontend image a `# syntax=` directive names are fetched by buildkitd itself rather than by a
`RUN` step, and no rule applies to them.
See [What buildkitd fetches itself](./docs/security.md#what-buildkitd-fetches-itself).
- `allowed_tls_rules` is not decrypted. The SNI and the port are checked, and the proxy resolves
that name itself, so the connection reaches the host the rule named, but nothing inside the TLS
session is seen.
- `allowed_ip_rules` is not inspected at all, and doesn't require TLS either: once an `ip:port` pair
is allowed, any TCP-based protocol can use that path. Prefer a domain rule wherever the
destination has a stable name.
- `universal` never sees the method or the path. They travel inside TLS, so neither is enforced and
neither reaches the report or the traffic artifact. A request fronted behind an allowed SNI is
invisible to it as well, while `inspect` matches on the real `Host` and blocks it. See
[Domain fronting](./docs/security.md#domain-fronting).
### Protocols
- UDP is dropped, so QUIC and HTTP/3 either fall back to TCP or fail. Port 53 to the proxy, which is
the resolver, is the one exception. ICMP is dropped too.
- IPv6 is not used anywhere. The rule syntax refuses an IPv6 address, forwarded IPv6 is dropped, and
the proxy reaches allowed names over IPv4 only, so an allowed name with AAAA records and no A
record never resolves and no rule can clear it.
### Service discovery
The resolver has no upstream, so it returns nothing for a discovery record: `SRV`, `TXT`, `TLSA` and
`URI` queries come back empty, and the build connects to the name a rule allowed rather than to one
a nameserver picked for it. Clients that treat `SRV` as a discovery layer fall back to the host name
itself, so the host a rule names is the host the build reaches.
What this breaks is a client with no fallback, where the record is the only way it can find the
service at all. A `mongodb+srv://` connection string is the one to expect: use `mongodb://` with the
shard hostnames written out and allowlist those instead. Active Directory and Kerberos discovery
have the same shape.
Under `inspect`, a lookup for a `_service._proto.` name is reported as `discovery` when the
rules allow that host, and is not counted as blocked. A service name under any other host is
reported as blocked; see
[Blocked service names](./docs/reference.md#blocked-service-names).
### Under the `inspect` engine
- A tool that pins a specific certificate, or ships a bundled trust store it never lets the system
update, still needs `proxy_engine: universal` or an `allowed_tls_rules` passthrough: `inspect`
re-signs the connection, and a pinned or bundled store will not accept the new certificate.
- A client that insists on HTTP/2 through ALPN, as gRPC clients do (grpc-go since 1.67, for one),
fails to connect: `inspect` answers no ALPN. Pass its host through with `allowed_tls_rules`,
which judges the SNI and port only. A client that falls back to HTTP/1.1, such as curl, works.
- The JVM (Java, Kotlin, Scala) reads only its own keystore rather than the CA-trust variables. A
JDK already in the image at `$JAVA_HOME` or behind the first `java` on `PATH` is handled: the CA is
added to its keystore for the step and removed before the layer is committed. A keystore sealed
with a password other than the JDK default still falls back to `proxy_engine: universal`:
Buildcage will not rewrite it. No other JDK trusts the CA, such as one further along `PATH`, a
Gradle toolchain under `~/.gradle/jdks` or one the step itself downloads (an `sdk install`, an
unpacked tarball). Fetch it in an earlier `RUN` step and put it first on `PATH` or on `JAVA_HOME`
with `ENV`:
```dockerfile
RUN tar xzf jdk-21.tar.gz -C /opt/java && /opt/java/jdk-21/bin/java -jar fetch.jar # fails
RUN tar xzf jdk-21.tar.gz -C /opt/java
ENV PATH=/opt/java/jdk-21/bin:$PATH
RUN java -jar fetch.jar # fine
```
- Chromium trusts the CA through a slot added to the NSS database it reads: `~/.pki/nssdb` when it
exists, else `~/.local/share/pki/nssdb`, else a new `~/.pki/nssdb`, which Chromium then fills and
the image keeps, as it would under a Chromium before M146. A Chromium before M146 does not read
`~/.local/share/pki/nssdb`, so use M146 or later where that is the database. A new database
belongs to the home's
owner, so under someone else's home, such as `www-data`'s `/var/www`, Chromium does not open it
and does not trust the CA. An existing database the step's user cannot write, such as root's used
after `USER`, takes no slot, since Chromium would not open it either, and the step warns; use
`proxy_engine: universal` for Chromium there.
- Only the NSS database under the `HOME` the step starts with carries the slot. Chromium started
with another `HOME` (`HOME=/tmp chromium`, `export HOME=...`) or as another user (`su`, `gosu`,
`sudo -u`) does not trust the CA. Switch users with `USER` instead, which the slot follows as long
as no `ENV HOME` pins the home, and leave `HOME` alone within the step. A Firefox carrying
Mozilla's own root list, such as Playwright's or the one Selenium drives, reads a per-profile
database and does not trust the CA; use `proxy_engine: universal` or an `allowed_tls_rules`
passthrough for it.
- A step that changes the CA's own trust in the NSS database (`certutil -M`), or exports it and
imports it back, copies the CA into the step's database, which fails the build as a copy the
wrapper cannot take out. A step cannot remove the database's directory (`rm -rf ~/.pki`) either,
since the step sees it as a mount point. Every `RUN` step has this mount point, created if the home
has no database, so `git clone .` into the home and `rm -rf ~/.[!.]*` both fail; use a
`WORKDIR` outside the home. A copy of the home (`cp -a ~ /backup`) has the slot cut out of its
`pkcs11.txt` before the layer is committed.
- A `RUN` step that copies the system CA bundle into a binary or an uncompressed archive
(`go:embed`, `include_str!`, `tar cf`) fails: the copy carries the build's CA, which cannot be cut
out of a binary without corrupting it. Copy the bundle in an earlier `RUN` step instead:
```dockerfile
RUN cp /etc/ssl/certs/ca-certificates.crt ./certs/ && go build # fails
RUN cp /etc/ssl/certs/ca-certificates.crt ./certs/
RUN go build # fine
```
- A copy of the CA left in a step's layer is removed. One that cannot be, such as in a JKS keystore
sealed with a password other than `changeit`, fails the build (`fail_on_ca_residue: false` makes it
a warning, see [CA residue](./docs/reference.md#ca-residue)). A copy that cannot be read, in a compressed archive or a keystore encrypted under a password other than
none or `changeit` or naming more than a million key-derivation iterations, is not found and stays
in the image, as is one hex-dumped or re-encoded as base64 outside a PEM block in lines shorter
than 48 characters. See
[Security Details](./docs/security.md#inspect-proxy-engine).
- `audit` terminates TLS as well. It drops the rules, not the interception, so a tool that cannot
accept the CA fails in `audit` exactly as it would in `restrict`.
- An image with no system CA store (`scratch`, distroless, or `debian:*-slim` before
`ca-certificates` is installed) still gets every variable set, but pointed at a file trusting only
the proxy's own CA. That is enough for ordinary HTTPS, since `inspect` re-signs all of it with
that CA, but not for an `allowed_tls_rules` or `allowed_ip_rules` passthrough, which presents its
own real certificate. The decision is made once, from the rootfs as the step begins, so installing
`ca-certificates` partway through a step doesn't help a passthrough made later in the same step:
```dockerfile
RUN apt-get install -y ca-certificates && \
curl https://internal.example.com/pkg.tgz -o pkg.tgz # still fails: CURL_CA_BUNDLE was already
# fixed to the proxy-CA-only fallback
RUN apt-get install -y ca-certificates
RUN curl https://internal.example.com/pkg.tgz -o pkg.tgz # this step starts with a store, so
# CURL_CA_BUNDLE points at it instead
```
- A task runner that starts each command with a filtered environment drops the CA variables before
the command sees them. vite-plus does this for a cached `vp run` task, which keeps `NODE_OPTIONS`
but not `NODE_EXTRA_CA_CERTS`, so Node inside it fails with `SELF_SIGNED_CERT_IN_CHAIN`. In an
image with a system CA store, which holds the CA already, have Node read that store. As an `ARG`
the flag reaches every later `RUN` in the stage without staying in the image, unless an
`ENV NODE_OPTIONS` overrides it:
```dockerfile
ARG NODE_OPTIONS=--use-system-ca # Node 22.15+ (23.9+ on 23.x); older Node refuses to start
RUN vp run build
```
Otherwise pass the variable through on the task itself, as `untrackedEnv` rather than `env`, which
would put its value in the cache key. A task with `cache: false` keeps the whole environment.
```ts
// vite.config.ts
build: { command: "vp build", untrackedEnv: ["NODE_EXTRA_CA_CERTS"] },
```
- The CA goes into a copy of each directory holding a bundle it is added to (the CA store's, and
each one a CA variable points into), and the copy is mounted over the directory for the step's
duration. When one of them holds another, only the outer one is mounted. Removing or renaming a
mounted directory fails, while what is inside it behaves normally and what the step writes there
is copied back when it ends:
```dockerfile
RUN rm -rf /etc/ssl/certs # fails: the directory is a mount point
RUN rm -rf /etc/ssl/certs/* # fine
```
The same goes for the directory holding the keystore of a JDK at `$JAVA_HOME` or behind the first
`java` on `PATH`, unless it is inside the CA store's mount. A JDK with a keystore of its own has
its `lib/security` mounted, so a step cannot remove that JDK; take it off `PATH` and `JAVA_HOME`
with `ENV` first. A distribution JDK links to a shared keystore: Debian's is inside
`/etc/ssl/certs` and adds no mount point, while on RHEL, Fedora and their derivatives
`/etc/pki/ca-trust/extracted/java` is one.
- A custom CA path that is unexpectedly large (more than 20 MiB or 512 files) has injection skipped
for that variable only, the same degradation as when no CA bundle is found at all. A system CA
bundle whose directory holds more than 64 MiB or 4,096 files, or is the container root, is
treated as no bundle at all.
- Neither engine produces SLSA provenance. The traffic artifact is an observation record with no
content digest.
### On the runner itself
An allowlisted name that resolves to cloud metadata, to loopback, or to an address the runner itself
holds is blocked, so a compromised name cannot turn the proxy into a route back into the runner. A
mirror or registry running on the runner is therefore not reachable by name: allow it with
`allowed_ip_rules`, which skips that guard except for the proxy's own port. See
[A name may not resolve inward](./docs/security.md#a-name-may-not-resolve-inward).
### What the audit allowlist covers
The generated allowlist covers only what the engine classified. `allowed_tls_rules` and
`allowed_ip_rules` come back exactly as the audit run was configured with them, since nothing behind
a passthrough was ever decrypted.
## FAQ
**Can I keep `inspect` but leave a few hosts undecrypted?**
Yes, that is what `allowed_tls_rules` is for. The SNI and port are checked and the connection passes
through untouched, so a JVM build or a tool that pins a certificate can sit inside an otherwise
inspected build. Those hosts are enforced at host-and-port granularity, the same as `universal`.
**A host only ever gets looked up, never connected to. How do I write a rule for it?**
The report gives it a row with `DNS` as the rule kind and no port. If you want it to stay
unreachable without failing the job, put the name in `known_blocked_rules`, which is the one input
where a rule may omit the port. If the build actually needs it, write an ordinary host or URL rule
and the lookup is reported as allowed.
**One registry needs several domains. How do I find them all?**
Run `audit` and read the report. PyPI, for example, uses both `pypi.org` and
`files.pythonhosted.org`, and the audit report lists every domain the build touched, so the
generated allowlist already has them.
**Which engine should I start with?**
`inspect`, unless something in the build carries its own trust store. It is the only engine that can
tell a fetch from a publish on the same host. See [Engines](#engines).
**Why not use BuildKit's built-in `--proxy-network`?**
BuildKit's exec network proxy injects `HTTP_PROXY`/`HTTPS_PROXY` into each `RUN` step and can record
what it fetched as SLSA provenance, which Buildcage does not do. Two limits kept it from being the
enforcement mechanism. Its source policy matches a request's host, port and URL path but has no
notion of an HTTP method, so it cannot allow a fetch from a registry while blocking a publish to the
same host, which `inspect` does by terminating TLS. And because it is an explicit proxy, what it
enforces and records reaches only tools that honor the proxy variables; a tool that ignores them is
not covered. Buildcage enforces at the network level instead, so it also covers those tools and can
act on the method and URL.
## GitHub's native egress firewall
GitHub is building an egress firewall directly into Actions runners
([technical preview](https://github.com/github-early-access/actions-native-egress-firewall) as of
September 2026): opt a job into a firewall-enabled runner image and its traffic is inspected outside
the runner VM, in `log` or `enforce` mode, from a single `.github/egress-firewall.yaml` in the
repository. Because it sits outside the VM, a workflow that gains root inside the runner cannot
switch it off. Firewall-enabled images are GitHub-hosted and Linux only.
One policy for the whole run is one allowlist for every step in it: the destinations
`actions/checkout`, the caches and the setup actions need stay open to the build as well. Buildcage
writes a separate allowlist for the build you don't trust, so a `docker build` gets the hosts that
build needs and nothing else, and a rule there can name a method and a URL rather than only a host.
The two compose: a perimeter the job can't switch off, and a tighter policy inside it.
Buildcage also runs on any Linux runner with Docker, self-hosted included, rather than on a
firewall-enabled runner image.
## Documentation
| Doc | What's in it |
| ------------------------------------------ | ----------------------------------------------------------------- |
| [Reference](./docs/reference.md) | Every input, the rule syntax in full, and the report's own output |
| [Security Details](./docs/security.md) | Architecture and threat model for every engine, attack resistance |
| [Development Guide](./docs/development.md) | Local usage, testing, logs, and the repository layout |
## Contributing
Contributions are welcome! Please feel free to submit issues or pull requests at [github.com/buildcage/docker](https://github.com/buildcage/docker).
## Show Your Support
Knowing that this project is useful to others gives me the motivation to keep working on it.
If you find Buildcage helpful, please consider giving it a star ⭐ on GitHub!
## Disclaimer
This software is provided "as is", without warranty of any kind, express or implied. The authors and contributors are not liable for any damages, losses, or security incidents arising from the use of this software. Use at your own risk.
## License
The Buildcage source code is licensed under the MIT License. See [LICENSE](./LICENSE) file for details.
The Docker image includes third-party components under their own licenses (GPL, Apache 2.0, ISC, etc.). See [THIRD_PARTY_LICENSES](./THIRD_PARTY_LICENSES) for the full list. The image carries that file, this repository's LICENSE and the license texts it points to under `/opt/buildcage/`.
The Actions bundle their npm dependencies (MIT, Apache 2.0, ISC) into the committed `dist/` files. See [THIRD_PARTY_LICENSES_NPM](./THIRD_PARTY_LICENSES_NPM) for their license texts.