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:.
On this page
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=vendoruses whatever is invendor/. It does not compare those files withgo.sum. A pull request that edits a vendored file builds fine, and reviewers rarely readvendor/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
# 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/# 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 .# 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 jobThe 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
# requirements.in: only your direct dependencies, pinned.
requests==2.32.5# Lock the full tree with hashes (in a trusted job):
pip-compile --generate-hashes --strip-extras -o requirements.txt requirements.in# 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:
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 nonego: 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-aa75c889a6d0The same build without vendor/ cannot fetch anything:
GOFLAGS=-mod=mod GOPROXY=off go build .go: downloading github.com/google/uuid v1.6.0
main.go:6:2: module lookup disabled by GOPROXY=offNow a pull request edits one vendored function. The build accepts it, the
output changes, and go mod verify still says everything is fine:
go build -o app . && ./app
go mod verifyid: 5b96e2b8-f96e-5ae2-9a5d-422c55ebf510
all modules verifiedRe-vendoring and diffing catches it:
cp -r vendor /tmp/pr-vendor && go mod vendor && diff -r /tmp/pr-vendor vendorgo: 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 1Python: lock and install:
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.txt180
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.0One direct dependency became five packages and 180 hashes (one per published file). Three ways the install fails closed. A file that does not match:
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 2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6A dependency with no hash, even without the flag, because other lines have hashes:
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:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3A version range instead of ==:
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.modandgo.sumare committed and owned by code owners.- Builds run with
GOFLAGS=-mod=vendorandGOPROXY=off. - A CI job re-runs
go mod vendorand fails on any diff. requirements.txtis generated bypip-compile --generate-hashesfromrequirements.in.- Installs use
--require-hashesand--only-binary :all:. - No build script uses
--no-require-hashesor--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 freeH2 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