Skip to content

Commit ccc4811

Browse files
authored
Merge pull request #2299 from Keerthana-64/redact-config-url-credentials
fix(util): redact URL credentials in config values on the command line
2 parents 9658d0f + f89c343 commit ccc4811

3 files changed

Lines changed: 128 additions & 22 deletions

File tree

‎git/util.py‎

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -652,42 +652,60 @@ def expand_path(p: Union[None, PathLike], expand_vars: bool = True) -> Optional[
652652
return None
653653

654654

655+
def _redact_url_credentials(text: str) -> Optional[str]:
656+
"""Return `text` with the userinfo of a URL redacted, or ``None`` if it has none."""
657+
try:
658+
url = urlsplit(text)
659+
except ValueError:
660+
# This is not a valid URL.
661+
return None
662+
if url.password is None and url.username is None:
663+
return None
664+
665+
# Match urllib.parse's userinfo boundary. Keeping the raw hostinfo
666+
# preserves hostname case, IPv6 brackets, and port formatting.
667+
_, _, hostinfo = url.netloc.rpartition("@")
668+
redacted = "*****:*****" if url.password is not None else "*****"
669+
return urlunsplit(url._replace(netloc=f"{redacted}@{hostinfo}"))
670+
671+
655672
def remove_password_if_present(cmdline: Sequence[str]) -> List[str]:
656673
"""Redact credentials in URLs and HTTP Authorization extra headers in a command line.
657674
675+
URLs are also redacted in the key and value of a configuration assignment, such
676+
as ``http.proxy=<url>``. Assignments are recognized in the ``-c <key>=<value>``,
677+
``-c<key>=<value>``, and ``--config=<key>=<value>`` forms.
678+
658679
If nothing is found, this just returns the command line as-is.
659680
660681
This should be used for every log line that print a command line, as well as
661682
exception messages.
662683
"""
663684
new_cmdline = []
664-
for index, to_parse in enumerate(cmdline):
665-
new_cmdline.append(to_parse)
666-
config_key, separator, header = to_parse.partition("=")
667-
header_name, colon, _ = header.partition(":")
685+
for to_parse in cmdline:
686+
if to_parse.startswith("--config="):
687+
option = "--config="
688+
elif to_parse.startswith("-c"):
689+
option = "-c"
690+
else:
691+
option = ""
692+
assignment = to_parse[len(option) :]
693+
# Redact the whole URL first, before splitting on a possible '=' in userinfo.
694+
assignment = _redact_url_credentials(assignment) or assignment
695+
config_key, separator, value = assignment.partition("=")
696+
header_name, colon, _ = value.partition(":")
668697
if (
669698
separator
670699
and colon
671700
and config_key.lower().endswith(".extraheader")
672701
and header_name.strip().lower() == "authorization"
673702
):
674-
new_cmdline[index] = "%s%s%s%s *****" % (config_key, separator, header_name, colon)
675-
continue
676-
try:
677-
url = urlsplit(to_parse)
678-
# Remove password from the URL if present.
679-
if url.password is None and url.username is None:
680-
continue
681-
682-
# Match urllib.parse's userinfo boundary. Keeping the raw hostinfo
683-
# preserves hostname case, IPv6 brackets, and port formatting.
684-
_, _, hostinfo = url.netloc.rpartition("@")
685-
redacted = "*****:*****" if url.password is not None else "*****"
686-
url = url._replace(netloc=f"{redacted}@{hostinfo}")
687-
new_cmdline[index] = urlunsplit(url)
688-
except ValueError:
689-
# This is not a valid URL.
703+
new_cmdline.append("%s%s%s%s%s *****" % (option, config_key, separator, header_name, colon))
690704
continue
705+
706+
if separator:
707+
value = _redact_url_credentials(value) or value
708+
new_cmdline.append(option + config_key + separator + value)
691709
return new_cmdline
692710

693711

‎test/test_git.py‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import logging
1111
import os
1212
import os.path as osp
13-
from pathlib import Path
1413
import pickle
1514
import re
1615
import shutil
@@ -20,14 +19,14 @@
2019
import tempfile
2120
import threading
2221
import time
22+
from pathlib import Path
2323
from unittest import mock, skipUnless
2424

2525
import ddt
2626

2727
from git import Git, GitCommandError, GitCommandNotFound, Repo, cmd, refresh
2828
from git.exc import UnsafeOptionError
2929
from git.util import cwd, finalize_process
30-
3130
from test.lib import TestBase, fixture_path, with_rw_directory
3231

3332

@@ -547,6 +546,38 @@ def test_it_redacts_authorization_extra_header_from_error(self):
547546
self.assertNotIn(token, message)
548547
self.assertIn("http.extraHeader=Authorization: *****", message)
549548

549+
@ddt.data(
550+
(
551+
"--config=http.https://alice@host.proxy=http://bob:token@proxy",
552+
"--config=http.https://*****@host.proxy=http://*****:*****@proxy",
553+
),
554+
(
555+
"-curl.https://user:token@host/.insteadOf=https://old/",
556+
"-curl.https://*****:*****@host/.insteadOf=https://old/",
557+
),
558+
)
559+
@ddt.unpack
560+
def test_clone_redacts_config_urls_from_trace_and_error(self, option, expected):
561+
for trace in (True, "full"):
562+
with self.subTest(trace=trace), tempfile.TemporaryDirectory() as directory:
563+
with mock.patch.object(Git, "GIT_PYTHON_TRACE", trace):
564+
with self.assertLogs("git.cmd", level="INFO") as logs:
565+
with self.assertRaises(GitCommandError) as context:
566+
Repo.clone_from(
567+
osp.join(directory, "missing-source"),
568+
osp.join(directory, "destination"),
569+
multi_options=[option],
570+
allow_unsafe_options=True,
571+
)
572+
self.assertIn(expected, context.exception.command)
573+
message = str(context.exception)
574+
assert logs is not None
575+
log_output = " ".join(logs.output)
576+
self.assertIn(expected, log_output)
577+
for credential in ("alice", "bob", "user", "token"):
578+
self.assertNotIn(credential, message)
579+
self.assertNotIn(credential, log_output)
580+
550581
def test_it_accepts_environment_variables(self):
551582
filename = fixture_path("ls_tree_empty")
552583
with open(filename, "r") as fh:

‎test/test_util.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,13 +849,70 @@ def test_remove_empty_password_keeps_host_intact(self):
849849
("//user:pass@[2001:db8::1]:0080/repo", "//*****:*****@[2001:db8::1]:0080/repo"),
850850
("https://user:p@ss@example.com/repo", "https://*****:*****@example.com/repo"),
851851
("https://user:@example.com/repo", "https://*****:*****@example.com/repo"),
852+
("https://user:p=ass@example.com/repo", "https://*****:*****@example.com/repo"),
852853
("https://@example.com/repo", "https://*****@example.com/repo"),
853854
("https://example.com/repo@name?q=a@b#c@d", "https://example.com/repo@name?q=a@b#c@d"),
854855
)
855856
@ddt.unpack
856857
def test_remove_password_preserves_url_components(self, url, expected):
857858
assert remove_password_if_present([url]) == [expected]
858859

