Skip to content

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

Open
wallrj wants to merge 1 commit into
cert-manager:mainfrom
wallrj:tools-cache-verify-at-use
Open

Verify the tool cache against reviewed hashes at link time#710
wallrj wants to merge 1 commit 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.

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

@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

@wallrj
wallrj force-pushed the tools-cache-verify-at-use branch from 7581e90 to 4c9617b Compare August 21, 2026 18:52
wallrj added a commit to wallrj/cert-manager that referenced this pull request Aug 21, 2026
Point the tools module in klone.yaml at
cert-manager/makefile-modules#710 (wallrj/makefile-modules branch
tools-cache-verify-at-use, commit 4c9617b) so that CI exercises the
verify-at-use tool cache check end to end. Everything under
make/_shared/tools is the vendored output of "make generate-klone".

Repoint klone.yaml at upstream main before any merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Richard Wall <richard@the-moon.net>
wallrj added a commit to wallrj/istio-csr that referenced this pull request Aug 21, 2026
Point the tools module in klone.yaml at
cert-manager/makefile-modules#710 (wallrj/makefile-modules branch
tools-cache-verify-at-use, commit 4c9617b) so that CI exercises the
verify-at-use tool cache check end to end. Everything under
make/_shared/tools is the vendored output of "make generate-klone".

Repoint klone.yaml at upstream main before any merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Richard Wall <richard@the-moon.net>
@wallrj

wallrj commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Rebased onto #708's updated commits (cherry-picked onto current main, head 4c9617b). The demo PRs above now klone the rebased head and all presubmits are green again on both.

with claude fable-5

@wallrj
wallrj force-pushed the tools-cache-verify-at-use branch 2 times, most recently from 8becb65 to 904be15 Compare August 24, 2026 17:12
@wallrj
wallrj marked this pull request as ready for review August 24, 2026 17:12
@cert-manager-prow cert-manager-prow Bot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Aug 24, 2026
@wallrj

wallrj commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main now that #708 has merged, and squashed to a single commit (904be15). The diff against main is unchanged apart from picking up the Renovate tool bumps that landed there (kubectl and preflight are bare binaries, so their upstream hashes are already binary hashes). Marked ready for review.

with claude fable-5

@cert-manager-prow cert-manager-prow Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Aug 24, 2026
The tool download cache is persisted between CI runs and, for some
jobs, shared with less-trusted jobs that can overwrite a cached binary
in place. A tool's SHA-256 was only checked at download time, so a
binary swapped in the cache afterwards was symlinked onto PATH and
executed unverified.

Make each per-tool symlink a .PHONY target that re-hashes the cached
binary against the reviewed SHA-256 in this file before linking it. On
a mismatch, delete the binary, re-download it via a sub-make, and
re-verify 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 stored
binary, not the downloaded archive:

- archive recipes now hash the binary after extraction (the download
  still fails closed via lock.sh);
- etcd and kube-apiserver gain their own binary hashes, and a failed
  extracted binary also deletes the cached kubebuilder tarball so the
  next run re-downloads instead of re-extracting the same bad bytes;
- the affected SHAs were relearned with "make learn-tools-shas".

The cached Go tarball is likewise re-verified against its reviewed
hash before extraction, healing a mismatch the same way. Go-installed
tools have no reviewed hash here — they are anchored by go.sum when
built — so the link-time check skips them.

The check guards on a dry_run variable derived from MAKEFLAGS because
GNU make executes recipe lines containing $(MAKE) even under -n. The
sub-make uses $(firstword $(MAKE)) so the vendor-go goal appended to
MAKE when Go vendoring is enabled is not dragged in. The symlink
recipe's touch is dropped: it dereferenced the link and updated the
cached binary's mtime on every run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 904be15 to 2bb389c Compare August 24, 2026 19:21
@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 24, 2026
@wallrj

wallrj commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Rebased again for #714 (protoc v36.0): re-verified the four release zips against the reviewed archive hashes on main, then recorded the extracted bin/protoc binary hashes, per this PR's convention. Head is now 2bb389c.

with claude fable-5

@cert-manager-prow cert-manager-prow Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Sep 2, 2026
@cert-manager-prow

Copy link
Copy Markdown
Contributor

PR needs rebase.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

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. 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants