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 refused and reported.
- Your Dockerfile doesn't change, BuildKit isn't patched, and nothing Buildcage does is left in the image layers.
- Run once in
auditmode 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 for what it does and why. To isolate a
workflow run: step rather than a Docker build, use
Buildcage for run: Steps.
- Requirements
- Usage
- Engines
- Inputs
- Report action
- How it works
- CA trust and compatibility
- Scope
- Limitations
- FAQ
- GitHub's native egress firewall
- Documentation
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 versionedubuntu-*images, and their-armvariants- Lightweight images such as
ubuntu-slimare 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
inspectengine, 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 withftype=0
A runner that falls short fails while the builder starts, before any RUN step runs.
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 mode to collect what the build reaches, then
switch to restrict. The examples below use the default inspect engine; Engines
covers the choice between the two.
- 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.0The 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.
Paste that allowlist into the setup step and switch the mode:
- 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/bulkEach 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 refused, 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.
Each pair builds the same Dockerfile with and without rules:
inspect on an apt and npm build (audit ·
restrict), universal on a Maven build
(audit ·
restrict).
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 refusing 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).
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.
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, and the grammar those rules are written in is in Rule syntax.
The builder is named buildcage unless builder_name says otherwise, and the Buildx endpoint has
to match whatever it is named.
The inputs can also live in a YAML file next to the Dockerfile, named by config_file. See
Config file.
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 refuses those
unless a rule names the address as its host.
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:
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:
allowed_tls_rules: |
db.example.com:5432
repo.maven.apache.org:443allowed_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:
allowed_ip_rules: |
192.168.1.10:443
10.0.0.0/8:443universal 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:
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:443A # 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.
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:
known_blocked_rules: |
telemetry.example.comA name refused 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 — a telemetry POST to an API you otherwise use, say:
known_blocked_rules: |
POST https://api.example.com/telemetryAny other blocked request to that host still fails the step. See Blocked rules for the full syntax.
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:
- name: Show Buildcage report
if: always()
uses: buildcage/docker/report@fb8ec1accd272b4a9c38483989af89dbb23161a6 # v4.1.0Whatever was refused is listed under Blocked Hosts with the reason, and, under inspect,
Communication details names the URL of every request, allowed or refused, with credential query
parameters replaced (see 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.
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.
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 has the architecture of each engine, with a diagram of what runs where and what it decides.
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), 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). 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. What this cannot cover is in Limitations, below.
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 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.
- buildkitd's own traffic is outside the allowlist.
FROM,ADD <url>, git contexts and the frontend image a# syntax=directive names are fetched by buildkitd itself rather than by aRUNstep, and no rule applies to them. See What buildkitd fetches itself. allowed_tls_rulesis 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_rulesis not inspected at all, and doesn't require TLS either: once anip:portpair is allowed, any TCP-based protocol can use that path. Prefer a domain rule wherever the destination has a stable name.universalnever 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, whileinspectmatches on the realHostand refuses it. See Domain fronting.
- 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.
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.<host> 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.
-
A tool that pins a specific certificate, or ships a bundled trust store it never lets the system update, still needs
proxy_engine: universalor anallowed_tls_rulespassthrough:inspectre-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:
inspectanswers no ALPN. Pass its host through withallowed_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_HOMEor behind the firstjavaonPATHis 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 toproxy_engine: universal: Buildcage will not rewrite it. No other JDK trusts the CA, such as one further alongPATH, a Gradle toolchain under~/.gradle/jdksor one the step itself downloads (ansdk install, an unpacked tarball). Fetch it in an earlierRUNstep and put it first onPATHor onJAVA_HOMEwithENV: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/nssdbwhen 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 aswww-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 afterUSER, takes no slot, since Chromium would not open it either, and the step warns; useproxy_engine: universalfor Chromium there. -
Only the NSS database under the
HOMEthe step starts with carries the slot. Chromium started with anotherHOME(HOME=/tmp chromium,export HOME=...) or as another user (su,gosu,sudo -u) does not trust the CA. Switch users withUSERinstead, which the slot follows as long as noENV HOMEpins the home, and leaveHOMEalone 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; useproxy_engine: universalor anallowed_tls_rulespassthrough 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. EveryRUNstep has this mount point, created if the home has no database, sogit clone <url> .into the home andrm -rf ~/.[!.]*both fail; use aWORKDIRoutside the home. A copy of the home (cp -a ~ /backup) has the slot cut out of itspkcs11.txtbefore the layer is committed. -
A
RUNstep 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 earlierRUNstep instead: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: falsemakes it a warning, see CA residue). A copy that cannot be read, in a compressed archive or a keystore encrypted under a password other than none orchangeitor 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. -
auditterminates TLS as well. It drops the rules, not the interception, so a tool that cannot accept the CA fails inauditexactly as it would inrestrict. -
An image with no system CA store (
scratch, distroless, ordebian:*-slimbeforeca-certificatesis installed) still gets every variable set, but pointed at a file trusting only the proxy's own CA. That is enough for ordinary HTTPS, sinceinspectre-signs all of it with that CA, but not for anallowed_tls_rulesorallowed_ip_rulespassthrough, which presents its own real certificate. The decision is made once, from the rootfs as the step begins, so installingca-certificatespartway through a step doesn't help a passthrough made later in the same step: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 runtask, which keepsNODE_OPTIONSbut notNODE_EXTRA_CA_CERTS, so Node inside it fails withSELF_SIGNED_CERT_IN_CHAIN. In an image with a system CA store, which holds the CA already, have Node read that store. As anARGthe flag reaches every laterRUNin the stage without staying in the image, unless anENV NODE_OPTIONSoverrides it: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
untrackedEnvrather thanenv, which would put its value in the cache key. A task withcache: falsekeeps the whole environment.// 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:
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_HOMEor behind the firstjavaonPATH, unless it is inside the CA store's mount. A JDK with a keystore of its own has itslib/securitymounted, so a step cannot remove that JDK; take it offPATHandJAVA_HOMEwithENVfirst. A distribution JDK links to a shared keystore: Debian's is inside/etc/ssl/certsand adds no mount point, while on RHEL, Fedora and their derivatives/etc/pki/ca-trust/extracted/javais 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.
An allowlisted name that resolves to cloud metadata, to loopback, or to an address the runner itself
holds is refused, 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.
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.
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.
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 refusing 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 is building an egress firewall directly into Actions runners
(technical preview 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.
| Doc | What's in it |
|---|---|
| Reference | Every input, the rule syntax in full, and the report's own output |
| Security Details | Architecture and threat model for every engine, attack resistance |
| Development Guide | Local usage, testing, logs, and the repository layout |
Contributions are welcome! Please feel free to submit issues or pull requests at github.com/buildcage/docker.
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!
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.
The Buildcage source code is licensed under the MIT License. See 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 for the full list.
The Actions bundle their npm dependencies (MIT, Apache 2.0, ISC) into the committed dist/ files. See THIRD_PARTY_LICENSES_NPM for their license texts.