860+
@ddt.data(
861+
(
862+
["-c", "http.proxy=http://user:pass@proxy.example.com:3128"],
863+
["-c", "http.proxy=http://*****:*****@proxy.example.com:3128"],
864+
),
865+
(
866+
["-c", "remote.origin.url=https://user:pass@example.com/repo"],
867+
["-c", "remote.origin.url=https://*****:*****@example.com/repo"],
868+
),
869+
(
870+
["--config=http.proxy=http://user:pass@proxy.example.com:3128"],
871+
["--config=http.proxy=http://*****:*****@proxy.example.com:3128"],
872+
),
873+
(
874+
["--config=url.https://user:pass@example.com/.insteadOf=https://example.com/"],
875+
["--config=url.https://*****:*****@example.com/.insteadOf=https://example.com/"],
876+
),
877+
(
878+
["--config=http.extraHeader=Authorization: Bearer pass"],
879+
["--config=http.extraHeader=Authorization: *****"],
880+
),
881+
(
882+
["--config=http.https://alice@host.proxy=http://bob:token@proxy"],
883+
["--config=http.https://*****@host.proxy=http://*****:*****@proxy"],
884+
),
885+
(
886+
["-c", "http.https://alice@host.proxy=http://bob:token@proxy"],
887+
["-c", "http.https://*****@host.proxy=http://*****:*****@proxy"],
888+
),
889+
(
890+
["-chttp.https://alice@host.proxy=http://bob:token@proxy"],
891+
["-chttp.https://*****@host.proxy=http://*****:*****@proxy"],
892+
),
893+
(
894+
["-curl.https://user:token@host/.insteadOf=https://old/"],
895+
["-curl.https://*****:*****@host/.insteadOf=https://old/"],
896+
),
897+
(
898+
["-chttp.proxy=http://bob:token@proxy"],
899+
["-chttp.proxy=http://*****:*****@proxy"],
900+
),
901+
(
902+
["--config=http.https://alice:token@host/.extraHeader=Authorization: Bearer pass"],
903+
["--config=http.https://*****:*****@host/.extraHeader=Authorization: *****"],
904+
),
905+
(
906+
["-chttp.extraHeader=Authorization: Bearer pass"],
907+
["-chttp.extraHeader=Authorization: *****"],
908+
),
909+
(["-c", "user.name=pass"], ["-c", "user.name=pass"]),
910+
(["-cuser.name=pass"], ["-cuser.name=pass"]),
911+
)
912+
@ddt.unpack
913+
def test_remove_password_from_config_values(self, cmdline, expected):
914+
assert remove_password_if_present(cmdline) == expected
915+
859916

860917
def test_mode_str_to_int_accepts_bytes():
861918
assert mode_str_to_int("100644") == 0o100644

0 commit comments

Comments
 (0)