fix(hooks): match the index case-insensitively, and isolate the pytest probe

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.
This commit is contained in:
Juan Garibay
2026-09-17 17:17:19 -04:00
parent 3c31747016
commit 4869db30c4
2 changed files with 41 additions and 7 deletions
+15 -4
View File
@@ -169,9 +169,20 @@ repo_ships_interpreter() {
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
git ls-files --error-unmatch -- "${real#"$top"/}" >/dev/null 2>&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
@@ -206,20 +217,20 @@ resolve_pytest() {
log " this hook runs on every push in every repository."
continue
fi
if "$venv/bin/python" -c "import pytest" >/dev/null 2>&1; then
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 -c "import pytest" >/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 -c "import pytest" >/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
+26 -3
View File
@@ -319,6 +319,7 @@ function runHermeticPythonPrePush({
venvName = null,
venvExit = 0,
trackVenv = false,
trackedVenvBasename = 'python',
trackedSymlinkVenv = false,
pytestCmd = null,
overrideStub = false,
@@ -340,9 +341,11 @@ function runHermeticPythonPrePush({
// A tracked venv has to live inside the repository to be trackable at all, and is
// found by directory-name discovery rather than by VIRTUAL_ENV.
const venvDir = venvName === null ? null : path.join(trackVenv ? projectDir : tempDir, venvName);
const venvPython = venvDir === null ? null : path.join(venvDir, 'bin', 'python');
const venvPython = venvDir === null
? null
: path.join(venvDir, 'bin', trackVenv ? trackedVenvBasename : 'python');
if (venvPython !== null) {
writeExecutable(venvPython, `#!/bin/sh\n${record}\nif [ "$1" = "-c" ]; then exit 0; fi\nexit ${venvExit}\n`);
writeExecutable(venvPython, `#!/bin/sh\n${record}\ncase " $* " in *" -c "*) exit 0 ;; esac\nexit ${venvExit}\n`);
if (trackVenv) {
// Staged, not committed: `git ls-files` reads the index, so this is enough to
// make the file repository-controlled without needing a committer identity.
@@ -414,7 +417,7 @@ if (
const python = toBashPath(venvPython);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [
`${python}|-c import pytest`,
`${python}|-I -c import pytest`,
`${python}|-m pytest -q`,
], JSON.stringify({ calls, python, stdout: result.stdout, stderr: result.stderr }, null, 2));
})
@@ -433,6 +436,26 @@ if (
passed++;
else failed++;
// A case-folded spelling, because macOS resolves `$venv/bin/python` to a committed
// `Python` while git matches index pathspecs case-sensitively. Skipped where the
// filesystem is case-sensitive and the two names cannot collide.
if (fs.existsSync(__filename.toUpperCase()) || fs.existsSync(__filename.toLowerCase())) {
if (
test('pre-push refuses a tracked interpreter committed under a folded case', () => {
const { result, calls } = runHermeticPythonPrePush({
venvName: '.venv',
trackVenv: true,
trackedVenvBasename: 'Python',
});
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [], JSON.stringify(calls));
assert.match(result.stdout, /the repository ships it/);
})
)
passed++;
else failed++;
}
if (
test('pre-push refuses a tracked interpreter reached through a committed symlink', () => {
const { result, calls } = runHermeticPythonPrePush({ trackedSymlinkVenv: true });