diff --git a/git/cmd.py b/git/cmd.py index 7f46edc8f..596ad1428 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -26,6 +26,7 @@ CommandError, GitCommandError, GitCommandNotFound, + UnsafeExecutionError, UnsafeOptionError, UnsafeProtocolError, ) @@ -398,6 +399,7 @@ class Git(metaclass=_GitMeta): __slots__ = ( "_working_dir", + "_safe", "cat_file_all", "cat_file_header", "_version_info", @@ -944,7 +946,7 @@ def __del__(self) -> None: self._stream.read(bytes_left + 1) # END handle incomplete read - 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: @@ -952,9 +954,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] = [] @@ -1201,6 +1242,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. @@ -1210,6 +1253,51 @@ def execute( if self.GIT_PYTHON_TRACE and (self.GIT_PYTHON_TRACE != "full" or as_process): _logger.info(" ".join(redacted_command)) + if self._safe: + if isinstance(command, str) or 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.", + ) + if shell: + raise UnsafeExecutionError( + redacted_command, + "Command cannot be executed in a shell 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] @@ -1227,6 +1315,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) 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, )