Skip to content

Commit cdfb428

Browse files
committed
fix(index): let git check the repository before running a commit hook
`run_commit_hook` finds a hook in `.git/hooks` or `core.hooksPath`, read through GitPython's own config parser, and executes it directly. No git command runs before that, so git's check that the repository is safe to use (the ownership and `safe.directory` check added for CVE-2022-24765) has not happened when code from the repository is executed. `IndexFile.commit` runs the `pre-commit` and `commit-msg` hooks before it calls git at all. In a repository owned by another user, where `git commit` stops with "detected dubious ownership" without running any hook, `repo.index.commit()` ran the repository's `pre-commit` hook and only then failed in `git hash-object`. That is the situation git's check exists for: a `.git` directory planted by another user in a shared or parent directory (`Repo(path, search_parent_directories=True)` finds it the same way git would). Before executing a hook that exists, `run_commit_hook` now runs `git rev-parse --git-dir` in the repository, so git applies its own policy, including `safe.directory` and the exemptions git makes. When git refuses, the `GitCommandError` it raises is the one the commit already ended with, only now before the hook runs. When git accepts the repository, hooks run as before, and nothing changes when there is no hook. Asking git, rather than reimplementing the ownership rules, keeps the result identical to `git commit` for every git version, including versions older than 2.35.2 that make no check. Adds `test_pre_commit_hook_not_run_in_repository_git_refuses` to `test/test_index.py`, which uses git's `GIT_TEST_ASSUME_DIFFERENT_OWNER` to simulate another owner; it fails before this change and passes after. `test_run_commit_hook` and `test_run_commit_hook_outside_worktree_on_windows` mock `Git.execute` (the latter because it fakes the Windows `PATH`), so they now allow this one call while still rejecting any git command during hook lookup. `ruff check`, `ruff format --check` and `mypy` pass. In a full clone prepared with `init-tests-after-clone.sh`, the pytest suite passes except `test_diff_with_staged_file`, `test_index_mutation`, `test_blame_content_with_carriage_returns`, `test_empty_repo`, `test_rebasing` and the two `test_actor_get_uid_laziness_*` tests, which fail the same way without this change on this machine.
1 parent ccc4811 commit cdfb428

2 files changed

Lines changed: 31 additions & 2 deletions

File tree

‎git/index/fun.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,20 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
185185
Arguments passed to hook file.
186186
187187
:raise git.exc.HookExecutionError:
188+
189+
:raise git.exc.GitCommandError:
190+
If Git refuses to work in the repository, for instance because it is owned by
191+
another user and not listed in ``safe.directory``. The hook is not run then.
188192
"""
189193
hp = _commit_hook_path(name, index)
190194
if not os.access(hp, os.X_OK):
191195
return
192196

197+
# Hooks are found and run without invoking Git, so Git's check of whether the
198+
# repository is safe to use has not happened yet. Let Git apply it before
199+
# executing code taken from the repository.
200+
index.repo.git.rev_parse("--git-dir")
201+
193202
env = os.environ.copy()
194203
env["GIT_INDEX_FILE"] = safe_decode(os.fspath(index.path))
195204
env["GIT_EDITOR"] = ":"

‎test/test_index.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@ def _make_hook(git_dir, name, content, make_exec=True):
189189
return hp
190190

191191

192+
def _only_ownership_check(command, *args, **kwargs):
193+
"""Stand in for :meth:`Git.execute`, allowing only the check before running a hook."""
194+
if command[1:] != ["rev-parse", "--git-dir"]:
195+
raise AssertionError("hook lookup must not run git: %r" % (command,))
196+
197+
192198
def _raw_index(path, mode=0o100644):
193199
"""Build an index without using the writer under test."""
194200
name = path.encode("utf-8")
@@ -1496,7 +1502,7 @@ def test_run_commit_hook(self, rw_repo):
14961502
_make_hook(index.repo.git_dir, "fake-hook", "echo 'ran fake hook' >output.txt")
14971503
output = Path(rw_repo.git_dir, "output.txt")
14981504
with mock.patch.object(Repo, "config_level", ("repository",)):
1499-
with mock.patch.object(Git, "execute", side_effect=AssertionError("hook lookup must not run git")):
1505+
with mock.patch.object(Git, "execute", side_effect=_only_ownership_check):
15001506
run_commit_hook("fake-hook", index)
15011507
self.assertEqual(output.read_text(encoding="utf-8"), "ran fake hook\n")
15021508

@@ -1533,7 +1539,7 @@ def command(hooks_path, exec_path):
15331539
):
15341540
with mock.patch("git.index.fun.safer_popen") as popen, mock.patch(
15351541
"git.index.fun.handle_process_output"
1536-
):
1542+
), mock.patch.object(Git, "execute", side_effect=_only_ownership_check):
15371543
popen.return_value.returncode = 0
15381544
run_commit_hook("fake-hook", repo.index)
15391545
return popen.call_args[0][0]
@@ -1707,6 +1713,20 @@ def test_pre_commit_hook_outside_worktree(self, rw_dir):
17071713
output = Path(repo.working_dir, "outside-hook-output.txt").read_text(encoding="utf-8")
17081714
self.assertEqual(output, "ran outside hook\n")
17091715

1716+
@with_rw_directory
1717+
def test_pre_commit_hook_not_run_in_repository_git_refuses(self, rw_dir):
1718+
repo = Repo.init(osp.join(rw_dir, "repo"))
1719+
if repo.git.version_info < (2, 35, 2):
1720+
self.skipTest("Git checks repository ownership since 2.35.2")
1721+
_make_hook(repo.git_dir, "pre-commit", "echo 'ran hook' >hook-output.txt")
1722+
1723+
# Git's test hook makes it treat the repository as owned by another user.
1724+
with mock.patch.dict(os.environ, {"GIT_TEST_ASSUME_DIFFERENT_OWNER": "1"}):
1725+
with self.assertRaisesRegex(GitCommandError, "dubious ownership"):
1726+
repo.index.commit("Git refuses this repository")
1727+
1728+
self.assertFalse(osp.exists(osp.join(repo.working_dir, "hook-output.txt")))
1729+
17101730
@pytest.mark.xfail(
17111731
type(_win_bash_status) is WinBashStatus.WslNoDistro,
17121732
reason="Currently uses the bash.exe of WSL, even with no WSL distro installed",

0 commit comments

Comments
 (0)