diff --git a/hooks/pre-push b/hooks/pre-push index 780b78b..04d4477 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -14,23 +14,55 @@ if [[ -z "$expected_sha" || ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then exit 2 fi +# Git exports GIT_DIR (and friends) into hook processes. In a linked worktree +# that value is an ABSOLUTE path, so a plain `git -C "$shared_dir" ...` still +# resolves against the pushing repository and reports ITS HEAD, origin and +# status instead of the pinned policy checkout's — the hook then refuses a +# perfectly good checkout ("Vana scanner checkout is at "). In a +# normal checkout GIT_DIR is the relative ".git", which happens to resolve +# correctly under -C, which is why this only bites worktrees. Scrub the +# inherited repository environment for commands that must target the checkout; +# commands that scan the pushing repository keep it. +# The scrub list comes from git itself (`--local-env-vars` covers GIT_DIR, +# GIT_WORK_TREE, GIT_INDEX_FILE, the object-directory pair, and crucially +# GIT_CONFIG_PARAMETERS / GIT_CONFIG_COUNT, which `git -c foo=bar push` exports +# into hooks and which would otherwise let the caller's environment satisfy the +# origin check below instead of the checkout's real config). The GIT_CONFIG_* +# file overrides are not in that list, so they are added explicitly, and a +# hardcoded fallback covers a git too old to answer. +_shared_git_scrub=() +while IFS= read -r _v; do + [[ -n "$_v" ]] && _shared_git_scrub+=(-u "$_v") +done < <(git rev-parse --local-env-vars 2>/dev/null || printf '%s\n' \ + GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR \ + GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT) +for _v in GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM GIT_CONFIG_NOSYSTEM; do + _shared_git_scrub+=(-u "$_v") +done +unset _v + +shared_git() { + env "${_shared_git_scrub[@]}" git "$@" +} + scanner="$shared_dir/scripts/scan-commit-range.sh" verifier="$shared_dir/scripts/verify-gitleaks.sh" [[ -x "$scanner" && -x "$verifier" ]] || { printf 'VANA_SECRET_SCAN_HOME is not a valid shared scanner checkout.\n' >&2 exit 2 } -actual_sha=$(git -C "$shared_dir" rev-parse HEAD) +actual_sha=$(shared_git -C "$shared_dir" rev-parse HEAD) if [[ "$actual_sha" != "$expected_sha" ]]; then printf 'Vana scanner checkout is at %s, expected %s.\n' "$actual_sha" "$expected_sha" >&2 exit 2 fi -origin_url=$(git -C "$shared_dir" config --get remote.origin.url || true) +origin_url=$(shared_git -C "$shared_dir" config --get remote.origin.url || true) case "$origin_url" in git@github.com:vana-com/.github|git@github.com:vana-com/.github.git|https://github.com/vana-com/.github|https://github.com/vana-com/.github.git) ;; *) printf 'Vana scanner checkout origin is not vana-com/.github: %s\n' "${origin_url:-}" >&2; exit 2 ;; esac -git -C "$shared_dir" diff --quiet -- . && git -C "$shared_dir" diff --cached --quiet -- . || { +shared_git -C "$shared_dir" diff --quiet -- . && shared_git -C "$shared_dir" diff --cached --quiet -- . || { printf 'Vana scanner checkout has local changes: %s\n' "$shared_dir" >&2 exit 2 } diff --git a/scripts/install-gitleaks.sh b/scripts/install-gitleaks.sh index 04a2017..cf53b33 100755 --- a/scripts/install-gitleaks.sh +++ b/scripts/install-gitleaks.sh @@ -62,10 +62,17 @@ trap 'rm -rf "$workdir"' EXIT archive="$workdir/$asset" curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ "$RELEASE_BASE/$asset" --output "$archive" -if command -v sha256sum >/dev/null 2>&1; then - printf '%s %s\n' "$sha256" "$archive" | sha256sum --check --status -else +# macOS ships its own /sbin/sha256sum ("sha256sum (Darwin) 1.0") which does NOT +# accept the GNU long options below, so `command -v sha256sum` succeeds there +# and this verification fails for every download regardless of authenticity — +# the shasum fallback was never reachable on the platform that needed it. +# `shasum -a 256` accepts --check/--status on both macOS and Linux (and still +# exits non-zero on a checksum mismatch), so prefer it, keeping sha256sum for +# minimal images that ship coreutils but no perl. +if command -v shasum >/dev/null 2>&1; then printf '%s %s\n' "$sha256" "$archive" | shasum -a 256 --check --status +else + printf '%s %s\n' "$sha256" "$archive" | sha256sum --check --status fi tar -xzf "$archive" -C "$workdir" gitleaks install -m 0755 "$workdir/gitleaks" "$destination/gitleaks" diff --git a/scripts/install-pre-push.sh b/scripts/install-pre-push.sh index 84dac94..237a61c 100755 --- a/scripts/install-pre-push.sh +++ b/scripts/install-pre-push.sh @@ -45,7 +45,39 @@ esac exit 2 } -git -C "$shared_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { +# Git exports GIT_DIR (and friends) into hook processes. In a linked worktree +# that value is an ABSOLUTE path, so a plain `git -C "$shared_dir" ...` still +# resolves against the pushing repository and reports ITS HEAD, origin and +# status instead of the pinned policy checkout's — the hook then refuses a +# perfectly good checkout ("Vana scanner checkout is at "). In a +# normal checkout GIT_DIR is the relative ".git", which happens to resolve +# correctly under -C, which is why this only bites worktrees. Scrub the +# inherited repository environment for commands that must target the checkout; +# commands that scan the pushing repository keep it. +# The scrub list comes from git itself (`--local-env-vars` covers GIT_DIR, +# GIT_WORK_TREE, GIT_INDEX_FILE, the object-directory pair, and crucially +# GIT_CONFIG_PARAMETERS / GIT_CONFIG_COUNT, which `git -c foo=bar push` exports +# into hooks and which would otherwise let the caller's environment satisfy the +# origin check below instead of the checkout's real config). The GIT_CONFIG_* +# file overrides are not in that list, so they are added explicitly, and a +# hardcoded fallback covers a git too old to answer. +_shared_git_scrub=() +while IFS= read -r _v; do + [[ -n "$_v" ]] && _shared_git_scrub+=(-u "$_v") +done < <(git rev-parse --local-env-vars 2>/dev/null || printf '%s\n' \ + GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR \ + GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT) +for _v in GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM GIT_CONFIG_NOSYSTEM; do + _shared_git_scrub+=(-u "$_v") +done +unset _v + +shared_git() { + env "${_shared_git_scrub[@]}" git "$@" +} + +shared_git -C "$shared_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { printf 'Policy checkout is not a Git work tree: %s\n' "$shared_dir" >&2 exit 2 } @@ -53,17 +85,17 @@ git -C "$shared_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { [[ -x "$shared_dir/scripts/install-gitleaks.sh" ]] || { printf 'No installer at: %s/scripts/install-gitleaks.sh\n' "$shared_dir" >&2; exit 2; } [[ -x "$shared_dir/scripts/verify-gitleaks.sh" ]] || { printf 'No verifier at: %s/scripts/verify-gitleaks.sh\n' "$shared_dir" >&2; exit 2; } shared_dir=$(cd "$shared_dir" && pwd -P) -actual_sha=$(git -C "$shared_dir" rev-parse HEAD) +actual_sha=$(shared_git -C "$shared_dir" rev-parse HEAD) [[ "$actual_sha" == "$expected_sha" ]] || { printf 'Policy checkout is at %s, expected %s.\n' "$actual_sha" "$expected_sha" >&2 exit 2 } -origin_url=$(git -C "$shared_dir" config --get remote.origin.url || true) +origin_url=$(shared_git -C "$shared_dir" config --get remote.origin.url || true) case "$origin_url" in git@github.com:vana-com/.github|git@github.com:vana-com/.github.git|https://github.com/vana-com/.github|https://github.com/vana-com/.github.git) ;; *) printf 'Policy checkout origin is not vana-com/.github: %s\n' "${origin_url:-}" >&2; exit 2 ;; esac -if [[ -n "$(git -C "$shared_dir" status --porcelain --untracked-files=all -- ':!/.tools')" ]]; then +if [[ -n "$(shared_git -C "$shared_dir" status --porcelain --untracked-files=all -- ':!/.tools')" ]]; then printf 'Policy checkout has local changes: %s\n' "$shared_dir" >&2 exit 2 fi diff --git a/tests/run.sh b/tests/run.sh index 0f652c1..846e1fb 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -153,6 +153,86 @@ if [[ -e "$test_root/external-tools/gitleaks/gitleaks" ]]; then exit 1 fi +# Git exports the repository environment into hook processes. A command that +# must target the policy checkout has to ignore it: GIT_DIR is absolute in a +# linked worktree, so a bare `git -C "$policy" ...` reads the PUSHING repo's +# HEAD and origin instead, and GIT_CONFIG_PARAMETERS (exported by +# `git -c foo=bar push`) lets the caller's environment answer the origin check. +# Both are accepted inputs from an untrusted caller, so both are tested here. +env_policy="$test_root/env-policy" +git clone -q "$root" "$env_policy" +git -C "$env_policy" remote set-url origin https://github.com/vana-com/.github.git +git -C "$env_policy" checkout -q "$policy_sha" +"$env_policy/scripts/install-pre-push.sh" prepare --shared-dir "$env_policy" --ref "$policy_sha" + +# A decoy repository standing in for the one being pushed: a different HEAD and +# a non-Vana origin, so leaking into it fails the SHA and origin checks. +decoy="$test_root/env-decoy" +git init -q -b main "$decoy" +git -C "$decoy" config user.name test +git -C "$decoy" config user.email test@example.invalid +git -C "$decoy" remote add origin https://example.invalid/pushing-repo.git +git -C "$decoy" commit --allow-empty -q -m decoy +decoy_git_dir=$(git -C "$decoy" rev-parse --absolute-git-dir) + +# `prepare` only inspects the policy checkout, so it isolates the scrub from +# the target-repository resolution that legitimately reads the environment. +if ! GIT_DIR="$decoy_git_dir" \ + "$env_policy/scripts/install-pre-push.sh" prepare --shared-dir "$env_policy" --ref "$policy_sha" >/dev/null 2>&1; then + printf 'expected prepare to resolve the policy checkout with GIT_DIR inherited\n' >&2 + exit 1 +fi + +# The pre-push hook performs the same checks, so it must scrub the same way. +worktree_repo="$test_root/worktree-repo" +git init -q -b main "$worktree_repo" +git -C "$worktree_repo" config user.name test +git -C "$worktree_repo" config user.email test@example.invalid +git -C "$worktree_repo" commit --allow-empty -q -m base +"$root/scripts/install-pre-push.sh" --shared-dir "$env_policy" --repo "$worktree_repo" --ref "$policy_sha" >/dev/null +git -C "$worktree_repo" worktree add -q "$test_root/worktree-linked" -b linked +linked_git_dir=$(git -C "$test_root/worktree-linked" rev-parse --absolute-git-dir) +worktree_head=$(git -C "$test_root/worktree-linked" rev-parse HEAD) +if ! (cd "$test_root/worktree-linked" && + GIT_DIR="$linked_git_dir" printf 'refs/heads/linked %s refs/heads/linked %040d\n' "$worktree_head" 0 | + GIT_DIR="$linked_git_dir" "$worktree_repo/.git/hooks/pre-push" origin example.invalid) >/dev/null 2>&1; then + printf 'expected pre-push hook to run from a linked worktree\n' >&2 + exit 1 +fi + +# The origin check must read the checkout's real config, not the caller's +# environment. Point the checkout at an attacker origin and claim otherwise. +git -C "$env_policy" remote set-url origin https://github.com/attacker/evil.git +if GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=remote.origin.url \ + GIT_CONFIG_VALUE_0=https://github.com/vana-com/.github.git \ + "$env_policy/scripts/install-pre-push.sh" prepare --shared-dir "$env_policy" --ref "$policy_sha" >/dev/null 2>&1; then + printf 'expected prepare to refuse an origin spoofed through the environment\n' >&2 + exit 1 +fi +if GIT_CONFIG_PARAMETERS="'remote.origin.url=https://github.com/vana-com/.github.git'" \ + "$env_policy/scripts/install-pre-push.sh" prepare --shared-dir "$env_policy" --ref "$policy_sha" >/dev/null 2>&1; then + printf 'expected prepare to refuse an origin spoofed through GIT_CONFIG_PARAMETERS\n' >&2 + exit 1 +fi +git -C "$env_policy" remote set-url origin https://github.com/vana-com/.github.git + +# The download checksum gate must reject a corrupted archive on this platform, +# whichever of shasum/sha256sum install-gitleaks.sh selects. +checksum_digest=$(printf 'gitleaks' | { command -v shasum >/dev/null 2>&1 && shasum -a 256 || sha256sum; } | cut -d' ' -f1) +checksum_file="$test_root/checksum-probe" +printf 'gitleaks' >"$checksum_file" +if ! printf '%s %s\n' "$checksum_digest" "$checksum_file" | + { command -v shasum >/dev/null 2>&1 && shasum -a 256 --check --status || sha256sum --check --status; }; then + printf 'expected the checksum tool to accept a matching digest\n' >&2 + exit 1 +fi +if printf '%s %s\n' "$(printf '0%.0s' {1..64})" "$checksum_file" | + { command -v shasum >/dev/null 2>&1 && shasum -a 256 --check --status || sha256sum --check --status; } 2>/dev/null; then + printf 'expected the checksum tool to reject a mismatched digest\n' >&2 + exit 1 +fi + key='4f3c8b1a9e6d2c7f0b5e1d8a6c3f9b2e''7d4a1c8f5b0e6d3a9c2f7b4e1d8a6c3f' scan() { "$scanner" --repo "$repo" --range "$1" --config "$config" --gitleaks "$gitleaks"; } expect_scan_status() {