mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
Red-teaming the guard from 3c317470 found two more ways to get a repository's own
code executed. Both are demonstrated by a planted binary that appends to a witness
file, counted before and after.
Case folding. git matches index pathspecs case-sensitively even where
core.ignorecase is set, but APFS does not -- so a repository that commits
`.venv/bin/Python` gets `$venv/bin/python` opening and running that file while the
guard's lowercase query finds nothing in the index and reports it untracked. The
witness logged two invocations. It applies to `venv` and `env` as well, and to any
folding of the name. The query now uses a `:(icase)` pathspec; all nine
directory-by-spelling combinations are refused, and an untracked venv still runs.
Module shadowing. `python -c "import pytest"` puts the working directory first on
sys.path, so a repository that commits a `pytest.py` in its root has that file
imported, and executed, by a check whose only job is to answer whether pytest is
installed. The probe is now `python -I -c "import pytest"` on the virtualenv, uv
and poetry paths alike. Isolation does not hide a real pytest -- it lives in the
interpreter's own site-packages, confirmed against a venv holding pytest 9.1.1.
Still true, and not something this hook can fix: running the repository's declared
suite runs the repository's code. `pytest` imports conftest.py, and the Node arm
runs package.json scripts. That is what a pre-push verification hook is for. The
line this guard draws is narrower and worth keeping -- a capability probe, and the
choice of which interpreter to trust, should not be things the pushed repository
gets to decide.
293 lines
12 KiB
Bash
Executable File
293 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# ECC Codex Git Hook: pre-push
|
|
# Runs a lightweight verification flow before pushes.
|
|
|
|
if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PREPUSH:-0}" == "1" ]]; then
|
|
exit 0
|
|
fi
|
|
|
|
if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then
|
|
exit 0
|
|
fi
|
|
|
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
exit 0
|
|
fi
|
|
|
|
# Skip checks for branch deletion pushes (e.g., git push origin --delete <branch>).
|
|
# The pre-push hook receives lines on stdin: <local ref> <local sha> <remote ref> <remote sha>.
|
|
# For deletions, the local sha is the zero OID.
|
|
is_delete_only=true
|
|
while read -r _local_ref local_sha _remote_ref _remote_sha; do
|
|
if [[ "$local_sha" != "0000000000000000000000000000000000000000" ]]; then
|
|
is_delete_only=false
|
|
break
|
|
fi
|
|
done
|
|
if [[ "$is_delete_only" == "true" ]]; then
|
|
exit 0
|
|
fi
|
|
|
|
ran_any_check=0
|
|
|
|
log() {
|
|
printf '[ECC pre-push] %s\n' "$*"
|
|
}
|
|
|
|
fail() {
|
|
printf '[ECC pre-push] FAILED: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
detect_pm() {
|
|
if [[ -f "pnpm-lock.yaml" ]]; then
|
|
echo "pnpm"
|
|
elif [[ -f "bun.lockb" ]]; then
|
|
echo "bun"
|
|
elif [[ -f "yarn.lock" ]]; then
|
|
echo "yarn"
|
|
elif [[ -f "package-lock.json" ]]; then
|
|
echo "npm"
|
|
else
|
|
echo "npm"
|
|
fi
|
|
}
|
|
|
|
has_node_script() {
|
|
local script_name="$1"
|
|
node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); process.exit(p.scripts && p.scripts[process.argv[1]] ? 0 : 1)' "$script_name" >/dev/null 2>&1
|
|
}
|
|
|
|
run_pnpm() {
|
|
if command -v corepack >/dev/null 2>&1; then
|
|
# Corepack may download the pinned pnpm version on a cache miss. Set
|
|
# COREPACK_ENABLE_NETWORK=0 to make an offline cache miss fail immediately.
|
|
corepack pnpm "$@"
|
|
elif command -v pnpm >/dev/null 2>&1; then
|
|
pnpm "$@"
|
|
else
|
|
fail "pnpm could not be resolved from PATH or Corepack"
|
|
fi
|
|
}
|
|
|
|
run_node_script() {
|
|
local pm="$1"
|
|
local script_name="$2"
|
|
case "$pm" in
|
|
pnpm) run_pnpm run "$script_name" ;;
|
|
bun) bun run "$script_name" ;;
|
|
yarn) yarn "$script_name" ;;
|
|
npm) npm run "$script_name" ;;
|
|
*) npm run "$script_name" ;;
|
|
esac
|
|
}
|
|
|
|
if [[ -f "package.json" ]]; then
|
|
pm="$(detect_pm)"
|
|
log "Node project detected (package manager: $pm)"
|
|
|
|
for script_name in lint typecheck test build; do
|
|
if has_node_script "$script_name"; then
|
|
ran_any_check=1
|
|
log "Running: $script_name"
|
|
run_node_script "$pm" "$script_name" || fail "$script_name failed"
|
|
else
|
|
log "Skipping missing script: $script_name"
|
|
fi
|
|
done
|
|
|
|
if [[ "${ECC_PREPUSH_AUDIT:-0}" == "1" ]]; then
|
|
ran_any_check=1
|
|
log "Running dependency audit (ECC_PREPUSH_AUDIT=1)"
|
|
case "$pm" in
|
|
pnpm) run_pnpm audit --prod || fail "pnpm audit failed" ;;
|
|
bun) bun audit || fail "bun audit failed" ;;
|
|
yarn) yarn npm audit --recursive || fail "yarn audit failed" ;;
|
|
npm) npm audit --omit=dev || fail "npm audit failed" ;;
|
|
*) npm audit --omit=dev || fail "npm audit failed" ;;
|
|
esac
|
|
fi
|
|
fi
|
|
|
|
if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then
|
|
ran_any_check=1
|
|
log "Go project detected. Running: go test ./..."
|
|
go test ./... || fail "go test failed"
|
|
fi
|
|
|
|
# Resolve how this project runs pytest, into PYTEST_CMD as an argv array.
|
|
#
|
|
# Looking only for `pytest` on PATH meant the hook skipped every project that keeps
|
|
# its tools in a virtualenv -- which is most of them -- and reported "pytest is not
|
|
# installed" while sitting next to a .venv with pytest in it. A gate that silently
|
|
# declines to gate is worse than no gate, because the skip line reads like a pass.
|
|
#
|
|
# An array rather than one string, because a virtualenv path may contain spaces:
|
|
# a scalar command splits `/home/me/my env/bin/python` into two paths that do not
|
|
# exist, and the hook then rejects the push for a reason that has nothing to do
|
|
# with the code being pushed.
|
|
#
|
|
# Echoes the command it will run, so the reason for a skip is always visible.
|
|
PYTEST_CMD=()
|
|
|
|
# Does this command actually run pytest? Accepting `--version` is not evidence --
|
|
# plenty of programs take it and exit 0 -- so the output has to name pytest. The
|
|
# version is captured rather than piped: under `set -o pipefail` a `| grep -q` can
|
|
# report the SIGPIPE of the program it just matched.
|
|
#
|
|
# Only ever called on a command this script composed itself. Probing an arbitrary
|
|
# operator-supplied command is not safe: a wrapper that ignores `--version` and
|
|
# execs pytest runs the entire suite during the probe, and is then rejected for
|
|
# not having printed a version.
|
|
is_pytest() {
|
|
local version
|
|
version="$("$@" --version 2>&1)" || return 1
|
|
grep -qiE 'pytest[[:space:]]+(version[[:space:]]+)?[0-9]' <<<"$version"
|
|
}
|
|
|
|
# Does the repository itself ship this interpreter?
|
|
#
|
|
# A virtualenv is never committed -- it is platform-specific binaries, and every
|
|
# Python project gitignores it. One that IS tracked is the repository handing this
|
|
# hook an executable and asking it to run. The hook is installed globally, so
|
|
# cloning a hostile repository and pushing it to your own fork would be enough,
|
|
# and on a machine with no pytest on PATH this arm is the only thing that would
|
|
# run at all. A developer's own venv is untracked, so nothing legitimate is lost.
|
|
#
|
|
# The path is resolved through symlinks before git is asked, because `git ls-files`
|
|
# reports paths as indexed and does not follow links. A repository that commits
|
|
# `.venv` as a symlink to `.` next to a tracked `bin/python` would otherwise be
|
|
# queried for `.venv/bin/python`, a path git has never heard of, and the answer
|
|
# would be "untracked". Measured: that shape ran the planted binary twice.
|
|
repo_ships_interpreter() {
|
|
local bindir real top
|
|
bindir="$(cd -P -- "$1" 2>/dev/null && pwd -P)" || return 1
|
|
[[ -n "$bindir" ]] || return 1
|
|
real="$bindir/python"
|
|
top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
|
|
top="$(cd -P -- "$top" 2>/dev/null && pwd -P)" || return 1
|
|
[[ -n "$top" && "$real" == "$top/"* ]] || return 1
|
|
# `:(icase)` because git matches index pathspecs case-sensitively even where
|
|
# core.ignorecase is set, while the filesystem underneath does not. On macOS's
|
|
# APFS -- the platform this hook most often runs on -- a committed
|
|
# `.venv/bin/Python` is what `$venv/bin/python` opens and executes, but a
|
|
# case-sensitive query for the lowercase name finds nothing in the index and the
|
|
# guard waves it through. Measured: that spelling ran the planted binary twice.
|
|
git ls-files --error-unmatch -- ":(icase)${real#"$top"/}" >/dev/null 2>&1
|
|
}
|
|
|
|
# `-I` isolates the probe: without it Python puts the working directory first on
|
|
# sys.path, so a repository that commits a `pytest.py` in its root gets that file
|
|
# imported -- and executed -- by a check whose only job is to answer whether pytest
|
|
# exists. Measured: a committed pytest.py ran during the probe. Isolation does not
|
|
# hide a real pytest, which lives in the interpreter's own site-packages.
|
|
resolve_pytest() {
|
|
# `${VAR+set}` rather than `-n "${VAR:-}"`, so that a variable set to nothing is
|
|
# still an override: `ECC_PYTEST_CMD=` and `ECC_PYTEST_CMD=" "` now behave
|
|
# alike, where the first used to fall through to discovery and the second failed
|
|
# the push. Falling through is the wrong half of that pair -- an override that
|
|
# evaluated empty (a command substitution that found nothing, say) would silently
|
|
# run a different runner than the operator asked for, which is the substitution
|
|
# this resolver refuses to make anywhere else.
|
|
#
|
|
# Not `[[ -v ECC_PYTEST_CMD ]]`: that is bash 4.2, and a stock macOS `/bin/bash`
|
|
# is 3.2, where it is a syntax error rather than a false. This hook ships to
|
|
# whatever `env bash` finds.
|
|
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
|
|
# Taken as given. This is a deliberate override, and the hook cannot inspect it
|
|
# without running it -- a wrapper script may ignore `--version` and run the
|
|
# suite, so probing costs a duplicate test run and then blocks the push anyway.
|
|
# Pointing this at something that is not pytest turns the gate off, and that is
|
|
# the operator's call to make, not a misconfiguration for the hook to second
|
|
# guess. Word-split, so the command names something on PATH or an interpreter
|
|
# whose path has no spaces; a venv with spaces is found by the loop below.
|
|
read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true
|
|
[[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but names no command.\
|
|
Point it at your test runner, or unset it to fall back to discovery."
|
|
return 0
|
|
fi
|
|
local venv
|
|
for venv in "${VIRTUAL_ENV:-}" .venv venv env; do
|
|
if [[ -n "$venv" && -x "$venv/bin/python" ]]; then
|
|
if repo_ships_interpreter "$venv/bin"; then
|
|
log "Ignoring $venv/bin/python: the repository ships it."
|
|
log " A committed virtualenv is an executable the repository controls, and"
|
|
log " this hook runs on every push in every repository."
|
|
continue
|
|
fi
|
|
if "$venv/bin/python" -I -c "import pytest" >/dev/null 2>&1; then
|
|
PYTEST_CMD=("$venv/bin/python" -m pytest)
|
|
return 0
|
|
fi
|
|
fi
|
|
done
|
|
if [[ -f "uv.lock" ]] && command -v uv >/dev/null 2>&1; then
|
|
if uv run --no-sync python -I -c "import pytest" >/dev/null 2>&1; then
|
|
PYTEST_CMD=(uv run --no-sync pytest)
|
|
return 0
|
|
fi
|
|
fi
|
|
if [[ -f "poetry.lock" ]] && command -v poetry >/dev/null 2>&1; then
|
|
if poetry run python -I -c "import pytest" >/dev/null 2>&1; then
|
|
PYTEST_CMD=(poetry run pytest)
|
|
return 0
|
|
fi
|
|
fi
|
|
# `command -v` proves only that a file of that name exists on PATH. This one the
|
|
# script composed itself, so confirming it costs a harmless `pytest --version`.
|
|
if command -v pytest >/dev/null 2>&1 && is_pytest pytest; then
|
|
PYTEST_CMD=(pytest)
|
|
return 0
|
|
fi
|
|
PYTEST_CMD=()
|
|
return 1
|
|
}
|
|
|
|
if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then
|
|
if resolve_pytest; then
|
|
ran_any_check=1
|
|
log "Python project detected. Running: ${PYTEST_CMD[*]} -q"
|
|
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
|
|
# resolve_pytest deliberately does not verify the override is pytest, because
|
|
# probing it can run the operator's suite. What this gate can honestly do
|
|
# about a stale override is refuse to be quiet about it: a bypass announced
|
|
# on every push is not the silent gate this resolver exists to prevent.
|
|
log " via ECC_PYTEST_CMD -- the hook runs what you pointed it at, and does"
|
|
log " not check that it is pytest. Unset it to gate on the real suite."
|
|
fi
|
|
pytest_status=0
|
|
"${PYTEST_CMD[@]}" -q || pytest_status=$?
|
|
case "$pytest_status" in
|
|
0) ;;
|
|
# pytest reserves 5 for NO_TESTS_COLLECTED, which is not a red suite. A
|
|
# pyproject.toml that only configures ruff or black is still a Python project
|
|
# by this hook's test, and blocking those pushes would make the gate something
|
|
# people switch off. Never silent, though: a bad rootdir, testpaths or a
|
|
# conftest that fails to import also collects nothing, and swallowing that is
|
|
# the same skip-reads-like-a-pass hole this resolver exists to close.
|
|
5)
|
|
log "pytest collected no tests (exit 5). Not gating this push."
|
|
log " If this repository is supposed to have tests, that is the bug:"
|
|
log " check rootdir, testpaths, and conftest.py import errors."
|
|
;;
|
|
# The code is in the message because 1 (tests failed) and 4 (usage error)
|
|
# need different responses, and "pytest failed" alone cannot tell them apart.
|
|
*) fail "pytest failed (exit $pytest_status)" ;;
|
|
esac
|
|
else
|
|
log "Python project detected but no pytest found (checked \$VIRTUAL_ENV, .venv,"
|
|
log " venv, env, uv, poetry, PATH). Set ECC_PYTEST_CMD to point at it."
|
|
fi
|
|
fi
|
|
|
|
|
|
if [[ "$ran_any_check" -eq 0 ]]; then
|
|
log "No supported checks found in this repository. Skipping."
|
|
else
|
|
log "Verification checks passed."
|
|
fi
|
|
|
|
exit 0
|