Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/html/topics/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,50 @@ the keyring to download and install keyring.

It is, thus, expected that users that wish to use pip's keyring support have
some mechanism for downloading and installing {pypi}`keyring`.

## Credential Helpers

pip supports using external credential helper commands for authentication.
This is enabled by passing `--credential-helper <command>`.

The command will be called with an additional argument specifying the action: `get`, `store`, or `erase`.

### Protocol

The credential helper communicates with pip using JSON over standard input and output.

#### `get` action

When pip needs credentials for a URL, it calls:
`<command> get`

It sends a JSON object to standard input:
```json
{"url": "...", "username": "..."}
```

The command should return a JSON object on standard output:
```json
{"username": "...", "password": "..."}
```
Or an empty response/`null` if no credentials are found.

#### `store` action

When a user successfully authenticates, pip can store the credentials by calling:
`<command> store`

It sends a JSON object to standard input:
```json
{"url": "...", "username": "...", "password": "..."}
```

#### `erase` action

When authentication fails with previously provided credentials, pip can notify the helper by calling:
`<command> erase`

It sends a JSON object to standard input:
```json
{"url": "...", "username": "..."}
```
2 changes: 2 additions & 0 deletions news/10389.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add ``--credential-helper`` command line option to allow using external
credential helper commands for authentication.
1 change: 1 addition & 0 deletions news/50.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement ``--index-mapping`` to allow users to pin specific packages or patterns to a particular index URL. This provides robust namespace isolation and completely mitigates dependency confusion for the mapped packages.
1 change: 1 addition & 0 deletions news/8606.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement ``--index-priority`` to allow users to prioritize package indexes in the order they are provided. This helps mitigate dependency confusion attacks by stopping the search after the first index that yields a match.
2 changes: 2 additions & 0 deletions src/pip/_internal/build_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ def install(
args.extend(["--client-cert", finder.client_cert])
if finder.prefer_binary:
args.append("--prefer-binary")
if finder.credential_helper:
args.extend(["--credential-helper", finder.credential_helper])

# Handle build constraints
if self._build_constraint_feature_enabled:
Expand Down
42 changes: 42 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,16 @@ class PipOption(Option):
" (default: %default)"
),
)

credential_helper: Callable[..., Option] = partial(
Option,
"--credential-helper",
dest="credential_helper",
metavar="command",
default=None,
help="Specify an external command to fetch credentials. "
"The command will be called with 'get', 'store' or 'erase' as the first argument.",
)

proxy: Callable[..., Option] = partial(
Option,
Expand Down Expand Up @@ -402,6 +412,35 @@ def extra_index_url() -> Option:
)


def index_strategy() -> Option:
return Option(
"--index-strategy",
dest="index_strategy",
choices=["first-match", "best-match"],
default="best-match",
help="Select the strategy used to select packages from indexes. "
"Choices: first-match, best-match. "
"Default: best-match. "
"first-match: stop searching indexes after finding the package in the "
"first index (respecting order of --index-url and --extra-index-url). "
"best-match: search all indexes for the best version.",
)


def index_mapping() -> Option:
return Option(
"--index-mapping",
dest="index_mappings",
metavar="PACKAGE:URL",
action="append",
default=[],
help="Map package names to specific indexes. "
"Format: <package_pattern>:<index_url>. "
"Example: --index-mapping 'my-internal-*:https://my-repo/simple'. "
"Can be used multiple times.",
)


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section of code seems unrelated to the purpose of the PR. What's going on here?

no_index: Callable[..., Option] = partial(
Option,
"--no-index",
Expand Down Expand Up @@ -1225,6 +1264,7 @@ def check_list_path_option(options: Values) -> None:
log,
no_input,
keyring_provider,
credential_helper,
proxy,
retries,
timeout,
Expand All @@ -1249,6 +1289,8 @@ def check_list_path_option(options: Values) -> None:
index_url,
extra_index_url,
no_index,
index_strategy,
index_mapping,
find_links,
uploaded_prior_to,
],
Expand Down
3 changes: 2 additions & 1 deletion src/pip/_internal/cli/index_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ def _build_session(
resume_retries=options.resume_retries,
trusted_hosts=options.trusted_hosts,
index_urls=self._get_index_urls(options),
keyring_provider=options.keyring_provider,
credential_helper=options.credential_helper,
ssl_context=ssl_context,
)

Expand All @@ -134,7 +136,6 @@ def _build_session(

# Determine if we can prompt the user for authentication or not
session.auth.prompting = not options.no_input
session.auth.keyring_provider = options.keyring_provider

return session

Expand Down
2 changes: 2 additions & 0 deletions src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,8 @@ def _build_package_finder(
release_control=options.release_control,
prefer_binary=options.prefer_binary,
ignore_requires_python=ignore_requires_python,
index_strategy=options.index_strategy,
index_mappings=options.index_mappings,
)

return PackageFinder.create(
Expand Down
97 changes: 88 additions & 9 deletions src/pip/_internal/index/package_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@

import datetime
import enum
import fnmatch
import functools
import itertools
import logging
import re
from collections.abc import Iterable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Any,
Optional,
Union,
)
Expand All @@ -29,7 +31,7 @@
InvalidWheelFilename,
UnsupportedWheel,
)
from pip._internal.index.collector import LinkCollector, parse_links
from pip._internal.index.collector import CollectedSources, LinkCollector, parse_links
from pip._internal.metadata import select_backend
from pip._internal.models.candidate import InstallationCandidate
from pip._internal.models.format_control import FormatControl
Expand Down Expand Up @@ -382,6 +384,8 @@ class CandidatePreferences:

prefer_binary: bool = False
release_control: ReleaseControl | None = None
index_strategy: str = "best-match"
index_mappings: list[str] = field(default_factory=list)


@dataclass(frozen=True)
Expand Down Expand Up @@ -698,6 +702,8 @@ def create(
candidate_prefs = CandidatePreferences(
prefer_binary=selection_prefs.prefer_binary,
release_control=selection_prefs.release_control,
index_strategy=selection_prefs.index_strategy,
index_mappings=selection_prefs.index_mappings,
)

return cls(
Expand Down Expand Up @@ -739,6 +745,10 @@ def trusted_hosts(self) -> Iterable[str]:
for host_port in self._link_collector.session.pip_trusted_origins:
yield build_netloc(*host_port)

@property
def credential_helper(self) -> str | None:
return self._link_collector.session.auth.credential_helper

@property
def custom_cert(self) -> str | None:
# session.verify is either a boolean (use default bundle/no SSL
Expand Down Expand Up @@ -877,6 +887,55 @@ def process_project_url(

return package_links

def _apply_index_mapping(
self, project_name: str, collected_sources: CollectedSources
) -> CollectedSources:
mappings = []
for m in self._candidate_prefs.index_mappings:
if ":" in m:
pattern, url = m.split(":", 1)
mappings.append((pattern, url))

if not mappings:
return collected_sources

target_url = None
for pattern, url in mappings:
if fnmatch.fnmatch(project_name, pattern):
target_url = url
break

if target_url is None:
return collected_sources

logger.info(
"Limiting search for %s to index %s due to index mapping",
project_name,
target_url,
)

def matches_target(source: Any) -> bool:
if source is None or source.link is None:
return False
return source.link.url.startswith(target_url)

new_index_urls = [s for s in collected_sources.index_urls if matches_target(s)]
new_find_links = [s for s in collected_sources.find_links if matches_target(s)]

if not new_index_urls and not new_find_links:
logger.warning(
"Index mapping for %s to %s resulted in no search locations. "
"Check if the URL is correctly specified in --index-url "
"or --find-links.",
project_name,
target_url,
)

return CollectedSources(
find_links=new_find_links,
index_urls=new_index_urls,
)

def find_all_candidates(self, project_name: str) -> list[InstallationCandidate]:
"""Find all available InstallationCandidate for project_name

Expand All @@ -899,13 +958,33 @@ def find_all_candidates(self, project_name: str) -> list[InstallationCandidate]:
),
)

page_candidates_it = itertools.chain.from_iterable(
source.page_candidates()
for sources in collected_sources
for source in sources
if source is not None
)
page_candidates = list(page_candidates_it)
if self._candidate_prefs.index_mappings:
collected_sources = self._apply_index_mapping(
project_name, collected_sources
)

page_candidates: list[InstallationCandidate] = []
if self._candidate_prefs.index_strategy == "first-match":
# Process find_links first (they are usually prioritized in pip)
for source in collected_sources.find_links:
if source is not None:
page_candidates.extend(source.page_candidates())

# Then process index_urls in order, stopping at the first one with hits
for source in collected_sources.index_urls:
if source is not None:
candidates = list(source.page_candidates())
if candidates:
page_candidates.extend(candidates)
break
else:
page_candidates_it = itertools.chain.from_iterable(
source.page_candidates()
for sources in collected_sources
for source in sources
if source is not None
)
page_candidates = list(page_candidates_it)

file_links_it = itertools.chain.from_iterable(
source.file_links()
Expand Down
10 changes: 10 additions & 0 deletions src/pip/_internal/models/selection_prefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class SelectionPreferences:
"format_control",
"prefer_binary",
"ignore_requires_python",
"index_strategy",
"index_mappings",
]

# Don't include an allow_yanked default value to make sure each call
Expand All @@ -31,6 +33,8 @@ def __init__(
format_control: FormatControl | None = None,
prefer_binary: bool = False,
ignore_requires_python: bool | None = None,
index_strategy: str = "best-match",
index_mappings: list[str] | None = None,
) -> None:
"""Create a SelectionPreferences object.

Expand All @@ -45,6 +49,10 @@ def __init__(
dist over a new source dist.
:param ignore_requires_python: Whether to ignore incompatible
"Requires-Python" values in links. Defaults to False.
:param index_strategy: Strategies for how to select packages from indexes.
"first-match" stops searching after the first index with hits.
"best-match" searches all indexes for the best version.
:param index_mappings: A list of package:url mapping strings.
"""
if ignore_requires_python is None:
ignore_requires_python = False
Expand All @@ -54,3 +62,5 @@ def __init__(
self.format_control = format_control
self.prefer_binary = prefer_binary
self.ignore_requires_python = ignore_requires_python
self.index_strategy = index_strategy
self.index_mappings = index_mappings or []
Loading