Compare commits

...

2 Commits
c9 ... c10s

Author SHA1 Message Date
Lukáš Zachar
be1de24036 Fix gating plan
- removed unnecessary older VERSION= tests
- removed bootstrap test
- fix pip_install_upgrade to run pip-3.14
- fix bash completion + remove unused metadata file
2026-07-08 09:32:27 +02:00
RHEL Packaging Agent
b1803d0f02 Security fix for CVE-2026-8643: path traversal in wheel entry points
Backport upstream commit 8eb178480bd1a2b223f509fc430796b265158dfb
to fix CVE-2026-8643. The patch adds validation to reject wheel
entry point names containing path traversal components (e.g. ".."
or absolute paths) that could cause scripts to be installed
outside the intended scripts directory. A new _script_within_dir
helper function and updated _raise_for_invalid_entrypoint logic
in pip's wheel installer enforce this check. Comprehensive tests
are included.

CVE: CVE-2026-8643
Upstream patches:
 - 8eb178480b.patch
Resolves: RHEL-191157

This commit was backported by Ymir, a Red Hat Enterprise Linux software maintenance AI agent.

Assisted-by: Ymir
2026-07-08 09:32:27 +02:00
7 changed files with 190 additions and 86 deletions

140
pip-CVE-2026-8643.patch Normal file
View File

