diff --git a/modules/tools/00_mod.mk b/modules/tools/00_mod.mk index 2f81d56c..26f2fc35 100644 --- a/modules/tools/00_mod.mk +++ b/modules/tools/00_mod.mk @@ -39,6 +39,7 @@ $(bin_dir)/tools $(DOWNLOAD_DIR)/tools: checkhash_script := $(dir $(lastword $(MAKEFILE_LIST)))/util/checkhash.sh lock_script := $(dir $(lastword $(MAKEFILE_LIST)))/util/lock.sh +verify_cache_script := $(dir $(lastword $(MAKEFILE_LIST)))/util/verify_cache.sh # $outfile is a variable in the lock script # Escape the dollar sign so it's passed literally to the shell script, not expanded by make @@ -785,3 +786,17 @@ go-tools: $(go_tool_names:%=$(bin_dir)/tools/%) ## Download and setup all tools ## @category [shared] Tools tools: non-go-tools go-tools + +.PHONY: verify-cache +## Verify the integrity of $(DOWNLOAD_DIR) after restoring from an untrusted +## cache. Checks SHA-256 of downloaded binaries, removes files with mismatched +## hashes and unknown files, and verifies the Go module cache via go.sum. Exits +## non-zero if any corruption is detected. Intended to be run after cache +## restore in CI environments where the cache write-side is not fully trusted +## (e.g. node-local hostPath shared with low-trust presubmit jobs). +## @category [shared] Tools +verify-cache: + @$(verify_cache_script) $(DOWNLOAD_DIR) \ + $(foreach t,$(non_go_tool_names),$(if $(value $(t)_$(HOST_OS)_$(HOST_ARCH)_SHA256SUM),$(t)@$($(call uc,$(t))_VERSION)_$(HOST_OS)_$(HOST_ARCH)=$($(t)_$(HOST_OS)_$(HOST_ARCH)_SHA256SUM))) \ + go@$(VENDORED_GO_VERSION)_$(HOST_OS)_$(HOST_ARCH).tar.gz=$(go_$(HOST_OS)_$(HOST_ARCH)_SHA256SUM) \ + kubebuilder_tools_$(KUBEBUILDER_ASSETS_VERSION)_$(HOST_OS)_$(HOST_ARCH).tar.gz=$(kubebuilder_tools_$(HOST_OS)_$(HOST_ARCH)_SHA256SUM) diff --git a/modules/tools/util/verify_cache.sh b/modules/tools/util/verify_cache.sh new file mode 100755 index 00000000..57031f20 --- /dev/null +++ b/modules/tools/util/verify_cache.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# Copyright 2026 The cert-manager Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +# Verify the integrity of a restored cache directory before it is trusted. +# +# Intended to run after restoring a cache in CI from a low-trust source +# (e.g. a node-local hostPath shared with presubmit jobs that could plant +# trojan binaries). It performs three checks: +# +# 1. For each file directly under /tools/: if the file name is +# not in the supplied allow-list, it is removed; otherwise its SHA-256 +# (via hash.sh) must match the expected hash or the file is removed. +# 2. Anything in / other than the tools/ directory is removed +# outright — only the tools/ subtree is considered part of the cache. +# 3. If go, go.mod and go.sum are present in the working directory, run +# `go mod verify` to validate the Go module cache. NOTE: `go mod verify` +# checks the modules in $GOMODCACHE (defaults to $GOPATH/pkg/mod); the +# caller must point GOMODCACHE at the restored cache for this to +# verify it. +# +# Exits 0 if nothing was removed (cache was clean), 1 if any file or +# directory was removed (cache was corrupted/tampered and CI should treat +# the restore as a miss). +# +# Usage: verify_cache.sh [= ...] +# Path to an existing cache directory containing tools/. +# = Allow-listed file under tools/ and its expected SHA-256. +# must be non-empty; every allow-listed file is +# hash-checked. + +if [[ $# -lt 1 ]]; then + echo "Usage: $(basename "$0") [= ...]" >&2 + exit 2 +fi + +cache_dir="$1" +shift + +if [[ ! -d "${cache_dir}" ]]; then + echo "error: cache directory does not exist: ${cache_dir}" >&2 + exit 2 +fi + +tools_dir="${cache_dir}/tools" + +declare -A hashes +for pair in "$@"; do + if [[ "${pair}" != *=* ]]; then + echo "error: expected =, got: ${pair}" >&2 + exit 2 + fi + name="${pair%%=*}" + hash="${pair#*=}" + if [[ -z "${name}" ]]; then + echo "error: empty file name in argument: ${pair}" >&2 + exit 2 + fi + if [[ -z "${hash}" ]]; then + echo "error: empty hash for file '${name}'; every allow-listed file must have a hash" >&2 + exit 2 + fi + hashes["${name}"]="${hash}" +done + +removed=0 + +# Verify tools/ directory +for file in "${tools_dir}"/*; do + [[ -f "${file}" ]] || continue + name=$(basename "${file}") + + # Remove unknown files + if [[ ! -v hashes["${name}"] ]]; then + rm -f "${file}" + removed=1 + continue + fi + + # Remove files with wrong hash + if [[ $("${SCRIPT_DIR}/hash.sh" "${file}") != "${hashes[${name}]}" ]]; then + rm -f "${file}" + removed=1 + fi +done + +# Remove all unexpected files/directories in cache root (not tools/) +for item in "${cache_dir}"/*; do + [[ -e "${item}" ]] || continue + [[ "$(basename "${item}")" == "tools" ]] && continue + rm -rf "${item}" + removed=1 +done + +if [[ -f go.mod ]] && [[ -f go.sum ]] && command -v go >/dev/null 2>&1; then + echo "## Verifying Go module cache" + go mod verify +fi + +exit "${removed}" diff --git a/tests/test_verify_cache.sh b/tests/test_verify_cache.sh new file mode 100755 index 00000000..a65f9c30 --- /dev/null +++ b/tests/test_verify_cache.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +# Copyright 2026 The cert-manager Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit +set -o nounset +set -o pipefail + +script_dir=$(dirname "$(realpath "$0")") +verify_cache="${script_dir}/../modules/tools/util/verify_cache.sh" +hash_script="${script_dir}/../modules/tools/util/hash.sh" + +echo "Testing verify_cache.sh" + +# Test 1: Empty cache passes +echo "Test 1: Empty cache" +tmp_dir=$(mktemp -d) +mkdir -p "${tmp_dir}/tools" +"${verify_cache}" "${tmp_dir}" file1=abc123 file2=def456 && echo "✓ pass" || echo "✗ fail" +rm -rf "${tmp_dir}" + +# Test 2: Valid files pass +echo "Test 2: Valid files" +tmp_dir=$(mktemp -d) +mkdir -p "${tmp_dir}/tools" +echo "content1" > "${tmp_dir}/tools/file1" +echo "content2" > "${tmp_dir}/tools/file2" +hash1=$("${hash_script}" "${tmp_dir}/tools/file1") +hash2=$("${hash_script}" "${tmp_dir}/tools/file2") +"${verify_cache}" "${tmp_dir}" "file1=${hash1}" "file2=${hash2}" && echo "✓ pass" || echo "✗ fail" +rm -rf "${tmp_dir}" + +# Test 3: Unknown file in tools/ removed +echo "Test 3: Unknown file removed" +tmp_dir=$(mktemp -d) +mkdir -p "${tmp_dir}/tools" +echo "content1" > "${tmp_dir}/tools/file1" +echo "trojan" > "${tmp_dir}/tools/evil" +hash1=$("${hash_script}" "${tmp_dir}/tools/file1") +if "${verify_cache}" "${tmp_dir}" "file1=${hash1}" 2>/dev/null; then + echo "✗ fail - should have exited 1" + rm -rf "${tmp_dir}" + exit 1 +fi +[[ ! -f "${tmp_dir}/tools/evil" ]] && echo "✓ pass" || echo "✗ fail - evil not removed" +rm -rf "${tmp_dir}" + +# Test 4: Wrong hash removed +echo "Test 4: Wrong hash removed" +tmp_dir=$(mktemp -d) +mkdir -p "${tmp_dir}/tools" +echo "content1" > "${tmp_dir}/tools/file1" +if "${verify_cache}" "${tmp_dir}" "file1=wronghash" 2>/dev/null; then + echo "✗ fail - should have exited 1" + rm -rf "${tmp_dir}" + exit 1 +fi +[[ ! -f "${tmp_dir}/tools/file1" ]] && echo "✓ pass" || echo "✗ fail - file1 not removed" +rm -rf "${tmp_dir}" + +# Test 5: Empty hash is rejected +echo "Test 5: Empty hash rejected" +tmp_dir=$(mktemp -d) +mkdir -p "${tmp_dir}/tools" +echo "content" > "${tmp_dir}/tools/etcd" +if "${verify_cache}" "${tmp_dir}" "etcd=" 2>/dev/null; then + echo "✗ fail - should have exited non-zero" + rm -rf "${tmp_dir}" + exit 1 +fi +echo "✓ pass - empty hash rejected" +rm -rf "${tmp_dir}" + +# Test 6: Mixed scenario in tools/ +echo "Test 6: Mixed scenario" +tmp_dir=$(mktemp -d) +mkdir -p "${tmp_dir}/tools" +echo "good" > "${tmp_dir}/tools/kind" +echo "bad" > "${tmp_dir}/tools/helm" +echo "trojan" > "${tmp_dir}/tools/evil" +hash_kind=$("${hash_script}" "${tmp_dir}/tools/kind") +if "${verify_cache}" "${tmp_dir}" "kind=${hash_kind}" "helm=wronghash" 2>/dev/null; then + echo "✗ fail - should have exited 1" + rm -rf "${tmp_dir}" + exit 1 +fi +[[ -f "${tmp_dir}/tools/kind" ]] && echo "✓ pass - kind kept" || echo "✗ fail - kind removed" +[[ ! -f "${tmp_dir}/tools/helm" ]] && echo "✓ pass - helm removed" || echo "✗ fail - helm kept" +[[ ! -f "${tmp_dir}/tools/evil" ]] && echo "✓ pass - evil removed" || echo "✗ fail - evil kept" +rm -rf "${tmp_dir}" + +# Test 7: Removes untrusted files/dirs from cache root +echo "Test 7: Cache root cleanup" +tmp_dir=$(mktemp -d) +mkdir -p "${tmp_dir}/tools" "${tmp_dir}/evil_dir" +echo "good" > "${tmp_dir}/tools/kind" +echo "trojan1" > "${tmp_dir}/trojan_root" +echo "trojan2" > "${tmp_dir}/evil_dir/payload" +hash_kind=$("${hash_script}" "${tmp_dir}/tools/kind") +if "${verify_cache}" "${tmp_dir}" "kind=${hash_kind}" 2>/dev/null; then + echo "✗ fail - should have exited 1" + rm -rf "${tmp_dir}" + exit 1 +fi +[[ -f "${tmp_dir}/tools/kind" ]] && echo "✓ pass - kind kept" || echo "✗ fail - kind removed" +[[ ! -f "${tmp_dir}/trojan_root" ]] && echo "✓ pass - trojan_root removed" || echo "✗ fail - trojan_root kept" +[[ ! -d "${tmp_dir}/evil_dir" ]] && echo "✓ pass - evil_dir removed" || echo "✗ fail - evil_dir kept" +rm -rf "${tmp_dir}" + +echo "All tests passed"