Skip to content

Commit bf919f6

Browse files
committed
Merge branch 'main' into fix-bash-exe
2 parents 50c74f4 + d28c20b commit bf919f6

18 files changed

+435
-154
lines changed

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ repos:
2929
hooks:
3030
- id: shellcheck
3131
args: [--color]
32-
exclude: ^git/ext/
32+
exclude: ^test/fixtures/polyglot$|^git/ext/
3333

3434
- repo: https://github.com/pre-commit/pre-commit-hooks
3535
rev: v4.4.0

.readthedocs.yaml

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Read the Docs configuration file for Sphinx projects
2+
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3+
4+
# Required
5+
version: 2
6+
7+
# Set the OS, Python version and other tools you might need
8+
build:
9+
os: ubuntu-22.04
10+
tools:
11+
python: "3.12"
12+
# You can also specify other tool versions:
13+
# nodejs: "20"
14+
# rust: "1.70"
15+
# golang: "1.20"
16+
17+
# Build documentation in the "docs/" directory with Sphinx
18+
sphinx:
19+
configuration: doc/source/conf.py
20+
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
21+
# builder: "dirhtml"
22+
# Fail on all warnings to avoid broken references
23+
# fail_on_warning: true
24+
25+
# Optionally build your docs in additional formats such as PDF and ePub
26+
# formats:
27+
# - pdf
28+
# - epub
29+
30+
# Optional but recommended, declare the Python requirements required
31+
# to build your documentation
32+
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
33+
# python:
34+
# install:
35+
# - requirements: docs/requirements.txt

AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Contributors are:
5353
-Santos Gallegos <stsewd _at_ proton.me>
5454
-Wenhan Zhu <wzhu.cosmos _at_ gmail.com>
5555
-Eliah Kagan <eliah.kagan _at_ gmail.com>
56+
-Ethan Lin <et.repositories _at_ gmail.com>
5657
-Randy Eckman <emanspeaks _at_ gmail.com>
5758

5859
Portions derived from other open source works and are clearly marked.

VERSION

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.1.40
1+
3.1.41

doc/requirements.txt

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1-
sphinx==4.3.0
1+
sphinx == 4.3.2
22
sphinx_rtd_theme
3+
sphinxcontrib-applehelp >= 1.0.2, <= 1.0.4
4+
sphinxcontrib-devhelp == 1.0.2
5+
sphinxcontrib-htmlhelp >= 2.0.0, <= 2.0.1
6+
sphinxcontrib-qthelp == 1.0.3
7+
sphinxcontrib-serializinghtml == 1.1.5
38
sphinx-autodoc-typehints

doc/source/changes.rst

+12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22
Changelog
33
=========
44

5+
3.1.41
6+
======
7+
8+
This release is relevant for security as it fixes a possible arbitary
9+
code execution on Windows.
10+
11+
See this PR for details: https://github.com/gitpython-developers/GitPython/pull/1792
12+
An advisory is available soon at: https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-2mqj-m65w-jghx
13+
14+
See the following for all changes.
15+
https://github.com/gitpython-developers/GitPython/releases/tag/3.1.41
16+
517
3.1.40
618
======
719

doc/source/conf.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
# Options for HTML output
9494
# -----------------------
9595

96-
html_theme = "sphinx_rtd_theme"
96+
# html_theme = "sphinx_rtd_theme"
9797
html_theme_options = {}
9898

9999
# The name for this set of Sphinx documents. If None, it defaults to

git/cmd.py