@ -0,0 +1,140 @@
From dba24a5eeb065d5ac552b3049d2cc99c0bda13fb Mon Sep 17 00:00:00 2001
From: Damian Shaw <damian.peter.shaw@gmail.com>
Date: Wed, 20 May 2026 15:20:25 -0400
Subject: [PATCH] Reject entry point names that escape scripts dir (#14000)
* Reject entry point names that escape scripts dir
* NEWS ENTRY
---
news/14000.bugfix.rst | 2 +
src/pip/_internal/operations/install/wheel.py | 26 +++++++-
tests/unit/test_wheel.py | 64 +++++++++++++++++++
2 files changed, 87 insertions(+), 3 deletions(-)
diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py
index 2724f150f..ec2faaa5d 100644
--- a/src/pip/_internal/operations/install/wheel.py
+++ b/src/pip/_internal/operations/install/wheel.py
@@ -397,11 +397,31 @@ class MissingCallableSuffix(InstallationError):
)
-def _raise_for_invalid_entrypoint(specification: str) -> None:
+def _script_within_dir(name: str, scripts_dir: str) -> bool:
+ """Return whether script ``name`` resolves to a path inside the ``scripts_dir``.
+
+ distlib joins the entry point name onto the scripts directory, so a name
+ with path separators or ``..`` components can resolve elsewhere.
+ """
+ root = os.path.normpath(scripts_dir)
+ dest = os.path.normpath(os.path.join(scripts_dir, name))
+ return dest.startswith(root + os.sep)
+
+
+def _raise_for_invalid_entrypoint(specification: str, scripts_dir: str) -> None:
entry = get_export_entry(specification)
- if entry is not None and entry.suffix is None:
+ if entry is None:
+ return
+
+ if entry.suffix is None:
raise MissingCallableSuffix(str(entry))
+ if not _script_within_dir(entry.name, scripts_dir):
+ raise InstallationError(
+ f"Invalid script entry point name {entry.name!r}: the script "
+ f"would be installed outside the scripts directory ({scripts_dir})."
+ )
+
class PipScriptMaker(ScriptMaker):
# Override distlib's default script template with one that
@@ -420,7 +440,7 @@ class PipScriptMaker(ScriptMaker):
def make(
self, specification: str, options: dict[str, Any] | None = None
) -> list[str]:
- _raise_for_invalid_entrypoint(specification)
+ _raise_for_invalid_entrypoint(specification, self.target_dir)
return super().make(specification, options)
diff --git a/tests/unit/test_wheel.py b/tests/unit/test_wheel.py
index 028ad3a46..bce0607ec 100644
--- a/tests/unit/test_wheel.py
+++ b/tests/unit/test_wheel.py
@@ -519,6 +519,32 @@ class TestInstallUnpackedWheel:
assert os.path.basename(wheel_path) in exc_text
assert entrypoint in exc_text
+ @pytest.mark.parametrize("bad_name", ["../../outside", "..", "."])
+ @pytest.mark.parametrize("entry_point_type", ["console_scripts", "gui_scripts"])
+ def test_wheel_install_rejects_entry_point_path_traversal(
+ self, data: TestData, tmpdir: Path, bad_name: str, entry_point_type: str
+ ) -> None:
+ """An entry point name with separators or ``..`` must not install a
+ script outside the scripts directory.
+ """
+ self.prep(data, tmpdir)
+ wheel_path = make_wheel(
+ "simple",
+ "0.1.0",
+ entry_points={entry_point_type: [f"{bad_name} = simple:main"]},
+ ).save_to_dir(tmpdir)
+ with pytest.raises(InstallationError) as e:
+ wheel.install_wheel(
+ "simple",
+ str(wheel_path),
+ scheme=self.scheme,
+ req_description="simple",
+ )
+
+ assert "outside the scripts directory" in str(e.value)
+ # Nothing was written outside the install destination.
+ assert not os.path.exists(os.path.join(str(tmpdir), "outside"))
+
class TestMessageAboutScriptsNotOnPATH:
tilde_warning_msg = (
@@ -722,3 +748,41 @@ def test_get_console_script_specs_replaces_python_version(
"not_pip_or_easy_install-99 = whatever",
"not_pip_or_easy_install-99.88 = whatever",
]
+
+
+@pytest.mark.parametrize(
+ "name, within",
+ [
+ ("pip", True),
+ ("pip3.13", True),
+ ("foo-bar.baz", True),
+ ("...", True), # a literal filename, not a path component
+ ("sub/script", True), # in-tree subdirectory
+ ("a/../b", True),
+ ("sub\\script", True), # backslash stays in-tree on POSIX and Windows
+ (" ../../inside", True), # distlib keeps a leading space; resolves in-tree
+ ("../outside", False),
+ ("../../outside", False),
+ ("a/../../outside", False),
+ ("/etc/cron.d/outside", False), # absolute path; os.path.join drops the root
+ # "." and ".." pass PyPI's [\w.-]+ name check but must be rejected here.
+ (".", False),
+ ("..", False),
+ ("", False),
+ ],
+)
+def test_script_within_dir(name: str, within: bool) -> None:
+ assert wheel._script_within_dir(name, "/srv/env/bin") is within
+
+
+def test_script_within_dir_allows_doubled_slash_root() -> None:
+ # A scripts directory can have a doubled leading slash
+ assert wheel._script_within_dir("pip", "//srv/env/bin") is True
+ assert wheel._script_within_dir("../outside", "//srv/env/bin") is False
+
+
+@pytest.mark.skipif(not WINDOWS, reason="drive letters only matter on Windows")
+def test_script_within_dir_rejects_other_drive() -> None:
+ # Validate that a script on a different drive is rejected,
+ # and doesn't throw an error
+ assert wheel._script_within_dir("D:\\outside", "C:\\env\\bin") is False

View File

@ -6,53 +6,37 @@ discover:
how: shell how: shell
url: https://gitlab.com/redhat/centos-stream/tests/python.git url: https://gitlab.com/redhat/centos-stream/tests/python.git
tests: tests:
- name: smoke312
path: /smoke
test: VERSION=3.12 ./venv.sh
- name: smoke314 - name: smoke314
path: /smoke path: /smoke
test: VERSION=3.14 ./venv.sh test: VERSION=3.14 ./venv.sh
- name: smoke312_virtualenv
path: /smoke
test: VERSION=3.12 METHOD=virtualenv ./venv.sh
- name: smoke314_virtualenv - name: smoke314_virtualenv
path: /smoke path: /smoke
test: VERSION=3.14 METHOD=virtualenv ./venv.sh test: VERSION=3.14 METHOD=virtualenv ./venv.sh
- name: rpms_pyproject-rpm-macros
how: shell
url: https://gitlab.com/redhat/centos-stream/rpms/pyproject-rpm-macros.git
ref: c10s
tests:
- name: pyproject_isort
path: /tests
test: ./mocktest.sh python-isort
- name: same_repo - name: same_repo
how: shell how: shell
dist-git-source: true
dist-git-download-only: true
tests: tests:
- name: mock_bootstrap_build
test: |
cd $TMT_SOURCE_DIR &&
$TMT_TREE/../discover/rpms_pyproject-rpm-macros/tests/tests/mocktest.sh python-pip --without tests --without man
- name: pip_install_upgrade - name: pip_install_upgrade
path: /tests/pip_install_upgrade/ path: /tests/pip_install_upgrade/
test: ./runtest.sh test: pybasever=3.14 ./runtest.sh
- name: bash_completion - name: bash_completion
path: /tests/bash_completion path: /tests/bash_completion
test: ./pip_completion_full_test.sh test: PACKAGE=python3.14-pip ./pip_completion_full_test.sh
prepare: prepare:
- name: Enable CRB
how: feature
crb: enabled
- name: Enable EPEL for tox and virtualenv
how: feature
epel: enabled
- name: Install dependencies - name: Install dependencies
how: install how: install
package: package:
- gcc - gcc
- virtualenv - virtualenv
- tox
- python3.14-devel - python3.14-devel
- python3-devel - python3.14-pip
- python3-tox - bash-completion
- mock
- rpmdevtools
- rpm-build
- grep - grep
- util-linux - util-linux
- shadow-utils - shadow-utils

View File

@ -109,6 +109,10 @@ Patch: downstream-remove-pytest-subket.patch
# Upstream fix: https://github.com/urllib3/urllib3/commit/f05b1329126d5be6de501f9d1e3e36738bc08857 # Upstream fix: https://github.com/urllib3/urllib3/commit/f05b1329126d5be6de501f9d1e3e36738bc08857
Patch: urllib3-CVE-2025-50181.patch Patch: urllib3-CVE-2025-50181.patch
# Reject entry point names that escape scripts dir (CVE-2026-8643)
# Upstream fix: https://github.com/pypa/pip/commit/8eb178480bd1a2b223f509fc430796b265158dfb
Patch: pip-CVE-2026-8643.patch
# Remove -s from Python shebang - ensure that packages installed with pip # Remove -s from Python shebang - ensure that packages installed with pip
# to user locations are seen by pip itself # to user locations are seen by pip itself
%undefine _py3_shebang_s %undefine _py3_shebang_s

View File

@ -1,35 +0,0 @@
summary: PIP bash completion functionality smoke test
description: |
Comprehensive test for pip bash completion functionality on Fedora/RHEL systems.
The test performs the following steps:
1. Finds the bash completion script in the given (e.g. python3-pip) RPM package
2. Discovers all pip executables in the package (e.g. /usr/bin/pip and /usr/bin/pip3.14)
3. Sources the completion script and verifies completion for all executables is registered
4. Runs functional TAB completion tests using expect (for regular and POSIX mode of Bash)
5. Validates that completion works for basic pip commands
This is a smoke test to ensure pip bash completion is properly
installed and functional after package installation.
component:
- python3-pip
test: ./pip_completion_full_test.sh
framework: shell
duration: 5m
tier: 1
require:
- python3-pip
- bash-completion
- expect
- rpm
- bash
environment:
PACKAGE: python3-pip

View File

@ -9,7 +9,7 @@ PACKAGE="${PACKAGE:-python3-pip}"
# Step 1: Find bash completion scripts in python3-pip RPM package # Step 1: Find bash completion scripts in python3-pip RPM package
echo "Step 1: Finding bash completion scripts in $PACKAGE RPM package..." echo "Step 1: Finding bash completion scripts in $PACKAGE RPM package..."
COMPLETION_FILE=$(rpm -ql $PACKAGE 2>/dev/null | grep -E "/usr/share/bash-completion/completions/" || true) COMPLETION_FILE=$(rpm -ql "$PACKAGE" 2>/dev/null | grep -E "/usr/share/bash-completion/completions/" || true)
if [[ -z "$COMPLETION_FILE" ]]; then if [[ -z "$COMPLETION_FILE" ]]; then
echo "✗ No bash completion files found in $PACKAGE package" echo "✗ No bash completion files found in $PACKAGE package"
@ -34,7 +34,7 @@ echo "$COMPLETION_FILE" | sed 's/^/ - /'
echo echo
echo "Step 2: Finding all pip binaries..." echo "Step 2: Finding all pip binaries..."
PIP_BINARIES=() PIP_BINARIES=()
PIP_FILES=$(rpm -ql $PACKAGE | grep /bin/p) PIP_FILES=$(rpm -ql "$PACKAGE" | grep /bin/pip)
for pip_file in $PIP_FILES; do for pip_file in $PIP_FILES; do
if [[ -x "$pip_file" ]]; then if [[ -x "$pip_file" ]]; then
pip_cmd=$(basename "$pip_file") pip_cmd=$(basename "$pip_file")

View File

@ -10,6 +10,11 @@ set timeout 5
set completion_file [lindex $argv 0] set completion_file [lindex $argv 0]
set pip_exec [lindex $argv 1] set pip_exec [lindex $argv 1]
if {$completion_file eq "" || $pip_exec eq ""} {
puts "Usage: test_pip_completion.exp <completion_file> <pip_binary>"
exit 1
}
puts "=== PIP Bash Completion Test (using expect) ===" puts "=== PIP Bash Completion Test (using expect) ==="
puts "Testing completion file: $completion_file" puts "Testing completion file: $completion_file"
@ -28,11 +33,15 @@ if {[info exists env(AS_POSIX)] && $env(AS_POSIX) == "1"} {
} else { } else {
spawn bash --norc spawn bash --norc
} }
expect "$ "
send "PS1='READY> '\r"
expect "READY> "
send "\r"
expect "READY> "
# Source the completion file # Source the completion file
send "source $completion_file\r" send "source $completion_file\r"
expect "$ " expect "READY> "
puts "Attempted to source completion file" puts "Attempted to source completion file"
# Test 1: Basic pip command completion # Test 1: Basic pip command completion
@ -44,12 +53,12 @@ send "\x09\x09"
expect { expect {
-re "(install|uninstall|list|show)" { -re "(install|uninstall|list|show)" {
puts "✓ Basic pip commands found in completion" puts "✓ Basic pip commands found in completion"
expect "$ " expect "READY> "
} }
-re "Display all" { -re "Display all" {
puts "✓ Completion showing options menu" puts "✓ Completion showing options menu"
send "n\r" send "n\r"
expect "$ " expect "READY> "
} }
timeout { timeout {
puts "✗ Timeout waiting for completion - test failed" puts "✗ Timeout waiting for completion - test failed"
@ -59,9 +68,9 @@ expect {
# Clear the line and ensure clean prompt # Clear the line and ensure clean prompt
send "\x03" send "\x03"
expect "$ " expect "READY> "
send "\r" send "\r"
expect "$ " expect "READY> "
# Test 2: Test partial command completion (simpler test) # Test 2: Test partial command completion (simpler test)
puts "\nTest 2: Testing '$pip_exec insta' + TAB completion..." puts "\nTest 2: Testing '$pip_exec insta' + TAB completion..."
@ -72,7 +81,7 @@ send "\x09"
expect { expect {
-re "install" { -re "install" {
puts "✓ Partial command completion works (insta -> install)" puts "✓ Partial command completion works (insta -> install)"
expect "$ " expect "READY> "
} }
timeout { timeout {
puts "✗ Timeout on partial completion test - test failed" puts "✗ Timeout on partial completion test - test failed"
@ -82,9 +91,9 @@ expect {
# Clear the line and ensure clean prompt # Clear the line and ensure clean prompt
send "\x03" send "\x03"
expect "$ " expect "READY> "
send "\r" send "\r"
expect "$ " expect "READY> "
# Test 3: Test help completion # Test 3: Test help completion
puts "\nTest 3: Testing '$pip_exec --' + TAB completion..." puts "\nTest 3: Testing '$pip_exec --' + TAB completion..."
@ -94,7 +103,7 @@ send "\x09\x09"
expect { expect {
-re "(--help|--version)" { -re "(--help|--version)" {
puts "✓ Command options found in completion" puts "✓ Command options found in completion"
expect "$ " expect "READY> "
} }
timeout { timeout {
puts "✗ Timeout on options completion test - test failed" puts "✗ Timeout on options completion test - test failed"
@ -104,9 +113,9 @@ expect {
# Final cleanup - make sure we're at clean prompt # Final cleanup - make sure we're at clean prompt
send "\x03" send "\x03"
expect "$ " expect "READY> "
send "\r" send "\r"
expect "$ " expect "READY> "
# Exit bash cleanly # Exit bash cleanly
send "exit\r" send "exit\r"

View File

@ -2,39 +2,41 @@
# This script requires root privileges and you should never run it on your own machine # This script requires root privileges and you should never run it on your own machine
test $EUID -eq 0 test $EUID -eq 0
PYTHON_VERSION=$(/usr/bin/python3 -c 'import sys; print("{}.{}".format(*sys.version_info))') pybasever=${pybasever:-3}
PYTHON_VERSION=$(/usr/bin/python${pybasever} -c 'import sys; print("{}.{}".format(*sys.version_info))')
RPM_SITELIB="/usr/lib/python${PYTHON_VERSION}/site-packages" RPM_SITELIB="/usr/lib/python${PYTHON_VERSION}/site-packages"
LOCAL_SITELIB="/usr/local/lib/python${PYTHON_VERSION}/site-packages" LOCAL_SITELIB="/usr/local/lib/python${PYTHON_VERSION}/site-packages"
USER_SITELIB="/home/fedora-test-user/.local/lib/python${PYTHON_VERSION}/site-packages" USER_SITELIB="/home/fedora-test-user/.local/lib/python${PYTHON_VERSION}/site-packages"
# First, let's install older Pello with pip as if it was installed by RPM # First, let's install older Pello with pip as if it was installed by RPM
# This is an approximation, but it usually works # This is an approximation, but it usually works
RPM_BUILD_ROOT=/ /usr/bin/pip install 'Pello==1.0.1' RPM_BUILD_ROOT=/ /usr/bin/pip-${pybasever} install 'Pello==1.0.1'
# Now, we'll upgrade it with regular pip # Now, we'll upgrade it with regular pip
/usr/bin/pip install --upgrade 'Pello==1.0.2' /usr/bin/pip-${pybasever} install --upgrade 'Pello==1.0.2'
# pip should see it # pip should see it
/usr/bin/pip freeze | grep '^Pello==1\.0\.2$' /usr/bin/pip-${pybasever} freeze | grep '^Pello==1\.0\.2$'
# Both installations should still exist # Both installations should still exist
test -d "${RPM_SITELIB}/pello-1.0.1.dist-info" test -d "${RPM_SITELIB}/pello-1.0.1.dist-info"
test -d "${LOCAL_SITELIB}/Pello-1.0.2.dist-info" test -d "${LOCAL_SITELIB}/Pello-1.0.2.dist-info"
# Let's ditch the local one # Let's ditch the local one
/usr/bin/pip uninstall --yes Pello /usr/bin/pip-${pybasever} uninstall --yes Pello
# It should only remove one of them # It should only remove one of them
test -d "${RPM_SITELIB}/pello-1.0.1.dist-info" test -d "${RPM_SITELIB}/pello-1.0.1.dist-info"
! test -d "${LOCAL_SITELIB}/Pello-1.0.2.dist-info" ! test -d "${LOCAL_SITELIB}/Pello-1.0.2.dist-info"
# And pip should still see the RPM-installed one # And pip should still see the RPM-installed one
/usr/bin/pip freeze | grep '^Pello==1\.0\.1$' /usr/bin/pip-${pybasever} freeze | grep '^Pello==1\.0\.1$'
# Again, but as regular user # Again, but as regular user
useradd fedora-test-user useradd fedora-test-user
su fedora-test-user -c '/usr/bin/pip install "Pello==1.0.2"' su fedora-test-user -c '/usr/bin/pip-${pybasever} install "Pello==1.0.2"'
test -d "${USER_SITELIB}/Pello-1.0.2.dist-info" test -d "${USER_SITELIB}/Pello-1.0.2.dist-info"
su fedora-test-user -c '/usr/bin/pip freeze' | grep '^Pello==1\.0\.2$' su fedora-test-user -c '/usr/bin/pip-${pybasever} freeze' | grep '^Pello==1\.0\.2$'
su fedora-test-user -c '/usr/bin/pip uninstall --yes Pello' su fedora-test-user -c '/usr/bin/pip-${pybasever} uninstall --yes Pello'
su fedora-test-user -c '/usr/bin/pip freeze' | grep '^Pello==1\.0\.1$' su fedora-test-user -c '/usr/bin/pip-${pybasever} freeze' | grep '^Pello==1\.0\.1$'