Skip to content

Verify the tool cache against reviewed hashes at link time - #710

Draft
wallrj wants to merge 5 commits into
cert-manager:mainfrom
wallrj:tools-cache-verify-at-use
Draft

Verify the tool cache against reviewed hashes at link time#710
wallrj wants to merge 5 commits into
cert-manager:mainfrom
wallrj:tools-cache-verify-at-use

Conversation

@wallrj

@wallrj wallrj commented Aug 21, 2026

Copy link
Copy Markdown
Member

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 onto PATH and 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 .PHONY target 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:

  • the archive recipes now hash the binary after extraction (the download still fails closed via lock.sh if the hash is wrong);
  • etcd and kube-apiserver gain 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;
  • the SHAs for the affected tools were relearned with make learn-tools-shas. Bare-binary tools (kubectl, kind, …) were already hashed as binaries and are unchanged.

Consumers adding tools through ADDITIONAL_TOOLS must follow the same convention: the *_SHA256SUM variables 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 go tarball is re-verified against its reviewed hash before extraction, healing a mismatch the same way. A poisoned Go toolchain would otherwise undermine the go.sum/GOSUMDB verification that the go-installed tools rely on. Those go-installed tools have no reviewed hash here — they are anchored by go.sum when 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-variables quiet).

Dry runs are safe: GNU make executes recipe lines containing $(MAKE) even under -n/-q/-t, so the check guards on a dry_run variable derived from MAKEFLAGS (filtering out long options such as --warn-undefined-variables, which contains an n).

Relationship to the other PRs

Testing

Locally, host platform (linux/amd64), against a consumer harness replicating repository-base:

  • poisoned the cached helm binary and rebuilt: detected, deleted, re-downloaded, re-verified, linked; a further rebuild is a silent no-op;
  • with a deliberately wrong reviewed hash, the build fails loudly ([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 -n with a poisoned cache prints the recipe but mutates nothing (previously it hashed and deleted for real); dry_run stays empty on normal runs despite MAKEFLAGS containing --warn-undefined-variables;
  • corrupted the cached kubebuilder tarball: extraction fails, the tarball is deleted with a clear message, and the next run heals fully — including the binary's executable bit, which a masked tar failure could previously drop;
  • replaced the cached Go tarball with garbage and removed the extracted goroot: make vendor-go detects the mismatch, re-downloads, re-verifies, and extracts in the same run;
  • learn mode (what Renovate's postUpgradeTasks runs): with a stale hash and LEARN_FILE set, the build succeeds and the learn file records the correct s/old/new/g replacement;
  • with Go vendoring enabled (this file appends the vendor-go goal to MAKE), both heal paths re-download via $(firstword $(MAKE)) without dragging vendor-go into the sub-make;
  • a failed re-download (forced with CURL=/bin/false) fails the build with the download error, not a misleading hash message;
  • steady-state runs no longer write to the download cache at all: the symlink recipe's touch followed the symlink and updated the cached binary's mtime on every run; the target is .PHONY now, so the touch is dropped;
  • the relearned etcd linux/amd64 binary hash matches the value independently computed in make: check the hash at runtime instead of after downloading cert-manager#8833.

Known limitations

  • Archives are parsed before they are hashed. Previously the archive hash gated 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.
  • The window is narrowed, not closed. Verification happens at link time, not exec time, and outside 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).
  • The extracted goroot tree is not re-verified. In CI the goroot directory is not cached, so every run re-extracts from the (now verified) tarball; on a developer machine the extracted tree in ~/.cache is trusted once extracted. Re-verifying it would need a tree hash.
  • PATH is process-wide (_bin/tools is prepended once for the whole make run), so this closes cache poisoning — every cache-backed tool on PATH is 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-target PATH scoping and is worth its own issue.

Opening as a draft pending #708 and maintainer review.

with claude opus-4.8 and claude fable-5

@cert-manager-prow cert-manager-prow Bot added do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. dco-signoff: yes Indicates that all commits in the pull request have the valid DCO sign-off message. labels Aug 21, 2026
@cert-manager-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign joshvanl for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@cert-manager-prow cert-manager-prow Bot added needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Aug 21, 2026
wallrj-cyberark and others added 4 commits August 21, 2026 15:07
- 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>
@wallrj
wallrj force-pushed the tools-cache-verify-at-use branch from a2a76e0 to a46b6ba Compare August 21, 2026 14:10
@cert-manager-prow cert-manager-prow Bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Aug 21, 2026
@wallrj
wallrj requested a lite review from Copilot August 21, 2026 14:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_run guard to avoid mutations during make -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 *_SHA256SUM represents the stored binary (post-extraction), and adds per-binary hashes for etcd and kube-apiserver extracted 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.

Comment thread modules/tools/00_mod.mk
Comment on lines +543 to +550
# 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread modules/tools/00_mod.mk
Comment on lines +403 to +407
@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); \

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread modules/tools/00_mod.mk
Comment on lines +556 to +559
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@wallrj

wallrj commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Demonstrated end to end in two consumer repos, with klone.yaml pointing the tools module at this branch (7581e90) and make/_shared/tools vendored via make generate-klone:

Both demo PRs are draft + /hold and will be closed once this merges; the module change reaches consumers through the normal klone bump.

with claude fable-5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates that all commits in the pull request have the valid DCO sign-off message. do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants