From 71a8ea5ee5aac779d23c2669650a11212d3d79da Mon Sep 17 00:00:00 2001 From: Malachi Moody <225752346+moodyastra@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:35:24 -0500 Subject: [PATCH 1/3] Strip auth from editable VCS URLs in pip freeze --- news/11410.bugfix.rst | 1 + src/pip/_internal/operations/freeze.py | 27 ++++++++++++++- tests/functional/test_freeze.py | 37 ++++++++++++++++++++ tests/unit/test_operations_freeze.py | 48 ++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 news/11410.bugfix.rst create mode 100644 tests/unit/test_operations_freeze.py diff --git a/news/11410.bugfix.rst b/news/11410.bugfix.rst new file mode 100644 index 0000000000..61d2ec9601 --- /dev/null +++ b/news/11410.bugfix.rst @@ -0,0 +1 @@ +Strip authentication credentials from editable VCS URLs in ``pip freeze``. diff --git a/src/pip/_internal/operations/freeze.py b/src/pip/_internal/operations/freeze.py index 486a833212..671a8469f1 100644 --- a/src/pip/_internal/operations/freeze.py +++ b/src/pip/_internal/operations/freeze.py @@ -3,6 +3,8 @@ import collections import logging import os +import re +import urllib.parse from collections.abc import Container, Generator, Iterable from dataclasses import dataclass, field from typing import NamedTuple @@ -18,6 +20,7 @@ ) from pip._internal.req.req_file import COMMENT_RE from pip._internal.utils.direct_url_helpers import direct_url_as_pep440_direct_reference +from pip._internal.utils.misc import remove_auth_from_url logger = logging.getLogger(__name__) @@ -27,6 +30,26 @@ class _EditableInfo(NamedTuple): comments: list[str] +_PEP_610_AUTH_ENV_VARS = re.compile(r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$") + + +def _strip_auth_from_editable_requirement(req: str) -> str: + """Remove credentials while preserving PEP 610-safe user information.""" + parsed_req = urllib.parse.urlsplit(req) + netloc = parsed_req.netloc + if "@" not in netloc: + return req + + user_pass = netloc.rsplit("@", 1)[0] + transport = parsed_req.scheme.rsplit("+", 1)[-1] + if (user_pass == "git" and transport == "ssh") or ( + _PEP_610_AUTH_ENV_VARS.fullmatch(user_pass) + ): + return req + + return remove_auth_from_url(req) + + def freeze( requirement: list[str] | None = None, local_only: bool = False, @@ -214,7 +237,9 @@ def _get_editable_info(dist: BaseDistribution) -> _EditableInfo: except InstallationError as exc: logger.warning("Error when trying to get requirement for VCS system %s", exc) else: - return _EditableInfo(requirement=req, comments=[]) + return _EditableInfo( + requirement=_strip_auth_from_editable_requirement(req), comments=[] + ) logger.warning("Could not determine repository location of %s", location) diff --git a/tests/functional/test_freeze.py b/tests/functional/test_freeze.py index 92e55aca6f..c0d703afa1 100644 --- a/tests/functional/test_freeze.py +++ b/tests/functional/test_freeze.py @@ -456,6 +456,43 @@ def test_freeze_git_remote(script: PipTestEnvironment) -> None: _check_output(result.stdout, expected) +@pytest.mark.git +def test_freeze_git_remote_strips_auth(script: PipTestEnvironment) -> None: + """Test that freezing a Git clone does not expose remote credentials.""" + pkg_version = _create_test_package(script.scratch_path) + script.run( + "git", + "clone", + os.fspath(pkg_version), + "pip-test-package", + expect_stderr=True, + ) + repo_dir = script.scratch_path / "pip-test-package" + script.run( + "python", + "setup.py", + "develop", + cwd=repo_dir, + expect_stderr=True, + ) + script.run( + "git", + "remote", + "set-url", + "origin", + "https://username:password@example.com/repo.git", + cwd=repo_dir, + ) + revision = script.run("git", "rev-parse", "HEAD", cwd=repo_dir).stdout.strip() + + result = script.pip("freeze", expect_stderr=True) + + expected = f"...-e git+https://example.com/repo.git@{revision}#egg=version_pkg..." + _check_output(result.stdout, expected) + assert "username" not in result.stdout + assert "password" not in result.stdout + + @need_mercurial def test_freeze_mercurial_clone(script: PipTestEnvironment) -> None: """ diff --git a/tests/unit/test_operations_freeze.py b/tests/unit/test_operations_freeze.py new file mode 100644 index 0000000000..5fe529dbd9 --- /dev/null +++ b/tests/unit/test_operations_freeze.py @@ -0,0 +1,48 @@ +import pytest + +from pip._internal.operations.freeze import _strip_auth_from_editable_requirement + + +@pytest.mark.parametrize( + "requirement, expected", + [ + ( + "git+https://example.com/repo.git@rev#egg=project", + "git+https://example.com/repo.git@rev#egg=project", + ), + ( + "git+https://username:password@example.com/repo.git@rev#egg=project", + "git+https://example.com/repo.git@rev#egg=project", + ), + ( + "git+https://token@example.com/repo.git@rev#egg=project", + "git+https://example.com/repo.git@rev#egg=project", + ), + ( + "git+ssh://git@example.com/repo.git@rev#egg=project", + "git+ssh://git@example.com/repo.git@rev#egg=project", + ), + ( + "git+https://git@example.com/repo.git@rev#egg=project", + "git+https://example.com/repo.git@rev#egg=project", + ), + ( + "git+https://${TOKEN}@example.com/repo.git@rev#egg=project", + "git+https://${TOKEN}@example.com/repo.git@rev#egg=project", + ), + ( + "git+https://${USER}:${PASSWORD}@example.com/repo.git@rev#egg=project", + "git+https://${USER}:${PASSWORD}@example.com/repo.git@rev#egg=project", + ), + ( + "git+https://${USER}:password@example.com/repo.git@rev#egg=project", + "git+https://example.com/repo.git@rev#egg=project", + ), + ( + "git+ssh://%67%69%74@example.com/repo.git@rev#egg=project", + "git+ssh://example.com/repo.git@rev#egg=project", + ), + ], +) +def test_strip_auth_from_editable_requirement(requirement: str, expected: str) -> None: + assert _strip_auth_from_editable_requirement(requirement) == expected From de6b66f1cbd3ab6548ea5ae6a284aa6fe76ae6f5 Mon Sep 17 00:00:00 2001 From: Malachi Moody <225752346+moodyastra@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:04:20 -0500 Subject: [PATCH 2/3] Explain literal git auth exception --- src/pip/_internal/operations/freeze.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pip/_internal/operations/freeze.py b/src/pip/_internal/operations/freeze.py index 671a8469f1..e00ab957fe 100644 --- a/src/pip/_internal/operations/freeze.py +++ b/src/pip/_internal/operations/freeze.py @@ -42,6 +42,8 @@ def _strip_auth_from_editable_requirement(req: str) -> str: user_pass = netloc.rsplit("@", 1)[0] transport = parsed_req.scheme.rsplit("+", 1)[-1] + # Keep this exception deliberately narrow: only the literal SSH user + # "git" is known to be non-secret. Encoded user information is stripped. if (user_pass == "git" and transport == "ssh") or ( _PEP_610_AUTH_ENV_VARS.fullmatch(user_pass) ): From 5f5f286ca47ca6bd295105a437ef660111c95b32 Mon Sep 17 00:00:00 2001 From: Malachi Moody <225752346+moodyastra@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:42:32 -0500 Subject: [PATCH 3/3] Address editable freeze review feedback --- src/pip/_internal/operations/freeze.py | 32 +++++++++++--------------- tests/functional/test_freeze.py | 8 +------ tests/unit/test_operations_freeze.py | 16 +++++++++++++ 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/pip/_internal/operations/freeze.py b/src/pip/_internal/operations/freeze.py index e00ab957fe..fcab87843b 100644 --- a/src/pip/_internal/operations/freeze.py +++ b/src/pip/_internal/operations/freeze.py @@ -3,8 +3,6 @@ import collections import logging import os -import re -import urllib.parse from collections.abc import Container, Generator, Iterable from dataclasses import dataclass, field from typing import NamedTuple @@ -14,13 +12,14 @@ from pip._internal.exceptions import BadCommand, InstallationError from pip._internal.metadata import BaseDistribution, get_environment +from pip._internal.models.direct_url import DirectUrl +from pip._internal.models.link import Link from pip._internal.req.constructors import ( install_req_from_editable, install_req_from_line, ) from pip._internal.req.req_file import COMMENT_RE from pip._internal.utils.direct_url_helpers import direct_url_as_pep440_direct_reference -from pip._internal.utils.misc import remove_auth_from_url logger = logging.getLogger(__name__) @@ -30,26 +29,21 @@ class _EditableInfo(NamedTuple): comments: list[str] -_PEP_610_AUTH_ENV_VARS = re.compile(r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$") - - def _strip_auth_from_editable_requirement(req: str) -> str: """Remove credentials while preserving PEP 610-safe user information.""" - parsed_req = urllib.parse.urlsplit(req) - netloc = parsed_req.netloc - if "@" not in netloc: - return req - - user_pass = netloc.rsplit("@", 1)[0] - transport = parsed_req.scheme.rsplit("+", 1)[-1] - # Keep this exception deliberately narrow: only the literal SSH user - # "git" is known to be non-secret. Encoded user information is stripped. - if (user_pass == "git" and transport == "ssh") or ( - _PEP_610_AUTH_ENV_VARS.fullmatch(user_pass) - ): + link = Link(req) + if "@" not in link.netloc: return req - return remove_auth_from_url(req) + # Apply the same auth rules used for PEP 610 direct URLs to the full + # editable requirement, preserving its VCS prefix, revision, and fragment. + safe_user_passwords = ("git",) if link.scheme.endswith("+ssh") else () + sanitized_req = DirectUrl(url=req).to_dict( + strip_user_password=True, + safe_user_passwords=safe_user_passwords, + )["url"] + assert isinstance(sanitized_req, str) + return sanitized_req def freeze( diff --git a/tests/functional/test_freeze.py b/tests/functional/test_freeze.py index c0d703afa1..8482682ec0 100644 --- a/tests/functional/test_freeze.py +++ b/tests/functional/test_freeze.py @@ -468,13 +468,7 @@ def test_freeze_git_remote_strips_auth(script: PipTestEnvironment) -> None: expect_stderr=True, ) repo_dir = script.scratch_path / "pip-test-package" - script.run( - "python", - "setup.py", - "develop", - cwd=repo_dir, - expect_stderr=True, - ) + script.pip("install", "--no-build-isolation", "-e", repo_dir) script.run( "git", "remote", diff --git a/tests/unit/test_operations_freeze.py b/tests/unit/test_operations_freeze.py index 5fe529dbd9..db084518a9 100644 --- a/tests/unit/test_operations_freeze.py +++ b/tests/unit/test_operations_freeze.py @@ -22,6 +22,22 @@ "git+ssh://git@example.com/repo.git@rev#egg=project", "git+ssh://git@example.com/repo.git@rev#egg=project", ), + ( + "hg+ssh://git@example.com/repo@rev#egg=project", + "hg+ssh://git@example.com/repo@rev#egg=project", + ), + ( + "svn+ssh://git@example.com/repo@rev#egg=project", + "svn+ssh://git@example.com/repo@rev#egg=project", + ), + ( + "bzr+ssh://git@example.com/repo@rev#egg=project", + "bzr+ssh://git@example.com/repo@rev#egg=project", + ), + ( + "bzr+sftp://git@example.com/repo@rev#egg=project", + "bzr+sftp://example.com/repo@rev#egg=project", + ), ( "git+https://git@example.com/repo.git@rev#egg=project", "git+https://example.com/repo.git@rev#egg=project",