+76-26
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
Iterator,
4848
List,
4949
Mapping,
50+
Optional,
5051
Sequence,
5152
TYPE_CHECKING,
5253
TextIO,
@@ -103,7 +104,7 @@ def handle_process_output(
103104
Callable[[bytes, "Repo", "DiffIndex"], None],
104105
],
105106
stderr_handler: Union[None, Callable[[AnyStr], None], Callable[[List[AnyStr]], None]],
106-
finalizer: Union[None, Callable[[Union[subprocess.Popen, "Git.AutoInterrupt"]], None]] = None,
107+
finalizer: Union[None, Callable[[Union[Popen, "Git.AutoInterrupt"]], None]] = None,
107108
decode_streams: bool = True,
108109
kill_after_timeout: Union[None, float] = None,
109110
) -> None:
@@ -208,6 +209,68 @@ def pump_stream(
208209
finalizer(process)
209210

210211

212+
def _safer_popen_windows(
213+
command: Union[str, Sequence[Any]],
214+
*,
215+
shell: bool = False,
216+
env: Optional[Mapping[str, str]] = None,
217+
**kwargs: Any,
218+
) -> Popen:
219+
"""Call :class:`subprocess.Popen` on Windows but don't include a CWD in the search.
220+
221+
This avoids an untrusted search path condition where a file like ``git.exe`` in a
222+
malicious repository would be run when GitPython operates on the repository. The
223+
process using GitPython may have an untrusted repository's working tree as its
224+
current working directory. Some operations may temporarily change to that directory
225+
before running a subprocess. In addition, while by default GitPython does not run
226+
external commands with a shell, it can be made to do so, in which case the CWD of
227+
the subprocess, which GitPython usually sets to a repository working tree, can
228+
itself be searched automatically by the shell. This wrapper covers all those cases.
229+
230+
:note: This currently works by setting the ``NoDefaultCurrentDirectoryInExePath``
231+
environment variable during subprocess creation. It also takes care of passing
232+
Windows-specific process creation flags, but that is unrelated to path search.
233+
234+
:note: The current implementation contains a race condition on :attr:`os.environ`.
235+
GitPython isn't thread-safe, but a program using it on one thread should ideally
236+
be able to mutate :attr:`os.environ` on another, without unpredictable results.
237+
See comments in https://github.com/gitpython-developers/GitPython/pull/1650.
238+
"""
239+
# CREATE_NEW_PROCESS_GROUP is needed for some ways of killing it afterwards. See:
240+
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen.send_signal
241+
# https://docs.python.org/3/library/subprocess.html#subprocess.CREATE_NEW_PROCESS_GROUP
242+
creationflags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
243+
244+
# When using a shell, the shell is the direct subprocess, so the variable must be
245+
# set in its environment, to affect its search behavior. (The "1" can be any value.)
246+
if shell:
247+
safer_env = {} if env is None else dict(env)
248+
safer_env["NoDefaultCurrentDirectoryInExePath"] = "1"
249+
else:
250+
safer_env = env
251+
252+
# When not using a shell, the current process does the search in a CreateProcessW
253+
# API call, so the variable must be set in our environment. With a shell, this is
254+
# unnecessary, in versions where https://github.com/python/cpython/issues/101283 is
255+
# patched. If not, in the rare case the ComSpec environment variable is unset, the
256+
# shell is searched for unsafely. Setting NoDefaultCurrentDirectoryInExePath in all
257+
# cases, as here, is simpler and protects against that. (The "1" can be any value.)
258+
with patch_env("NoDefaultCurrentDirectoryInExePath", "1"):
259+
return Popen(
260+
command,
261+
shell=shell,
262+
env=safer_env,
263+
creationflags=creationflags,
264+
**kwargs,
265+
)
266+
267+
268+
if os.name == "nt":
269+
safer_popen = _safer_popen_windows
270+
else:
271+
safer_popen = Popen
272+
273+
211274
def dashify(string: str) -> str:
212275
return string.replace("_", "-")
213276

@@ -226,14 +289,6 @@ def dict_to_slots_and__excluded_are_none(self: object, d: Mapping[str, Any], exc
226289
## -- End Utilities -- @}
227290

228291

229-
if os.name == "nt":
230-
# CREATE_NEW_PROCESS_GROUP is needed to allow killing it afterwards. See:
231-
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen.send_signal
232-
PROC_CREATIONFLAGS = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
233-
else:
234-
PROC_CREATIONFLAGS = 0
235-
236-
237292
class Git(LazyMixin):
238293
"""The Git class manages communication with the Git binary.
239294
@@ -1160,11 +1215,8 @@ def execute(
11601215
redacted_command,
11611216
'"kill_after_timeout" feature is not supported on Windows.',
11621217
)
1163-
# Only search PATH, not CWD. This must be in the *caller* environment. The "1" can be any value.
1164-
maybe_patch_caller_env = patch_env("NoDefaultCurrentDirectoryInExePath", "1")
11651218
else:
11661219
cmd_not_found_exception = FileNotFoundError
1167-
maybe_patch_caller_env = contextlib.nullcontext()
11681220
# END handle
11691221

11701222
stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
@@ -1179,20 +1231,18 @@ def execute(
11791231
universal_newlines,
11801232
)
11811233
try:
1182-
with maybe_patch_caller_env:
1183-
proc = Popen(
1184-
command,
1185-
env=env,
1186-
cwd=cwd,
1187-
bufsize=-1,
1188-
stdin=(istream or DEVNULL),
1189-
stderr=PIPE,
1190-
stdout=stdout_sink,
1191-
shell=shell,
1192-
universal_newlines=universal_newlines,
1193-
creationflags=PROC_CREATIONFLAGS,
1194-
**subprocess_kwargs,
1195-
)
1234+
proc = safer_popen(
1235+
command,
1236+
env=env,
1237+
cwd=cwd,
1238+
bufsize=-1,
1239+
stdin=(istream or DEVNULL),
1240+
stderr=PIPE,
1241+
stdout=stdout_sink,
1242+
shell=shell,
1243+
universal_newlines=universal_newlines,
1244+
**subprocess_kwargs,
1245+
)
11961246
except cmd_not_found_exception as err:
11971247
raise GitCommandNotFound(redacted_command, err) from err
11981248
else:

git/index/fun.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
import subprocess
2020

21-
from git.cmd import PROC_CREATIONFLAGS, handle_process_output, Git
21+
from git.cmd import handle_process_output, Git, safer_popen
2222
from git.compat import defenc, force_bytes, force_text, safe_decode
2323
from git.exc import HookExecutionError, UnmergedEntriesError
2424
from git.objects.fun import (
@@ -98,13 +98,12 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
9898
relative_hp = Path(hp).relative_to(index.repo.working_dir).as_posix()
9999
cmd = [Git.GIT_PYTHON_BASH_EXECUTABLE, relative_hp]
100100

101-
process = subprocess.Popen(
101+
process = safer_popen(
102102
cmd + list(args),
103103
env=env,
104104
stdout=subprocess.PIPE,
105105
stderr=subprocess.PIPE,
106106
cwd=index.repo.working_dir,
107-
creationflags=PROC_CREATIONFLAGS,
108107
)
109108
except Exception as ex:
110109
raise HookExecutionError(hp, ex) from ex

git/objects/tree.py

+1-49
Original file line numberDiff line numberDiff line change
@@ -53,54 +53,6 @@
5353
__all__ = ("TreeModifier", "Tree")
5454

5555

56-
def git_cmp(t1: TreeCacheTup, t2: TreeCacheTup) -> int:
57-
a, b = t1[2], t2[2]
58-
# assert isinstance(a, str) and isinstance(b, str)
59-
len_a, len_b = len(a), len(b)
60-
min_len = min(len_a, len_b)
61-
min_cmp = cmp(a[:min_len], b[:min_len])
62-
63-
if min_cmp:
64-
return min_cmp
65-
66-
return len_a - len_b
67-
68-
69-
def merge_sort(a: List[TreeCacheTup], cmp: Callable[[TreeCacheTup, TreeCacheTup], int]) -> None:
70-
if len(a) < 2:
71-
return
72-
73-
mid = len(a) // 2
74-
lefthalf = a[:mid]
75-
righthalf = a[mid:]
76-
77-
merge_sort(lefthalf, cmp)
78-
merge_sort(righthalf, cmp)
79-
80-
i = 0
81-
j = 0
82-
k = 0
83-
84-
while i < len(lefthalf) and j < len(righthalf):
85-
if cmp(lefthalf[i], righthalf[j]) <= 0:
86-
a[k] = lefthalf[i]
87-
i = i + 1
88-
else:
89-
a[k] = righthalf[j]
90-
j = j + 1
91-
k = k + 1
92-
93-
while i < len(lefthalf):
94-
a[k] = lefthalf[i]
95-
i = i + 1
96-
k = k + 1
97-
98-
while j < len(righthalf):
99-
a[k] = righthalf[j]
100-
j = j + 1
101-
k = k + 1
102-
103-
10456
class TreeModifier:
10557
"""A utility class providing methods to alter the underlying cache in a list-like fashion.
10658
@@ -131,7 +83,7 @@ def set_done(self) -> "TreeModifier":
13183
13284
:return self:
13385
"""
134-
merge_sort(self._cache, git_cmp)
86+
self._cache.sort(key=lambda x: (x[2] + "/") if x[1] == Tree.tree_id << 12 else x[2])
13587
return self
13688

13789
# } END interface

git/util.py

+11
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,17 @@ def _get_exe_extensions() -> Sequence[str]:
327327

328328

329329
def py_where(program: str, path: Optional[PathLike] = None) -> List[str]:
330+
"""Perform a path search to assist :func:`is_cygwin_git`.
331+
332+
This is not robust for general use. It is an implementation detail of
333+
:func:`is_cygwin_git`. When a search following all shell rules is needed,
334+
:func:`shutil.which` can be used instead.
335+
336+
:note: Neither this function nor :func:`shutil.which` will predict the effect of an
337+
executable search on a native Windows system due to a :class:`subprocess.Popen`
338+
call without ``shell=True``, because shell and non-shell executable search on
339+
Windows differ considerably.
340+
"""
330341
# From: http://stackoverflow.com/a/377028/548792
331342
winprog_exts = _get_exe_extensions()
332343

test-requirements.txt

-1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,3 @@ pytest-cov
99
pytest-instafail
1010
pytest-mock
1111
pytest-sugar
12-
sumtypes

test/fixtures/polyglot

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env sh
2+
# Valid script in both Bash and Python, but with different behavior.
3+
""":"
4+
echo 'Ran intended hook.' >output.txt
5+
exit
6+
" """
7+
from pathlib import Path
8+
Path('payload.txt').write_text('Ran impostor hook!', encoding='utf-8')

0 commit comments

Comments
 (0)