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..fcab87843b 100644 --- a/src/pip/_internal/operations/freeze.py +++ b/src/pip/_internal/operations/freeze.py @@ -12,6 +12,8 @@ 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, @@ -27,6 +29,23 @@ class _EditableInfo(NamedTuple): comments: list[str] +def _strip_auth_from_editable_requirement(req: str) -> str: + """Remove credentials while preserving PEP 610-safe user information.""" + link = Link(req) + if "@" not in link.netloc: + return 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( requirement: list[str] | None = None, local_only: bool = False, @@ -214,7 +233,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..8482682ec0 100644 --- a/tests/functional/test_freeze.py +++ b/tests/functional/test_freeze.py @@ -456,6 +456,37 @@ 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.pip("install", "--no-build-isolation", "-e", repo_dir) + 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..db084518a9 --- /dev/null +++ b/tests/unit/test_operations_freeze.py @@ -0,0 +1,64 @@ +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", + ), + ( + "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", + ), + ( + "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