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