Security Details

July 12, 2026 · View on GitHub

This document explains, from a user's perspective, how Buildcage enforces network isolation during Docker builds: what's inspected, what's blocked, what attacks are resisted, and what's visible in the report. For implementation internals (the supervisor binary, RPC plumbing, log parsing), see the Development Guide.

For a high-level overview, see How It Works in the README.

Transparent Proxy Engine (default)

Architecture

Transparent proxy engine architecture

All containers spawned by BuildKit RUN steps are placed on an isolated network (CNI). Only TCP is intercepted — non-TCP protocols never reach the proxy at all (see Non-TCP Protocol Tunneling). DNS queries resolve to the proxy IP, and the proxy checks each request's SNI (HTTPS) or Host header (HTTP) against the allowlist before forwarding or blocking; direct-IP connections take a third, uninspected path (see below).

  1. BuildKit RUN steps run in isolated containers connected to a private network (CNI)
  2. All TCP traffic is redirected to the proxy via an iptables NAT rule — regardless of destination, so both DNS-resolved and direct-IP connections reach it; all other protocols (UDP, ICMP, etc.) are dropped before they ever reach the proxy
  3. All DNS queries return the proxy IP (172.20.0.1), which is what lets the proxy classify DNS-resolved connections by SNI/Host header below
  4. The proxy classifies each TCP connection into one of three paths:
    • HTTPS (DNS-resolved, TLS ClientHello seen): reads the SNI (Server Name Indication) without decrypting
    • HTTP (DNS-resolved, non-TLS): reads the Host header
    • Direct IP (connection target bypassed DNS): no content inspection at all — see Direct IP Address Connections
  5. Allowlist check: If the domain (HTTPS/HTTP) or IP:port (direct IP) is allowed → connection proceeds. Otherwise → blocked.

Note: Base image pulls (FROM instructions) are performed by buildkitd itself, which runs outside the isolated network. Only commands in RUN steps are subject to network filtering.

Why this approach?

  • No MITM certificate injection needed
  • TLS certificate validation works normally
  • Zero modification to your Dockerfile required
  • Works with any programming language or package manager
  • Cannot inspect encrypted HTTPS payload content (see Known Limitations)

Security Mechanisms

Network Isolation

  • CNI configuration: Places temporary containers from BuildKit RUN steps into isolated-net (buildkit0 bridge, 172.20.0.0/24).
  • iptables: A PREROUTING REDIRECT rule sends all TCP traffic from buildkit0 to the proxy, regardless of destination. A separate FORWARD rule then drops everything else from buildkit0 — every non-TCP protocol, and any TCP that somehow bypasses the redirect — so only TCP ever reaches the proxy; also blocks direct access to buildkitd API.
  • Gateway enforcement: All TCP traffic is redirected to the proxy process; non-TCP traffic never reaches it at all (dropped by the FORWARD rule above).

DNS-Level Control

  • Universal DNS redirect: All domain name queries return the proxy IP (172.20.0.1), which lets the proxy classify TCP connections that go through DNS by SNI/Host header (see Architecture). This is separate from the TCP redirect above — direct-IP connections skip DNS but still reach the proxy via the iptables redirect.
  • ECH prevention: The internal DNS server operates without any upstream resolvers, so DNS HTTPS (type 65) records — required to initiate Encrypted Client Hello (ECH) — are never returned to build containers.

HTTP/HTTPS Proxy Control

  • HTTPS: Determines the target server name by reading the SNI field, without terminating TLS. Certificate validation is unaffected.
  • HTTP: Determines the target domain by inspecting the Host header, then checks it against the allowlist.
  • Dynamic allowlist: Controlled via allowed_https_rules, allowed_http_rules, and allowed_ip_rules environment variables.
  • Missing Host header rejection: HTTP requests without a valid Host header are rejected with HTTP 400, preventing requests that cannot be checked against the allowlist.

Direct IP Address Connections

  • Traffic redirection: All TCP traffic from the isolated network is redirected to HAProxy via an iptables PREROUTING REDIRECT rule.
  • IP allowlist check: Connections to raw IP addresses (e.g., curl http://1.2.3.4/) are checked against allowed_ip_rules, matched as ip:port. If no allowed_ip_rules are configured, all direct IP connections are blocked.
  • No content inspection: Unlike the HTTPS/HTTP paths, a matched direct-IP connection is passed through as a raw TCP stream — its protocol is never checked. Once an ip:port pair is allowlisted, any TCP-based protocol can use that path, not just HTTP/HTTPS. Prefer domain-based rules (allowed_https_rules / allowed_http_rules) when possible; reserve allowed_ip_rules for destinations that genuinely have no stable hostname.

Attack Resistance

Buildcage's architecture defends against the following attack vectors.

SNI Spoofing

An attacker may attempt to set the SNI field in a TLS ClientHello to an allowed domain while actually trying to reach an unauthorized server.

Why this is prevented: The proxy resolves the domain name presented in the SNI field using external DNS and forwards the connection to the resulting IP address. Regardless of what SNI value the client provides, the proxy always connects to the legitimate server for that domain — never to an attacker-controlled server.

Encrypted Client Hello (ECH)

TLS 1.3 Encrypted Client Hello (ECH) encrypts the true SNI, which could theoretically bypass SNI-based filtering.

Why this is prevented: ECH requires the client to obtain ECHConfig public keys via DNS HTTPS (type 65) records. The internal DNS server has no upstream resolvers and cannot return these records, so build containers can never initiate an ECH handshake.

DNS Tunneling

An attacker may attempt to encode data into DNS queries to exfiltrate information or establish communication with external servers.

Why this is prevented: The internal DNS server has no upstream resolvers and answers all queries locally. Additionally, all forwarded traffic from the isolated network is dropped by iptables — including any attempt to reach external DNS servers directly. With no path for DNS queries to reach the outside, encoded data has no route to an attacker's infrastructure.

Non-TCP Protocol Tunneling (ICMP, UDP, QUIC)

An attacker may attempt to tunnel data using non-TCP protocols such as ICMP echo packets, raw UDP, or QUIC (HTTP/3) to bypass the proxy.

Why this is prevented: The iptables FORWARD rule drops all traffic from the isolated network regardless of protocol — not just TCP. Since the proxy only handles TCP (HTTP, HTTPS, and allowlisted direct-IP connections — see Architecture), there is no exit path for UDP or ICMP traffic. QUIC, which relies on UDP, is also blocked as a result.

IPv6 Bypass

An attacker may attempt to use IPv6 to circumvent IPv4-based iptables rules.

Why this is prevented: Equivalent ip6tables rules drop all forwarded IPv6 traffic from the isolated network. Additionally, the internal DNS server returns the IPv6 unspecified address (::) for all queries, effectively disabling IPv6 name resolution within build containers.

Alternative DNS Transports (DoH / DoT)

An attacker may attempt to use DNS over HTTPS (DoH) or DNS over TLS (DoT) to bypass the internal DNS server and resolve domains through encrypted channels.

Why this is prevented: DoT uses port 853, which the proxy does not listen on — making it unreachable from the isolated network. DoH operates over HTTPS and is therefore subject to the same SNI-based allowlist check as any other HTTPS connection. Only DoH servers hosted on explicitly allowed domains could be reached, and exploiting them would require the same preconditions as domain fronting (the attacker must control infrastructure behind an allowed domain).

Known Limitations

Domain Fronting

Buildcage inspects the SNI (Server Name Indication) field in HTTPS connections but cannot decrypt the actual request content inside the TLS tunnel. This creates a potential bypass technique called "domain fronting."

How it works:

Attack flow:
1. ClientHello SNI: allowed.example.com  ← Buildcage only sees this → ✅ allowed
2. HTTP Host header: malicious.example.com  ← encrypted, cannot be inspected
3. CDN routes based on Host header → reaches attacker's server

For this attack to succeed, the allowed domain and the attack target domain must reside on the same CDN or hosting infrastructure.

Why we don't prevent this:

To fully defend against domain fronting, the proxy would need to terminate TLS (MITM) and inspect HTTP contents. However, this presents significant challenges:

  • MITM CA certificate generation and management — The proxy would need to generate TLS certificates for each domain on the fly.
  • CA certificate injection into build containers — Build containers generated by BuildKit's OCI worker have independent filesystems, making it technically difficult to trust a MITM CA (would require modifications to buildkitd itself).
  • Interference with TLS validation — Trusting a self-signed CA would affect normal TLS certificate validation within build containers.

Given these implementation costs versus the strict preconditions for the attack (the attacker's server must be on the same infrastructure as the allowed domain), this is treated as an accepted risk.

Mitigation strategies:

  • Keep allowed domains to a minimum — Only specify the domains you need in allowed_http_rules / allowed_https_rules.
  • Be specific with allowed domains — Avoid broad wildcard CDN domains (e.g., *.cdn.example.com) when possible.
  • Use service-specific domains — Prefer registry.npmjs.org over generic CDN wildcard domains.
  • Major CDN countermeasures — Major CDN providers like CloudFront and Cloudflare have already introduced measures to restrict domain fronting. Consult your CDN provider's documentation for current details.
  • Regular audits — Periodically run in audit mode to detect anomalies in connection patterns.

Explicit Proxy Engine

Warning

explicit is an experimental engine. Its underlying BuildKit feature (--proxy-network) is still maturing, and it has structural limitations not present in the transparent engine — see Coverage and known limitations below before relying on it. transparent remains the default and recommended engine.

Architecture

Explicit proxy engine architecture

proxy_engine: explicit uses BuildKit's native --proxy-network (available since moby/buildkit v0.31.0) instead of the CNI/DNS-redirect/HAProxy stack described in Transparent Proxy Engine. Each RUN step is isolated into its own private point-to-point network namespace whose only reachable peer is buildkitd's built-in MITM proxy. HTTP_PROXY/HTTPS_PROXY and a generated CA certificate are injected into the step automatically — no Dockerfile changes needed for tools that already respect these standard variables. The proxy decrypts the traffic and checks the host against a BuildKit source policy compiled from your allowlist — the exact same allowed_https_rules / allowed_http_rules / allowed_ip_rules syntax as transparent mode (see Rule Syntax). Enforcement is domain (and port) granularity, same as transparent — the generated policy always allows any path once the host matches, since the rule syntax has no path component. The decrypted path is still visible, so it shows up in the report and BuildKit's own build output even though it isn't used to allow or deny the request. allowed_ip_rules entries are compiled into the same kind of policy rule as domain rules (matched as an https/http identifier) — unlike transparent mode, there's no raw, uninspected TCP passthrough for IP-based rules here.

If the build client already sets its own static source policy (e.g. via EXPERIMENTAL_BUILDKIT_SOURCE_POLICY, which docker buildx build reads unconditionally), buildcage merges its own rules in last, so a client-supplied policy can never widen access beyond your allowlist. A separate dynamic, session-based policy mechanism (docker buildx build --policy=...) is left untouched and applies as an additional condition alongside buildcage's policy.

For how the supervisor binary, gRPC interception, and policy compilation work internally, see Explicit Engine Internals in the Development Guide.

Coverage and Visibility

TrafficAllowedDenied
RUN step, proxy-aware toolLogged per-step in the report's "Communication details"Logged in a flat DENIED list (no per-step attribution; whole-second timestamps)
RUN step, non-cooperative tool or raw socket— (immediate "network unreachable"; no trace anywhere — not in the build log, the report, or provenance)same as above
ADD <url>Not tracked by the report — the URL is developer-specified in the Dockerfile, already an intentional, reviewable part of the buildAborts the entire build immediately at LLB load time; logged the same way as a denied RUN
FROM / git contextsUnaffected — buildcage's policy only ever matches http(s):// sourcesUnaffected

The key structural difference from transparent mode: there, a non-cooperative process still reaches the CNI bridge and is observed, blocked, and logged. Under explicit, each RUN step's network namespace has no broader network to route through, so that traffic leaves no trace at all — a structural trade-off for gaining full path-level visibility and BuildKit-native provenance integration.

For exactly how the report action extracts allowed/denied data from buildkitd's own logs, see Viewing Logs in the Development Guide.

Trusting the Buildcage Image

Buildcage is a security tool — so it's fair to ask: how do you trust Buildcage itself?

The upstream image is verified at action startup via Sigstore: the signature cryptographically binds the published image to the exact source commit SHA, so a tampered or substituted image fails verification before use.

Using the upstream image

The simplest option. Pin to a commit SHA (or version tag) and update on your own schedule — the Sigstore verification ensures you are always running exactly what was built from that commit.

Self-hosting

If you need to keep build infrastructure private or control exactly which version is deployed, you can fork the repository and build the Docker image within your own infrastructure. See the Self-Hosting Guide.

Image Provenance Verification

Buildcage uses Sigstore keyless signing to cryptographically bind each release's Docker image to the CI workflow that built it.

How it works

Signing (at release time): When a release tag is pushed, the docker-publish.yml workflow builds and signs the Docker image using a short-lived OIDC identity issued by GitHub Actions. The signature is stored as a Sigstore Bundle v0.3 attached to the image via the OCI 1.1 Referrers API in GHCR. The bundle contains the signature, a Fulcio leaf certificate embedding the workflow identity, and a Rekor transparency log entry.

Verification (at action startup, main phase): The setup action verifies the image entirely in-process using @sigstore/verify, @sigstore/tuf, and @sigstore/bundle — no external binary (e.g. cosign) is downloaded or required. Running in the main phase ensures docker/login-action (if present) has already stored registry credentials before verification begins. The verification flow is:

1. Fetch manifest-list digest
       docker buildx imagetools inspect <image>:<tag>
       (uses docker login credentials — supports private packages)

2. Fetch registry pull token
       GET https://ghcr.io/token?scope=repository:<repo>:pull
         → logged in (docker/login-action): Basic auth with Docker config credentials
         → not logged in: anonymous request (public packages only)

3. Pull Sigstore Bundle from OCI Referrers API
       GET /v2/<repo>/referrers/<digest>  → locate bundle manifest
       GET /v2/<repo>/blobs/<bundleDigest> → fetch bundle JSON

4. Cryptographic + identity verification (@sigstore/verify, TUF-backed trust root)
       verifyBundle(bundleJson, {
         certificateIssuer,       ← OIDC issuer enforced cryptographically
         certificateIdentityURI,  ← SAN regexp: workflow URL + ref/version
         certificateOIDs,         ← OID 1.13: Source Repository Digest (SHA pin)
       }, expectedDigest)

5. Signed digest assertion (fail-closed)
       Parse DSSE payload → subject[].digest.sha256 (in-toto v1, --new-bundle-format)
                          or critical.image.docker-manifest-digest (legacy simple-signing)
       Must equal the digest fetched in step 1 (strict string equality)
       Mismatch → VERIFY_FAILED (closes the Referrers API attribution gap)

For private self-hosted packages, place docker/login-action before the buildcage setup step in your workflow and ensure the job has packages: read permission. Credentials stored by Docker login are picked up automatically.

All identity checks — OIDC issuer, signing workflow, ref/SHA claim, and manifest digest — are enforced inside the single verifyBundle() call, equivalent to cosign's --certificate-oidc-issuer, --certificate-identity-regexp, --certificate-github-workflow-sha, and the implicit digest-match that cosign performs against its target image argument.

Identity matching by reference type

How the action is pinned Identity check Mechanism
@<40-char SHA> Source Repository Digest strictly equals the pinned SHA certificateOIDs — Fulcio OID 1.3.6.1.4.1.57264.1.13, raw byte match
@v2.2.0 (exact version) SAN matches ...@refs/tags/v2\.2\.0(\.|$) certificateIdentityURI regexp
@v2 (major-floating) SAN matches ...@refs/tags/v2(\.|$) certificateIdentityURI regexp
Branch name or local ./setup Hard fail — pin to a version tag or commit SHA

For strongest guarantees, pin to a commit SHA:

uses: dash14/buildcage/setup@<40-char-sha> # vX.Y.Z

The SHA check is the core of tamper detection: it confirms the Docker image was built from exactly the same source tree as the pinned action commit. An image built from a different commit — even if signed — will fail verification.

What this prevents

An attacker who can push a malicious image to ghcr.io/dash14/buildcage without compromising the repository cannot produce a valid Sigstore bundle. The bundle's Fulcio certificate requires a GitHub Actions OIDC token that is only issued during an actual workflow run on the real repository.

This is one layer of a defense-in-depth strategy, not a complete guarantee. It reduces the attack surface to the registry layer and forces attackers to compromise the GitHub account or the repository itself — raising the cost significantly and leaving an audit trail in the Rekor transparency log.

The binding of the image digest to the exact source commit SHA also serves as an alternative to reproducible builds: it establishes that the published artifact was produced from a specific source commit without requiring an independent rebuild.

Verification Limitations

  • Account compromise: If the repository owner's GitHub account or the repository itself is compromised, an attacker could trigger the release workflow and produce a legitimately-signed malicious image.
  • Trust in Sigstore infrastructure: Verification relies on the availability and integrity of the Rekor transparency log and the Fulcio certificate authority. The TUF-backed trust root is fetched at verification time; a network outage will cause the main phase to hard-fail.
  • TOCTOU window: The manifest digest is fetched before the bundle is pulled. A highly targeted attack that replaces the registry content in the window between these two steps would still succeed — though such an attack requires compromising the registry itself. Note that the subsequent docker pull is digest-pinned (image@sha256:…), so there is no TOCTOU between verification and the actual image pull; the residual window is limited to between the manifest-digest fetch and the bundle fetch.
  • Local/CI self-test bypass: BUILDCAGE_BUILD_TEST_HOOKS=1 pnpm build compiles a setup/dist/main.cjs where a BUILDCAGE_LOCAL_IMAGE_REF escape hatch is reachable, used only by this repo's own test_action CI job and local development. The bypass logic lives in its own module (setup/src/lib/local-image-override.js), loaded only via a dynamic import() behind this build-time flag; in every normal build (including every published release), rollup's own module-graph tree-shaking excludes that entire file from the bundle — not just the call to it. A consumer of the published dash14/buildcage/setup@<ref> action cannot reach it no matter what env: they set, since the code is physically absent from what they execute. unit_test's CI job additionally asserts, by inspecting the built file directly, that a normal build never contains a live runtime read of BUILDCAGE_BUILD_TEST_HOOKS — guarding against a future refactor silently breaking that guarantee. See development.md for details.