diff --git a/git/cmd.py b/git/cmd.py index 15d7820df..133d8c684 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -26,6 +26,7 @@ CommandError, GitCommandError, GitCommandNotFound, + UnsafeExecutionError, UnsafeOptionError, UnsafeProtocolError, ) @@ -627,6 +628,7 @@ class Git(metaclass=_GitMeta): __slots__ = ( "_working_dir", + "_safe", "cat_file_all", "cat_file_header", "_version_info", @@ -961,7 +963,7 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> CatFileContentStream: TypeAlias = _CatFileContentStream - def __init__(self, working_dir: Union[None, PathLike] = None) -> None: + def __init__(self, working_dir: Union[None, PathLike] = None, safe: bool = False) -> None: """Initialize this instance with: :param working_dir: @@ -969,9 +971,48 @@ def __init__(self, working_dir: Union[None, PathLike] = None) -> None: directory as returned by :func:`os.getcwd`. This is meant to be the working tree directory if available, or the ``.git`` directory in case of bare repositories. + + :param safe: + Lock down the configuration to make it as safe as possible + when working with publicly accessible, untrusted + repositories. This disables all known options that can run + external programs and limits networking to the HTTP protocol + via ``https://`` URLs. This might not cover Git config + options that were added since this was implemented, or + options that have unknown exploit vectors. It is a best + effort defense rather than an exhaustive protection measure. + + In order to make this more likely to work with submodules, + some attempts are made to rewrite remote URLs to ``https://`` + using `insteadOf` in the config. This might not work on all + projects, so submodules should always use ``https://`` URLs. + + :envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these + environment variables are forced to `/bin/true`: + :envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`, + :envvar:`GIT_PAGER`, :envvar:`GIT_SSH`, + :envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`. + + Git config options are supplied via the command line to set + up key parts of safe mode. + + - Direct options for executing external commands are set to ``/bin/true``: + ``core.askpass``, ``core.sshCommand`` and ``credential.helper``. + + - External password prompts are disabled by skipping authentication using + ``http.emptyAuth=true``. + + - Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``. + + - Hook scripts are disabled using ``core.hooksPath=/dev/null``. + + It was not possible to cover all config items that might execute an external + command, for example, ``receive.procReceiveRefs``, + ``uploadpack.packObjectsHook`` and ``remote..vcs``. """ super().__init__() self._working_dir = expand_path(working_dir) + self._safe = safe self._git_options: Union[List[str], Tuple[str, ...]] = () self._persistent_git_options: List[str] = [] @@ -1218,6 +1259,8 @@ def execute( :raise git.exc.GitCommandError: + :raise git.exc.UnsafeExecutionError: + :note: If you add additional keyword arguments to the signature of this method, you must update the ``execute_kwargs`` variable housed in this module. @@ -1227,6 +1270,64 @@ def execute( if self.GIT_PYTHON_TRACE and (self.GIT_PYTHON_TRACE != "full" or as_process): _logger.info(" ".join(redacted_command)) + if shell is None: + # Get the value of USE_SHELL with no deprecation warning. Do this without + # warnings.catch_warnings, to avoid a race condition with application code + # configuring warnings. The value could be looked up in type(self).__dict__ + # or Git.__dict__, but those can break under some circumstances. This works + # the same as self.USE_SHELL in more situations; see Git.__getattribute__. + shell = super().__getattribute__("USE_SHELL") + + if self._safe: + if shell: + raise UnsafeExecutionError( + redacted_command, + "Command cannot be executed in a shell when in safe mode.", + ) + if not isinstance(command, Sequence): + raise UnsafeExecutionError( + redacted_command, + "Command must be a Sequence to be executed in safe mode.", + ) + if command[0] != self.GIT_PYTHON_GIT_EXECUTABLE: + raise UnsafeExecutionError( + redacted_command, + f'Only "{self.GIT_PYTHON_GIT_EXECUTABLE}" can be executed when in safe mode.', + ) + config_args = [ + "-c", + "core.askpass=/bin/true", + "-c", + "core.fsmonitor=false", + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.sshCommand=/bin/true", + "-c", + "credential.helper=/bin/true", + "-c", + "http.emptyAuth=true", + "-c", + "protocol.allow=never", + "-c", + "protocol.https.allow=always", + "-c", + "url.https://bitbucket.org/.insteadOf=git@bitbucket.org:", + "-c", + "url.https://codeberg.org/.insteadOf=git@codeberg.org:", + "-c", + "url.https://github.com/.insteadOf=git@github.com:", + "-c", + "url.https://gitlab.com/.insteadOf=git@gitlab.com:", + "-c", + "url.https://.insteadOf=git://", + "-c", + "url.https://.insteadOf=http://", + "-c", + "url.https://.insteadOf=ssh://", + ] + command = [command.pop(0)] + config_args + command + # Allow the user to have the command executed in their working dir. try: cwd = self._working_dir or os.getcwd() # type: Union[None, str] @@ -1244,6 +1345,15 @@ def execute( # just to be sure. env["LANGUAGE"] = "C" env["LC_ALL"] = "C" + # Globally disable things that can execute commands, including password prompts. + if self._safe: + env["GIT_ASKPASS"] = "/bin/true" + env["GIT_EDITOR"] = "/bin/true" + env["GIT_PAGER"] = "/bin/true" + env["GIT_SSH"] = "/bin/true" + env["GIT_SSH_COMMAND"] = "/bin/true" + env["GIT_TERMINAL_PROMPT"] = "false" + env["SSH_ASKPASS"] = "/bin/true" env.update(self._environment) if inline_env is not None: env.update(inline_env) @@ -1260,13 +1370,6 @@ def execute( # END handle stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb") - if shell is None: - # Get the value of USE_SHELL with no deprecation warning. Do this without - # warnings.catch_warnings, to avoid a race condition with application code - # configuring warnings. The value could be looked up in type(self).__dict__ - # or Git.__dict__, but those can break under some circumstances. This works - # the same as self.USE_SHELL in more situations; see Git.__getattribute__. - shell = super().__getattribute__("USE_SHELL") _logger.debug( "Popen(%s, cwd=%s, stdin=%s, shell=%s, universal_newlines=%s)", redacted_command, diff --git a/git/exc.py b/git/exc.py index 583eee8c1..dae3b9941 100644 --- a/git/exc.py +++ b/git/exc.py @@ -159,6 +159,10 @@ def __init__( super().__init__(command, status, stderr, stdout) +class UnsafeExecutionError(CommandError): + """Thrown if anything but git is executed when in safe mode.""" + + class CheckoutError(GitError): """Thrown if a file could not be checked out from the index as it contained changes. diff --git a/git/repo/base.py b/git/repo/base.py index 7e918df8c..d84811028 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -131,6 +131,9 @@ class Repo: git_dir: PathLike """The ``.git`` repository directory.""" + safe: None + """Whether this is operating using restricted protocol and execution access.""" + _common_dir: PathLike = "" # Precompiled regex @@ -175,6 +178,7 @@ def __init__( odbt: Type[LooseObjectDB] = GitCmdObjectDB, search_parent_directories: bool = False, expand_vars: bool = True, + safe: bool = False, ) -> None: R"""Create a new :class:`Repo` instance. @@ -204,6 +208,44 @@ def __init__( Please note that this was the default behaviour in older versions of GitPython, which is considered a bug though. + :param safe: + Lock down the configuration to make it as safe as possible + when working with publicly accessible, untrusted + repositories. This disables all known options that can run + external programs and limits networking to the HTTP protocol + via ``https://`` URLs. This might not cover Git config + options that were added since this was implemented, or + options that have unknown exploit vectors. It is a best + effort defense rather than an exhaustive protection measure. + + In order to make this more likely to work with submodules, + some attempts are made to rewrite remote URLs to ``https://`` + using `insteadOf` in the config. This might not work on all + projects, so submodules should always use ``https://`` URLs. + + :envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these + environment variables are forced to `/bin/true`: + :envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`, + :envvar:`GIT_PAGER`, :envvar:`GIT_SSH`, + :envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`. + + Git config options are supplied via the command line to set + up key parts of safe mode. + + - Direct options for executing external commands are set to ``/bin/true``: + ``core.askpass``, ``core.sshCommand`` and ``credential.helper``. + + - External password prompts are disabled by skipping authentication using + ``http.emptyAuth=true``. + + - Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``. + + - Hook scripts are disabled using ``core.hooksPath=/dev/null``. + + It was not possible to cover all config items that might execute an external + command, for example, ``receive.procReceiveRefs``, + ``uploadpack.packObjectsHook`` and ``remote..vcs``. + :raise git.exc.InvalidGitRepositoryError: :raise git.exc.NoSuchPathError: @@ -235,6 +277,8 @@ def __init__( if not os.path.exists(epath): raise NoSuchPathError(epath) + self.safe = safe + # Walk up the path to find the `.git` dir. curpath = epath git_dir = None @@ -309,7 +353,7 @@ def __init__( # END working dir handling self.working_dir: PathLike = self._working_tree_dir or self.common_dir - self.git = self.GitCommandWrapperType(self.working_dir) + self.git = self.GitCommandWrapperType(self.working_dir, safe) # Special handling, in special times. rootpath = osp.join(self.common_dir, "objects") @@ -1305,6 +1349,7 @@ def init( mkdir: bool = True, odbt: Type[GitCmdObjectDB] = GitCmdObjectDB, expand_vars: bool = True, + safe: bool = False, **kwargs: Any, ) -> "Repo": """Initialize a git repository at the given path if specified. @@ -1329,6 +1374,44 @@ def init( information disclosure, allowing attackers to access the contents of environment variables. + :param safe: + Lock down the configuration to make it as safe as possible + when working with publicly accessible, untrusted + repositories. This disables all known options that can run + external programs and limits networking to the HTTP protocol + via ``https://`` URLs. This might not cover Git config + options that were added since this was implemented, or + options that have unknown exploit vectors. It is a best + effort defense rather than an exhaustive protection measure. + + In order to make this more likely to work with submodules, + some attempts are made to rewrite remote URLs to ``https://`` + using `insteadOf` in the config. This might not work on all + projects, so submodules should always use ``https://`` URLs. + + :envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these + environment variables are forced to `/bin/true`: + :envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`, + :envvar:`GIT_PAGER`, :envvar:`GIT_SSH`, + :envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`. + + Git config options are supplied via the command line to set + up key parts of safe mode. + + - Direct options for executing external commands are set to ``/bin/true``: + ``core.askpass``, ``core.sshCommand`` and ``credential.helper``. + + - External password prompts are disabled by skipping authentication using + ``http.emptyAuth=true``. + + - Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``. + + - Hook scripts are disabled using ``core.hooksPath=/dev/null``. + + It was not possible to cover all config items that might execute an external + command, for example, ``receive.procReceiveRefs``, + ``uploadpack.packObjectsHook`` and ``remote..vcs``. + :param kwargs: Keyword arguments serving as additional options to the :manpage:`git-init(1)` command. @@ -1342,9 +1425,9 @@ def init( os.makedirs(path, 0o755) # git command automatically chdir into the directory - git = cls.GitCommandWrapperType(path) + git = cls.GitCommandWrapperType(path, safe) git.init(**kwargs) - return cls(path, odbt=odbt) + return cls(path, odbt=odbt, safe=safe) @classmethod def _clone( @@ -1357,6 +1440,7 @@ def _clone( multi_options: Optional[List[str]] = None, allow_unsafe_protocols: bool = False, allow_unsafe_options: bool = False, + safe: Union[bool, None] = None, **kwargs: Any, ) -> "Repo": odbt = kwargs.pop("odbt", odb_default_type) @@ -1418,7 +1502,11 @@ def _clone( if not osp.isabs(path): path = osp.join(git._working_dir, path) if git._working_dir is not None else path - repo = cls(path, odbt=odbt) + # if safe is not explicitly defined, then the new Repo instance should inherit the safe value + if safe is None: + safe = git._safe + + repo = cls(path, odbt=odbt, safe=safe) # Retain env values that were passed to _clone(). repo.git.update_environment(**git.environment()) @@ -1501,6 +1589,7 @@ def clone_from( multi_options: Optional[List[str]] = None, allow_unsafe_protocols: bool = False, allow_unsafe_options: bool = False, + safe: bool = False, **kwargs: Any, ) -> "Repo": """Create a clone from the given URL. @@ -1531,13 +1620,52 @@ def clone_from( :param allow_unsafe_options: Allow unsafe options to be used, like ``--upload-pack``. + :param safe: + Lock down the configuration to make it as safe as possible + when working with publicly accessible, untrusted + repositories. This disables all known options that can run + external programs and limits networking to the HTTP protocol + via ``https://`` URLs. This might not cover Git config + options that were added since this was implemented, or + options that have unknown exploit vectors. It is a best + effort defense rather than an exhaustive protection measure. + + In order to make this more likely to work with submodules, + some attempts are made to rewrite remote URLs to ``https://`` + using `insteadOf` in the config. This might not work on all + projects, so submodules should always use ``https://`` URLs. + + :envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these + environment variables are forced to `/bin/true`: + :envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`, + :envvar:`GIT_PAGER`, :envvar:`GIT_SSH`, + :envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`. + + Git config options are supplied via the command line to set + up key parts of safe mode. + + - Direct options for executing external commands are set to ``/bin/true``: + ``core.askpass``, ``core.sshCommand`` and ``credential.helper``. + + - External password prompts are disabled by skipping authentication using + ``http.emptyAuth=true``. + + - Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``. + + - Hook scripts are disabled using ``core.hooksPath=/dev/null``. + + It was not possible to cover all config items that might execute an external + command, for example, ``receive.procReceiveRefs``, + ``uploadpack.packObjectsHook`` and ``remote..vcs``. + :param kwargs: See the :meth:`clone` method. :return: :class:`Repo` instance pointing to the cloned directory. + """ - git = cls.GitCommandWrapperType(os.getcwd()) + git = cls.GitCommandWrapperType(os.getcwd(), safe) if env is not None: git.update_environment(**env) return cls._clone( @@ -1549,6 +1677,7 @@ def clone_from( multi_options, allow_unsafe_protocols=allow_unsafe_protocols, allow_unsafe_options=allow_unsafe_options, + safe=safe, **kwargs, ) diff --git a/test/test_git.py b/test/test_git.py index 5bcf89bdd..475ae99a0 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -26,6 +26,7 @@ import ddt from git import Git, GitCommandError, GitCommandNotFound, Repo, cmd, refresh +from git.exc import UnsafeExecutionError from git.util import cwd, finalize_process from test.lib import TestBase, fixture_path, with_rw_directory @@ -796,3 +797,26 @@ def test_execute_kwargs_set_agrees_with_method(self): self.assertEqual(command_param, "command") self.assertEqual(set(most_params), cmd.execute_kwargs) # Most important. self.assertEqual(extra_kwargs_param, "subprocess_kwargs") + + def test_safe_mode_shell_none_works(self): + self.assertIn("git version", Git(".", safe=True).execute(["git", "version"], shell=None)) + + def test_safe_mode_shell_false_works(self): + self.assertIn("git version", Git(".", safe=True).execute(["git", "version"], shell=False)) + + def test_safe_mode_blocks_shell(self): + with self.assertRaises(UnsafeExecutionError): + Git(".", safe=True).execute(["git", "version"], shell=True) + + @mock.patch.object(Git, "USE_SHELL", True) + def test_safe_mode_blocks_use_shell(self): + with self.assertRaises(UnsafeExecutionError): + Git(".", safe=True).execute(["git", "version"]) + + def test_safe_mode_only_git(self): + with self.assertRaises(UnsafeExecutionError): + Git(".", safe=True).execute(["echo", "something"]) + + def test_safe_mode_only_right_git(self): + with self.assertRaises(UnsafeExecutionError): + Git(".", safe=True).execute(["/path/to/fake/git", "version"]) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy