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