Skip to content

Commit 6c4cd6a

Browse files
committed
fix(util): redact URL credentials in config values on the command line
`remove_password_if_present` is applied to every command line that GitPython logs or stores in `GitCommandError` and the other `CommandError` subclasses. It redacted an argument only when the whole argument was a URL with userinfo, or when it was an `http.extraHeader=Authorization: ...` assignment. Two common forms were missed: - A URL that is the value of a configuration assignment, such as `-c http.proxy=http://user:pass@proxy:3128` or `-c remote.origin.url=https://user:token@host/repo`. The text before `://` contains `=`, so `urlsplit` finds no scheme and no userinfo, and the argument was kept verbatim. - The `--config=<key>=<value>` form, which is what `transform_kwargs` produces for a `config=...` keyword argument. The leading `--config=` defeated both checks, so even `--config=http.extraHeader=Authorization: Bearer <token>` was printed in full. For example, after `repo.git.set_persistent_git_options(c="http.proxy=http://user:secret@proxy:3128")` every failing command raised a `GitCommandError` whose message contained `user:secret`, and the same text reached the log when command tracing is on. The URL redaction moves into a small `_redact_url_credentials` helper. `remove_password_if_present` strips a leading `--config=` before the extra header check, and when the whole argument is not a URL with userinfo it tries the assignment after `--config=` (which covers `url.<base>.insteadOf` keys that carry credentials) and then the value after the first `=`. Only the userinfo is replaced, so the key and the rest of the URL stay readable, and arguments without a URL are returned unchanged. Arguments that were already redacted produce the same output as before. Adds `test_remove_password_from_config_values` to `test/test_util.py`. Its five redaction cases fail before this change and pass after it, and the existing redaction tests still pass. `ruff check`, `ruff format --check` and `mypy` are clean.
1 parent 9d3aff0 commit 6c4cd6a

2 files changed

Lines changed: 65 additions & 20 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 when they are the value of a configuration assignment, such
676+
as ``http.proxy=<url>``, and assignments are recognized in both the ``-c <key>=<value>``
677+
and the ``--config=<key>=<value>`` form.
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+
option = "--config=" if to_parse.startswith("--config=") else ""
687+
assignment = to_parse[len(option) :]
688+
config_key, separator, value = assignment.partition("=")
689+
header_name, colon, _ = value.partition(":")
668690
if (
669691
separator
670692
and colon
671693
and config_key.lower().endswith(".extraheader")
672694
and header_name.strip().lower() == "authorization"
673695
):
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.
696+
new_cmdline.append("%s%s%s%s%s *****" % (option, config_key, separator, header_name, colon))
690697
continue
698+
699+
redacted = _redact_url_credentials(to_parse)
700+
if redacted is None and option:
701+
redacted = _redact_url_credentials(assignment)
702+
if redacted is not None:
703+
redacted = option + redacted
704+
if redacted is None and separator:
705+
redacted = _redact_url_credentials(value)
706+
if redacted is not None:
707+
redacted = option + config_key + separator + redacted
708+
new_cmdline.append(to_parse if redacted is None else redacted)
691709
return new_cmdline
692710

693711

‎test/test_util.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,33 @@ def test_remove_empty_password_keeps_host_intact(self):
856856
def test_remove_password_preserves_url_components(self, url, expected):
857857
assert remove_password_if_present([url]) == [expected]
858858

859+
@ddt.data(
860+
(
861+
["-c", "http.proxy=http://user:pass@proxy.example.com:3128"],
862+
["-c", "http.proxy=http://*****:*****@proxy.example.com:3128"],
863+
),
864+
(
865+
["-c", "remote.origin.url=https://user:pass@example.com/repo"],
866+
["-c", "remote.origin.url=https://*****:*****@example.com/repo"],
867+
),
868+
(
869+
["--config=http.proxy=http://user:pass@proxy.example.com:3128"],
870+
["--config=http.proxy=http://*****:*****@proxy.example.com:3128"],
871+
),
872+
(
873+
["--config=url.https://user:pass@example.com/.insteadOf=https://example.com/"],
874+
["--config=url.https://*****:*****@example.com/.insteadOf=https://example.com/"],
875+
),
876+
(
877+
["--config=http.extraHeader=Authorization: Bearer pass"],
878+
["--config=http.extraHeader=Authorization: *****"],
879+
),
880+
(["-c", "user.name=pass"], ["-c", "user.name=pass"]),
881+
)
882+
@ddt.unpack
883+
def test_remove_password_from_config_values(self, cmdline, expected):
884+
assert remove_password_if_present(cmdline) == expected
885+
859886

860887
def test_mode_str_to_int_accepts_bytes():
861888
assert mode_str_to_int("100644") == 0o100644

0 commit comments

Comments
 (0)