26
26
CommandError ,
27
27
GitCommandError ,
28
28
GitCommandNotFound ,
29
+ UnsafeExecutionError ,
29
30
UnsafeOptionError ,
30
31
UnsafeProtocolError ,
31
32
)
@@ -627,6 +628,7 @@ class Git(metaclass=_GitMeta):
627
628
628
629
__slots__ = (
629
630
"_working_dir" ,
631
+ "_safe" ,
630
632
"cat_file_all" ,
631
633
"cat_file_header" ,
632
634
"_version_info" ,
@@ -961,17 +963,56 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) ->
961
963
962
964
CatFileContentStream : TypeAlias = _CatFileContentStream
963
965
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 :
965
967
"""Initialize this instance with:
966
968
967
969
:param working_dir:
968
970
Git directory we should work in. If ``None``, we always work in the current
969
971
directory as returned by :func:`os.getcwd`.
970
972
This is meant to be the working tree directory if available, or the
971
973
``.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``.
972
1012
"""
973
1013
super ().__init__ ()
974
1014
self ._working_dir = expand_path (working_dir )
1015
+ self ._safe = safe
975
1016
self ._git_options : Union [List [str ], Tuple [str , ...]] = ()
976
1017
self ._persistent_git_options : List [str ] = []
977
1018
@@ -1218,6 +1259,8 @@ def execute(
1218
1259
1219
1260
:raise git.exc.GitCommandError:
1220
1261
1262
+ :raise git.exc.UnsafeExecutionError:
1263
+
1221
1264
:note:
1222
1265
If you add additional keyword arguments to the signature of this method, you
1223
1266
must update the ``execute_kwargs`` variable housed in this module.
@@ -1227,6 +1270,64 @@ def execute(
1227
1270
if self .GIT_PYTHON_TRACE and (self .GIT_PYTHON_TRACE != "full" or as_process ):
1228
1271
_logger .info (" " .join (redacted_command ))
1229
1272
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
+
1230
1331
# Allow the user to have the command executed in their working dir.
1231
1332
try :
1232
1333
cwd = self ._working_dir or os .getcwd () # type: Union[None, str]
@@ -1244,6 +1345,15 @@ def execute(
1244
1345
# just to be sure.
1245
1346
env ["LANGUAGE" ] = "C"
1246
1347
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"
1247
1357
env .update (self ._environment )
1248
1358
if inline_env is not None :
1249
1359
env .update (inline_env )
@@ -1260,13 +1370,6 @@ def execute(
1260
1370
# END handle
1261
1371
1262
1372
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" )
1270
1373
_logger .debug (
1271
1374
"Popen(%s, cwd=%s, stdin=%s, shell=%s, universal_newlines=%s)" ,
1272
1375
redacted_command ,
0 commit comments