Build and supply chain

Vendored Go modules and hashed Python requirements, the secure way

Your build downloads a few hundred packages from the internet every time it runs, and trusts whatever arrives. Vendoring and hashes turn that into a list you reviewed. The catch is that go build never checks the vendor folder against anything.

The short answer

For Go, commit vendor/ with go mod vendor, build with -mod=vendor and GOPROXY=off, and in CI re-run go mod vendor and fail on any diff, because go build does not verify vendored files. For Python, lock with pip-compile --generate-hashes and install with --require-hashes and --only-binary :all:.

Updated Houssam Hammoudi, CTOTested with Go 1.27.1 (golang:1.27-alpine), pip 26.2.1 and pip-tools 7.6.1 (python:3.14-slim)

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 normal Go or Python build resolves dependencies at build time. The build trusts the network, the proxy or index, and the maintainers of every package in the tree, on every run. A hijacked package, a typo-squatted name or a compromised mirror reaches your binary without a line changing in your repository.

Vendoring (Go) and hash-locking (Python) fix that: the exact bytes, or their hashes, live in your repository and go through code review.

Both have a gap people miss:

  • Go: go build -mod=vendor uses whatever is in vendor/. It does not compare those files with go.sum. A pull request that edits a vendored file builds fine, and reviewers rarely read vendor/ diffs.
  • Python: hash-checking covers only the packages that have hashes, and a single unpinned or unhashed line changes how the whole file behaves.

What the docs say

When vendoring is enabled, build commands like go build and go test load packages from the vendor directory instead of accessing the network or the local module cache.

Source: Go Modules Reference, Vendoring

go mod verify checks that dependencies of the main module stored in the module cache have not been modified since they were downloaded.

Source: Go Modules Reference, go mod verify

By default, pip does not perform any checks to protect against remote tampering and involves running arbitrary code from distributions.

Source: pip docs, Secure installs

Note that hash-checking is an all-or-nothing proposition.

Source: pip docs, Secure installs

Read the second quote closely: go mod verify checks the module cache, not vendor/. The Go reference never says that edited vendored files go unnoticed; the test below shows they do.

The secure configuration

Go

bash
# Once, and on every dependency change (network allowed, in a trusted job):
go get github.com/google/[email protected]
go mod tidy
go mod vendor            # writes vendor/ and vendor/modules.txt
git add go.mod go.sum vendor/
bash
# Every build (no network needed):
export GOFLAGS=-mod=vendor   # use vendor/ only
export GOPROXY=off           # never download a module
export CGO_ENABLED=0
go build -trimpath -o app .
bash
# The CI check that go build does not do: vendor/ must equal what go.sum describes.
cp -r vendor /tmp/pr-vendor
go mod vendor                          # re-creates vendor/ from go.mod and go.sum
diff -r /tmp/pr-vendor vendor          # any difference fails the job

The check needs network access (or a module proxy you run) to download the modules again; run it in a separate job from the build. Protect vendor/, go.mod and go.sum with code owners.

Python

text
# requirements.in: only your direct dependencies, pinned.
requests==2.32.5
bash
# Lock the full tree with hashes (in a trusted job):
pip-compile --generate-hashes --strip-extras -o requirements.txt requirements.in
dockerfile
# Install: every file must match a hash; wheels only, so no setup.py runs.
RUN pip install --no-cache-dir \
      --require-hashes \
      --only-binary :all: \
      -r requirements.txt

--require-hashes makes the mode explicit even if someone removes every hash from the file. --only-binary :all: refuses source distributions, whose build step runs arbitrary code at install time. If a package has no wheel for your platform, build that wheel once in a separate job and host it yourself.

Prove it

Go: vendor once, then build with no network and an empty module cache:

bash
go mod init example.com/app && go get github.com/google/[email protected] && go mod vendor
cat vendor/modules.txt
GOFLAGS=-mod=vendor GOPROXY=off go build -o app . && ./app      # --network none
text
go: downloading github.com/google/uuid v1.6.0
go: added github.com/google/uuid v1.6.0
# github.com/google/uuid v1.6.0
## explicit
github.com/google/uuid
id: 4fd35a71-71ef-5a55-a9d9-aa75c889a6d0

The same build without vendor/ cannot fetch anything:

bash
GOFLAGS=-mod=mod GOPROXY=off go build .
text
go: downloading github.com/google/uuid v1.6.0
main.go:6:2: module lookup disabled by GOPROXY=off

Now a pull request edits one vendored function. The build accepts it, the output changes, and go mod verify still says everything is fine:

bash
go build -o app . && ./app
go mod verify
text
id: 5b96e2b8-f96e-5ae2-9a5d-422c55ebf510
all modules verified

Re-vendoring and diffing catches it:

bash
cp -r vendor /tmp/pr-vendor && go mod vendor && diff -r /tmp/pr-vendor vendor
text
go: downloading github.com/google/uuid v1.6.0
--- /tmp/pr-vendor/github.com/google/uuid/hash.go
+++ vendor/github.com/google/uuid/hash.go
@@ -55,6 +55,5 @@
 //
 //  NewHash(sha1.New(), space, data, 5)
 func NewSHA1(space UUID, data []byte) UUID {
-	data = []byte("attacker")
 	return NewHash(sha1.New(), space, data, 5)
 }
exit code 1

Python: lock and install:

bash
pip-compile --generate-hashes --strip-extras -o requirements.txt requirements.in
grep -c -- "--hash=sha256:" requirements.txt ; grep -E "^[a-z]" requirements.txt
pip install --require-hashes --only-binary :all: -r requirements.txt
text
180
certifi==2026.7.22
charset-normalizer==3.5.1
idna==3.20
requests==2.32.5
urllib3==2.8.0

certifi            2026.7.22
charset-normalizer 3.5.1
idna               3.20
requests           2.32.5
urllib3            2.8.0

One direct dependency became five packages and 180 hashes (one per published file). Three ways the install fails closed. A file that does not match:

text
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
        Expected sha256 0000000000000000000000000000000000000000000000000000000000000000
             Got        2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6

A dependency with no hash, even without the flag, because other lines have hashes:

text
ERROR: Hashes are required in --require-hashes mode, but they are missing from some requirements. Here is a list of those requirements along with the hashes their downloaded archives actually had. Add lines like these to your requirements files to prevent tampering. (If you did not enable --require-hashes manually, note that it turns on automatically when any package has a hash.)
    idna==3.10 --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3

A version range instead of ==:

text
ERROR: In --require-hashes mode, all requirements must have their versions pinned with ==. These do not:
    requests>=2.32 from https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl (from -r unpinned.txt (line 1))

Mistakes people make

Trusting go mod verify for vendor/

It checks the module cache. With -mod=vendor the build never reads the cache, so the check says nothing about what you compile. Re-vendor and diff.

Accepting the hash pip prints

The "missing hash" error prints the hash of whatever was just downloaded. Pasting it in makes the error go away and trusts that download. Regenerate the lock with pip-compile from requirements.in in a trusted job instead.

--no-require-hashes

pip 26.2 added a --no-require-hashes flag that turns the automatic mode off. A build that passes it installs unhashed packages next to hashed ones. Fail CI if any build script contains it.

Source distributions in the lock

Hashes prove the file is the one you locked. They do not stop a setup.py in that file from running code during install. Use --only-binary :all:.

Nobody reads vendor/ diffs

Dependency updates produce thousands of changed lines. Review go.mod and go.sum changes, let the re-vendor check prove vendor/ matches them, and keep dependency updates in their own pull requests.

Checklist

  • vendor/, go.mod and go.sum are committed and owned by code owners.
  • Builds run with GOFLAGS=-mod=vendor and GOPROXY=off.
  • A CI job re-runs go mod vendor and fails on any diff.
  • requirements.txt is generated by pip-compile --generate-hashes from requirements.in.
  • Installs use --require-hashes and --only-binary :all:.
  • No build script uses --no-require-hashes or --trusted-host.
  • Hashes are never copied from pip error messages.
  • Dependency updates arrive in their own reviewed pull requests.

Vendoring moves trust from the network to your code review. The re-vendor check makes sure the review is looking at the real thing.

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