Original flaw
Earlier flawGit command keyword options could smuggle unsafe dash-prefixed value tokens.
Sink: GitPython shared _option_candidates unsafe-option guard
Originally written by Michael Trier
Loading…
How AI contributed
Incomplete remediationGitPython's check_unsafe_options guard (the control introduced by CVE-2026-42215 / GHSA-2f96 and hardened since) can be bypassed for every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by smuggling an option token inside the VALUE of a single-character kwarg. In the default allow_unsafe_options=False configuration this yields arbitrary command execution via --upload-pack.
Only the highlighted steps are this advisory. The first card is the earlier flaw the AI tried, and failed, to close.
Original flaw
Earlier flawSink: GitPython shared _option_candidates unsafe-option guard
Originally written by Michael Trier
This advisoryGHSA-R9MR-M37C-5FR3
AI tried to fix this
The AI change was a real security patch, but it left the same advisory reachable.
Missed: Did not reject dash-prefixed values associated with otherwise allowed options.
Fixed again
This is the patch that actually stops the same attack path.
Code comparison
--- a/git/cmd.py+++ b/git/cmd.py@@ -649,6 +649,11 @@ class Git(metaclass=_GitMeta): re_unsafe_protocol = re.compile(r"(.+)::.+") + unsafe_git_ls_remote_options = [+ # This option allows arbitrary command execution in git-ls-remote.+ "--upload-pack",+ ]+ def __getstate__(self) -> Dict[str, Any]: return slots_to_dict(self, exclude=self._excluded_) @@ -1022,6 +1027,20 @@ class Git(metaclass=_GitMeta): f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." ) + @classmethod+ def _option_candidates(cls, args: Sequence[Any] =, kwargs: Optional[Mapping[str, Any]] = None) -> List[str]:+ """Collect possible option spellings before command-line transformation."""+ options = [+ option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-")+ ]+ if kwargs:+ for key, value in kwargs.items():+ values = value if isinstance(value, (list, tuple)) else (value,)+ if any(value is True or (value is not False and value is not None) for value in values):+ key = str(key)+ options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}")+ return options+ AutoInterrupt: TypeAlias = _AutoInterrupt CatFileContentStream: TypeAlias = _CatFileContentStream@@ -1079,6 +1098,22 @@ class Git(metaclass=_GitMeta): self._persistent_git_options = self.transform_kwargs(split_single_char_options=True, **kwargs) + def ls_remote(+ self,+ *args: Any,+ allow_unsafe_options: bool = False,+ **kwargs: Any,+ ) -> Union[str, bytes, Tuple[int, Union[str, bytes], str], "Git.AutoInterrupt"]:+ """List references in a remote repository.++ :param allow_unsafe_options:+ Allow unsafe options, like ``--upload-pack``.+ """+ if not allow_unsafe_options:+ candidate_options = self._option_candidates(args, kwargs)+ Git.check_unsafe_options(options=candidate_options, unsafe_options=self.unsafe_git_ls_remote_options)+ return self._call_process("ls_remote", *args, **kwargs)+ @property def working_dir(self) -> Union[None, PathLike]: """:return: Git directory we are working on"""@@ -1585,7 +1620,7 @@ class Git(metaclass=_GitMeta): return args @classmethod- def _unpack_args(cls, arg_list: Sequence[str]) -> List[str]:+ def _unpack_args(cls, arg_list: Sequence[Any]) -> List[str]: outlist = [] if isinstance(arg_list, (list, tuple)): for arg in arg_list:--- a/test/test_git.py+++ b/test/test_git.py@@ -164,6 +164,9 @@ class TestGit(TestBase): (["c"], ["-c"]), (["--upload-pack=/tmp/helper"], ["--upload-pack"]), (["--config core.filemode=false"], ["--config"]),+ (["--upl=/tmp/helper"], ["--upload-pack"]),+ (["conf"], ["--config"]),+ (["--out=/tmp/output"], ["--output"]), ] for options, unsafe_options in cases:@@ -198,6 +201,19 @@ class TestGit(TestBase): Git.check_unsafe_options(options=["-oupstream", "-bcurrent"], unsafe_options=unsafe_options) + def test_option_candidates_ignore_untransformed_kwargs(self):+ options = Git._option_candidates(+ kwargs={+ "output": None,+ "upload_pack": False,+ "exec": [],+ "config": [None, False],+ "max_count": 1,+ }+ )++ self.assertEqual(options, ["--max-count"])+ _shell_cases = ( # value_in_call, value_from_class, expected_popen_arg (None, False, False),--- a/git/cmd.py+++ b/git/cmd.py@@ -1039,11 +1039,18 @@ class Git(metaclass=_GitMeta): option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-") ] if kwargs:+ split_single_char_options = kwargs.get("split_single_char_options", True) for key, value in kwargs.items(): values = value if isinstance(value, (list, tuple)) else (value,) if any(value is True or (value is not False and value is not None) for value in values): key = str(key) options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}")+ if len(key) == 1 and split_single_char_options:+ options.extend(+ str(value)+ for value in values+ if value is not True and value not in (False, None) and str(value).startswith("-")+ ) return options AutoInterrupt: TypeAlias = _AutoInterrupt--- a/test/test_git.py+++ b/test/test_git.py@@ -214,6 +214,23 @@ class TestGit(TestBase): self.assertEqual(options, ["--max-count"]) + def test_option_candidates_include_split_single_char_option_values(self):+ cases = [+ ({"n": "--upload-pack=helper"}, ["-n", "--upload-pack=helper"], ["--upload-pack"]),+ ({"g": ("safe", "--out=target")}, ["-g", "--out=target"], ["--output"]),+ ]++ for kwargs, candidates, unsafe_options in cases:+ self.assertEqual(Git._option_candidates(kwargs=kwargs), candidates)+ with self.assertRaises(UnsafeOptionError):+ Git.check_unsafe_options(options=candidates, unsafe_options=unsafe_options)++ self.assertEqual(self.git.transform_kwargs(n="--upload-pack=helper"), ["-n", "--upload-pack=helper"])++ unsplit_kwargs = {"n": "--upload-pack=helper", "split_single_char_options": False}+ self.assertEqual(self.git.transform_kwargs(**unsplit_kwargs), ["-n--upload-pack=helper"])+ self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n"])+ _shell_cases = ( # value_in_call, value_from_class, expected_popen_arg (None, False, False),Candidate 3001861c3154988eeebcbc1bd6e19b797d0b66a754b090fe05bc8268980d0e2b · Fix 236f71691c9f896ee953bab14eb09c1bf10d97cef1df5f496aaaf5d8df707de4
Releases
Advisory references