2626 CommandError,
2727 GitCommandError,
2828 GitCommandNotFound,
29+ UnsafeExecutionError,
2930 UnsafeOptionError,
3031 UnsafeProtocolError,
3132)
@@ -627,6 +628,7 @@ class Git(metaclass=_GitMeta):
627628
628629 __slots__ = (
629630 "_working_dir",
631+ "_safe",
630632 "cat_file_all",
631633 "cat_file_header",
632634 "_version_info",
@@ -961,17 +963,56 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) ->
961963
962964 CatFileContentStream: TypeAlias = _CatFileContentStream
963965
964- def __init__(self, working_dir: Union[None, PathLike] = None) -> None:
966+ def __init__(self, working_dir: Union[None, PathLike] = None, safe: bool = False ) -> None:
965967 """Initialize this instance with:
966968
967969 :param working_dir:
968970 Git directory we should work in. If ``None``, we always work in the current
969971 directory as returned by :func:`os.getcwd`.
970972 This is meant to be the working tree directory if available, or the
971973 ``.git`` directory in case of bare repositories.
974+
975+ :param safe:
976+ Lock down the configuration to make it as safe as possible
977+ when working with publicly accessible, untrusted
978+ repositories. This disables all known options that can run
979+ external programs and limits networking to the HTTP protocol
980+ via ``https://`` URLs. This might not cover Git config
981+ options that were added since this was implemented, or
982+ options that have unknown exploit vectors. It is a best
983+ effort defense rather than an exhaustive protection measure.
984+
985+ In order to make this more likely to work with submodules,
986+ some attempts are made to rewrite remote URLs to ``https://``
987+ using `insteadOf` in the config. This might not work on all
988+ projects, so submodules should always use ``https://`` URLs.
989+
990+ :envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these
991+ environment variables are forced to `/bin/true`:
992+ :envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`,
993+ :envvar:`GIT_PAGER`, :envvar:`GIT_SSH`,
994+ :envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`.
995+
996+ Git config options are supplied via the command line to set
997+ up key parts of safe mode.
998+
999+ - Direct options for executing external commands are set to ``/bin/true``:
1000+ ``core.askpass``, ``core.sshCommand`` and ``credential.helper``.
1001+
1002+ - External password prompts are disabled by skipping authentication using
1003+ ``http.emptyAuth=true``.
1004+
1005+ - Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``.
1006+
1007+ - Hook scripts are disabled using ``core.hooksPath=/dev/null``.
1008+
1009+ It was not possible to cover all config items that might execute an external
1010+ command, for example, ``receive.procReceiveRefs``,
1011+ ``uploadpack.packObjectsHook`` and ``remote.<name>.vcs``.
9721012 """
9731013 super().__init__()
9741014 self._working_dir = expand_path(working_dir)
1015+ self._safe = safe
9751016 self._git_options: Union[List[str], Tuple[str, ...]] = ()
9761017 self._persistent_git_options: List[str] = []
9771018
@@ -1218,6 +1259,8 @@ def execute(
12181259
12191260 :raise git.exc.GitCommandError:
12201261
1262+ :raise git.exc.UnsafeExecutionError:
1263+
12211264 :note:
12221265 If you add additional keyword arguments to the signature of this method, you
12231266 must update the ``execute_kwargs`` variable housed in this module.
@@ -1227,6 +1270,64 @@ def execute(
12271270 if self.GIT_PYTHON_TRACE and (self.GIT_PYTHON_TRACE != "full" or as_process):
12281271 _logger.info(" ".join(redacted_command))
12291272
1273+ if shell is None:
1274+ # Get the value of USE_SHELL with no deprecation warning. Do this without
1275+ # warnings.catch_warnings, to avoid a race condition with application code
1276+ # configuring warnings. The value could be looked up in type(self).__dict__
1277+ # or Git.__dict__, but those can break under some circumstances. This works
1278+ # the same as self.USE_SHELL in more situations; see Git.__getattribute__.
1279+ shell = super().__getattribute__("USE_SHELL")
1280+
1281+ if self._safe:
1282+ if shell:
1283+ raise UnsafeExecutionError(
1284+ redacted_command,
1285+ "Command cannot be executed in a shell when in safe mode.",
1286+ )
1287+ if not isinstance(command, Sequence):
1288+ raise UnsafeExecutionError(
1289+ redacted_command,
1290+ "Command must be a Sequence to be executed in safe mode.",
1291+ )
1292+ if command[0] != self.GIT_PYTHON_GIT_EXECUTABLE:
1293+ raise UnsafeExecutionError(
1294+ redacted_command,
1295+ f'Only "{self.GIT_PYTHON_GIT_EXECUTABLE}" can be executed when in safe mode.',
1296+ )
1297+ config_args = [
1298+ "-c",
1299+ "core.askpass=/bin/true",
1300+ "-c",
1301+ "core.fsmonitor=false",
1302+ "-c",
1303+ "core.hooksPath=/dev/null",
1304+ "-c",
1305+ "core.sshCommand=/bin/true",
1306+ "-c",
1307+ "credential.helper=/bin/true",
1308+ "-c",
1309+ "http.emptyAuth=true",
1310+ "-c",
1311+ "protocol.allow=never",
1312+ "-c",
1313+ "protocol.https.allow=always",
1314+ "-c",
1315+ "url.https://bitbucket.org/.insteadOf=git@bitbucket.org:",
1316+ "-c",
1317+ "url.https://codeberg.org/.insteadOf=git@codeberg.org:",
1318+ "-c",
1319+ "url.https://github.com/.insteadOf=git@github.com:",
1320+ "-c",
1321+ "url.https://gitlab.com/.insteadOf=git@gitlab.com:",
1322+ "-c",
1323+ "url.https://.insteadOf=git://",
1324+ "-c",
1325+ "url.https://.insteadOf=http://",
1326+ "-c",
1327+ "url.https://.insteadOf=ssh://",
1328+ ]
1329+ command = [command.pop(0)] + config_args + command
1330+
12301331 # Allow the user to have the command executed in their working dir.
12311332 try:
12321333 cwd = self._working_dir or os.getcwd() # type: Union[None, str]
@@ -1244,6 +1345,15 @@ def execute(
12441345 # just to be sure.
12451346 env["LANGUAGE"] = "C"
12461347 env["LC_ALL"] = "C"
1348+ # Globally disable things that can execute commands, including password prompts.
1349+ if self._safe:
1350+ env["GIT_ASKPASS"] = "/bin/true"
1351+ env["GIT_EDITOR"] = "/bin/true"
1352+ env["GIT_PAGER"] = "/bin/true"
1353+ env["GIT_SSH"] = "/bin/true"
1354+ env["GIT_SSH_COMMAND"] = "/bin/true"
1355+ env["GIT_TERMINAL_PROMPT"] = "false"
1356+ env["SSH_ASKPASS"] = "/bin/true"
12471357 env.update(self._environment)
12481358 if inline_env is not None:
12491359 env.update(inline_env)
@@ -1260,13 +1370,6 @@ def execute(
12601370 # END handle
12611371
12621372 stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
1263- if shell is None:
1264- # Get the value of USE_SHELL with no deprecation warning. Do this without
1265- # warnings.catch_warnings, to avoid a race condition with application code
1266- # configuring warnings. The value could be looked up in type(self).__dict__
1267- # or Git.__dict__, but those can break under some circumstances. This works
1268- # the same as self.USE_SHELL in more situations; see Git.__getattribute__.
1269- shell = super().__getattribute__("USE_SHELL")
12701373 _logger.debug(
12711374 "Popen(%s, cwd=%s, stdin=%s, shell=%s, universal_newlines=%s)",
12721375 redacted_command,
0 commit comments