Build and supply chain

Builds with no internet access, the secure way

Your build can reach the whole internet, so a malicious dependency can send your source, your secrets and your signing token anywhere it likes. Taking the internet away from the build is simple. Finding out everything the build quietly downloaded is the real work.

The short answer

Split every build in two. A fetch stage with network access resolves, hash-checks and stores every input: wheels, vendored modules and base images mirrored by digest. The build stage runs on a network with no route out, reaches only your registry, and installs with --no-index, -mod=vendor and GOPROXY=off.

Updated Houssam Hammoudi, CTOTested with kaniko executor v1.24.0, pip 26.2.1, pip-tools 7.6.1, crane, registry:2

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

A build that can reach the internet can be made to do anything the internet asks. The risks are specific:

  • A compromised package's install script sends environment variables, including CI tokens, to an outside host.
  • A dependency is fetched fresh on every build, so a package replaced upstream is picked up without a lockfile change.
  • A build step pipes curl into sh, and nobody notices because it has always worked.

Blocking egress without preparation just breaks the build. Every download has to move to a stage that verifies it, and the build has to be told not to look anywhere else.

What the docs say

-mod=vendor tells the go command to use the vendor directory. In this mode, the go command will not use the network or the module cache.

Source: Go Modules Reference

Ignore package index (only looking at --find-links URLs instead).

Source: pip docs, pip install --no-index

The command is run with no network access (lo is still available, but is isolated to this process)

Source: Dockerfile reference, RUN --network

Kaniko's README has no per-step network option like BuildKit's RUN --network=none; the only network-related flag it documents, --skip-push-permission-check, is about delaying the first registry request. With kaniko, the network restriction has to come from where the pod or container runs, which is what this page does.

The secure configuration

Stage 1: fetch (network on, trusted inputs only)

bash
# Python: lock with hashes, then download exactly those files as wheels.
pip-compile --generate-hashes --strip-extras -o requirements.txt requirements.in
pip download --only-binary :all: --require-hashes -r requirements.txt -d wheelhouse

# Go: vendor (see the vendoring page for the re-vendor check).
go mod vendor

# Base images: mirror by digest into the registry the build can reach.
BASE=$(crane digest python:3.14-slim)
crane copy "index.docker.io/library/python@${BASE}" registry.example.com/mirror/python:3.14-slim

The fetch job has no secrets except a push credential for the mirror, and its outputs (the wheelhouse, vendor/, the mirrored digests) are stored as build inputs.

Stage 2: build (no route to the internet)

dockerfile
FROM registry.example.com/mirror/python@sha256:<digest>
COPY requirements.txt /tmp/requirements.txt
COPY wheelhouse/ /tmp/wheelhouse/
# No index, no network: only the hashed wheels from the build context.
RUN pip install --no-cache-dir --no-index --find-links /tmp/wheelhouse \
      --require-hashes --only-binary :all: -r /tmp/requirements.txt \
 && rm -rf /tmp/wheelhouse
USER 65532:65532

For Go stages, set the environment so nothing falls back to the network:

dockerfile
ENV GOFLAGS=-mod=vendor GOPROXY=off GOTOOLCHAIN=local

Where the build runs decides whether the network is really gone:

  • On Kubernetes: a NetworkPolicy on the build pods that allows egress only to the registry mirror and DNS (see the page on egress isolation for CI build pods).
  • On a plain Docker runner: an --internal network that contains the registry mirror and the build container, and nothing else.
bash
docker network create --internal build-net
docker network connect --alias registry build-net registry-mirror
docker run --rm --network build-net \
  -v "$PWD:/workspace:ro" "$KANIKO_IMAGE" \
  --context dir:///workspace --destination registry:5000/team-a/app:1.0 \
  --insecure-registry registry:5000 --no-push --tar-path /out/app.tar

Use --insecure-registry only for a mirror on a private network like this test; a production mirror serves TLS.

Prove it

The fetch stage, with network:

bash
pip download --only-binary :all: --require-hashes -r requirements.txt -d wheelhouse
crane copy python:3.14-slim@sha256:... registry:5000/mirror/python:3.14-slim
text
certifi-2026.7.22-py3-none-any.whl
charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
idna-3.20-py3-none-any.whl
requests-2.32.5-py3-none-any.whl
urllib3-2.8.0-py3-none-any.whl
mirrored registry:5000/mirror/python@sha256:7bf6c3111fe094f8ee1a1cbcdc63c4cfb345b0e3df42d5aa9a90b3b4b022ab6d

The build network really has no way out:

bash
docker run --rm --network build-net alpine wget -T 5 https://pypi.org/simple/
text
wget: bad address 'pypi.org'

Kaniko builds the image on that network, pulling the base from the mirror and installing from the wheelhouse (snapshot log lines trimmed):

text
level=info msg="Retrieving image registry:5000/mirror/python@sha256:<digest> from registry registry:5000"
level=info msg="COPY requirements.txt /tmp/requirements.txt"
level=info msg="COPY wheelhouse/ /tmp/wheelhouse/"
Looking in links: /tmp/wheelhouse
Processing ./tmp/wheelhouse/certifi-2026.7.22-py3-none-any.whl (from -r /tmp/requirements.txt (line 1))
Processing ./tmp/wheelhouse/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (from -r /tmp/requirements.txt (line 4))
Processing ./tmp/wheelhouse/idna-3.20-py3-none-any.whl (from -r /tmp/requirements.txt (line 177))
Processing ./tmp/wheelhouse/requests-2.32.5-py3-none-any.whl (from -r /tmp/requirements.txt (line 180))
Processing ./tmp/wheelhouse/urllib3-2.8.0-py3-none-any.whl (from -r /tmp/requirements.txt (line 183))
Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests

Successfully installed certifi-2026.7.22 charset-normalizer-3.5.1 idna-3.20 requests-2.32.5 urllib3-2.8.0
requests 2.32.5
level=info msg="USER 65532:65532"
level=info msg="Skipping push to container registry due to --no-push flag"

The same Dockerfile with the wheelhouse lines replaced by a normal index install fails, because the index is unreachable:

text
ERROR: Could not find a version that satisfies the requirement certifi==2026.7.22 (from versions: none)
ERROR: No matching distribution found for certifi==2026.7.22
error building image: error building stage: failed to execute command: waiting for process to exit: exit status 1

That failure is the feature: any download you forgot to move into the fetch stage shows up as a broken build, not as a quiet request to the internet.

Mistakes people make

Blocking egress but allowing "just DNS"

DNS to any resolver is a data channel. Allow DNS only to the cluster or network resolver, and have that resolver answer only for the mirror's name.

A proxy that allows everything

An HTTP proxy for the build that forwards to any host is egress with extra steps. If you need a proxy, it allows the mirror host and nothing else.

Go toolchain downloads

A go.mod that asks for a newer Go version makes go try to download a toolchain. Set GOTOOLCHAIN=local so the build fails instead of fetching.

Fetch and build in the same job

If one job both downloads and builds, the build steps run with network access. Separate jobs, separate networks, and the build job gets the fetch job's outputs as files.

Forgetting the base image

FROM python:3.14-slim makes the builder pull from Docker Hub. The build network cannot reach it, so the build fails, which is correct. Fix it with a mirrored digest, not by opening the network.

Checklist

  • Every build has a fetch stage and a build stage in separate jobs.
  • The fetch stage verifies hashes and digests before storing inputs.
  • The build stage runs where only the registry mirror and a resolver are reachable.
  • Python installs use --no-index --find-links with --require-hashes.
  • Go builds set GOFLAGS=-mod=vendor, GOPROXY=off and GOTOOLCHAIN=local.
  • Base images come from the mirror, pinned by digest.
  • DNS from build pods goes only to the internal resolver.
  • A test build without the wheelhouse fails, proving the network is closed.

An offline build fails loudly on every download you forgot. That is much better than succeeding quietly on the one you did not know about.

H2-CSDE

Learn it on a live range

Dependencies and vendoring, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.

Start free

H2 Scanner

Want this caught before it merges?

The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.

Talk to us