Verify the tool cache against reviewed hashes at link time - #710
Conversation
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
- Tools built with "go install" are cached at $(DOWNLOAD_DIR)/tools/<tool>@<version>_<os>_<arch>, a path which says nothing about the Go toolchain that built them. - CI persists that download directory between runs, so after a VENDORED_GO_VERSION bump the stale binary is restored and reused indefinitely, even when it can no longer parse the new standard library. - Include the Go version in the path of Go-built tools, so that a Go upgrade forces a rebuild, and depend on the VENDORED_GO_VERSION stamp file so the unversioned symlink is re-pointed. Refs: cert-manager/cert-manager#9174 Signed-off-by: Richard Wall <richard.wall@cyberark.com>
- When vendoring is disabled, tools are built with the system Go, so keying the download path on VENDORED_GO_VERSION mislabels binaries in the shared cache and a system Go upgrade never invalidates them. Key the path and the stamp file on the Go version actually used: "go env GOVERSION" for the system Go, go$(VENDORED_GO_VERSION) when vendoring. - Make the versioned binary a normal prerequisite of the unversioned symlink, so a rebuilt binary always re-points the symlink. Previously an existing symlink newer than the stamp files caused the binary to be rebuilt at the new path while the symlink kept pointing at the old one. In the steady state the symlink resolves to the same file as the prerequisite, so nothing is remade. - Update the LN comment for the new path format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Richard Wall <richard@the-moon.net>
The download directory ($(DOWNLOAD_DIR)) is persisted between CI runs, and for some repositories it is a node-local directory shared with less-trusted jobs that can overwrite a cached binary in place. Until now a tool's SHA-256 was only checked when it was first downloaded, so a binary swapped in the cache afterwards was linked onto PATH and executed unverified. Make the per-tool symlink a .PHONY target and re-hash the cached binary against the reviewed SHA-256 in this file on every build, before linking. A mismatch deletes the binary and re-downloads it, so a poisoned cache cannot be used. The reviewed hash is the trust anchor, so no signing or fork-write-scoping is needed. For this the reviewed SHA must be the hash of the extracted binary, not the downloaded archive: the archive recipes now hash the binary after extraction (the download still fails closed via the lock script if the hash is wrong), etcd and kube-apiserver gain their own binary hashes, and the SHAs for the affected tools have been relearned with "make learn-tools-shas". Tools built from source with "go install" have no reviewed hash here; they are anchored by go.sum/GOSUMDB when built and their staleness is already handled by keying the download path on the Go toolchain version, so the check skips them (empty hash variables are defined to keep --warn-undefined-variables quiet). Stacked on cert-manager#708. Supersedes cert-manager#625 (whole-cache purge run as a separate step you must remember) and cert-manager/cert-manager#8833 (verify at symlink-creation), folding both into cert-manager#708's tool_link_defs seam so the check is automatic and cannot be bypassed by a job that forgets to run it. Known limitation: the vendored-Go tarball is still verified at download only; the shared PATH is process-wide, so this closes cache poisoning, not the separate problem of a target using an undeclared tool. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Richard Wall <richard@the-moon.net>
Address four review findings on the verify-at-use change: - Re-verify the binary after a mismatch triggers a re-download, and fail the build with an actionable message if it still does not match. Without this, a consumer-defined ADDITIONAL_TOOLS entry whose SHA256SUM follows the old archive-hash convention would silently re-download on every run and link an unverified binary. Document that SHA256SUM variables must hold the hash of the stored binary, not the archive. - Skip the check during dry runs. GNU make executes recipe lines containing "$(MAKE)" even under -n/-q/-t, so "make -n" was hashing every cached tool and deleting any mismatch for real. Long options such as --warn-undefined-variables are filtered out of MAKEFLAGS before looking for the single-letter flags, since they can contain the letter "n". - Delete the cached kubebuilder_tools tarball when an extracted etcd or kube-apiserver binary fails its hash check. The tarball is the input the bad binary came from; keeping it made every retry re-extract the same bytes and fail until the cache was cleared by hand. Also chain tar, chmod and checkhash with &&: previously a tar failure was masked when the extracted bytes still hashed correctly, producing a cached binary without its executable bit. - Verify the vendored Go tarball against its reviewed hash before extraction, healing a mismatch like tool_link_defs does. The tarball lives in the persisted download cache but the extracted goroot does not, and a poisoned Go toolchain would undermine the go.sum/GOSUMDB verification that the go-installed tools rely on. Also reword the comments: the check runs at link time, not exec time, so it narrows rather than closes the window in which a concurrent writer could swap a binary between verification and use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Richard Wall <richard@the-moon.net>
a2a76e0 to
a46b6ba
Compare
There was a problem hiding this comment.
Pull request overview
This PR hardens modules/tools/00_mod.mk against tool-cache poisoning by re-verifying cached artifacts against the reviewed SHA-256 values at link time (before placing tools on PATH), and by shifting archive-based tools to verify the extracted binary rather than the downloaded archive.
Changes:
- Adds a link-time cache integrity check for tools (with automatic purge + re-download on mismatch), and introduces a
dry_runguard to avoid mutations duringmake -n/-q/-t. - Re-verifies the cached vendored Go tarball immediately before extraction to prevent a poisoned Go toolchain from undermining downstream verification.
- Updates tool SHA-256 values/recipes so
*_SHA256SUMrepresents the stored binary (post-extraction), and adds per-binary hashes foretcdandkube-apiserverextracted from the kubebuilder tarball.
Suppressed comments (1)
modules/tools/00_mod.mk:563
- Because $(bin_dir)/tools/$1 is now a .PHONY target, this rule runs on every make invocation that needs the tool.
touch $@will therefore update the mtime of the cached binary on every run (touch follows symlinks), causing unnecessary filesystem writes and potentially interfering with any consumers that key off mtimes in $(DOWNLOAD_DIR). Since the target is .PHONY, the touch is no longer needed.
@# cd into tools dir and create relative symlink (e.g., ../downloaded/tools/helm@v4.0.1_darwin_arm64)
@# patsubst converts absolute path to relative by replacing $(bin_dir) with ..
@cd $$(dir $$@) && $$(LN) $$(patsubst $$(bin_dir)/%,../%,$$($(call uc,$1)_DOWNLOAD_PATH)) $$(notdir $$@)
@touch $$@ # making sure the target of the symlink is newer than *_VERSION
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # Tools built from source with "go install" have no reviewed hash here; their | ||
| # integrity comes from go.sum/GOSUMDB when they are built, and their staleness | ||
| # is handled by keying the download path on the Go toolchain version (see | ||
| # go_dependency). | ||
| define tool_link_defs | ||
| .PHONY: $$(bin_dir)/tools/$1 | ||
| $$(bin_dir)/tools/$1: $$(bin_dir)/scratch/$(call uc,$1)_VERSION $(if $(filter $1,$(go_tool_names)),$$(bin_dir)/scratch/GO_TOOLCHAIN_VERSION) $$($(call uc,$1)_DOWNLOAD_PATH) | $$(bin_dir)/tools | ||
| @# Re-verify the cached binary against the reviewed hash before trusting it. |
There was a problem hiding this comment.
False positive: the pattern rule at $(bin_dir)/scratch/%_VERSION matches GO_TOOLCHAIN_VERSION, and the variable it stamps is defined in both vendoring branches (L351/L358). Verified empirically: make _bin/scratch/GO_TOOLCHAIN_VERSION writes go1.27.0 to the stamp file, and go-installed tool targets resolve with no missing-rule error.
with claude fable-5
| @if [ -z "$(dry_run)" ] && [ -z "$${LEARN_FILE:-}" ] && ! $(checkhash_script) $| $(go_$(HOST_OS)_$(HOST_ARCH)_SHA256SUM) >/dev/null 2>&1; then \ | ||
| echo "[verify] cache integrity check failed for the vendored Go tarball, re-downloading" >&2; \ | ||
| rm -f $|; \ | ||
| $(MAKE) --no-print-directory $|; \ | ||
| $(checkhash_script) $| $(go_$(HOST_OS)_$(HOST_ARCH)_SHA256SUM); \ |
There was a problem hiding this comment.
Good catch — fixed in 7581e90 with $(firstword $(MAKE)) rather than MAKE_COMMAND: keeping the literal $(MAKE) text on the recipe line means make still marks it as recursive, so the dry-run guard keeps working. Tested with vendoring enabled (MAKE = make vendor-go): a poisoned helm binary and a poisoned Go tarball both heal without dragging the vendor-go goal into the sub-make.
with claude fable-5
| rm -f "$$($(call uc,$1)_DOWNLOAD_PATH)"; \ | ||
| $$(MAKE) --no-print-directory "$$($(call uc,$1)_DOWNLOAD_PATH)"; \ | ||
| $$(checkhash_script) "$$($(call uc,$1)_DOWNLOAD_PATH)" "$$$$expected" || { echo "[verify] $1 still does not match its reviewed hash after re-download; $1_$$(HOST_OS)_$$(HOST_ARCH)_SHA256SUM must be the hash of the stored binary, not the archive" >&2; exit 1; }; \ | ||
| fi |
There was a problem hiding this comment.
Fixed in 7581e90: the sub-make now short-circuits with || exit 1, so a failed download surfaces its own error instead of the misleading hash message. Tested by forcing the download to fail with CURL=/bin/false.
with claude fable-5
Address Copilot review findings on the verify-at-use change: - Invoke the targeted re-download sub-makes with $(firstword $(MAKE)). When Go vendoring is enabled this file appends the "vendor-go" goal to MAKE, so "$(MAKE) --no-print-directory <target>" would also build vendor-go -- in the goroot recipe, recursing straight back into the recipe that invoked it. firstword keeps the literal "$(MAKE)" text on the recipe line, so make still marks it recursive and the dry-run guard still applies. - Short-circuit when the re-download itself fails (network error, 404) instead of falling through to the hash check, whose "must be the hash of the stored binary, not the archive" message would be misleading. - Drop "touch $@" from the tool symlink recipe. The target is .PHONY now, so the mtime no longer triggers anything, and touch follows the symlink: it was updating the cached binary's mtime in the shared download cache on every run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Richard Wall <richard@the-moon.net>
|
Demonstrated end to end in two consumer repos, with
Both demo PRs are draft + with claude fable-5 |
Motivation
$(DOWNLOAD_DIR)(the tool cache) is persisted between CI runs, and for some jobs it is a node-local directory shared with less-trusted jobs that can overwrite a cached binary in place. A tool's SHA-256 is currently only checked when it is first downloaded, so a binary swapped in the cache afterwards is symlinked ontoPATHand executed unverified. This is the tool-cache half of the cache-poisoning problem that #625 and cert-manager/cert-manager#8833 both set out to fix.Approach
The reviewed SHA-256 already in this file is the trust anchor — no signing or fork-write-scoping needed. This PR makes the per-tool symlink a
.PHONYtarget and, on every build, re-hashes the cached binary against that reviewed value before linking it. A mismatch deletes the binary, re-downloads it, and re-verifies the replacement, failing the build with an actionable message if it still does not match.For that to work the reviewed hash must be the hash of the extracted binary, not the downloaded archive:
lock.shif the hash is wrong);etcdandkube-apiservergain their own binary hashes (they are extracted from the kubebuilder tarball and cached individually). If an extracted binary fails its check, the cached tarball is deleted along with it, so the next run re-downloads instead of re-extracting the same bad bytes forever;make learn-tools-shas. Bare-binary tools (kubectl, kind, …) were already hashed as binaries and are unchanged.Consumers adding tools through
ADDITIONAL_TOOLSmust follow the same convention: the*_SHA256SUMvariables hold the hash of the stored binary. An entry still using the old archive-hash convention now fails the build with a message saying exactly that, rather than silently re-downloading on every run.The vendored Go toolchain is covered too: the cached
gotarball is re-verified against its reviewed hash before extraction, healing a mismatch the same way. A poisoned Go toolchain would otherwise undermine thego.sum/GOSUMDB verification that the go-installed tools rely on. Those go-installed tools have no reviewed hash here — they are anchored bygo.sumwhen built, and their staleness is already handled by #708 keying the download path on the Go toolchain version — so the link-time check skips them (empty hash variables are defined to keep--warn-undefined-variablesquiet).Dry runs are safe: GNU make executes recipe lines containing
$(MAKE)even under-n/-q/-t, so the check guards on adry_runvariable derived fromMAKEFLAGS(filtering out long options such as--warn-undefined-variables, which contains ann).Relationship to the other PRs
tool_defsinto the$(XXX_DOWNLOAD_PATH)/tool_link_defsseam this change hooks into. The first two commits here are Key go-installed tool binaries on the Go toolchain that builds them #708's; review the commits after those. Rebase ontomainonce Key go-installed tool binaries on the Go toolchain that builds them #708 merges.verify-cachetarget a job has to remember to run after restore and before any tool executes; here the check is part of building the symlink, so nothing can forget it.Testing
Locally, host platform (
linux/amd64), against a consumer harness replicatingrepository-base:helmbinary and rebuilt: detected, deleted, re-downloaded, re-verified, linked; a further rebuild is a silent no-op;[verify] helm still does not match its reviewed hash after re-download; helm_linux_amd64_SHA256SUM must be the hash of the stored binary, not the archive) instead of looping;make -nwith a poisoned cache prints the recipe but mutates nothing (previously it hashed and deleted for real);dry_runstays empty on normal runs despiteMAKEFLAGScontaining--warn-undefined-variables;tarfailure could previously drop;make vendor-godetects the mismatch, re-downloads, re-verifies, and extracts in the same run;postUpgradeTasksruns): with a stale hash andLEARN_FILEset, the build succeeds and the learn file records the corrects/old/new/greplacement;vendor-gogoal toMAKE), both heal paths re-download via$(firstword $(MAKE))without draggingvendor-gointo the sub-make;CURL=/bin/false) fails the build with the download error, not a misleading hash message;touchfollowed the symlink and updated the cached binary's mtime on every run; the target is.PHONYnow, so thetouchis dropped;etcdlinux/amd64binary hash matches the value independently computed in make: check the hash at runtime instead of after downloading cert-manager#8833.Known limitations
tar/unzip; now the binary hash is checked after extraction, so an archive-parser vulnerability is reachable from a MITM'd or compromised download (the result still fails closed — the bad binary is never linked). Restoring the parse gate would mean keeping a second, archive-level hash per tool per platform plus learn-script support for both. I have left that as a maintainer call rather than doubling the hash tables preemptively; downloads come over TLS from the tools' release hosts.lock.sh, so a writer to a shared cache could still swap a binary between the hash check and its use. Fully closing that needs verification at exec time (or a cache that less-trusted jobs cannot write to at all).~/.cacheis trusted once extracted. Re-verifying it would need a tree hash.PATHis process-wide (_bin/toolsis prepended once for the wholemakerun), so this closes cache poisoning — every cache-backed tool onPATHis verified this run — but not the separate hygiene problem of a target using a tool it did not declare as$(NEEDS_*). That would need per-targetPATHscoping and is worth its own issue.Opening as a draft pending #708 and maintainer review.
with claude opus-4.8 and claude fable-5