From 15ce78d2cf274cce9b66125b173d36390f77b209 Mon Sep 17 00:00:00 2001 From: Durga Jagadeesh Palli Date: Wed, 28 Oct 2026 08:11:41 +0000 Subject: [PATCH 1/2] [High] Patch python-virtualenv for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, CVE-2026-113716, CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, CVE-2026-113722, CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, CVE-2026-113732, CVE-2026-113776 --- SPECS/python-virtualenv/CVE-2026-102938.patch | 265 ++++++++++++ SPECS/python-virtualenv/CVE-2026-113713.patch | 109 +++++ SPECS/python-virtualenv/CVE-2026-113715.patch | 192 +++++++++ SPECS/python-virtualenv/CVE-2026-113716.patch | 337 +++++++++++++++ SPECS/python-virtualenv/CVE-2026-113719.patch | 188 +++++++++ SPECS/python-virtualenv/CVE-2026-113720.patch | 144 +++++++ SPECS/python-virtualenv/CVE-2026-113721.patch | 395 ++++++++++++++++++ SPECS/python-virtualenv/CVE-2026-113722.patch | 251 +++++++++++ SPECS/python-virtualenv/CVE-2026-113723.patch | 250 +++++++++++ SPECS/python-virtualenv/CVE-2026-113725.patch | 92 ++++ SPECS/python-virtualenv/CVE-2026-113726.patch | 113 +++++ SPECS/python-virtualenv/CVE-2026-113732.patch | 105 +++++ SPECS/python-virtualenv/CVE-2026-113776.patch | 99 +++++ .../python-virtualenv/python-virtualenv.spec | 36 +- 14 files changed, 2575 insertions(+), 1 deletion(-) create mode 100644 SPECS/python-virtualenv/CVE-2026-102938.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113713.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113715.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113716.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113719.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113720.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113721.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113722.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113723.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113725.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113726.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113732.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113776.patch diff --git a/SPECS/python-virtualenv/CVE-2026-102938.patch b/SPECS/python-virtualenv/CVE-2026-102938.patch new file mode 100644 index 00000000000..d8cbd80d0a4 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-102938.patch @@ -0,0 +1,265 @@ +From a30f995461043acb6cacbf3a890951563ccf7140 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Thu, 17 Sep 2026 18:06:12 +0200 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(create):=20keep=20pyvenv.cfg?= + =?UTF-8?q?=20values=20on=20a=20single=20line=20(#3247)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/a30f995461043acb6cacbf3a890951563ccf7140.patch +--- + docs/changelog/3247.bugfix.rst | 5 ++ + pyproject.toml | 10 +++- + src/virtualenv/create/pyenv_cfg.py | 3 +- + tasks/fuzz_pyenv_cfg.py | 43 +++++++++++++++++ + tests/property/conftest.py | 12 +++++ + tests/property/test_pyenv_cfg.py | 76 ++++++++++++++++++++++++++++++ + tox.ini | 15 ++++++ + 7 files changed, 162 insertions(+), 2 deletions(-) + create mode 100644 docs/changelog/3247.bugfix.rst + create mode 100644 tasks/fuzz_pyenv_cfg.py + create mode 100644 tests/property/conftest.py + create mode 100644 tests/property/test_pyenv_cfg.py + +diff --git a/docs/changelog/3247.bugfix.rst b/docs/changelog/3247.bugfix.rst +new file mode 100644 +index 0000000..e596d45 +--- /dev/null ++++ b/docs/changelog/3247.bugfix.rst +@@ -0,0 +1,5 @@ ++Write ``pyvenv.cfg`` values on a single line, so a prompt carrying a line boundary can no longer inject configuration. ++``--prompt``, the ``VIRTUALENV_PROMPT`` environment variable and the config file all set the prompt, and ``pyvenv.cfg`` ++has no escape syntax, so a newline, a carriage return, or any other boundary ``str.splitlines`` recognizes, such as ++``U+2028``, started a new configuration line. Reading the file back picked up those lines as keys, and since the last ++value for a key wins, they replaced anything written earlier, including ``home``. +diff --git a/pyproject.toml b/pyproject.toml +index 03706b2..82ed4e7 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -54,6 +54,13 @@ optional-dependencies.docs = [ + "sphinxcontrib-towncrier>=0.2.1a0", + "towncrier>=23.6", + ] ++optional-dependencies.fuzz = [ ++ # Atheris only ships prebuilt Linux wheels for these CPython versions; other platforms need a custom build. ++ "atheris>=2.3; sys_platform=='linux' and python_version>='3.12' and python_version<'3.15'", ++] ++optional-dependencies.property = [ ++ "hypothesis>=6.140", ++] + optional-dependencies.test = [ + "covdefaults>=2.3", + "coverage>=7.2.7", +@@ -150,10 +157,11 @@ max_supported_python = "3.14" + + [tool.pytest.ini_options] + markers = [ ++ "property: hypothesis property tests, run by their own tox environment", + "slow", + ] + timeout = 600 +-addopts = "--showlocals --no-success-flaky-report" ++addopts = "--showlocals --no-success-flaky-report -m 'not property'" + env = [ + "PYTHONIOENCODING=utf-8", + ] +diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cfg.py +index 4ac788d..4645f94 100644 +--- a/src/virtualenv/create/pyenv_cfg.py ++++ b/src/virtualenv/create/pyenv_cfg.py +@@ -5,6 +5,7 @@ import os + from collections import OrderedDict + + from virtualenv.util.path import write_file ++from virtualenv.util.text import collapse_line_boundaries + + LOGGER = logging.getLogger(__name__) + +@@ -38,7 +39,7 @@ class PyEnvCfg: + text = "" + for key, value in self.content.items(): + normalized_value = os.path.realpath(value) if value and os.path.exists(value) else value +- line = f"{key} = {normalized_value}" ++ line = collapse_line_boundaries(f"{key} = {normalized_value}") + LOGGER.debug("\t%s", line) + text += line + text += "\n" +diff --git a/tasks/fuzz_pyenv_cfg.py b/tasks/fuzz_pyenv_cfg.py +new file mode 100644 +index 0000000..fb37af2 +--- /dev/null ++++ b/tasks/fuzz_pyenv_cfg.py +@@ -0,0 +1,43 @@ ++""" ++Fuzz the pyvenv.cfg writer and reader for configuration injection. ++ ++This asserts the same invariant as tests/property/test_pyenv_cfg.py, with a coverage-guided driver rather than ++hypothesis: a value must never be read back as an extra configuration key. That is the property that #3247 violated, ++where a prompt containing a line boundary could override ``home``. ++ ++Run directly: python tasks/fuzz_pyenv_cfg.py -runs=100000 ++ ++""" ++ ++from __future__ import annotations ++ ++import sys ++import tempfile ++from collections import OrderedDict ++from pathlib import Path ++ ++import atheris ++ ++with atheris.instrument_imports(): ++ from virtualenv.create.pyenv_cfg import PyEnvCfg ++ ++ ++@atheris.instrument_func ++def test_one_input(data: bytes) -> None: ++ provider = atheris.FuzzedDataProvider(data) ++ keys = ("home", "prompt", "version", "base-prefix") ++ content = OrderedDict( ++ (keys[i], provider.ConsumeUnicodeNoSurrogates(provider.remaining_bytes() // (len(keys) - i or 1))) ++ for i in range(len(keys)) ++ ) ++ with tempfile.TemporaryDirectory() as folder: ++ path = Path(folder) / "pyvenv.cfg" ++ PyEnvCfg(content, path).write() ++ read_back = PyEnvCfg.from_file(path).content ++ extra = set(read_back) - set(content) ++ assert not extra, (extra, dict(content)) # noqa: S101 # Atheris treats AssertionError as the crash signal. ++ ++ ++if __name__ == "__main__": ++ atheris.Setup(sys.argv, test_one_input) ++ atheris.Fuzz() +diff --git a/tests/property/conftest.py b/tests/property/conftest.py +new file mode 100644 +index 0000000..b5e7a98 +--- /dev/null ++++ b/tests/property/conftest.py +@@ -0,0 +1,12 @@ ++"""Keep the property tests out of collection when hypothesis is absent. ++ ++The marker deselection in addopts runs after import, so without this the default test environments, which do not install ++hypothesis, fail at collection rather than skipping. ++ ++""" ++ ++from __future__ import annotations ++ ++from importlib.util import find_spec ++ ++collect_ignore_glob = [] if find_spec("hypothesis") else ["*.py"] +diff --git a/tests/property/test_pyenv_cfg.py b/tests/property/test_pyenv_cfg.py +new file mode 100644 +index 0000000..cb36f2e +--- /dev/null ++++ b/tests/property/test_pyenv_cfg.py +@@ -0,0 +1,76 @@ ++"""Property tests for the pyvenv.cfg reader and writer. ++ ++pyvenv.cfg is a line-based format with no escape syntax, so the invariant that matters is that a value can never be read ++back as extra configuration. These properties are what caught #3247: a prompt containing a line boundary used to inject ++arbitrary keys, overriding any key written before it, including ``home``. ++ ++""" ++ ++from __future__ import annotations ++ ++import os ++from collections import OrderedDict ++ ++import pytest ++from hypothesis import example, given ++from hypothesis import strategies as st ++ ++from virtualenv.create.pyenv_cfg import PyEnvCfg ++ ++pytestmark = pytest.mark.property ++ ++# Anything str.splitlines() treats as a boundary is what makes a value escape its own line. ++LINE_BOUNDARIES = "\n\r\v\f\x1c\x1d\x1e\x85" + chr(0x2028) + chr(0x2029) ++ ++# Lone surrogates cannot be written as UTF-8, which is a format limit shared with stdlib venv. ++printable = st.characters(blacklist_categories=("Cs",), max_codepoint=0x2100) ++ ++keys = st.text( ++ st.characters(blacklist_categories=("Cs",), blacklist_characters="=" + LINE_BOUNDARIES), min_size=1 ++).filter(lambda k: k.strip() == k and not k.startswith("#")) ++contents = st.dictionaries(keys, st.text(printable), max_size=6) ++ ++ ++def _round_trip(content: dict[str, str], tmp_path) -> dict[str, str]: ++ path = tmp_path / "pyvenv.cfg" ++ PyEnvCfg(OrderedDict(content), path).write() ++ return dict(PyEnvCfg.from_file(path).content) ++ ++ ++@given(content=contents) ++@example(content={"prompt": 'x"\nhome = /attacker\nprompt = "z'}) ++@example(content={"prompt": "a" + chr(0x2028) + "home = /attacker"}) ++@example(content={"home": "a\r\nevil = 1"}) ++def test_writing_then_reading_never_invents_keys(content, tmp_path_factory) -> None: ++ """A value must never be read back as a new configuration key.""" ++ read_back = _round_trip(content, tmp_path_factory.mktemp("cfg")) ++ ++ assert set(read_back) <= set(content) ++ ++ ++@given(content=contents) ++@example(content={"prompt": 'x"\nhome = /attacker\nprompt = "z'}) ++def test_written_file_has_one_line_per_key(content, tmp_path_factory) -> None: ++ """However exotic the values, the file keeps its line-per-key shape.""" ++ path = tmp_path_factory.mktemp("cfg") / "pyvenv.cfg" ++ PyEnvCfg(OrderedDict(content), path).write() ++ ++ lines = path.read_text(encoding="utf-8").splitlines() ++ ++ assert len(lines) == len(content) ++ assert all(" = " in line for line in lines) ++ ++ ++@given( ++ content=st.dictionaries( ++ keys, ++ st.text(st.characters(blacklist_categories=("Cs",), blacklist_characters=LINE_BOUNDARIES), min_size=1).filter( ++ # Existing paths are deliberately normalized by write(), and are not under test here. ++ lambda v: v.strip() == v and v[0] not in "'\"" and v[-1] not in "'\"" and not os.path.exists(v) ++ ), ++ max_size=6, ++ ) ++) ++def test_plain_values_round_trip_unchanged(content, tmp_path_factory) -> None: ++ """A value that the format can represent comes back exactly as written.""" ++ assert _round_trip(content, tmp_path_factory.mktemp("cfg")) == content +diff --git a/tox.ini b/tox.ini +index 7cdebdb..17a6ea9 100644 +--- a/tox.ini ++++ b/tox.ini +@@ -90,6 +90,21 @@ commands = + - python upgrade_wheels.py + uv_seed = true + ++[testenv:fuzz] ++description = run the atheris fuzz target for pyvenv.cfg (Linux + CPython 3.12-3.14 only) ++extras = ++ fuzz ++commands = ++ python {toxinidir}/tasks/fuzz_pyenv_cfg.py -runs=20000 ++ ++[testenv:property] ++description = run the hypothesis property tests ++extras = ++ test ++ property ++commands = ++ pytest {posargs:--junitxml "{toxworkdir}/junit.{envname}.xml" tests/property -m property} ++ + [testenv:release] + description = do a release, required posarg of the version number + deps = +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113713.patch b/SPECS/python-virtualenv/CVE-2026-113713.patch new file mode 100644 index 00000000000..1254853a159 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113713.patch @@ -0,0 +1,109 @@ +From b81995c3feab9e1edb453c75ffc6616e1a77335e Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 10:09:55 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(seed):=20copy=20from=20tempo?= + =?UTF-8?q?rary=20app-data=20(#3437)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/b81995c3feab9e1edb453c75ffc6616e1a77335e.patch +--- + docs/changelog/3437.bugfix.rst | 2 + + .../seed/embed/via_app_data/via_app_data.py | 4 +- + .../embed/test_bootstrap_link_via_app_data.py | 43 ++++++++++++++++++- + 3 files changed, 46 insertions(+), 3 deletions(-) + create mode 100644 docs/changelog/3437.bugfix.rst + +diff --git a/docs/changelog/3437.bugfix.rst b/docs/changelog/3437.bugfix.rst +new file mode 100644 +index 0000000..0345827 +--- /dev/null ++++ b/docs/changelog/3437.bugfix.rst +@@ -0,0 +1,2 @@ ++Always copy seed packages when app-data is temporary, including when symlink seeding is enabled through an environment ++variable or configuration file. Seeded packages remain available after temporary cache cleanup. +diff --git a/src/virtualenv/seed/embed/via_app_data/via_app_data.py b/src/virtualenv/seed/embed/via_app_data/via_app_data.py +index a2e9630..0aada69 100644 +--- a/src/virtualenv/seed/embed/via_app_data/via_app_data.py ++++ b/src/virtualenv/seed/embed/via_app_data/via_app_data.py +@@ -23,7 +23,9 @@ LOGGER = logging.getLogger(__name__) + class FromAppData(BaseEmbed): + def __init__(self, options) -> None: + super().__init__(options) +- self.symlinks = options.symlink_app_data ++ self.symlinks = options.symlink_app_data and not self.app_data.transient ++ if options.symlink_app_data and self.app_data.transient: ++ LOGGER.debug("copy seed packages because temporary app-data is removed after creation") + + @classmethod + def add_parser_arguments(cls, parser, interpreter, app_data): +diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +index 4076573..d59288a 100644 +--- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py ++++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +@@ -1,8 +1,10 @@ + from __future__ import annotations + + import contextlib ++import logging + import os + import sys ++from pathlib import Path + from stat import S_IWGRP, S_IWOTH, S_IWUSR + from subprocess import Popen, check_call + from threading import Thread +@@ -18,11 +20,48 @@ from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, BUNDLE_SUPPORT + from virtualenv.util.path import safe_delete + + if TYPE_CHECKING: +- from pathlib import Path +- + from pytest_mock import MockerFixture + + ++@pytest.mark.parametrize("source", ["environment", "configuration", "command-line"]) ++def test_transient_seed_survives_cache_cleanup(tmp_path, source, unusable_cache): ++ config = tmp_path / "virtualenv.ini" ++ config.write_text("[virtualenv]\nsymlink_app_data = true\n" if source == "configuration" else "", encoding="utf-8") ++ env = { ++ **os.environ, ++ "VIRTUALENV_CONFIG_FILE": str(config), ++ "VIRTUALENV_OVERRIDE_APP_DATA": str(unusable_cache), ++ } ++ if source == "environment": ++ env["VIRTUALENV_SYMLINK_APP_DATA"] = "true" ++ args = [str(tmp_path / "env"), "--no-periodic-update", "--no-setuptools"] ++ if source == "command-line": ++ args.append("--symlink-app-data") ++ session = cli_run(args, env=env) ++ assert ( ++ session.creator.app_data.transient, ++ Path(str(session.creator.app_data)).exists(), ++ (session.creator.purelib / "pip" / "__init__.py").is_file(), ++ ) == (True, False, True) ++ ++ ++def test_transient_seed_reports_copy_fallback(tmp_path, caplog, unusable_cache): ++ caplog.set_level(logging.DEBUG) ++ cli_run( ++ [str(tmp_path / "env"), "--no-periodic-update", "--no-setuptools"], ++ env={**os.environ, "VIRTUALENV_OVERRIDE_APP_DATA": str(unusable_cache), "VIRTUALENV_SYMLINK_APP_DATA": "true"}, ++ setup_logging=False, ++ ) ++ assert "copy seed packages because temporary app-data is removed after creation" in caplog.text ++ ++ ++@pytest.fixture ++def unusable_cache(tmp_path): ++ blocked = tmp_path / "blocked" ++ blocked.touch() ++ return blocked / "cache" ++ ++ + @pytest.mark.slow + @pytest.mark.parametrize("copies", [False, True] if fs_supports_symlink() else [True]) + def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies, for_py_version): # noqa: PLR0915 +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113715.patch b/SPECS/python-virtualenv/CVE-2026-113715.patch new file mode 100644 index 00000000000..4c4f4d5f768 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113715.patch @@ -0,0 +1,192 @@ +From b0008a58885ded9227faf8f910c6c402712c750a Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 02:56:02 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(create):=20reject=20ambiguou?= + =?UTF-8?q?s=20venv=20paths=20(#3426)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference:https://github.com/pypa/virtualenv/commit/b0008a58885ded9227faf8f910c6c402712c750a.patch +--- + docs/changelog/3426.bugfix.rst | 4 ++ + src/virtualenv/create/via_global_ref/venv.py | 22 ++++++-- + src/virtualenv/util/text.py | 29 +++++++++++ + tests/unit/create/test_creator.py | 53 ++++++++++++++++++++ + 4 files changed, 104 insertions(+), 4 deletions(-) + create mode 100644 docs/changelog/3426.bugfix.rst + create mode 100644 src/virtualenv/util/text.py + +diff --git a/docs/changelog/3426.bugfix.rst b/docs/changelog/3426.bugfix.rst +new file mode 100644 +index 0000000..a5a5700 +--- /dev/null ++++ b/docs/changelog/3426.bugfix.rst +@@ -0,0 +1,4 @@ ++Reject destinations containing line boundaries when using the stdlib ``venv`` creator, before it writes ``pyvenv.cfg``. ++Other creators retain their existing destination support. Write the final configuration from the selected interpreter ++and virtualenv options instead of merging stdlib's intermediate configuration. GraalPy launcher settings are derived ++from the selected interpreter. +diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py +index 005d34f..e852b46 100644 +--- a/src/virtualenv/create/via_global_ref/venv.py ++++ b/src/virtualenv/create/via_global_ref/venv.py +@@ -1,13 +1,14 @@ + from __future__ import annotations + + import logging +-from copy import copy ++from argparse import ArgumentTypeError + + from virtualenv.create.via_global_ref.store import handle_store_python + from virtualenv.discovery.py_info import PythonInfo + from virtualenv.util.error import ProcessCallFailedError + from virtualenv.util.path import ensure_within + from virtualenv.util.subprocess import run_cmd ++from virtualenv.util.text import collapse_line_boundaries + + from .api import ViaGlobalRefApi, ViaGlobalRefMeta + from .builtin.cpython.mac_os import CPython3macOsBrew +@@ -48,6 +49,18 @@ class Venv(ViaGlobalRefApi): + super().create() + self.executables_for_win_pypy_less_v37() + ++ @classmethod ++ def validate_dest(cls, raw_value): ++ return cls._check_line_boundaries(super().validate_dest(cls._check_line_boundaries(raw_value))) ++ ++ @staticmethod ++ def _check_line_boundaries(value): ++ # stdlib venv writes the destination into pyvenv.cfg without escaping line boundaries. ++ if collapse_line_boundaries(value) != value: ++ msg = f"destination {value!r} must not contain a line boundary as venv cannot record it in pyvenv.cfg" ++ raise ArgumentTypeError(msg) ++ return value ++ + def executables_for_win_pypy_less_v37(self): + """ + PyPy <= 3.6 (v7.3.3) for Windows contains only pypy3.exe and pypy3w.exe +@@ -85,10 +98,11 @@ class Venv(ViaGlobalRefApi): + return cmd + + def set_pyenv_cfg(self): +- # prefer venv options over ours, but keep our extra +- venv_content = copy(self.pyenv_cfg.refresh()) + super().set_pyenv_cfg() +- self.pyenv_cfg.update(venv_content) ++ if self.interpreter.implementation == "GraalVM": ++ # GraalPy launchers need these settings on Windows and macOS. ++ self.pyenv_cfg["venvlauncher_command"] = self.interpreter.system_executable ++ self.pyenv_cfg["base-executable"] = self.interpreter.system_executable + + def __getattribute__(self, item): + describe = object.__getattribute__(self, "describe") +diff --git a/src/virtualenv/util/text.py b/src/virtualenv/util/text.py +new file mode 100644 +index 0000000..ff8997b +--- /dev/null ++++ b/src/virtualenv/util/text.py +@@ -0,0 +1,29 @@ ++"""Shared text-safety helpers for values written into line-based formats.""" ++ ++from __future__ import annotations ++ ++# These are the boundaries recognized by str.splitlines(). ++_LINE_BOUNDARIES = ( ++ "\n", ++ "\r", ++ "\v", ++ "\f", ++ "\x1c", ++ "\x1d", ++ "\x1e", ++ "\x85", ++ chr(0x2028), ++ chr(0x2029), ++) ++ ++ ++def collapse_line_boundaries(text): ++ """Replace every line-boundary character with a space so text cannot become extra lines.""" ++ for boundary in _LINE_BOUNDARIES: ++ text = text.replace(boundary, " ") ++ return text ++ ++ ++__all__ = [ ++ "collapse_line_boundaries", ++] +diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py +index e22f028..0504319 100644 +--- a/tests/unit/create/test_creator.py ++++ b/tests/unit/create/test_creator.py +@@ -96,6 +96,40 @@ def system(session_app_data): + CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] + + ++@pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") ++@pytest.mark.parametrize( ++ "boundary", ++ [ ++ pytest.param(char, id=name) ++ for name, char in ( ++ ("newline", "\n"), ++ ("return", "\r"), ++ ("vertical-tab", "\v"), ++ ("form-feed", "\f"), ++ ("file-separator", "\x1c"), ++ ("group-separator", "\x1d"), ++ ("record-separator", "\x1e"), ++ ("next-line", "\x85"), ++ ("line-separator", "\u2028"), ++ ("paragraph-separator", "\u2029"), ++ ) ++ ], ++) ++def test_venv_creator_rejects_line_boundary(tmp_path, capsys, boundary): ++ destination = tmp_path / f"project{boundary}name" ++ with pytest.raises(SystemExit) as context: ++ cli_run([str(destination), "--creator", "venv", "--no-seed"]) ++ assert context.value.code == 2 ++ assert "must not contain a line boundary" in capsys.readouterr().err ++ assert not list(tmp_path.iterdir()) ++ ++ ++@pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") ++def test_venv_creator_accepts_plain_destination(tmp_path): ++ session = cli_run([str(tmp_path / "environment"), "--creator", "venv", "--no-seed"]) ++ assert (session.creator.dest / "pyvenv.cfg").is_file() ++ ++ + @pytest.mark.parametrize( + "junction", + [ +@@ -321,6 +355,25 @@ def test_create_vcs_ignore_exists_override(tmp_path): + assert git_ignore.read_text(encoding="utf-8") == "magic" + + ++@pytest.mark.skipif(not CURRENT.has_venv, reason="requires venv") ++@pytest.mark.parametrize("global_packages", [pytest.param(False, id="isolated"), pytest.param(True, id="global")]) ++def test_venv_configuration_uses_selected_options(tmp_path, global_packages): ++ args = [str(tmp_path / "env"), "--creator", "venv", "--no-seed"] ++ if global_packages: ++ args.append("--system-site-packages") ++ session = cli_run(args) ++ session.creator.pyenv_cfg.path.write_text("vendor-marker = unused\n", encoding="utf-8") ++ session.creator.set_pyenv_cfg() ++ executable = session.creator.interpreter.system_executable if CURRENT.implementation == "GraalVM" else None ++ expected = { ++ "vendor-marker": None, ++ "include-system-site-packages": str(global_packages).lower(), ++ "venvlauncher_command": executable, ++ "base-executable": executable, ++ } ++ assert {key: session.creator.pyenv_cfg.content.get(key) for key in expected} == expected ++ ++ + @pytest.mark.skipif(not CURRENT.has_venv, reason="requires interpreter with venv") + def test_venv_fails_not_inline(tmp_path, capsys, mocker): + if hasattr(os, "geteuid") and os.geteuid() == 0: +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113716.patch b/SPECS/python-virtualenv/CVE-2026-113716.patch new file mode 100644 index 00000000000..a5ce05e8a75 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113716.patch @@ -0,0 +1,337 @@ +From 0b97a7e86888aa4da9fc93c7e1ddec81c95b7703 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Fri, 16 Oct 2026 03:20:32 -0700 +Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix(create):=20replace=20dir?= + =?UTF-8?q?ectory=20links?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Reported by Anthropic's OSS Scanner: ANT-2026-MUFJEG11. +Upstream-reference: https://patch-diff.githubusercontent.com/raw/pypa/virtualenv/pull/3429.patch +--- + docs/changelog/3429.bugfix.rst | 3 + + .../builtin/via_global_self_do.py | 4 +- + src/virtualenv/create/via_global_ref/venv.py | 6 +- + src/virtualenv/util/path/__init__.py | 3 +- + src/virtualenv/util/path/_sync.py | 37 ++++++++- + tests/unit/create/test_creator.py | 51 +++++++++++- + tests/unit/test_util.py | 83 +++++++++++++++++++ + 7 files changed, 179 insertions(+), 8 deletions(-) + create mode 100644 docs/changelog/3429.bugfix.rst + +diff --git a/docs/changelog/3429.bugfix.rst b/docs/changelog/3429.bugfix.rst +new file mode 100644 +index 0000000..0f7596e +--- /dev/null ++++ b/docs/changelog/3429.bugfix.rst +@@ -0,0 +1,3 @@ ++Replace pre-existing directory symlinks and Windows junctions in the environment's executable and library paths before ++creating files. Links within those paths are replaced even when they point inside the environment. Log replaced links ++and junctions, and reject a non-directory encountered while creating their replacements. +diff --git a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py b/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py +index 2f7f2f1..7c05ec5 100644 +--- a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py ++++ b/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py +@@ -8,7 +8,7 @@ from virtualenv.create.via_global_ref.builtin.ref import ( + RefMust, + RefWhen, + ) +-from virtualenv.util.path import ensure_dir ++from virtualenv.util.path import ensure_within + + from .builtin_way import VirtualenvBuiltin + +@@ -79,7 +79,7 @@ class ViaGlobalRefVirtualenvBuiltin(ViaGlobalRefApi, VirtualenvBuiltin, ABC): + if any(i for i in dirs if i is not directory and directory.parts == i.parts[: len(directory.parts)]): + dirs.remove(directory) + for directory in sorted(dirs): +- ensure_dir(directory) ++ ensure_within(self.dest, directory) + + self.set_pyenv_cfg() + self.pyenv_cfg.write() +diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py +index d5037bc..005d34f 100644 +--- a/src/virtualenv/create/via_global_ref/venv.py ++++ b/src/virtualenv/create/via_global_ref/venv.py +@@ -6,7 +6,7 @@ from copy import copy + from virtualenv.create.via_global_ref.store import handle_store_python + from virtualenv.discovery.py_info import PythonInfo + from virtualenv.util.error import ProcessCallFailedError +-from virtualenv.util.path import ensure_dir ++from virtualenv.util.path import ensure_within + from virtualenv.util.subprocess import run_cmd + + from .api import ViaGlobalRefApi, ViaGlobalRefMeta +@@ -39,12 +39,12 @@ class Venv(ViaGlobalRefApi): + return None + + def create(self): ++ for directory in (self.bin_dir, self.purelib, self.platlib): ++ ensure_within(self.dest, directory) + if self.can_be_inline: + self.create_inline() + else: + self.create_via_sub_process() +- for lib in self.libs: +- ensure_dir(lib) + super().create() + self.executables_for_win_pypy_less_v37() + +diff --git a/src/virtualenv/util/path/__init__.py b/src/virtualenv/util/path/__init__.py +index c144d8d..f2cccc8 100644 +--- a/src/virtualenv/util/path/__init__.py ++++ b/src/virtualenv/util/path/__init__.py +@@ -1,13 +1,14 @@ + from __future__ import annotations + + from ._permission import make_exe, set_tree +-from ._sync import copy, copytree, ensure_dir, safe_delete, symlink ++from ._sync import copy, copytree, ensure_dir, ensure_within, safe_delete, symlink + from ._win import get_short_path_name + + __all__ = [ + "copy", + "copytree", + "ensure_dir", ++ "ensure_within", + "get_short_path_name", + "make_exe", + "safe_delete", +diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py +index 4dd5a98..80c1c20 100644 +--- a/src/virtualenv/util/path/_sync.py ++++ b/src/virtualenv/util/path/_sync.py +@@ -4,7 +4,10 @@ import logging + import os + import shutil + import sys +-from stat import S_IWUSR ++from stat import S_ISDIR, S_ISLNK, S_IWUSR ++ ++if os.name == "nt": ++ from stat import IO_REPARSE_TAG_MOUNT_POINT + + LOGGER = logging.getLogger(__name__) + +@@ -15,6 +18,37 @@ def ensure_dir(path): + os.makedirs(str(path)) + + ++def ensure_within(root, path): ++ parts = path.relative_to(root).parts ++ if ".." in parts: ++ msg = f"environment directory {path} must remain inside {root}" ++ raise ValueError(msg) ++ ensure_dir(root) ++ folder = root ++ for part in parts: ++ folder /= part ++ try: ++ metadata = folder.lstat() ++ except FileNotFoundError: ++ pass ++ else: ++ if S_ISLNK(metadata.st_mode): ++ LOGGER.warning("replace environment directory link %s", folder) ++ folder.unlink() ++ elif os.name == "nt" and metadata.st_reparse_tag == IO_REPARSE_TAG_MOUNT_POINT: ++ LOGGER.warning("replace environment directory junction %s", folder) ++ folder.rmdir() ++ try: ++ folder.mkdir() ++ except FileExistsError: ++ created = folder.lstat() ++ if not S_ISDIR(created.st_mode) or ( ++ os.name == "nt" and created.st_reparse_tag == IO_REPARSE_TAG_MOUNT_POINT ++ ): ++ msg = f"environment directory is not a real directory: {folder}" ++ raise NotADirectoryError(msg) from None ++ ++ + def ensure_safe_to_do(src, dest): + if src == dest: + msg = f"source and destination is the same {src}" +@@ -79,6 +113,7 @@ __all__ = [ + "copy", + "copytree", + "ensure_dir", ++ "ensure_within", + "safe_delete", + "symlink", + "symlink", +diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py +index c72e830..e22f028 100644 +--- a/tests/unit/create/test_creator.py ++++ b/tests/unit/create/test_creator.py +@@ -29,7 +29,7 @@ from virtualenv.create.via_global_ref import api + from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew + from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix + from virtualenv.discovery.py_info import PythonInfo +-from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive ++from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink + from virtualenv.run import cli_run, session_via_cli + + CURRENT = PythonInfo.current_system() +@@ -94,6 +94,55 @@ def system(session_app_data): + + + CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] ++ ++ ++@pytest.mark.parametrize( ++ "junction", ++ [ ++ pytest.param( ++ False, id="symlink", marks=pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++ ), ++ pytest.param(True, id="junction", marks=pytest.mark.skipif(os.name != "nt", reason="Windows junctions")), ++ ], ++) ++@pytest.mark.parametrize( ++ "creator_name", ++ [ ++ "builtin", ++ pytest.param( ++ "venv", marks=pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") ++ ), ++ ], ++) ++@pytest.mark.parametrize("folder", ["bin_dir", "purelib", "platlib"]) ++@pytest.mark.parametrize("inside", [False, True], ids=["outside", "inside"]) ++def test_creator_replaces_directory_links(tmp_path, caplog, creator_name, folder, junction, inside): # noqa: PLR0913 ++ session = session_via_cli([str(tmp_path / "env"), "--creator", creator_name, "--no-seed"], setup_logging=False) ++ creator = session.creator ++ creator.dest.mkdir() ++ stored = (creator.dest if inside else tmp_path) / "stored" ++ stored.mkdir() ++ marker = stored / "keep.txt" ++ marker.write_text("preserve", encoding="utf-8") ++ link = creator.dest / getattr(creator, folder).relative_to(creator.dest).parts[0] ++ if junction: ++ cmd = shutil.which("cmd") ++ assert cmd is not None ++ subprocess.run([cmd, "/c", "mklink", "/J", str(link), str(stored)], check=True, capture_output=True) ++ else: ++ link.symlink_to(stored, target_is_directory=True) ++ ++ with session: ++ session.run() ++ ++ assert link.resolve() == link ++ assert creator.exe.is_file() ++ assert list(stored.iterdir()) == [marker] ++ assert marker.read_text(encoding="utf-8") == "preserve" ++ kind = "junction" if junction else "link" ++ assert f"replace environment directory {kind} {link}" in caplog.text ++ ++ + CREATE_METHODS = [] + for k, v in CURRENT.creators().key_to_meta.items(): + if k in CURRENT_CREATORS: +diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py +index b83c247..10ed7e7 100644 +--- a/tests/unit/test_util.py ++++ b/tests/unit/test_util.py +@@ -1,14 +1,97 @@ + from __future__ import annotations + + import concurrent.futures ++import os ++import shutil ++import subprocess + import traceback ++from pathlib import Path + + import pytest + ++from virtualenv.info import fs_supports_symlink + from virtualenv.util.lock import ReentrantFileLock ++from virtualenv.util.path import ensure_within + from virtualenv.util.subprocess import run_cmd + + ++@pytest.mark.parametrize("existing", [False, True]) ++def test_environment_directories_create_nested_path(tmp_path, existing): ++ folder = tmp_path / "env" / "lib" / "python" ++ if existing: ++ folder.mkdir(parents=True) ++ ensure_within(tmp_path / "env", folder) ++ assert folder.is_dir() ++ ++ ++def test_environment_directories_reject_regular_file(tmp_path): ++ folder = tmp_path / "file" ++ folder.touch() ++ with pytest.raises(NotADirectoryError, match="not a real directory"): ++ ensure_within(tmp_path, folder) ++ assert folder.is_file() ++ ++ ++def test_environment_directories_reject_parent_components(tmp_path): ++ with pytest.raises(ValueError, match="must remain inside"): ++ ensure_within(tmp_path, tmp_path / ".." / "folder") ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++@pytest.mark.parametrize("existing", [False, True]) ++def test_environment_directories_remove_links(tmp_path, caplog, existing): ++ target = tmp_path / "stored" ++ if existing: ++ target.mkdir() ++ link = tmp_path / "link" ++ link.symlink_to(target, target_is_directory=True) ++ ++ ensure_within(tmp_path, link / "nested") ++ ++ assert not link.is_symlink() ++ assert (link / "nested").is_dir() ++ assert target.exists() is existing ++ if existing: ++ assert not list(target.iterdir()) ++ assert f"replace environment directory link {link}" in caplog.text ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++def test_environment_directories_reject_replaced_link(tmp_path, monkeypatch): ++ target = tmp_path / "stored" ++ target.mkdir() ++ link = tmp_path / "link" ++ link.symlink_to(target, target_is_directory=True) ++ mkdir = Path.mkdir ++ ++ def replace_link(path, *args, **kwargs): ++ if path == link: ++ path.symlink_to(target, target_is_directory=True) ++ return mkdir(path, *args, **kwargs) ++ ++ monkeypatch.setattr(Path, "mkdir", replace_link) ++ ++ with pytest.raises(NotADirectoryError, match="not a real directory"): ++ ensure_within(tmp_path, link) ++ assert not list(target.iterdir()) ++ ++ ++@pytest.mark.skipif(os.name != "nt", reason="Windows junctions") ++def test_environment_directories_report_replaced_junction(tmp_path, caplog): ++ target = tmp_path / "stored" ++ target.mkdir() ++ link = tmp_path / "link" ++ cmd = shutil.which("cmd") ++ assert cmd is not None ++ subprocess.run([cmd, "/c", "mklink", "/J", str(link), str(target)], check=True, capture_output=True) ++ ++ ensure_within(tmp_path, link) ++ ++ assert link.resolve() == link ++ assert not list(target.iterdir()) ++ assert f"replace environment directory junction {link}" in caplog.text ++ ++ + def test_run_fail(tmp_path): + code, out, err = run_cmd([str(tmp_path)]) + assert err +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113719.patch b/SPECS/python-virtualenv/CVE-2026-113719.patch new file mode 100644 index 00000000000..8ab99e136c9 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113719.patch @@ -0,0 +1,188 @@ +From d4c57191ece240e7e3c8c5112acad240c5d2c7ee Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Fri, 16 Oct 2026 03:19:37 -0700 +Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(seed):=20contain=20metad?= + =?UTF-8?q?ata=20cleanup?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Reported by Anthropic's OSS Scanner: ANT-2026-SXOEHWP7. + +Upstream-reference: https://patch-diff.githubusercontent.com/raw/pypa/virtualenv/pull/3428.patch +--- + docs/changelog/3428.bugfix.rst | 3 + + .../embed/via_app_data/pip_install/base.py | 35 +++++--- + .../embed/test_bootstrap_link_via_app_data.py | 82 +++++++++++++++++++ + 3 files changed, 109 insertions(+), 11 deletions(-) + create mode 100644 docs/changelog/3428.bugfix.rst + +diff --git a/docs/changelog/3428.bugfix.rst b/docs/changelog/3428.bugfix.rst +new file mode 100644 +index 0000000..43c9058 +--- /dev/null ++++ b/docs/changelog/3428.bugfix.rst +@@ -0,0 +1,3 @@ ++Limit seed-package cleanup to entries inside the environment. Ignore metadata entries that name the environment itself, ++a directory holding the metadata (a blank ``top_level.txt`` line names ``site-packages``), or resolve outside the ++environment, with a warning. Launcher entries inside the environment remain supported. +diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +index 3823103..c734794 100644 +--- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py ++++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +@@ -149,35 +149,48 @@ class PipInstall(ABC): + paths = (p for p in in_folders if p.stem.split("-")[0] == dist_name and p.suffix == ".dist-info" and p.is_dir()) + existing_dist = next(paths, None) + if existing_dist is not None: +- self._uninstall_dist(existing_dist) ++ self._uninstall_dist(existing_dist, self._creator.dest) + + @staticmethod +- def _uninstall_dist(dist): ++ def _uninstall_dist(dist, env_dir): + dist_base = dist.parent + LOGGER.debug("uninstall existing distribution %s from %s", dist.stem, dist_base) + +- top_txt = dist / "top_level.txt" # add top level packages at folder level ++ top_txt = dist / "top_level.txt" + paths = ( + {dist.parent / i.strip() for i in top_txt.read_text(encoding="utf-8").splitlines()} + if top_txt.exists() + else set() + ) +- paths.add(dist) # add the dist-info folder itself ++ paths.add(dist) + +- base_dirs, record = paths.copy(), dist / "RECORD" # collect entries in record that we did not register yet ++ base_dirs = paths.copy() ++ record = dist / "RECORD" + for name in ( + (i.split(",")[0] for i in record.read_text(encoding="utf-8").splitlines()) if record.exists() else () + ): + path = dist_base / name +- if not any(p in base_dirs for p in path.parents): # only add if not already added as a base dir ++ if not any(p in base_dirs for p in path.parents): + paths.add(path) + +- for path in sorted(paths): # actually remove stuff in a stable order +- if path.exists(): +- if path.is_dir() and not path.is_symlink(): +- safe_delete(path) ++ root = env_dir.resolve() ++ metadata_parents = set(dist.resolve().parents) ++ for entry in sorted(paths): ++ if entry.exists() or entry.is_symlink(): ++ linked = entry.is_symlink() ++ # Unlinking the final link leaves its target intact; parent links still affect containment. ++ resolved = entry.parent.resolve() / entry.name if linked else entry.resolve() ++ if root not in resolved.parents or resolved in metadata_parents: ++ LOGGER.warning( ++ "skip removal of %s listed by %s: not inside environment %s or a parent of the metadata", ++ entry, ++ dist.name, ++ root, ++ ) ++ elif entry.is_dir() and not linked: ++ safe_delete(entry) + else: +- path.unlink() ++ entry.unlink() + + def clear(self): + if self._image_dir.exists(): +diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +index ac62117..4b178dd 100644 +--- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py ++++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +@@ -26,6 +26,88 @@ if TYPE_CHECKING: + from pytest_mock import MockerFixture + + ++@pytest.mark.parametrize( ++ ("metadata_file", "content"), ++ [ ++ pytest.param("top_level.txt", "pip\n{dest}\n", id="top-level-env-root"), ++ pytest.param("top_level.txt", "pip\n\n", id="top-level-blank"), ++ pytest.param("RECORD", "{record}\n{outside},,", id="record-traversal"), ++ ], ++) ++def test_seed_metadata_cleanup_stays_inside_package(tmp_path, caplog, metadata_file, content): ++ args = [str(tmp_path / "env"), "--pip", "embed", "--no-periodic-update"] ++ session = cli_run(args, setup_logging=False) ++ purelib = session.creator.purelib ++ markers = [tmp_path / "keep.txt", session.creator.dest / "keep.txt", purelib / "keep.txt"] ++ for marker in markers: ++ marker.write_text("keep", encoding="utf-8") ++ dist_info = next(purelib.glob("pip-*.dist-info")) ++ (dist_info / metadata_file).write_text( ++ content.format( ++ dest=session.creator.dest, ++ outside=os.path.relpath(markers[0], purelib), ++ record=(dist_info / "RECORD").read_text(encoding="utf-8"), ++ ), ++ encoding="utf-8", ++ ) ++ cli_run(args, setup_logging=False) ++ assert ( ++ [i.exists() for i in markers], ++ (purelib / "pip" / "__init__.py").is_file(), ++ "skip removal of" in caplog.text, ++ ) == ( ++ [True, True, True], ++ True, ++ True, ++ ) ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++def test_seed_metadata_unlinks_symlink_without_following(tmp_path, caplog): ++ args = [str(tmp_path / "env"), "--pip", "embed", "--no-periodic-update"] ++ session = cli_run(args, setup_logging=False) ++ outside = tmp_path / "outside" ++ outside.mkdir() ++ (outside / "keep.txt").write_text("keep", encoding="utf-8") ++ link = session.creator.purelib / "linked" ++ link.symlink_to(outside, target_is_directory=True) ++ record = next(session.creator.purelib.glob("pip-*.dist-info")) / "RECORD" ++ record.write_text(f"{record.read_text(encoding='utf-8')}\nlinked,,", encoding="utf-8") ++ cli_run(args, setup_logging=False) ++ assert (link.is_symlink(), (outside / "keep.txt").exists(), "skip removal of" in caplog.text) == ( ++ False, ++ True, ++ False, ++ ) ++ ++ ++@pytest.mark.parametrize( ++ "linked", ++ [ ++ pytest.param(False, id="copy"), ++ pytest.param( ++ True, id="symlink", marks=pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++ ), ++ ], ++) ++def test_seed_metadata_keeps_reseeding_supported(tmp_path, linked, caplog): ++ args = [ ++ str(tmp_path / "env"), ++ "--pip", ++ "embed", ++ "--no-periodic-update", ++ *(["--symlink-app-data"] if linked else []), ++ ] ++ cli_run(args, setup_logging=False) ++ session = cli_run(args, setup_logging=False) ++ package = session.creator.purelib / "pip" ++ assert ((package / "__init__.py").is_file(), package.is_symlink(), "skip removal of" in caplog.text) == ( ++ True, ++ linked, ++ False, ++ ) ++ ++ + @pytest.mark.parametrize("source", ["environment", "configuration", "command-line"]) + def test_transient_seed_survives_cache_cleanup(tmp_path, source, unusable_cache): + config = tmp_path / "virtualenv.ini" +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113720.patch b/SPECS/python-virtualenv/CVE-2026-113720.patch new file mode 100644 index 00000000000..768b1a6daaf --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113720.patch @@ -0,0 +1,144 @@ +From dbf30cf5a0cc257bb16e6a257ecdd3e723545813 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Fri, 16 Oct 2026 03:06:10 -0700 +Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix:=20apply=20umask=20to=20?= + =?UTF-8?q?copied=20files?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://patch-diff.githubusercontent.com/raw/pypa/virtualenv/pull/3425.patch +--- + docs/changelog/3425.bugfix.rst | 4 +++ + src/virtualenv/util/path/_sync.py | 30 ++++++++++++-------- + tests/unit/test_util.py | 46 ++++++++++++++++++++++++++++++- + 3 files changed, 68 insertions(+), 12 deletions(-) + create mode 100644 docs/changelog/3425.bugfix.rst + +diff --git a/docs/changelog/3425.bugfix.rst b/docs/changelog/3425.bugfix.rst +new file mode 100644 +index 0000000..ff158d1 +--- /dev/null ++++ b/docs/changelog/3425.bugfix.rst +@@ -0,0 +1,4 @@ ++Apply the current umask when copying interpreter and seed-package files instead of inheriting cached permission bits. ++Executable sources remain executable within the permissions allowed by that umask. Copied files no longer grant group or ++world write access, even with a permissive umask. Source modes are not preserved: data files start from 0644 and ++executable files from 0755 before applying the umask. +diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py +index 80c1c20..ab748c5 100644 +--- a/src/virtualenv/util/path/_sync.py ++++ b/src/virtualenv/util/path/_sync.py +@@ -4,7 +4,8 @@ import logging + import os + import shutil + import sys +-from stat import S_ISDIR, S_ISLNK, S_IWUSR ++from pathlib import Path ++from stat import S_ISDIR, S_ISLNK, S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR + + if os.name == "nt": + from stat import IO_REPARSE_TAG_MOUNT_POINT +@@ -70,22 +71,29 @@ def symlink(src, dest): + + + def copy(src, dest): +- ensure_safe_to_do(src, dest) +- is_dir = src.is_dir() +- method = copytree if is_dir else shutil.copy + LOGGER.debug("copy %s", _Debug(src, dest)) +- method(str(src), str(dest)) ++ if src.is_dir(): ++ ensure_safe_to_do(src, dest) ++ copytree(str(src), str(dest)) ++ else: ++ _copy_file(src, dest) + + + def copytree(src, dest): + for root, _, files in os.walk(src): +- dest_dir = os.path.join(dest, os.path.relpath(root, src)) +- if not os.path.isdir(dest_dir): +- os.makedirs(dest_dir) ++ dest_dir = Path(dest) / os.path.relpath(root, src) ++ dest_dir.mkdir(parents=True, exist_ok=True) + for name in files: +- src_f = os.path.join(root, name) +- dest_f = os.path.join(dest_dir, name) +- shutil.copy(src_f, dest_f) ++ _copy_file(Path(root) / name, dest_dir / name) ++ ++ ++def _copy_file(src, dest): ++ ensure_safe_to_do(src, dest) ++ mode = 0o755 if src.stat().st_mode & (S_IXUSR | S_IXGRP | S_IXOTH) else 0o644 ++ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | (os.O_BINARY if os.name == "nt" else 0) ++ # Creation applies the current umask without changing the process-wide setting. ++ with src.open("rb") as source, os.fdopen(os.open(str(dest), flags, mode), "wb") as target: ++ shutil.copyfileobj(source, target, length=1024 * 1024) + + + def safe_delete(dest): +diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py +index 10ed7e7..9fce8ee 100644 +--- a/tests/unit/test_util.py ++++ b/tests/unit/test_util.py +@@ -11,10 +11,54 @@ import pytest + + from virtualenv.info import fs_supports_symlink + from virtualenv.util.lock import ReentrantFileLock +-from virtualenv.util.path import ensure_within ++from virtualenv.util.path import copy, ensure_within + from virtualenv.util.subprocess import run_cmd + + ++@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits") ++@pytest.mark.parametrize("directory", [pytest.param(False, id="file"), pytest.param(True, id="tree")]) ++@pytest.mark.parametrize( ++ ("source_mode", "mask", "expected"), ++ [ ++ pytest.param(0o666, 0o022, 0o644, id="normal"), ++ pytest.param(0o666, 0o077, 0o600, id="private"), ++ pytest.param(0o444, 0o022, 0o644, id="read-only-source"), ++ pytest.param(0o600, 0o022, 0o644, id="private-source"), ++ pytest.param(0o777, 0o022, 0o755, id="executable"), ++ pytest.param(0o777, 0o077, 0o700, id="private-executable"), ++ pytest.param(0o666, 0o000, 0o644, id="permissive-umask"), ++ pytest.param(0o777, 0o000, 0o755, id="permissive-executable"), ++ pytest.param(0o666, 0o002, 0o644, id="group-umask"), ++ pytest.param(0o777, 0o002, 0o755, id="group-executable"), ++ ], ++) ++def test_copy_uses_current_umask(tmp_path, directory, source_mode, mask, expected): ++ source = tmp_path / "source" ++ source.mkdir() ++ payload = source / "entry" ++ payload.write_bytes(b"copied bytes") ++ payload.chmod(source_mode) ++ destination = tmp_path / "destination" ++ previous = os.umask(mask) ++ try: ++ copy(source if directory else payload, destination) ++ finally: ++ os.umask(previous) ++ result = destination / "entry" if directory else destination ++ assert (result.read_bytes(), result.stat().st_mode & 0o777) == (b"copied bytes", expected) ++ ++ ++@pytest.mark.parametrize("directory", [pytest.param(False, id="file"), pytest.param(True, id="tree")]) ++def test_copy_replaces_contents(tmp_path, directory): ++ source = tmp_path / "source" ++ source.mkdir() ++ (source / "entry").write_bytes(b"new") ++ destination = tmp_path / "destination" ++ destination.write_bytes(b"old") ++ copy(source if directory else source / "entry", destination) ++ assert (destination / "entry" if directory else destination).read_bytes() == b"new" ++ ++ + @pytest.mark.parametrize("existing", [False, True]) + def test_environment_directories_create_nested_path(tmp_path, existing): + folder = tmp_path / "env" / "lib" / "python" +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113721.patch b/SPECS/python-virtualenv/CVE-2026-113721.patch new file mode 100644 index 00000000000..601c816b2b6 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113721.patch @@ -0,0 +1,395 @@ +From d25f28699eefb1fc34a251e5adf2b5de844c36cd Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 03:25:09 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(create):=20replace=20generat?= + =?UTF-8?q?ed=20file=20links=20(#3431)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> + +Upstream-reference: https://github.com/pypa/virtualenv/commit/d25f28699eefb1fc34a251e5adf2b5de844c36cd.patch +--- + docs/changelog/3431.bugfix.rst | 5 + + src/virtualenv/activation/via_template.py | 11 +- + src/virtualenv/create/creator.py | 6 +- + src/virtualenv/create/pyenv_cfg.py | 4 +- + src/virtualenv/create/via_global_ref/api.py | 5 +- + src/virtualenv/create/via_global_ref/venv.py | 5 + + .../embed/via_app_data/pip_install/base.py | 4 + + src/virtualenv/util/path/__init__.py | 3 +- + src/virtualenv/util/path/_sync.py | 9 ++ + tests/unit/create/test_creator.py | 120 +++++++++++++++++- + tests/unit/test_util.py | 11 +- + 11 files changed, 166 insertions(+), 17 deletions(-) + create mode 100644 docs/changelog/3431.bugfix.rst + +diff --git a/docs/changelog/3431.bugfix.rst b/docs/changelog/3431.bugfix.rst +new file mode 100644 +index 0000000..424d6ac +--- /dev/null ++++ b/docs/changelog/3431.bugfix.rst +@@ -0,0 +1,5 @@ ++Replace existing symbolic and hard links when writing environment configuration, activation scripts, import hooks, and ++seed launchers. Create generated text files exclusively so a replacement link cannot redirect the final file open. ++Before recreating an environment with the stdlib venv creator, replace executable-directory symlinks and Windows ++junctions with real directories. Remove symbolic and hard links inside that directory before creating interpreter files, ++including when switching from symlinks to copies. +diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py +index f40e454..8287096 100644 +--- a/src/virtualenv/activation/via_template.py ++++ b/src/virtualenv/activation/via_template.py +@@ -6,6 +6,8 @@ import shlex + import sys + from abc import ABC, abstractmethod + ++from virtualenv.util.path import write_file ++ + from .activator import Activator + + if sys.version_info >= (3, 10): +@@ -57,14 +59,9 @@ class ViaTemplateActivator(Activator, ABC): + for template in templates: + text = self.instantiate_template(replacements, template, creator) + dest = to_folder / self.as_name(template) +- # remove the file if it already exists - this prevents permission +- # errors when the dest is not writable +- if dest.exists(): +- dest.unlink() +- # Powershell assumes Windows 1252 encoding when reading files without BOM ++ # PowerShell assumes Windows 1252 when the UTF-8 BOM is absent. + encoding = "utf-8-sig" if str(template).endswith(".ps1") else "utf-8" +- # use write_bytes to avoid platform specific line normalization (\n -> \r\n) +- dest.write_bytes(text.encode(encoding)) ++ write_file(dest, text.encode(encoding)) + generated.append(dest) + return generated + +diff --git a/src/virtualenv/create/creator.py b/src/virtualenv/create/creator.py +index 1e577ad..4eaccaa 100644 +--- a/src/virtualenv/create/creator.py ++++ b/src/virtualenv/create/creator.py +@@ -12,7 +12,7 @@ from collections import OrderedDict + from pathlib import Path + + from virtualenv.discovery.cached_py_info import LogCmd +-from virtualenv.util.path import safe_delete ++from virtualenv.util.path import safe_delete, write_file + from virtualenv.util.subprocess import run_cmd + from virtualenv.version import __version__ + +@@ -173,7 +173,7 @@ class Creator(ABC): + # For information about cache directory tags, see: + # https://bford.info/cachedir/ + """).strip() +- cachedir_tag_file.write_text(cachedir_tag_text, encoding="utf-8") ++ write_file(cachedir_tag_file, cachedir_tag_text.encode("utf-8")) + + def set_pyenv_cfg(self): + self.pyenv_cfg.content = OrderedDict() +@@ -187,7 +187,7 @@ class Creator(ABC): + # mark this folder to be ignored by VCS, handle https://www.python.org/dev/peps/pep-0610/#registered-vcs + git_ignore = self.dest / ".gitignore" + if not git_ignore.exists(): +- git_ignore.write_text("# created by virtualenv automatically\n*\n", encoding="utf-8") ++ write_file(git_ignore, b"# created by virtualenv automatically\n*\n") + # Mercurial - does not support the .hgignore file inside a subdirectory directly, but only if included via the + # subinclude directive from root, at which point on might as well ignore the directory itself, see + # https://www.selenic.com/mercurial/hgignore.5.html for more details +diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cfg.py +index 1d1beac..4ac788d 100644 +--- a/src/virtualenv/create/pyenv_cfg.py ++++ b/src/virtualenv/create/pyenv_cfg.py +@@ -4,6 +4,8 @@ import logging + import os + from collections import OrderedDict + ++from virtualenv.util.path import write_file ++ + LOGGER = logging.getLogger(__name__) + + +@@ -40,7 +42,7 @@ class PyEnvCfg: + LOGGER.debug("\t%s", line) + text += line + text += "\n" +- self.path.write_text(text, encoding="utf-8") ++ write_file(self.path, text.encode("utf-8")) + + def refresh(self): + self.content = self._read_values(self.path) +diff --git a/src/virtualenv/create/via_global_ref/api.py b/src/virtualenv/create/via_global_ref/api.py +index 3f04f46..462eb93 100644 +--- a/src/virtualenv/create/via_global_ref/api.py ++++ b/src/virtualenv/create/via_global_ref/api.py +@@ -7,6 +7,7 @@ from pathlib import Path + + from virtualenv.create.creator import Creator, CreatorMeta + from virtualenv.info import fs_supports_symlink ++from virtualenv.util.path import write_file + + LOGGER = logging.getLogger(__name__) + +@@ -96,10 +97,10 @@ class ViaGlobalRefApi(Creator, ABC): + if text: + pth = self.purelib / "_virtualenv.pth" + LOGGER.debug("create virtualenv import hook file %s", pth) +- pth.write_text("import _virtualenv", encoding="utf-8") ++ write_file(pth, b"import _virtualenv") + dest_path = self.purelib / "_virtualenv.py" + LOGGER.debug("create %s", dest_path) +- dest_path.write_text(text, encoding="utf-8") ++ write_file(dest_path, text.encode("utf-8")) + + def env_patch_text(self): + """Patch the distutils package to not be derailed by its configuration files.""" +diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py +index 1de0d68..365e373 100644 +--- a/src/virtualenv/create/via_global_ref/venv.py ++++ b/src/virtualenv/create/via_global_ref/venv.py +@@ -47,8 +47,13 @@ class Venv(ViaGlobalRefApi): + return None + + def create(self): ++ # stdlib venv writes interpreter files, configuration and activation scripts in place, so a planted symlink or ++ # hard link would receive them outside the environment + for directory in (self.bin_dir, self.purelib, self.platlib): + ensure_within(self.dest, directory) ++ for path in (self.dest / "pyvenv.cfg", self.dest / ".gitignore", *self.bin_dir.iterdir()): ++ if path.is_symlink() or (path.is_file() and path.stat().st_nlink > 1): ++ path.unlink() + if self.can_be_inline: + self.create_inline() + else: +diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +index c734794..70cb698 100644 +--- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py ++++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +@@ -219,6 +219,10 @@ class ScriptMakerCustom(ScriptMaker): + + def _write_script(self, names, shebang, script_bytes, filenames, ext): + names.add(f"{self._name}{self.version_info[0]}.{self.version_info[1]}") ++ for name in names: ++ path = Path(self.target_dir) / (f"{name}.exe" if os.name == "nt" else name) ++ if path.is_symlink(): ++ path.unlink() + super()._write_script(names, shebang, script_bytes, filenames, ext) + + +diff --git a/src/virtualenv/util/path/__init__.py b/src/virtualenv/util/path/__init__.py +index f2cccc8..451630f 100644 +--- a/src/virtualenv/util/path/__init__.py ++++ b/src/virtualenv/util/path/__init__.py +@@ -1,7 +1,7 @@ + from __future__ import annotations + + from ._permission import make_exe, set_tree +-from ._sync import copy, copytree, ensure_dir, ensure_within, safe_delete, symlink ++from ._sync import copy, copytree, ensure_dir, ensure_within, safe_delete, symlink, write_file + from ._win import get_short_path_name + + __all__ = [ +@@ -14,4 +14,5 @@ __all__ = [ + "safe_delete", + "set_tree", + "symlink", ++ "write_file", + ] +diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py +index ab748c5..152f9e2 100644 +--- a/src/virtualenv/util/path/_sync.py ++++ b/src/virtualenv/util/path/_sync.py +@@ -50,6 +50,14 @@ def ensure_within(root, path): + raise NotADirectoryError(msg) from None + + ++def write_file(path, data): ++ if path.is_symlink() or path.exists(): ++ path.unlink() ++ # Exclusive creation rejects links introduced after the old entry was removed. ++ with path.open("xb") as stream: ++ stream.write(data) ++ ++ + def ensure_safe_to_do(src, dest): + if src == dest: + msg = f"source and destination is the same {src}" +@@ -125,4 +133,5 @@ __all__ = [ + "safe_delete", + "symlink", + "symlink", ++ "write_file", + ] +diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py +index 0504319..0c70d4e 100644 +--- a/tests/unit/create/test_creator.py ++++ b/tests/unit/create/test_creator.py +@@ -26,10 +26,10 @@ from virtualenv.__main__ import run, run_with_catch + from virtualenv.create.creator import DEBUG_SCRIPT, Creator, get_env_debug_info + from virtualenv.create.pyenv_cfg import PyEnvCfg + from virtualenv.create.via_global_ref import api +-from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew ++from virtualenv.create.via_global_ref.builtin.cpython.common import is_mac_os_framework, is_macos_brew + from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix + from virtualenv.discovery.py_info import PythonInfo +-from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink ++from virtualenv.info import IS_GRAALPY, IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink + from virtualenv.run import cli_run, session_via_cli + + CURRENT = PythonInfo.current_system() +@@ -96,6 +96,122 @@ def system(session_app_data): + CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] + + ++@pytest.mark.parametrize("creator_name", [pytest.param("builtin", id="builtin"), pytest.param("venv", id="venv")]) ++@pytest.mark.parametrize("existing", [pytest.param(False, id="dangling"), pytest.param(True, id="existing")]) ++@pytest.mark.parametrize( ++ "generated", ++ [ ++ pytest.param(name, id=name) ++ for name in ("pyvenv.cfg", "activate", "pip", "CACHEDIR.TAG", ".gitignore", "_virtualenv.py", "_virtualenv.pth") ++ ], ++) ++def test_creator_replaces_generated_file_links(tmp_path, creator_name, existing, generated): ++ command = [ ++ str(tmp_path / "env"), ++ "--creator", ++ creator_name, ++ "--pip", ++ "embed", ++ "--no-periodic-update", ++ ] ++ session = session_via_cli(command) ++ session.creator.bin_dir.mkdir(parents=True) ++ target = session.creator.dest / "original" ++ if existing: ++ target.write_bytes(b"original") ++ path = ( ++ session.creator.bin_dir / ("pip.exe" if generated == "pip" and os.name == "nt" else generated) ++ if generated in {"activate", "pip"} ++ else session.creator.dest / generated ++ ) ++ if generated.startswith("_virtualenv"): ++ path = session.creator.purelib / generated ++ path.parent.mkdir(parents=True, exist_ok=True) ++ path.symlink_to(target) ++ with session: ++ session.run() ++ if existing and (generated == "CACHEDIR.TAG" or (generated == ".gitignore" and creator_name == "builtin")): ++ assert path.is_symlink() ++ else: ++ assert not path.is_symlink() ++ assert (target.read_bytes() if existing else target.exists()) == (b"original" if existing else False) ++ ++ ++@pytest.mark.parametrize( ++ "kind", ++ [ ++ pytest.param("file", id="file"), ++ pytest.param("symlink", id="symlink"), ++ pytest.param("dangling", id="dangling"), ++ pytest.param("junction", id="junction"), ++ ], ++) ++def test_venv_prepares_real_executable_directory(tmp_path, kind): ++ session = session_via_cli([str(tmp_path / "env"), "--creator", "venv", "--no-seed", "--activators="]) ++ session.creator.dest.mkdir() ++ if kind == "file": ++ session.creator.bin_dir.write_bytes(b"file") ++ else: ++ target = session.creator.dest / "empty" ++ if kind != "dangling": ++ target.mkdir() ++ if kind == "junction": ++ if not IS_WIN: ++ pytest.skip("requires Windows junctions") ++ cmd = shutil.which("cmd") ++ assert cmd is not None ++ subprocess.run( ++ [cmd, "/c", "mklink", "/J", str(session.creator.bin_dir), str(target)], ++ capture_output=True, ++ check=True, ++ ) ++ else: ++ if not fs_supports_symlink(): ++ pytest.skip("requires directory symlinks") ++ session.creator.bin_dir.symlink_to(target, target_is_directory=True) ++ if kind == "file": ++ with pytest.raises(NotADirectoryError, match="environment directory is not a real directory"): ++ session.run() ++ else: ++ session.run() ++ assert session.creator.bin_dir.resolve() == session.creator.bin_dir ++ ++ ++@pytest.mark.skipif(not CURRENT.has_venv or not fs_supports_symlink(), reason="requires venv and symlinks") ++@pytest.mark.skipif( ++ is_mac_os_framework(CURRENT) or is_macos_brew(CURRENT) or (IS_GRAALPY and not IS_WIN), ++ reason="stdlib creator requires symlinks on this runtime", ++) ++def test_venv_recreate_with_copied_interpreter(tmp_path): ++ args = [str(tmp_path / "env"), "--creator", "venv", "--no-seed"] ++ cli_run([*args, "--symlinks"]) ++ session = cli_run([*args, "--copies"]) ++ assert (session.creator.exe.is_file(), session.creator.exe.resolve().parent) == ( ++ True, ++ session.creator.bin_dir.resolve(), ++ ) ++ ++ ++@pytest.mark.skipif(not CURRENT.has_venv, reason="requires interpreter with venv") ++@pytest.mark.parametrize("mode", [pytest.param("--copies", id="copies"), pytest.param("--symlinks", id="symlinks")]) ++def test_venv_replaces_hard_linked_files(tmp_path, mode): ++ if mode == "--symlinks" and not fs_supports_symlink(): ++ pytest.skip("requires symlinks") ++ if mode == "--copies" and (is_mac_os_framework(CURRENT) or is_macos_brew(CURRENT) or (IS_GRAALPY and not IS_WIN)): ++ pytest.skip("stdlib creator requires symlinks on this runtime") ++ session = session_via_cli([str(tmp_path / "env"), "--creator", "venv", "--no-seed", "--activators=", mode]) ++ session.creator.bin_dir.mkdir(parents=True) ++ names = ("python.exe", "pythonw.exe") if IS_WIN else ("python", "python3", f"python3.{CURRENT.version_info.minor}") ++ planted = [session.creator.dest / "pyvenv.cfg", *(session.creator.bin_dir / name for name in names)] ++ for path in planted: ++ (tmp_path / f"{path.name}.original").write_bytes(b"original") ++ os.link(tmp_path / f"{path.name}.original", path) ++ session.run() ++ assert [((tmp_path / f"{path.name}.original").read_bytes(), path.lstat().st_nlink) for path in planted] == [ ++ (b"original", 1) ++ ] * len(planted) ++ ++ + @pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") + @pytest.mark.parametrize( + "boundary", +diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py +index 9fce8ee..5f522f4 100644 +--- a/tests/unit/test_util.py ++++ b/tests/unit/test_util.py +@@ -11,7 +11,7 @@ import pytest + + from virtualenv.info import fs_supports_symlink + from virtualenv.util.lock import ReentrantFileLock +-from virtualenv.util.path import copy, ensure_within ++from virtualenv.util.path import copy, ensure_within, write_file + from virtualenv.util.subprocess import run_cmd + + +@@ -136,6 +136,15 @@ def test_environment_directories_report_replaced_junction(tmp_path, caplog): + assert f"replace environment directory junction {link}" in caplog.text + + ++@pytest.mark.parametrize("existing", [pytest.param(False, id="new"), pytest.param(True, id="replace")]) ++def test_write_file_replaces_content(tmp_path, existing): ++ path = tmp_path / "generated" ++ if existing: ++ path.write_bytes(b"previous") ++ write_file(path, b"generated") ++ assert path.read_bytes() == b"generated" ++ ++ + def test_run_fail(tmp_path): + code, out, err = run_cmd([str(tmp_path)]) + assert err +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113722.patch b/SPECS/python-virtualenv/CVE-2026-113722.patch new file mode 100644 index 00000000000..e8441790bb2 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113722.patch @@ -0,0 +1,251 @@ +From 2519941bed33ee0d3ac99277f67913e439e476a9 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 03:41:16 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(seed):=20preserve=20interpre?= + =?UTF-8?q?ter=20paths=20in=20console=20scripts=20(#3417)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/2519941bed33ee0d3ac99277f67913e439e476a9.patch +--- + docs/changelog/3417.bugfix.rst | 5 ++ + src/virtualenv/seed/embed/pip_invoke.py | 45 ++++++++++ + .../embed/via_app_data/pip_install/base.py | 15 ++++ + .../embed/test_bootstrap_link_via_app_data.py | 89 ++++++++++++++++++- + 4 files changed, 153 insertions(+), 1 deletion(-) + create mode 100644 docs/changelog/3417.bugfix.rst + +diff --git a/docs/changelog/3417.bugfix.rst b/docs/changelog/3417.bugfix.rst +new file mode 100644 +index 0000000..510469c +--- /dev/null ++++ b/docs/changelog/3417.bugfix.rst +@@ -0,0 +1,5 @@ ++Preserve spaces, quotes, and backslashes in interpreter paths when pip console scripts need a POSIX shell launcher. ++Paths containing tabs also use the shell launcher so the kernel does not split the interpreter name. ++ ++Apply the same launcher quoting after pip seeding and refresh the installed file records. Later independent pip installs ++or upgrades can overwrite these launchers; recreate affected environments after upgrading virtualenv. +diff --git a/src/virtualenv/seed/embed/pip_invoke.py b/src/virtualenv/seed/embed/pip_invoke.py +index b733c51..4da8975 100644 +--- a/src/virtualenv/seed/embed/pip_invoke.py ++++ b/src/virtualenv/seed/embed/pip_invoke.py +@@ -1,11 +1,20 @@ + from __future__ import annotations + ++import csv ++import hashlib ++import io + import logging ++import os ++from base64 import urlsafe_b64encode + from contextlib import contextmanager ++from importlib.metadata import distributions ++from pathlib import Path ++from stat import S_IMODE + from subprocess import Popen + + from virtualenv.discovery.cached_py_info import LogCmd + from virtualenv.seed.embed.base_embed import BaseEmbed ++from virtualenv.seed.embed.via_app_data.pip_install.base import shell_shebang + from virtualenv.seed.wheels import Version, get_wheel, pip_wheel_env_run + + LOGGER = logging.getLogger(__name__) +@@ -22,6 +31,42 @@ class PipInvoke(BaseEmbed): + with self.get_pip_install_cmd(creator.exe, for_py_version) as cmd: + env = pip_wheel_env_run(self.extra_search_dir, self.app_data, self.env) + self._execute(cmd, env) ++ if os.name == "posix": ++ self._rewrite_launchers(creator) ++ ++ def _rewrite_launchers(self, creator): ++ selected = self.distribution_to_versions() ++ exe = str(creator.exe).encode("utf-8") ++ for distribution in distributions(path=sorted({str(creator.purelib), str(creator.platlib)})): ++ if distribution.metadata["Name"].lower().replace("-", "_") not in selected: ++ continue ++ rows = list(csv.reader(io.StringIO(distribution.read_text("RECORD") or ""))) ++ changed = False ++ for row in rows: ++ path = Path(os.path.abspath(str(distribution.locate_file(row[0])))) ++ if path.parent != creator.script_dir or not path.is_file(): ++ continue ++ content = path.read_bytes() ++ if content.startswith(b"#!/bin/sh\n'''exec' "): ++ body = content.split(b"\n", 3)[3] ++ elif content.startswith(b"#!" + exe + b"\n") and len(exe.split()) > 1: ++ body = content.split(b"\n", 1)[1] ++ else: ++ continue ++ content = shell_shebang(str(creator.exe)) + body ++ mode = S_IMODE(path.stat().st_mode) & 0o755 ++ path.unlink() ++ with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode), "wb") as stream: ++ stream.write(content) ++ row[1:] = [ ++ "sha256=" + urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode("ascii"), ++ str(len(content)), ++ ] ++ changed = True ++ if changed: ++ record = next(row[0] for row in rows if row[0].endswith(".dist-info/RECORD")) ++ with Path(str(distribution.locate_file(record))).open("w", encoding="utf-8", newline="") as stream: ++ csv.writer(stream).writerows(rows) + + @staticmethod + def _execute(cmd, env): +diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +index d5e87ad..3823103 100644 +--- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py ++++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +@@ -3,6 +3,7 @@ from __future__ import annotations + import logging + import os + import re ++import shlex + import zipfile + from abc import ABC, abstractmethod + from configparser import ConfigParser +@@ -195,12 +196,26 @@ class ScriptMakerCustom(ScriptMaker): + self.version_info = version_info.major, version_info.minor + self.variants = {"", "X", "X.Y"} + self._name = name ++ self._raw_executable = str(executable) ++ ++ def _build_shebang(self, executable, post_interp): ++ shebang = super()._build_shebang(executable, post_interp) ++ if os.name != "posix" or len(shebang.split()) == 1: ++ return shebang ++ return shell_shebang(self._raw_executable, post_interp) + + def _write_script(self, names, shebang, script_bytes, filenames, ext): + names.add(f"{self._name}{self.version_info[0]}.{self.version_info[1]}") + super()._write_script(names, shebang, script_bytes, filenames, ext) + + ++def shell_shebang(executable, post_interp=b""): ++ # The exec line is read by both sh and Python; double-quoted backslashes survive both readers. ++ quoted = shlex.quote(executable).replace("\\", "'\"\\\\\"'") ++ return b"#!/bin/sh\n'''exec' " + quoted.encode("utf-8") + post_interp + b' "$0" "$@"\n' + b"' '''\n" ++ ++ + __all__ = [ + "PipInstall", ++ "shell_shebang", + ] +diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +index d59288a..ac62117 100644 +--- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py ++++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +@@ -1,12 +1,15 @@ + from __future__ import annotations + + import contextlib ++import csv ++import hashlib + import logging + import os + import sys ++from base64 import urlsafe_b64encode + from pathlib import Path + from stat import S_IWGRP, S_IWOTH, S_IWUSR +-from subprocess import Popen, check_call ++from subprocess import Popen, check_call, run + from threading import Thread + from typing import TYPE_CHECKING + +@@ -62,6 +65,90 @@ def unusable_cache(tmp_path): + return blocked / "cache" + + ++@pytest.mark.parametrize( ++ "name", ++ [ ++ pytest.param("plain", id="plain"), ++ pytest.param("two words", id="space"), ++ pytest.param("owner's env", id="apostrophe"), ++ pytest.param("$(echo a) `echo b`", id="command-substitution"), ++ pytest.param("a" * 120, id="long", marks=pytest.mark.skipif(os.name == "nt", reason="POSIX shebang limit")), ++ pytest.param("env \\path", id="backslash", marks=pytest.mark.skipif(os.name == "nt", reason="POSIX filename")), ++ pytest.param("env\tname", id="tab", marks=pytest.mark.skipif(os.name == "nt", reason="POSIX filename")), ++ ], ++) ++@pytest.mark.parametrize( ++ "via_python", ++ [ ++ pytest.param(False, id="launcher"), ++ pytest.param( ++ True, id="python", marks=pytest.mark.skipif(os.name == "nt", reason="Windows launcher is an executable") ++ ), ++ ], ++) ++@pytest.mark.parametrize("seeder", ["app-data", "pip"]) ++def test_console_script_preserves_interpreter_path(tmp_path, current_fastest, name, via_python, seeder): ++ destination = os.path.join(str(tmp_path), name, "env") ++ run( ++ [ ++ sys.executable, ++ "-m", ++ "virtualenv", ++ destination, ++ "--seeder", ++ seeder, ++ "--creator", ++ current_fastest, ++ "--activators", ++ "", ++ "--no-periodic-update", ++ "--pip", ++ "embed", ++ ], ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=60, ++ check=True, ++ ) ++ scripts = os.path.join(destination, "Scripts" if os.name == "nt" else "bin") ++ result = run( ++ [ ++ *([os.path.join(scripts, "python")] if via_python else []), ++ os.path.join(scripts, "pip.exe" if os.name == "nt" else "pip"), ++ "--version", ++ ], ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ assert os.path.normcase(destination) in os.path.normcase(result.stdout) ++ ++ ++@pytest.mark.skipif(os.name != "posix", reason="POSIX shell launchers") ++@pytest.mark.parametrize( ++ ("name", "via_shell"), ++ [ ++ pytest.param("plain", False, id="plain"), ++ pytest.param("two words", True, id="space"), ++ pytest.param("env\tname", True, id="tab"), ++ ], ++) ++def test_pip_launcher_record_matches_contents(tmp_path, name, via_shell): ++ session = cli_run([str(tmp_path / name), "--seeder", "pip", "--pip", "embed", "--no-periodic-update"]) ++ record = next(session.creator.purelib.glob("pip-*.dist-info/RECORD")) ++ with record.open(encoding="utf-8", newline="") as stream: ++ row = next(row for row in csv.reader(stream) if row[0].endswith("/bin/pip")) ++ content = (session.creator.script_dir / "pip").read_bytes() ++ assert content.startswith(b"#!/bin/sh\n") is via_shell ++ assert row[1:] == [ ++ "sha256=" + urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode("ascii"), ++ str(len(content)), ++ ] ++ ++ + @pytest.mark.slow + @pytest.mark.parametrize("copies", [False, True] if fs_supports_symlink() else [True]) + def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies, for_py_version): # noqa: PLR0915 +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113723.patch b/SPECS/python-virtualenv/CVE-2026-113723.patch new file mode 100644 index 00000000000..d76496ba7ca --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113723.patch @@ -0,0 +1,250 @@ +From 829486f85bbb761e38c91fdd94355fd95ca23729 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Fri, 16 Oct 2026 02:07:18 -0700 +Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=90=9B=20fix(bash):=20limit=20prompt?= + =?UTF-8?q?=20changes=20by=20shell?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://patch-diff.githubusercontent.com/raw/pypa/virtualenv/pull/3418.patch +--- + docs/changelog/3418.bugfix.rst | 5 + + src/virtualenv/activation/bash/activate.sh | 23 +++- + tests/unit/activation/test_bash.py | 153 ++++++++++++++++++++- + 3 files changed, 177 insertions(+), 4 deletions(-) + create mode 100644 docs/changelog/3418.bugfix.rst + +diff --git a/docs/changelog/3418.bugfix.rst b/docs/changelog/3418.bugfix.rst +new file mode 100644 +index 0000000..81f7d5a +--- /dev/null ++++ b/docs/changelog/3418.bugfix.rst +@@ -0,0 +1,5 @@ ++Leave ``PS1`` unchanged when sourcing the POSIX activation script in a shell other than bash or zsh, whose prompt ++escaping rules virtualenv handles; ``VIRTUAL_ENV_PROMPT`` stays available to custom prompts in other shells. ++ ++In bash, keep the prompt literal under older compatibility levels, with ``promptvars`` disabled and in posix mode, and ++avoid ``nounset`` errors after ``VIRTUAL_ENV_PROMPT`` is unset. +diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh +index 0221057..49825ec 100644 +--- a/src/virtualenv/activation/bash/activate.sh ++++ b/src/virtualenv/activation/bash/activate.sh +@@ -103,9 +103,28 @@ if [ __TK_LIBRARY__ != "" ]; then + export TK_LIBRARY + fi + +-if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] ; then ++if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] && ++ { [ -n "${BASH_VERSION-}" ] || [ -n "${ZSH_VERSION-}" ]; }; then + _OLD_VIRTUAL_PS1="${PS1-}" +- PS1="(${VIRTUAL_ENV_PROMPT}) ${PS1-}" ++ # Expand the value only after bash decodes prompt escapes. Without expansion, only backslashes need doubling. ++ # zsh also needs dollar signs, backticks and percent signs escaped, including when PROMPT_SUBST is enabled later. ++ if [ -n "${BASH_VERSION-}" ] && { shopt -q promptvars || shopt -qo posix; }; then ++ PS1='(${VIRTUAL_ENV_PROMPT-}) '"${PS1-}" ++ else ++ if [ -n "${BASH_VERSION-}" ]; then ++ # A literal replacement value avoids compatibility-dependent quote removal. ++ _VIRTUAL_BACKSLASH='\\\\' ++ _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/$_VIRTUAL_BACKSLASH}" ++ unset _VIRTUAL_BACKSLASH ++ else ++ _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/\\\\}" ++ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}" ++ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}" ++ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}" ++ fi ++ PS1="(${_VIRTUAL_PROMPT}) ${PS1-}" ++ unset _VIRTUAL_PROMPT ++ fi + export PS1 + fi + +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index 89a5dd4..980e0b1 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -1,6 +1,8 @@ + from __future__ import annotations + ++import os + import shlex ++import shutil + import subprocess + from argparse import Namespace + from types import SimpleNamespace +@@ -10,6 +12,149 @@ import pytest + from virtualenv.activation import BashActivator + from virtualenv.info import IS_WIN + ++_BASH = shutil.which("bash") ++ ++ ++@pytest.fixture ++def bash_activate_script(tmp_path): ++ def generate(prompt=None, name="env"): ++ creator = SimpleNamespace( ++ dest=tmp_path / name, ++ bin_dir=tmp_path / name / "bin", ++ interpreter=SimpleNamespace(), ++ pyenv_cfg={}, ++ env_name=name, ++ ) ++ creator.bin_dir.mkdir(parents=True) ++ BashActivator(Namespace(prompt=prompt)).generate(creator) ++ return creator.bin_dir / "activate" ++ ++ return generate ++ ++ ++def _interactive_bash_prompt(script, tmp_path, before="", after=""): ++ result = subprocess.run( ++ [_BASH, "--noprofile", "--norc", "-i"], ++ input=f"{before}\nPS1=__END__\n. {BashActivator.quote(str(script))}\n{after}\nexit\n", ++ cwd=tmp_path, ++ env={**os.environ, "PS1": "", "PROMPT_COMMAND": ""}, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ return result.stderr ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize( ++ ("setup", "expected"), ++ [ ++ pytest.param("", "(${VIRTUAL_ENV_PROMPT-}) base ", id="bash"), ++ pytest.param("unset BASH_VERSION ZSH_VERSION;", "base ", id="unrecognized-shell"), ++ pytest.param("VIRTUAL_ENV_DISABLE_PROMPT=1;", "base ", id="disabled-prompt"), ++ ], ++) ++def test_prompt_requires_known_shell(tmp_path, bash_activate_script, setup, expected): ++ script = bash_activate_script(prompt="example") ++ result = subprocess.run( ++ [ ++ _BASH, ++ "--noprofile", ++ "--norc", ++ "-c", ++ f'{setup} PS1="base "; . "$1"; printf "%s|%s" "$PS1" "$VIRTUAL_ENV_PROMPT"; ' ++ 'deactivate; printf "|%s" "$PS1"', ++ "test", ++ str(script), ++ ], ++ cwd=tmp_path, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ assert result.stdout == f"{expected}|example|base " ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize( ++ "prompt", ++ [ ++ pytest.param("plain", id="plain"), ++ pytest.param(r"e\\z", id="backslashes"), ++ pytest.param("e\\", id="trailing-backslash"), ++ pytest.param("space * ? [x]", id="spaces-and-globs"), ++ pytest.param("x$HOMEy", id="variable"), ++ pytest.param("x$(touch PWNED)y", id="command-substitution"), ++ pytest.param("x`touch PWNED`y", id="backticks"), ++ ], ++) ++@pytest.mark.parametrize("compatibility", ["31", "42", "51"], ids=["bash31", "bash42", "bash51"]) ++@pytest.mark.parametrize("promptvars", [True, False], ids=["promptvars-on", "promptvars-off"]) ++def test_bash_prompt_renders_name_literally(tmp_path, bash_activate_script, prompt, compatibility, promptvars): ++ script = bash_activate_script(prompt=prompt) ++ before = f"BASH_COMPAT={compatibility}\nshopt {'-s' if promptvars else '-u'} promptvars" ++ ++ rendered = _interactive_bash_prompt(script, tmp_path, before=before) ++ ++ assert not (tmp_path / "PWNED").exists() ++ assert f"({prompt}) __END__" in rendered ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize("name", ["plain", "x$(touch PWNED)y", "x`touch PWNED`y"]) ++def test_bash_prompt_from_directory_name(tmp_path, bash_activate_script, name): ++ script = bash_activate_script(name=name) ++ ++ rendered = _interactive_bash_prompt(script, tmp_path) ++ ++ assert not (tmp_path / "PWNED").exists() ++ assert f"({name}) __END__" in rendered ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize( ++ ("before", "after", "expected"), ++ [ ++ pytest.param("shopt -u promptvars", "", "(x$(touch PWNED)y) __END__", id="promptvars-disabled"), ++ pytest.param( ++ "shopt -u promptvars; set -o posix", "", "(x$(touch PWNED)y) __END__", id="promptvars-disabled-posix" ++ ), ++ pytest.param("", "set -u; unset VIRTUAL_ENV_PROMPT", "() __END__", id="unset-with-nounset"), ++ ], ++) ++def test_bash_prompt_options(tmp_path, bash_activate_script, before, after, expected): ++ script = bash_activate_script(prompt="x$(touch PWNED)y") ++ ++ rendered = _interactive_bash_prompt(script, tmp_path, before=before, after=after) ++ ++ assert not (tmp_path / "PWNED").exists() ++ assert expected in rendered ++ assert "unbound variable" not in rendered ++ ++ ++@pytest.mark.skipif(IS_WIN, reason="requires POSIX shells") ++@pytest.mark.parametrize("shell", ["dash", "ksh", "ksh93", "mksh"]) ++def test_bash_activation_preserves_other_shell_prompts(tmp_path, bash_activate_script, shell): ++ executable = shutil.which(shell) ++ if executable is None: ++ pytest.skip(f"{shell} is not installed") ++ script = bash_activate_script(prompt="x$(touch PWNED)y") ++ result = subprocess.run( ++ [executable, "-c", 'PS1="base "; . "$1"; printf "%s|%s" "$PS1" "$VIRTUAL_ENV_PROMPT"', "test", str(script)], ++ cwd=tmp_path, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ assert result.stdout == "base |x$(touch PWNED)y" ++ assert not (tmp_path / "PWNED").exists() ++ + + @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") + @pytest.mark.parametrize( +@@ -92,7 +237,7 @@ def test_bash_missing_path_does_not_run_commands(tmp_path, name): + creator.bin_dir.rmdir() + creator.dest.rmdir() + result = subprocess.run( +- ["bash", "-c", f"source {shlex.quote(str(script))}"], ++ [_BASH, "-c", f"source {shlex.quote(str(script))}"], + capture_output=True, + text=True, + cwd=str(tmp_path), +@@ -124,6 +269,10 @@ def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester): + return super().activate_call(script) + " || exit 1" + + def print_prompt(self): +- return self.print_os_env_var("PS1") ++ return ( ++ "case \"$PS1\" in '(${VIRTUAL_ENV_PROMPT-}) '*) " ++ "printf '(%s) %s\\n' \"$VIRTUAL_ENV_PROMPT\" \"${PS1#'(${VIRTUAL_ENV_PROMPT-}) '}\";; " ++ "*) printf '%s\\n' \"$PS1\";; esac" ++ ) + + activation_tester(Bash) +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113725.patch b/SPECS/python-virtualenv/CVE-2026-113725.patch new file mode 100644 index 00000000000..29320a475ed --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113725.patch @@ -0,0 +1,92 @@ +From c3104e02474b652832a9bbaae79f6e1696296932 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sun, 11 Oct 2026 17:36:53 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(activation):=20escape=20the?= + =?UTF-8?q?=20bash=20prompt=20for=20both=20PS1=20passes=20(#3390)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +bash handles `PS1` in two passes each time it draws the prompt: it first +decodes backslash prompt escapes, turning `\\` into `\` and `\$` into +`$` (or `#` for root), and then expands the result. The bash `activate` +script escaped the environment name for the expansion pass only (#3326), +so a backslash in the name undoes that escaping. 🔐 A name such as +`a\$(touch PWNED)`, from the directory, `--prompt` or +`VIRTUALENV_PROMPT`, runs the command each time bash draws the prompt. +For root, `\$` decodes to `#`, so the same name renders wrong but runs +nothing. + +Under bash, `activate` now escapes backslashes, dollar signs and +backticks a second time, so both passes leave the name literal. zsh does +no decode pass, so it keeps the single escaping added in 5b915367. The +prompt tests gain cases for a backslash before `$(...)` and before +backticks, and for names with a backslash before `$`, before a backtick, +doubled, and trailing; they render through bash 5.2 and zsh 5.9 and pass +as root and as an unprivileged user. + +Every release up to 21.14.6 is affected, and earlier releases before +21.13.0 do not escape the name at all. + +Upstream-reference: https://github.com/pypa/virtualenv/commit/c3104e02474b652832a9bbaae79f6e1696296932.patch +--- + docs/changelog/3390.bugfix.rst | 3 +++ + src/virtualenv/create/via_global_ref/venv.py | 7 +++++++ + tests/unit/activation/test_bash.py | 6 ++++++ + 3 files changed, 16 insertions(+) + create mode 100644 docs/changelog/3390.bugfix.rst + +diff --git a/docs/changelog/3390.bugfix.rst b/docs/changelog/3390.bugfix.rst +new file mode 100644 +index 0000000..de64ad8 +--- /dev/null ++++ b/docs/changelog/3390.bugfix.rst +@@ -0,0 +1,3 @@ ++Escape the environment name in the bash activator for both of bash's ``PS1`` passes, so a name or ``--prompt`` value ++with a backslash before ``$(...)`` or backticks no longer runs as a command each time bash draws the prompt - by ++:user:`gaborbernat`. +diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py +index e852b46..1de0d68 100644 +--- a/src/virtualenv/create/via_global_ref/venv.py ++++ b/src/virtualenv/create/via_global_ref/venv.py +@@ -1,6 +1,13 @@ + from __future__ import annotations + ++import csv ++import hashlib ++import io + import logging ++import os ++from base64 import urlsafe_b64encode ++from importlib.metadata import distributions ++from stat import S_IMODE + from argparse import ArgumentTypeError + + from virtualenv.create.via_global_ref.store import handle_store_python +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index ee0c805..c5b4dce 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -68,6 +68,8 @@ def prompt_after_activate(request, tmp_path, bash_activate_script): + pytest.param("x$(touch PWNED)y", "env", id="prompt-command-substitution"), + pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"), + pytest.param("x${$(touch PWNED)}y", "env", id="prompt-parameter-expansion"), ++ pytest.param(r"x\$(touch PWNED)y", "env", id="prompt-backslash-command-substitution"), ++ pytest.param(r"x\`touch PWNED`y", "env", id="prompt-backslash-backticks"), + pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"), + pytest.param(None, "A__VIRTUAL_NAME__B/x'$(touch PWNED)'y", id="placeholder-in-parent-dir"), + ], +@@ -84,6 +86,10 @@ def test_prompt_does_not_run_commands(prompt_after_activate, prompt, name): + [ + pytest.param("myenv", id="plain"), + pytest.param(r"a%n$HOME\b`c`", id="special-characters"), ++ pytest.param(r"a\$HOME", id="backslash-before-dollar"), ++ pytest.param(r"a\`c`", id="backslash-before-backtick"), ++ pytest.param(r"x\\y", id="doubled-backslash"), ++ pytest.param("e\\", id="trailing-backslash"), + ], + ) + def test_prompt_renders_name_literally(prompt_after_activate, prompt): +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113726.patch b/SPECS/python-virtualenv/CVE-2026-113726.patch new file mode 100644 index 00000000000..cc9583eedaf --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113726.patch @@ -0,0 +1,113 @@ +The bash activate script, which zsh also sources, escaped backslashes, +dollar signs and backticks in the environment name only under bash. +zsh with PROMPT_SUBST re-expands PS1 at every prompt draw, so a name +such as x$(touch PWNED)y ran as a command each time the prompt showed. + +Apply the same escaping under zsh, also when PROMPT_SUBST is off since +it can be set after activation, and double % so zsh shows the name +instead of reading it as prompt escapes. The prompt tests now render +PS1 through zsh as well as bash. + +Upstream-reference: https://github.com/pypa/virtualenv/commit/5b915367da82bd024b2a0d28d2f98007034b4aa2.patch +--- + tests/unit/activation/test_bash.py | 55 +++++++++++++++++++++++++++--- + 1 file changed, 50 insertions(+), 5 deletions(-) + +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index 980e0b1..1c081ab 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -32,9 +32,13 @@ def bash_activate_script(tmp_path): + return generate + + +-def _interactive_bash_prompt(script, tmp_path, before="", after=""): ++def _interactive_prompt(script, tmp_path, before="", after="", shell="bash"): ++ executable = shutil.which(shell) ++ if executable is None: ++ pytest.skip(f"{shell} is not installed") ++ flags = {"bash": ["--noprofile", "--norc", "-i"], "zsh": ["-f", "-i"]}[shell] + result = subprocess.run( +- [_BASH, "--noprofile", "--norc", "-i"], ++ [executable, *flags], + input=f"{before}\nPS1=__END__\n. {BashActivator.quote(str(script))}\n{after}\nexit\n", + cwd=tmp_path, + env={**os.environ, "PS1": "", "PROMPT_COMMAND": ""}, +@@ -47,6 +51,47 @@ def _interactive_bash_prompt(script, tmp_path, before="", after=""): + return result.stderr + + ++@pytest.fixture(params=["bash", "zsh"]) ++def prompt_after_activate(request, tmp_path, bash_activate_script): ++ def build(prompt, name): ++ script = bash_activate_script(prompt=prompt, name=name) ++ before = "setopt prompt_subst" if request.param == "zsh" else "" ++ return tmp_path, _interactive_prompt(script, tmp_path, before=before, shell=request.param) ++ ++ return build ++ ++ ++@pytest.mark.skipif(IS_WIN, reason="requires POSIX shells") ++@pytest.mark.parametrize( ++ ("prompt", "name"), ++ [ ++ pytest.param("x$(touch PWNED)y", "env", id="prompt-command-substitution"), ++ pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"), ++ pytest.param("x${$(touch PWNED)}y", "env", id="prompt-parameter-expansion"), ++ pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"), ++ ], ++) ++def test_prompt_does_not_run_commands(prompt_after_activate, prompt, name): ++ work_dir, _ = prompt_after_activate(prompt, name) ++ ++ assert not (work_dir / "PWNED").exists() ++ ++ ++@pytest.mark.skipif(IS_WIN, reason="requires POSIX shells") ++@pytest.mark.parametrize( ++ "prompt", ++ [ ++ pytest.param("myenv", id="plain"), ++ pytest.param(r"a%n$HOME\b`c`", id="special-characters"), ++ ], ++) ++def test_prompt_renders_name_literally(prompt_after_activate, prompt): ++ work_dir, rendered = prompt_after_activate(prompt, "env") ++ ++ assert f"({prompt}) __END__" in rendered ++ assert not (work_dir / "PWNED").exists() ++ ++ + @pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") + @pytest.mark.parametrize( + ("setup", "expected"), +@@ -98,7 +143,7 @@ def test_bash_prompt_renders_name_literally(tmp_path, bash_activate_script, prom + script = bash_activate_script(prompt=prompt) + before = f"BASH_COMPAT={compatibility}\nshopt {'-s' if promptvars else '-u'} promptvars" + +- rendered = _interactive_bash_prompt(script, tmp_path, before=before) ++ rendered = _interactive_prompt(script, tmp_path, before=before) + + assert not (tmp_path / "PWNED").exists() + assert f"({prompt}) __END__" in rendered +@@ -109,7 +154,7 @@ def test_bash_prompt_renders_name_literally(tmp_path, bash_activate_script, prom + def test_bash_prompt_from_directory_name(tmp_path, bash_activate_script, name): + script = bash_activate_script(name=name) + +- rendered = _interactive_bash_prompt(script, tmp_path) ++ rendered = _interactive_prompt(script, tmp_path) + + assert not (tmp_path / "PWNED").exists() + assert f"({name}) __END__" in rendered +@@ -129,7 +174,7 @@ def test_bash_prompt_from_directory_name(tmp_path, bash_activate_script, name): + def test_bash_prompt_options(tmp_path, bash_activate_script, before, after, expected): + script = bash_activate_script(prompt="x$(touch PWNED)y") + +- rendered = _interactive_bash_prompt(script, tmp_path, before=before, after=after) ++ rendered = _interactive_prompt(script, tmp_path, before=before, after=after) + + assert not (tmp_path / "PWNED").exists() + assert expected in rendered +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113732.patch b/SPECS/python-virtualenv/CVE-2026-113732.patch new file mode 100644 index 00000000000..98bf29ca948 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113732.patch @@ -0,0 +1,105 @@ +From 3bddeaf8d02c3ee7f576503efb082f8f494cfcab Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Wed, 30 Sep 2026 22:59:45 -0700 +Subject: [PATCH] Merge commit from fork + +FishActivator inherited shlex.quote, whose POSIX rules leave a backslash +literal inside single quotes. fish reads \' there as an escaped quote, so a +path or --prompt with a backslash before a quote ended the literal early and +ran the rest as commands, without any error from fish. + +Give FishActivator its own quote() that escapes backslashes and quotes. + +Upstream-reference: https://github.com/pypa/virtualenv/commit/3bddeaf8d02c3ee7f576503efb082f8f494cfcab.patch +--- + src/virtualenv/activation/fish/__init__.py | 5 +++ + tests/unit/activation/test_fish.py | 41 ++++++++++++++++++++-- + 2 files changed, 44 insertions(+), 2 deletions(-) + +diff --git a/src/virtualenv/activation/fish/__init__.py b/src/virtualenv/activation/fish/__init__.py +index 2626356..d91da4f 100644 +--- a/src/virtualenv/activation/fish/__init__.py ++++ b/src/virtualenv/activation/fish/__init__.py +@@ -6,6 +6,11 @@ from virtualenv.activation.via_template import ViaTemplateActivator + class FishActivator(ViaTemplateActivator): + def templates(self): + yield "activate.fish" ++ @staticmethod ++ def quote(string: str) -> str: ++ # fish reads \\ and \' as escapes inside single quotes, unlike POSIX sh, so shlex.quote's output lets a ++ # backslash before a quote end the literal early ++ return "'" + string.replace("\\", "\\\\").replace("'", "\\'") + "'" + + def replacements(self, creator, dest): + data = super().replacements(creator, dest) +diff --git a/tests/unit/activation/test_fish.py b/tests/unit/activation/test_fish.py +index cd901f4..6e7fe5b 100644 +--- a/tests/unit/activation/test_fish.py ++++ b/tests/unit/activation/test_fish.py +@@ -6,12 +6,16 @@ import shutil + import subprocess + import sys + from argparse import Namespace ++from pathlib import Path + from types import SimpleNamespace + + import pytest + + from virtualenv.activation import FishActivator + from virtualenv.info import IS_WIN ++from virtualenv.run import cli_run ++ ++FISH = shutil.which("fish") + + + @pytest.mark.parametrize( +@@ -50,12 +54,45 @@ def test_fish_tkinter_generation(tmp_path, tcl_lib, tk_lib, present): + + # THEN + if present: +- assert f"set -gx TCL_LIBRARY {shlex.quote(tcl_lib)}\n" in content +- assert f"set -gx TK_LIBRARY {shlex.quote(tk_lib)}\n" in content ++ assert f"set -gx TCL_LIBRARY {FishActivator.quote(tcl_lib)}\n" in content ++ assert f"set -gx TK_LIBRARY {FishActivator.quote(tk_lib)}\n" in content + else: + assert "if test -n ''\n if set -q TCL_LIBRARY;" in content + assert "if test -n ''\n if set -q TK_LIBRARY;" in content + ++@pytest.mark.skipif(IS_WIN, reason="fish is not available on Windows") ++@pytest.mark.skipif(FISH is None, reason="fish is not installed") ++@pytest.mark.parametrize( ++ ("prompt", "env_name"), ++ [ ++ pytest.param(r"x\'$(touch PWNED)'\'", "env", id="prompt-backslash-quote"), ++ pytest.param(None, r"x\'$(touch PWNED)'\'", id="dirname-backslash-quote"), ++ ], ++) ++def test_fish_activate_does_not_run_commands( ++ tmp_path: Path, current_fastest: str, prompt: str | None, env_name: str ++) -> None: ++ dest = tmp_path / env_name ++ args = ["--without-pip", str(dest), "--creator", current_fastest, "--no-periodic-update", "--activators", "fish"] ++ if prompt is not None: ++ args += ["--prompt", prompt] ++ cli_run(args) ++ (work_dir := tmp_path / "workdir").mkdir() ++ ++ # the path is passed as an argument so the driver cannot expand a payload dir name while locating the script ++ result = subprocess.run( ++ [FISH, "--no-config", "-c", 'source $argv[1]; printf "%s" "$VIRTUAL_ENV"', str(dest / "bin" / "activate.fish")], ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ cwd=str(work_dir), ++ timeout=60, ++ check=False, ++ ) ++ ++ assert not (work_dir / "PWNED").exists() ++ assert result.stdout == str(dest), result.stderr ++ + + @pytest.mark.skipif(IS_WIN, reason="fish is not available on Windows") + @pytest.mark.skipif(shutil.which("fish") is None, reason="fish is not installed") +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113776.patch b/SPECS/python-virtualenv/CVE-2026-113776.patch new file mode 100644 index 00000000000..30ee21fb494 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113776.patch @@ -0,0 +1,99 @@ +From 602b26623a724827ff171bf11fef4ad186d01f74 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Wed, 30 Sep 2026 23:01:05 -0700 +Subject: [PATCH] Merge commit from fork + +instantiate_template replaced one placeholder at a time across the whole +template, so a placeholder name inside an already quoted value, such as a +parent directory named __VIRTUAL_NAME__, was rewritten from inside the +finished literal. The second value's quotes closed the first, and the rest +of the path ran as code in bash, fish, xonsh and activate_this.py; in csh +the prompt display tokens broke activate.csh the same way. + +Build every token's final text first and substitute them in one regex pass, +with the csh display tokens joining that pass through substitutions(). + +Upstream-reference: https://github.com/pypa/virtualenv/commit/602b26623a724827ff171bf11fef4ad186d01f74.patch +--- + src/virtualenv/activation/via_template.py | 21 +++++++++++++++++---- + tests/conftest.py | 3 ++- + tests/unit/activation/test_bash.py | 3 ++- + 3 files changed, 21 insertions(+), 6 deletions(-) + +diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py +index 85f9326..f40e454 100644 +--- a/src/virtualenv/activation/via_template.py ++++ b/src/virtualenv/activation/via_template.py +@@ -1,6 +1,7 @@ + from __future__ import annotations + + import os ++import re + import shlex + import sys + from abc import ABC, abstractmethod +@@ -74,10 +75,22 @@ class ViaTemplateActivator(Activator, ABC): + # read content as binary to avoid platform specific line normalization (\n -> \r\n) + binary = read_binary(self.__module__, template) + text = binary.decode("utf-8", errors="strict") +- for key, value in replacements.items(): +- value_uni = self._repr_unicode(creator, value) +- text = text.replace(key, self.quote(value_uni)) +- return text ++ substitutions = self.substitutions(replacements, creator) ++ # Substitute once so placeholder names inside quoted paths remain data. ++ pattern = re.compile("|".join(re.escape(key) for key in sorted(substitutions, key=len, reverse=True))) ++ return pattern.sub(lambda match: substitutions[match.group()], text) ++ ++ def substitutions(self, replacements, creator): ++ """ ++ Map each placeholder to the final text that replaces it in the template. ++ ++ :param replacements: the raw placeholder values ++ :param creator: the creator of the environment ++ ++ :returns: the placeholder to substituted text mapping ++ ++ """ ++ return {key: self.quote(self._repr_unicode(creator, value)) for key, value in replacements.items()} + + @staticmethod + def _repr_unicode(creator, value): # noqa: ARG004 +diff --git a/tests/conftest.py b/tests/conftest.py +index e4fc284..8c6ced7 100644 +--- a/tests/conftest.py ++++ b/tests/conftest.py +@@ -281,7 +281,8 @@ def is_inside_ci(): + + @pytest.fixture(scope="session") + def special_char_name(): +- base = "'\";&&e-$ èрт🚒♞中片-j" ++ # __VIRTUAL_NAME__ must stay data when embedded in an environment path. ++ base = "'\";&&e-$ __VIRTUAL_NAME__èрт🚒♞中片-j" + if IS_WIN: + # get rid of invalid characters on Windows + base = base.replace('"', "") +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index 1c081ab..ee0c805 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -23,7 +23,7 @@ def bash_activate_script(tmp_path): + bin_dir=tmp_path / name / "bin", + interpreter=SimpleNamespace(), + pyenv_cfg={}, +- env_name=name, ++ env_name=(tmp_path / name).name, + ) + creator.bin_dir.mkdir(parents=True) + BashActivator(Namespace(prompt=prompt)).generate(creator) +@@ -69,6 +69,7 @@ def prompt_after_activate(request, tmp_path, bash_activate_script): + pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"), + pytest.param("x${$(touch PWNED)}y", "env", id="prompt-parameter-expansion"), + pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"), ++ pytest.param(None, "A__VIRTUAL_NAME__B/x'$(touch PWNED)'y", id="placeholder-in-parent-dir"), + ], + ) + def test_prompt_does_not_run_commands(prompt_after_activate, prompt, name): +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/python-virtualenv.spec b/SPECS/python-virtualenv/python-virtualenv.spec index 4548a3163c6..697f6975e67 100644 --- a/SPECS/python-virtualenv/python-virtualenv.spec +++ b/SPECS/python-virtualenv/python-virtualenv.spec @@ -1,7 +1,7 @@ Summary: Virtual Python Environment builder Name: python-virtualenv Version: 20.36.1 -Release: 8%{?dist} +Release: 9%{?dist} License: MIT Vendor: Microsoft Corporation Distribution: Azure Linux @@ -25,6 +25,20 @@ Patch1012: CVE-2026-13346v1.patch Patch1013: CVE-2026-102925.patch Patch1014: CVE-2026-102930.patch Patch1015: remove-compromised-wheel-ca-roots.patch +# Keep these backports in dependency order rather than CVE-number order. +Patch1016: CVE-2026-113732.patch +Patch1017: CVE-2026-113716.patch +Patch1018: CVE-2026-113723.patch +Patch1019: CVE-2026-113726.patch +Patch1020: CVE-2026-113713.patch +Patch1021: CVE-2026-113776.patch +Patch1022: CVE-2026-113715.patch +Patch1023: CVE-2026-113722.patch +Patch1024: CVE-2026-113725.patch +Patch1025: CVE-2026-113720.patch +Patch1026: CVE-2026-113719.patch +Patch1027: CVE-2026-113721.patch +Patch1028: CVE-2026-102938.patch BuildArch: noarch %description @@ -60,6 +74,19 @@ virtualenv is a tool to create isolated Python environment. %patch -P 1013 -p1 %patch -P 1014 -p1 %patch -P 1015 -p1 +%patch -P 1016 -p1 +%patch -P 1017 -p1 +%patch -P 1018 -p1 +%patch -P 1019 -p1 +%patch -P 1020 -p1 +%patch -P 1021 -p1 +%patch -P 1022 -p1 +%patch -P 1023 -p1 +%patch -P 1024 -p1 +%patch -P 1025 -p1 +%patch -P 1026 -p1 +%patch -P 1027 -p1 +%patch -P 1028 -p1 # Manual patching for CVE-2025-50181 and CVE-2026-1703v0 # For CVE-2025-50181, poolmanager.py file is located in 2 different places and each is of different version so the same patch cannot be applied to all of them. @@ -215,6 +242,13 @@ export PYTHONWARNDEFAULTENCODING=1 %{_bindir}/virtualenv %changelog +* Tue Oct 27 2026 Durga Jagadeesh Palli - 20.36.1-9 +- Patch for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, and CVE-2026-113716. +- Patch for CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, and CVE-2026-113722. +- Patch for CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, and CVE-2026-113732. +- Patch for CVE-2026-113776. +- Fix missing imports and executable detection in the Fish activation regression test in CVE-2026-113732. + * Fri Oct 23 2026 Jon Slobodzian - 20.36.1-8 - Remove affected eMudhra and AAA roots from both embedded pip wheels. - Regenerate wheel RECORD entries after the existing security backports. From c792b715fcb427db3098f8cffa00b29d480af1bd Mon Sep 17 00:00:00 2001 From: Durga Jagadeesh Palli Date: Wed, 28 Oct 2026 12:30:33 +0000 Subject: [PATCH 2/2] address review feedback and CVE-2026-115586 --- SPECS/python-virtualenv/CVE-2026-102938.patch | 263 +++----------- SPECS/python-virtualenv/CVE-2026-113716.patch | 71 ++-- SPECS/python-virtualenv/CVE-2026-113719.patch | 43 ++- SPECS/python-virtualenv/CVE-2026-113720.patch | 130 +++++-- SPECS/python-virtualenv/CVE-2026-113721.patch | 132 ++++--- SPECS/python-virtualenv/CVE-2026-113723.patch | 35 +- SPECS/python-virtualenv/CVE-2026-113725.patch | 61 +--- SPECS/python-virtualenv/CVE-2026-113726.patch | 46 +-- SPECS/python-virtualenv/CVE-2026-113732.patch | 45 ++- SPECS/python-virtualenv/CVE-2026-115586.patch | 325 ++++++++++++++++++ .../python-virtualenv/python-virtualenv.spec | 5 +- 11 files changed, 675 insertions(+), 481 deletions(-) create mode 100644 SPECS/python-virtualenv/CVE-2026-115586.patch diff --git a/SPECS/python-virtualenv/CVE-2026-102938.patch b/SPECS/python-virtualenv/CVE-2026-102938.patch index d8cbd80d0a4..465ffa41874 100644 --- a/SPECS/python-virtualenv/CVE-2026-102938.patch +++ b/SPECS/python-virtualenv/CVE-2026-102938.patch @@ -1,74 +1,27 @@ From a30f995461043acb6cacbf3a890951563ccf7140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 17 Sep 2026 18:06:12 +0200 -Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(create):=20keep=20pyvenv.cfg?= - =?UTF-8?q?=20values=20on=20a=20single=20line=20(#3247)?= +Subject: [PATCH] fix(create): keep configuration values on one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream-reference: https://github.com/pypa/virtualenv/commit/a30f995461043acb6cacbf3a890951563ccf7140.patch + +Backport-note: Reuse the shared line-boundary sanitizer and retain exclusive +file writes. Keep only the writer fix and ordinary pytest unit coverage; do +not add optional dependency extras, property/fuzz infrastructure, or pytest +gating. --- - docs/changelog/3247.bugfix.rst | 5 ++ - pyproject.toml | 10 +++- - src/virtualenv/create/pyenv_cfg.py | 3 +- - tasks/fuzz_pyenv_cfg.py | 43 +++++++++++++++++ - tests/property/conftest.py | 12 +++++ - tests/property/test_pyenv_cfg.py | 76 ++++++++++++++++++++++++++++++ - tox.ini | 15 ++++++ - 7 files changed, 162 insertions(+), 2 deletions(-) - create mode 100644 docs/changelog/3247.bugfix.rst - create mode 100644 tasks/fuzz_pyenv_cfg.py - create mode 100644 tests/property/conftest.py - create mode 100644 tests/property/test_pyenv_cfg.py + src/virtualenv/create/pyenv_cfg.py | 3 ++- + tests/unit/create/test_pyenv_cfg.py | 42 +++++++++++++++++++++++++++++++++++ + 2 files changed, 44 insertions(+), 1 deletion(-) -diff --git a/docs/changelog/3247.bugfix.rst b/docs/changelog/3247.bugfix.rst -new file mode 100644 -index 0000000..e596d45 ---- /dev/null -+++ b/docs/changelog/3247.bugfix.rst -@@ -0,0 +1,5 @@ -+Write ``pyvenv.cfg`` values on a single line, so a prompt carrying a line boundary can no longer inject configuration. -+``--prompt``, the ``VIRTUALENV_PROMPT`` environment variable and the config file all set the prompt, and ``pyvenv.cfg`` -+has no escape syntax, so a newline, a carriage return, or any other boundary ``str.splitlines`` recognizes, such as -+``U+2028``, started a new configuration line. Reading the file back picked up those lines as keys, and since the last -+value for a key wins, they replaced anything written earlier, including ``home``. -diff --git a/pyproject.toml b/pyproject.toml -index 03706b2..82ed4e7 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -54,6 +54,13 @@ optional-dependencies.docs = [ - "sphinxcontrib-towncrier>=0.2.1a0", - "towncrier>=23.6", - ] -+optional-dependencies.fuzz = [ -+ # Atheris only ships prebuilt Linux wheels for these CPython versions; other platforms need a custom build. -+ "atheris>=2.3; sys_platform=='linux' and python_version>='3.12' and python_version<'3.15'", -+] -+optional-dependencies.property = [ -+ "hypothesis>=6.140", -+] - optional-dependencies.test = [ - "covdefaults>=2.3", - "coverage>=7.2.7", -@@ -150,10 +157,11 @@ max_supported_python = "3.14" - - [tool.pytest.ini_options] - markers = [ -+ "property: hypothesis property tests, run by their own tox environment", - "slow", - ] - timeout = 600 --addopts = "--showlocals --no-success-flaky-report" -+addopts = "--showlocals --no-success-flaky-report -m 'not property'" - env = [ - "PYTHONIOENCODING=utf-8", - ] diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cfg.py index 4ac788d..4645f94 100644 --- a/src/virtualenv/create/pyenv_cfg.py +++ b/src/virtualenv/create/pyenv_cfg.py -@@ -5,6 +5,7 @@ import os +@@ -5,6 +5,7 @@ from collections import OrderedDict from virtualenv.util.path import write_file @@ -76,7 +29,7 @@ index 4ac788d..4645f94 100644 LOGGER = logging.getLogger(__name__) -@@ -38,7 +39,7 @@ class PyEnvCfg: +@@ -38,7 +39,7 @@ text = "" for key, value in self.content.items(): normalized_value = os.path.realpath(value) if value and os.path.exists(value) else value @@ -85,181 +38,53 @@ index 4ac788d..4645f94 100644 LOGGER.debug("\t%s", line) text += line text += "\n" -diff --git a/tasks/fuzz_pyenv_cfg.py b/tasks/fuzz_pyenv_cfg.py -new file mode 100644 -index 0000000..fb37af2 ---- /dev/null -+++ b/tasks/fuzz_pyenv_cfg.py -@@ -0,0 +1,43 @@ -+""" -+Fuzz the pyvenv.cfg writer and reader for configuration injection. -+ -+This asserts the same invariant as tests/property/test_pyenv_cfg.py, with a coverage-guided driver rather than -+hypothesis: a value must never be read back as an extra configuration key. That is the property that #3247 violated, -+where a prompt containing a line boundary could override ``home``. -+ -+Run directly: python tasks/fuzz_pyenv_cfg.py -runs=100000 -+ -+""" -+ -+from __future__ import annotations -+ -+import sys -+import tempfile -+from collections import OrderedDict -+from pathlib import Path -+ -+import atheris -+ -+with atheris.instrument_imports(): -+ from virtualenv.create.pyenv_cfg import PyEnvCfg -+ -+ -+@atheris.instrument_func -+def test_one_input(data: bytes) -> None: -+ provider = atheris.FuzzedDataProvider(data) -+ keys = ("home", "prompt", "version", "base-prefix") -+ content = OrderedDict( -+ (keys[i], provider.ConsumeUnicodeNoSurrogates(provider.remaining_bytes() // (len(keys) - i or 1))) -+ for i in range(len(keys)) -+ ) -+ with tempfile.TemporaryDirectory() as folder: -+ path = Path(folder) / "pyvenv.cfg" -+ PyEnvCfg(content, path).write() -+ read_back = PyEnvCfg.from_file(path).content -+ extra = set(read_back) - set(content) -+ assert not extra, (extra, dict(content)) # noqa: S101 # Atheris treats AssertionError as the crash signal. -+ -+ -+if __name__ == "__main__": -+ atheris.Setup(sys.argv, test_one_input) -+ atheris.Fuzz() -diff --git a/tests/property/conftest.py b/tests/property/conftest.py -new file mode 100644 -index 0000000..b5e7a98 ---- /dev/null -+++ b/tests/property/conftest.py -@@ -0,0 +1,12 @@ -+"""Keep the property tests out of collection when hypothesis is absent. -+ -+The marker deselection in addopts runs after import, so without this the default test environments, which do not install -+hypothesis, fail at collection rather than skipping. -+ -+""" -+ -+from __future__ import annotations -+ -+from importlib.util import find_spec -+ -+collect_ignore_glob = [] if find_spec("hypothesis") else ["*.py"] -diff --git a/tests/property/test_pyenv_cfg.py b/tests/property/test_pyenv_cfg.py +diff --git a/tests/unit/create/test_pyenv_cfg.py b/tests/unit/create/test_pyenv_cfg.py new file mode 100644 -index 0000000..cb36f2e +index 0000000..932a5d1 --- /dev/null -+++ b/tests/property/test_pyenv_cfg.py -@@ -0,0 +1,76 @@ -+"""Property tests for the pyvenv.cfg reader and writer. -+ -+pyvenv.cfg is a line-based format with no escape syntax, so the invariant that matters is that a value can never be read -+back as extra configuration. These properties are what caught #3247: a prompt containing a line boundary used to inject -+arbitrary keys, overriding any key written before it, including ``home``. -+ -+""" -+ ++++ b/tests/unit/create/test_pyenv_cfg.py +@@ -0,0 +1,42 @@ +from __future__ import annotations + -+import os +from collections import OrderedDict + +import pytest -+from hypothesis import example, given -+from hypothesis import strategies as st + +from virtualenv.create.pyenv_cfg import PyEnvCfg + -+pytestmark = pytest.mark.property -+ -+# Anything str.splitlines() treats as a boundary is what makes a value escape its own line. -+LINE_BOUNDARIES = "\n\r\v\f\x1c\x1d\x1e\x85" + chr(0x2028) + chr(0x2029) -+ -+# Lone surrogates cannot be written as UTF-8, which is a format limit shared with stdlib venv. -+printable = st.characters(blacklist_categories=("Cs",), max_codepoint=0x2100) + -+keys = st.text( -+ st.characters(blacklist_categories=("Cs",), blacklist_characters="=" + LINE_BOUNDARIES), min_size=1 -+).filter(lambda k: k.strip() == k and not k.startswith("#")) -+contents = st.dictionaries(keys, st.text(printable), max_size=6) -+ -+ -+def _round_trip(content: dict[str, str], tmp_path) -> dict[str, str]: ++@pytest.mark.parametrize("boundary", ["\n", "\r", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"]) ++@pytest.mark.parametrize("field", ["key", "value"]) ++def test_write_keeps_configuration_on_one_line_per_key(tmp_path, boundary, field): + path = tmp_path / "pyvenv.cfg" -+ PyEnvCfg(OrderedDict(content), path).write() -+ return dict(PyEnvCfg.from_file(path).content) -+ -+ -+@given(content=contents) -+@example(content={"prompt": 'x"\nhome = /attacker\nprompt = "z'}) -+@example(content={"prompt": "a" + chr(0x2028) + "home = /attacker"}) -+@example(content={"home": "a\r\nevil = 1"}) -+def test_writing_then_reading_never_invents_keys(content, tmp_path_factory) -> None: -+ """A value must never be read back as a new configuration key.""" -+ read_back = _round_trip(content, tmp_path_factory.mktemp("cfg")) -+ -+ assert set(read_back) <= set(content) -+ -+ -+@given(content=contents) -+@example(content={"prompt": 'x"\nhome = /attacker\nprompt = "z'}) -+def test_written_file_has_one_line_per_key(content, tmp_path_factory) -> None: -+ """However exotic the values, the file keeps its line-per-key shape.""" -+ path = tmp_path_factory.mktemp("cfg") / "pyvenv.cfg" -+ PyEnvCfg(OrderedDict(content), path).write() -+ -+ lines = path.read_text(encoding="utf-8").splitlines() -+ -+ assert len(lines) == len(content) -+ assert all(" = " in line for line in lines) -+ -+ -+@given( -+ content=st.dictionaries( -+ keys, -+ st.text(st.characters(blacklist_categories=("Cs",), blacklist_characters=LINE_BOUNDARIES), min_size=1).filter( -+ # Existing paths are deliberately normalized by write(), and are not under test here. -+ lambda v: v.strip() == v and v[0] not in "'\"" and v[-1] not in "'\"" and not os.path.exists(v) -+ ), -+ max_size=6, -+ ) ++ home = str(tmp_path / "trusted") ++ key = f"prompt{boundary}suffix" if field == "key" else "prompt" ++ value = f"before{boundary}home = /untrusted" if field == "value" else "literal" ++ content = OrderedDict([("home", home), (key, value)]) ++ ++ PyEnvCfg(content, path).write() ++ ++ assert len(path.read_text(encoding="utf-8").splitlines()) == len(content) ++ assert PyEnvCfg.from_file(path).content == OrderedDict([ ++ ("home", home), ++ (key.replace(boundary, " "), value.replace(boundary, " ")), ++ ]) ++ ++ ++@pytest.mark.parametrize( ++ ("value", "expected"), ++ [ ++ pytest.param("plain", "plain", id="plain"), ++ pytest.param("a'\"$HOME\\b", "a'\"$HOME\\b", id="special-characters"), ++ pytest.param("a\r\nhome = /untrusted", "a home = /untrusted", id="crlf"), ++ pytest.param("", "", id="empty"), ++ ], +) -+def test_plain_values_round_trip_unchanged(content, tmp_path_factory) -> None: -+ """A value that the format can represent comes back exactly as written.""" -+ assert _round_trip(content, tmp_path_factory.mktemp("cfg")) == content -diff --git a/tox.ini b/tox.ini -index 7cdebdb..17a6ea9 100644 ---- a/tox.ini -+++ b/tox.ini -@@ -90,6 +90,21 @@ commands = - - python upgrade_wheels.py - uv_seed = true - -+[testenv:fuzz] -+description = run the atheris fuzz target for pyvenv.cfg (Linux + CPython 3.12-3.14 only) -+extras = -+ fuzz -+commands = -+ python {toxinidir}/tasks/fuzz_pyenv_cfg.py -runs=20000 ++def test_configuration_value_round_trip(tmp_path, value, expected): ++ path = tmp_path / "pyvenv.cfg" + -+[testenv:property] -+description = run the hypothesis property tests -+extras = -+ test -+ property -+commands = -+ pytest {posargs:--junitxml "{toxworkdir}/junit.{envname}.xml" tests/property -m property} ++ PyEnvCfg(OrderedDict([("prompt", value)]), path).write() + - [testenv:release] - description = do a release, required posarg of the version number - deps = ++ assert PyEnvCfg.from_file(path).content == OrderedDict([("prompt", expected)]) -- 2.45.4 - diff --git a/SPECS/python-virtualenv/CVE-2026-113716.patch b/SPECS/python-virtualenv/CVE-2026-113716.patch index a5ce05e8a75..f5ba9b528f2 100644 --- a/SPECS/python-virtualenv/CVE-2026-113716.patch +++ b/SPECS/python-virtualenv/CVE-2026-113716.patch @@ -1,24 +1,24 @@ -From 0b97a7e86888aa4da9fc93c7e1ddec81c95b7703 Mon Sep 17 00:00:00 2001 +From 554d9f9d4f7758f5cf53f993031e29787f1f74c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= -Date: Fri, 16 Oct 2026 03:20:32 -0700 -Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix(create):=20replace=20dir?= - =?UTF-8?q?ectory=20links?= +Date: Sat, 17 Oct 2026 03:36:11 -0700 +Subject: [PATCH] fix(create): replace environment directory links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -Reported by Anthropic's OSS Scanner: ANT-2026-MUFJEG11. -Upstream-reference: https://patch-diff.githubusercontent.com/raw/pypa/virtualenv/pull/3429.patch +Upstream-reference: https://github.com/pypa/virtualenv/commit/554d9f9d4f7758f5cf53f993031e29787f1f74c5.patch + +Backport-note: Include the shared directory-containment helper required by the +older creators. The generated-file protection is applied separately afterward. --- - docs/changelog/3429.bugfix.rst | 3 + - .../builtin/via_global_self_do.py | 4 +- - src/virtualenv/create/via_global_ref/venv.py | 6 +- - src/virtualenv/util/path/__init__.py | 3 +- - src/virtualenv/util/path/_sync.py | 37 ++++++++- - tests/unit/create/test_creator.py | 51 +++++++++++- - tests/unit/test_util.py | 83 +++++++++++++++++++ + docs/changelog/3429.bugfix.rst | 3 + + .../via_global_ref/builtin/via_global_self_do.py | 4 - + src/virtualenv/create/via_global_ref/venv.py | 6 + + src/virtualenv/util/path/__init__.py | 3 - + src/virtualenv/util/path/_sync.py | 37 +++++++++ + tests/unit/create/test_creator.py | 51 ++++++++++++ + tests/unit/test_util.py | 83 ++++++++++++++++++++ 7 files changed, 179 insertions(+), 8 deletions(-) - create mode 100644 docs/changelog/3429.bugfix.rst diff --git a/docs/changelog/3429.bugfix.rst b/docs/changelog/3429.bugfix.rst new file mode 100644 @@ -33,7 +33,7 @@ diff --git a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py index 2f7f2f1..7c05ec5 100644 --- a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py +++ b/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py -@@ -8,7 +8,7 @@ from virtualenv.create.via_global_ref.builtin.ref import ( +@@ -8,7 +8,7 @@ RefMust, RefWhen, ) @@ -42,7 +42,7 @@ index 2f7f2f1..7c05ec5 100644 from .builtin_way import VirtualenvBuiltin -@@ -79,7 +79,7 @@ class ViaGlobalRefVirtualenvBuiltin(ViaGlobalRefApi, VirtualenvBuiltin, ABC): +@@ -79,7 +79,7 @@ if any(i for i in dirs if i is not directory and directory.parts == i.parts[: len(directory.parts)]): dirs.remove(directory) for directory in sorted(dirs): @@ -55,7 +55,7 @@ diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/creat index d5037bc..005d34f 100644 --- a/src/virtualenv/create/via_global_ref/venv.py +++ b/src/virtualenv/create/via_global_ref/venv.py -@@ -6,7 +6,7 @@ from copy import copy +@@ -6,7 +6,7 @@ from virtualenv.create.via_global_ref.store import handle_store_python from virtualenv.discovery.py_info import PythonInfo from virtualenv.util.error import ProcessCallFailedError @@ -64,7 +64,7 @@ index d5037bc..005d34f 100644 from virtualenv.util.subprocess import run_cmd from .api import ViaGlobalRefApi, ViaGlobalRefMeta -@@ -39,12 +39,12 @@ class Venv(ViaGlobalRefApi): +@@ -39,12 +39,12 @@ return None def create(self): @@ -103,7 +103,7 @@ diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync. index 4dd5a98..80c1c20 100644 --- a/src/virtualenv/util/path/_sync.py +++ b/src/virtualenv/util/path/_sync.py -@@ -4,7 +4,10 @@ import logging +@@ -4,7 +4,10 @@ import os import shutil import sys @@ -115,10 +115,12 @@ index 4dd5a98..80c1c20 100644 LOGGER = logging.getLogger(__name__) -@@ -15,6 +18,37 @@ def ensure_dir(path): +@@ -13,6 +16,37 @@ + if not path.exists(): + LOGGER.debug("create folder %s", path) os.makedirs(str(path)) - - ++ ++ +def ensure_within(root, path): + parts = path.relative_to(root).parts + if ".." in parts: @@ -148,12 +150,10 @@ index 4dd5a98..80c1c20 100644 + ): + msg = f"environment directory is not a real directory: {folder}" + raise NotADirectoryError(msg) from None -+ -+ + + def ensure_safe_to_do(src, dest): - if src == dest: - msg = f"source and destination is the same {src}" -@@ -79,6 +113,7 @@ __all__ = [ +@@ -79,6 +113,7 @@ "copy", "copytree", "ensure_dir", @@ -165,7 +165,7 @@ diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator. index c72e830..e22f028 100644 --- a/tests/unit/create/test_creator.py +++ b/tests/unit/create/test_creator.py -@@ -29,7 +29,7 @@ from virtualenv.create.via_global_ref import api +@@ -29,7 +29,7 @@ from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix from virtualenv.discovery.py_info import PythonInfo @@ -174,7 +174,7 @@ index c72e830..e22f028 100644 from virtualenv.run import cli_run, session_via_cli CURRENT = PythonInfo.current_system() -@@ -94,6 +94,55 @@ def system(session_app_data): +@@ -94,6 +94,55 @@ CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] @@ -234,7 +234,7 @@ diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index b83c247..10ed7e7 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py -@@ -1,14 +1,97 @@ +@@ -1,12 +1,95 @@ from __future__ import annotations import concurrent.futures @@ -250,8 +250,8 @@ index b83c247..10ed7e7 100644 from virtualenv.util.lock import ReentrantFileLock +from virtualenv.util.path import ensure_within from virtualenv.util.subprocess import run_cmd - - ++ ++ +@pytest.mark.parametrize("existing", [False, True]) +def test_environment_directories_create_nested_path(tmp_path, existing): + folder = tmp_path / "env" / "lib" / "python" @@ -327,11 +327,8 @@ index b83c247..10ed7e7 100644 + assert link.resolve() == link + assert not list(target.iterdir()) + assert f"replace environment directory junction {link}" in caplog.text -+ -+ + + def test_run_fail(tmp_path): - code, out, err = run_cmd([str(tmp_path)]) - assert err -- 2.45.4 - diff --git a/SPECS/python-virtualenv/CVE-2026-113719.patch b/SPECS/python-virtualenv/CVE-2026-113719.patch index 8ab99e136c9..83af93b77f7 100644 --- a/SPECS/python-virtualenv/CVE-2026-113719.patch +++ b/SPECS/python-virtualenv/CVE-2026-113719.patch @@ -1,21 +1,20 @@ -From d4c57191ece240e7e3c8c5112acad240c5d2c7ee Mon Sep 17 00:00:00 2001 +From e3cea6fa10a50fa29cf1fdddd772458fba076d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= -Date: Fri, 16 Oct 2026 03:19:37 -0700 -Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(seed):=20contain=20metad?= - =?UTF-8?q?ata=20cleanup?= +Date: Sat, 17 Oct 2026 03:19:58 -0700 +Subject: [PATCH] fix(seed): contain metadata cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -Reported by Anthropic's OSS Scanner: ANT-2026-SXOEHWP7. +Upstream-reference: https://github.com/pypa/virtualenv/commit/e3cea6fa10a50fa29cf1fdddd772458fba076d5f.patch -Upstream-reference: https://patch-diff.githubusercontent.com/raw/pypa/virtualenv/pull/3428.patch +Backport-note: Adapt the metadata cleanup to the older seeder and preserve the +preceding console-launcher backport. --- - docs/changelog/3428.bugfix.rst | 3 + - .../embed/via_app_data/pip_install/base.py | 35 +++++--- - .../embed/test_bootstrap_link_via_app_data.py | 82 +++++++++++++++++++ - 3 files changed, 109 insertions(+), 11 deletions(-) - create mode 100644 docs/changelog/3428.bugfix.rst + docs/changelog/3428.bugfix.rst | 3 + + .../seed/embed/via_app_data/pip_install/base.py | 37 ++++++--- + .../seed/embed/test_bootstrap_link_via_app_data.py | 82 ++++++++++++++++++++ + 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/docs/changelog/3428.bugfix.rst b/docs/changelog/3428.bugfix.rst new file mode 100644 @@ -30,7 +29,7 @@ diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/vi index 3823103..c734794 100644 --- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py -@@ -149,35 +149,48 @@ class PipInstall(ABC): +@@ -149,35 +149,48 @@ paths = (p for p in in_folders if p.stem.split("-")[0] == dist_name and p.suffix == ".dist-info" and p.is_dir()) existing_dist = next(paths, None) if existing_dist is not None: @@ -51,9 +50,10 @@ index 3823103..c734794 100644 else set() ) - paths.add(dist) # add the dist-info folder itself -+ paths.add(dist) - +- - base_dirs, record = paths.copy(), dist / "RECORD" # collect entries in record that we did not register yet ++ paths.add(dist) ++ + base_dirs = paths.copy() + record = dist / "RECORD" for name in ( @@ -94,10 +94,12 @@ diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/u index ac62117..4b178dd 100644 --- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py -@@ -26,6 +26,88 @@ if TYPE_CHECKING: - from pytest_mock import MockerFixture - +@@ -24,6 +24,88 @@ + if TYPE_CHECKING: + from pytest_mock import MockerFixture ++ ++ +@pytest.mark.parametrize( + ("metadata_file", "content"), + [ @@ -178,11 +180,8 @@ index ac62117..4b178dd 100644 + linked, + False, + ) -+ -+ + + @pytest.mark.parametrize("source", ["environment", "configuration", "command-line"]) - def test_transient_seed_survives_cache_cleanup(tmp_path, source, unusable_cache): - config = tmp_path / "virtualenv.ini" -- 2.45.4 - diff --git a/SPECS/python-virtualenv/CVE-2026-113720.patch b/SPECS/python-virtualenv/CVE-2026-113720.patch index 768b1a6daaf..6210d0d5664 100644 --- a/SPECS/python-virtualenv/CVE-2026-113720.patch +++ b/SPECS/python-virtualenv/CVE-2026-113720.patch @@ -1,19 +1,22 @@ -From dbf30cf5a0cc257bb16e6a257ecdd3e723545813 Mon Sep 17 00:00:00 2001 +From 35f0b33562790176dd186d0a3c517af3acc7a0e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= -Date: Fri, 16 Oct 2026 03:06:10 -0700 -Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix:=20apply=20umask=20to=20?= - =?UTF-8?q?copied=20files?= +Date: Sat, 17 Oct 2026 02:57:36 -0700 +Subject: [PATCH] fix(copy): apply the umask and replace dangling links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -Upstream-reference: https://patch-diff.githubusercontent.com/raw/pypa/virtualenv/pull/3425.patch +Upstream-reference: https://github.com/pypa/virtualenv/commit/35f0b33562790176dd186d0a3c517af3acc7a0e1.patch + +Backport-note: Also restore upstream's dangling-symlink destination cleanup +prerequisite before using exclusive file creation. Add file, directory, +copytree, and builtin-creator regression tests without following link targets. --- - docs/changelog/3425.bugfix.rst | 4 +++ - src/virtualenv/util/path/_sync.py | 30 ++++++++++++-------- - tests/unit/test_util.py | 46 ++++++++++++++++++++++++++++++- - 3 files changed, 68 insertions(+), 12 deletions(-) - create mode 100644 docs/changelog/3425.bugfix.rst + docs/changelog/3425.bugfix.rst | 4 ++ + src/virtualenv/util/path/_sync.py | 32 +++++++++----- + tests/unit/test_util.py | 82 +++++++++++++++++++++++++++++++++++++ + tests/unit/create/test_creator.py | 23 +++++++++- + 4 files changed, 126 insertions(+), 15 deletions(-) diff --git a/docs/changelog/3425.bugfix.rst b/docs/changelog/3425.bugfix.rst new file mode 100644 @@ -26,10 +29,10 @@ index 0000000..ff158d1 +world write access, even with a permissive umask. Source modes are not preserved: data files start from 0644 and +executable files from 0755 before applying the umask. diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py -index 80c1c20..ab748c5 100644 +index 80c1c20..f44a589 100644 --- a/src/virtualenv/util/path/_sync.py +++ b/src/virtualenv/util/path/_sync.py -@@ -4,7 +4,8 @@ import logging +@@ -4,7 +4,8 @@ import os import shutil import sys @@ -39,7 +42,16 @@ index 80c1c20..ab748c5 100644 if os.name == "nt": from stat import IO_REPARSE_TAG_MOUNT_POINT -@@ -70,22 +71,29 @@ def symlink(src, dest): +@@ -53,7 +54,7 @@ + if src == dest: + msg = f"source and destination is the same {src}" + raise ValueError(msg) +- if not dest.exists(): ++ if not dest.exists() and not dest.is_symlink(): + return + if dest.is_dir() and not dest.is_symlink(): + LOGGER.debug("remove directory %s", dest) +@@ -70,22 +71,29 @@ def copy(src, dest): @@ -80,18 +92,18 @@ index 80c1c20..ab748c5 100644 def safe_delete(dest): diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py -index 10ed7e7..9fce8ee 100644 +index 10ed7e7..fce3f18 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py -@@ -11,10 +11,54 @@ import pytest +@@ -11,8 +11,88 @@ from virtualenv.info import fs_supports_symlink from virtualenv.util.lock import ReentrantFileLock -from virtualenv.util.path import ensure_within -+from virtualenv.util.path import copy, ensure_within ++from virtualenv.util.path import copy, copytree, ensure_within from virtualenv.util.subprocess import run_cmd - - ++ ++ +@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits") +@pytest.mark.parametrize("directory", [pytest.param(False, id="file"), pytest.param(True, id="tree")]) +@pytest.mark.parametrize( @@ -136,9 +148,85 @@ index 10ed7e7..9fce8ee 100644 + assert (destination / "entry" if directory else destination).read_bytes() == b"new" + + ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++@pytest.mark.parametrize("directory", [pytest.param(False, id="file"), pytest.param(True, id="tree")]) ++def test_copy_replaces_dangling_destination_link(tmp_path, directory): ++ source = tmp_path / "source" ++ source.mkdir() ++ payload = source / "entry" ++ payload.write_bytes(b"new") ++ target = tmp_path / "missing" ++ destination = tmp_path / "destination" ++ destination.symlink_to(target, target_is_directory=directory) ++ ++ copy(source if directory else payload, destination) ++ ++ assert not destination.is_symlink() ++ assert (destination / "entry" if directory else destination).read_bytes() == b"new" ++ assert not target.exists() ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++def test_copytree_replaces_dangling_file_link(tmp_path): ++ source = tmp_path / "source" ++ source.mkdir() ++ (source / "entry").write_bytes(b"new") ++ destination = tmp_path / "destination" ++ destination.mkdir() ++ target = tmp_path / "missing" ++ link = destination / "entry" ++ link.symlink_to(target) ++ ++ copytree(str(source), str(destination)) ++ ++ assert not link.is_symlink() ++ assert link.read_bytes() == b"new" ++ assert not target.exists() + + @pytest.mark.parametrize("existing", [False, True]) - def test_environment_directories_create_nested_path(tmp_path, existing): - folder = tmp_path / "env" / "lib" / "python" +diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py +index 0504319..9d854da 100644 +--- a/tests/unit/create/test_creator.py ++++ b/tests/unit/create/test_creator.py +@@ -26,10 +26,10 @@ + from virtualenv.create.creator import DEBUG_SCRIPT, Creator, get_env_debug_info + from virtualenv.create.pyenv_cfg import PyEnvCfg + from virtualenv.create.via_global_ref import api +-from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew ++from virtualenv.create.via_global_ref.builtin.cpython.common import is_mac_os_framework, is_macos_brew + from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix + from virtualenv.discovery.py_info import PythonInfo +-from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink ++from virtualenv.info import IS_GRAALPY, IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink + from virtualenv.run import cli_run, session_via_cli + + CURRENT = PythonInfo.current_system() +@@ -94,6 +94,25 @@ + + + CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++@pytest.mark.skipif( ++ is_mac_os_framework(CURRENT) or is_macos_brew(CURRENT) or (IS_GRAALPY and not IS_WIN), ++ reason="requires interpreter copy support", ++) ++def test_builtin_replaces_dangling_interpreter_link(tmp_path): ++ session = session_via_cli([str(tmp_path / "env"), "--creator", "builtin", "--copies", "--no-seed", "--activators="]) ++ session.creator.bin_dir.mkdir(parents=True) ++ target = tmp_path / "missing-python" ++ session.creator.exe.symlink_to(target) ++ ++ with session: ++ session.run() ++ ++ assert session.creator.exe.is_file() ++ assert not session.creator.exe.is_symlink() ++ assert not target.exists() + + + @pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") -- 2.45.4 - diff --git a/SPECS/python-virtualenv/CVE-2026-113721.patch b/SPECS/python-virtualenv/CVE-2026-113721.patch index 601c816b2b6..f8e6eb05f0a 100644 --- a/SPECS/python-virtualenv/CVE-2026-113721.patch +++ b/SPECS/python-virtualenv/CVE-2026-113721.patch @@ -1,29 +1,29 @@ From d25f28699eefb1fc34a251e5adf2b5de844c36cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 17 Oct 2026 03:25:09 -0700 -Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(create):=20replace=20generat?= - =?UTF-8?q?ed=20file=20links=20(#3431)?= +Subject: [PATCH] fix(create): replace generated file links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - Upstream-reference: https://github.com/pypa/virtualenv/commit/d25f28699eefb1fc34a251e5adf2b5de844c36cd.patch + +Backport-note: Rebase on the preceding dangling-link cleanup and regression +tests. Run import-hook coverage with the current interpreter because this +release still generates those hooks there. --- - docs/changelog/3431.bugfix.rst | 5 + - src/virtualenv/activation/via_template.py | 11 +- - src/virtualenv/create/creator.py | 6 +- - src/virtualenv/create/pyenv_cfg.py | 4 +- - src/virtualenv/create/via_global_ref/api.py | 5 +- - src/virtualenv/create/via_global_ref/venv.py | 5 + - .../embed/via_app_data/pip_install/base.py | 4 + - src/virtualenv/util/path/__init__.py | 3 +- - src/virtualenv/util/path/_sync.py | 9 ++ - tests/unit/create/test_creator.py | 120 +++++++++++++++++- - tests/unit/test_util.py | 11 +- - 11 files changed, 166 insertions(+), 17 deletions(-) - create mode 100644 docs/changelog/3431.bugfix.rst + docs/changelog/3431.bugfix.rst | 5 + + src/virtualenv/activation/via_template.py | 11 +- + src/virtualenv/create/creator.py | 6 + + src/virtualenv/create/pyenv_cfg.py | 4 + + src/virtualenv/create/via_global_ref/api.py | 5 + + src/virtualenv/create/via_global_ref/venv.py | 5 + + .../seed/embed/via_app_data/pip_install/base.py | 4 + + src/virtualenv/util/path/__init__.py | 3 - + src/virtualenv/util/path/_sync.py | 9 ++ + tests/unit/create/test_creator.py | 116 ++++++++++++++++++++ + tests/unit/test_util.py | 11 ++ + 11 files changed, 164 insertions(+), 15 deletions(-) diff --git a/docs/changelog/3431.bugfix.rst b/docs/changelog/3431.bugfix.rst new file mode 100644 @@ -40,16 +40,16 @@ diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activati index f40e454..8287096 100644 --- a/src/virtualenv/activation/via_template.py +++ b/src/virtualenv/activation/via_template.py -@@ -6,6 +6,8 @@ import shlex +@@ -5,6 +5,8 @@ + import shlex import sys from abc import ABC, abstractmethod - -+from virtualenv.util.path import write_file + ++from virtualenv.util.path import write_file + from .activator import Activator - if sys.version_info >= (3, 10): -@@ -57,14 +59,9 @@ class ViaTemplateActivator(Activator, ABC): +@@ -57,14 +59,9 @@ for template in templates: text = self.instantiate_template(replacements, template, creator) dest = to_folder / self.as_name(template) @@ -70,7 +70,7 @@ diff --git a/src/virtualenv/create/creator.py b/src/virtualenv/create/creator.py index 1e577ad..4eaccaa 100644 --- a/src/virtualenv/create/creator.py +++ b/src/virtualenv/create/creator.py -@@ -12,7 +12,7 @@ from collections import OrderedDict +@@ -12,7 +12,7 @@ from pathlib import Path from virtualenv.discovery.cached_py_info import LogCmd @@ -79,7 +79,7 @@ index 1e577ad..4eaccaa 100644 from virtualenv.util.subprocess import run_cmd from virtualenv.version import __version__ -@@ -173,7 +173,7 @@ class Creator(ABC): +@@ -173,7 +173,7 @@ # For information about cache directory tags, see: # https://bford.info/cachedir/ """).strip() @@ -88,7 +88,7 @@ index 1e577ad..4eaccaa 100644 def set_pyenv_cfg(self): self.pyenv_cfg.content = OrderedDict() -@@ -187,7 +187,7 @@ class Creator(ABC): +@@ -187,7 +187,7 @@ # mark this folder to be ignored by VCS, handle https://www.python.org/dev/peps/pep-0610/#registered-vcs git_ignore = self.dest / ".gitignore" if not git_ignore.exists(): @@ -101,16 +101,16 @@ diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cf index 1d1beac..4ac788d 100644 --- a/src/virtualenv/create/pyenv_cfg.py +++ b/src/virtualenv/create/pyenv_cfg.py -@@ -4,6 +4,8 @@ import logging +@@ -3,6 +3,8 @@ + import logging import os from collections import OrderedDict - -+from virtualenv.util.path import write_file + - LOGGER = logging.getLogger(__name__) ++from virtualenv.util.path import write_file + LOGGER = logging.getLogger(__name__) -@@ -40,7 +42,7 @@ class PyEnvCfg: +@@ -40,7 +42,7 @@ LOGGER.debug("\t%s", line) text += line text += "\n" @@ -123,7 +123,7 @@ diff --git a/src/virtualenv/create/via_global_ref/api.py b/src/virtualenv/create index 3f04f46..462eb93 100644 --- a/src/virtualenv/create/via_global_ref/api.py +++ b/src/virtualenv/create/via_global_ref/api.py -@@ -7,6 +7,7 @@ from pathlib import Path +@@ -7,6 +7,7 @@ from virtualenv.create.creator import Creator, CreatorMeta from virtualenv.info import fs_supports_symlink @@ -131,7 +131,7 @@ index 3f04f46..462eb93 100644 LOGGER = logging.getLogger(__name__) -@@ -96,10 +97,10 @@ class ViaGlobalRefApi(Creator, ABC): +@@ -96,10 +97,10 @@ if text: pth = self.purelib / "_virtualenv.pth" LOGGER.debug("create virtualenv import hook file %s", pth) @@ -145,10 +145,10 @@ index 3f04f46..462eb93 100644 def env_patch_text(self): """Patch the distutils package to not be derailed by its configuration files.""" diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py -index 1de0d68..365e373 100644 +index e852b46..4eab2de 100644 --- a/src/virtualenv/create/via_global_ref/venv.py +++ b/src/virtualenv/create/via_global_ref/venv.py -@@ -47,8 +47,13 @@ class Venv(ViaGlobalRefApi): +@@ -40,8 +40,13 @@ return None def create(self): @@ -166,7 +166,7 @@ diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/vi index c734794..70cb698 100644 --- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py -@@ -219,6 +219,10 @@ class ScriptMakerCustom(ScriptMaker): +@@ -219,6 +219,10 @@ def _write_script(self, names, shebang, script_bytes, filenames, ext): names.add(f"{self._name}{self.version_info[0]}.{self.version_info[1]}") @@ -190,58 +190,47 @@ index f2cccc8..451630f 100644 from ._win import get_short_path_name __all__ = [ -@@ -14,4 +14,5 @@ __all__ = [ +@@ -14,4 +14,5 @@ "safe_delete", "set_tree", "symlink", + "write_file", ] diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py -index ab748c5..152f9e2 100644 +index f44a589..a29a810 100644 --- a/src/virtualenv/util/path/_sync.py +++ b/src/virtualenv/util/path/_sync.py -@@ -50,6 +50,14 @@ def ensure_within(root, path): +@@ -48,6 +48,14 @@ + ): + msg = f"environment directory is not a real directory: {folder}" raise NotADirectoryError(msg) from None - - ++ ++ +def write_file(path, data): + if path.is_symlink() or path.exists(): + path.unlink() + # Exclusive creation rejects links introduced after the old entry was removed. + with path.open("xb") as stream: + stream.write(data) -+ -+ + + def ensure_safe_to_do(src, dest): - if src == dest: - msg = f"source and destination is the same {src}" -@@ -125,4 +133,5 @@ __all__ = [ +@@ -125,4 +133,5 @@ "safe_delete", "symlink", "symlink", + "write_file", ] diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py -index 0504319..0c70d4e 100644 +index 9d854da..d20f843 100644 --- a/tests/unit/create/test_creator.py +++ b/tests/unit/create/test_creator.py -@@ -26,10 +26,10 @@ from virtualenv.__main__ import run, run_with_catch - from virtualenv.create.creator import DEBUG_SCRIPT, Creator, get_env_debug_info - from virtualenv.create.pyenv_cfg import PyEnvCfg - from virtualenv.create.via_global_ref import api --from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew -+from virtualenv.create.via_global_ref.builtin.cpython.common import is_mac_os_framework, is_macos_brew - from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix - from virtualenv.discovery.py_info import PythonInfo --from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink -+from virtualenv.info import IS_GRAALPY, IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink - from virtualenv.run import cli_run, session_via_cli - - CURRENT = PythonInfo.current_system() -@@ -96,6 +96,122 @@ def system(session_app_data): - CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] - - +@@ -113,6 +113,122 @@ + assert session.creator.exe.is_file() + assert not session.creator.exe.is_symlink() + assert not target.exists() ++ ++ +@pytest.mark.parametrize("creator_name", [pytest.param("builtin", id="builtin"), pytest.param("venv", id="venv")]) +@pytest.mark.parametrize("existing", [pytest.param(False, id="dangling"), pytest.param(True, id="existing")]) +@pytest.mark.parametrize( @@ -356,25 +345,23 @@ index 0504319..0c70d4e 100644 + assert [((tmp_path / f"{path.name}.original").read_bytes(), path.lstat().st_nlink) for path in planted] == [ + (b"original", 1) + ] * len(planted) -+ -+ + + @pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") - @pytest.mark.parametrize( - "boundary", diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py -index 9fce8ee..5f522f4 100644 +index fce3f18..bb94bd0 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py -@@ -11,7 +11,7 @@ import pytest +@@ -11,7 +11,7 @@ from virtualenv.info import fs_supports_symlink from virtualenv.util.lock import ReentrantFileLock --from virtualenv.util.path import copy, ensure_within -+from virtualenv.util.path import copy, ensure_within, write_file +-from virtualenv.util.path import copy, copytree, ensure_within ++from virtualenv.util.path import copy, copytree, ensure_within, write_file from virtualenv.util.subprocess import run_cmd -@@ -136,6 +136,15 @@ def test_environment_directories_report_replaced_junction(tmp_path, caplog): +@@ -172,6 +172,15 @@ assert f"replace environment directory junction {link}" in caplog.text @@ -392,4 +379,3 @@ index 9fce8ee..5f522f4 100644 assert err -- 2.45.4 - diff --git a/SPECS/python-virtualenv/CVE-2026-113723.patch b/SPECS/python-virtualenv/CVE-2026-113723.patch index d76496ba7ca..6f42220396c 100644 --- a/SPECS/python-virtualenv/CVE-2026-113723.patch +++ b/SPECS/python-virtualenv/CVE-2026-113723.patch @@ -1,19 +1,21 @@ -From 829486f85bbb761e38c91fdd94355fd95ca23729 Mon Sep 17 00:00:00 2001 +From 5f48d711731311428aebfae61d46957762155b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= -Date: Fri, 16 Oct 2026 02:07:18 -0700 -Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=90=9B=20fix(bash):=20limit=20prompt?= - =?UTF-8?q?=20changes=20by=20shell?= +Date: Sat, 17 Oct 2026 02:35:17 -0700 +Subject: [PATCH] fix(activation): preserve Bash compatibility-mode prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -Upstream-reference: https://patch-diff.githubusercontent.com/raw/pypa/virtualenv/pull/3418.patch +Upstream-reference: https://github.com/pypa/virtualenv/commit/5f48d711731311428aebfae61d46957762155b22.patch + +Backport-note: Apply the final squashed upstream implementation to the older +activation template and test harness. Apply CVE-2026-115586.patch afterward to +fix the subsequent prompt-state regression. --- - docs/changelog/3418.bugfix.rst | 5 + - src/virtualenv/activation/bash/activate.sh | 23 +++- - tests/unit/activation/test_bash.py | 153 ++++++++++++++++++++- + docs/changelog/3418.bugfix.rst | 5 + + src/virtualenv/activation/bash/activate.sh | 23 ++++ + tests/unit/activation/test_bash.py | 153 ++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 4 deletions(-) - create mode 100644 docs/changelog/3418.bugfix.rst diff --git a/docs/changelog/3418.bugfix.rst b/docs/changelog/3418.bugfix.rst new file mode 100644 @@ -30,7 +32,7 @@ diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activat index 0221057..49825ec 100644 --- a/src/virtualenv/activation/bash/activate.sh +++ b/src/virtualenv/activation/bash/activate.sh -@@ -103,9 +103,28 @@ if [ __TK_LIBRARY__ != "" ]; then +@@ -103,9 +103,28 @@ export TK_LIBRARY fi @@ -74,10 +76,11 @@ index 89a5dd4..980e0b1 100644 import subprocess from argparse import Namespace from types import SimpleNamespace -@@ -10,6 +12,149 @@ import pytest +@@ -9,6 +11,149 @@ + from virtualenv.activation import BashActivator from virtualenv.info import IS_WIN - ++ +_BASH = shutil.which("bash") + + @@ -220,11 +223,10 @@ index 89a5dd4..980e0b1 100644 + ) + assert result.stdout == "base |x$(touch PWNED)y" + assert not (tmp_path / "PWNED").exists() -+ + @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") - @pytest.mark.parametrize( -@@ -92,7 +237,7 @@ def test_bash_missing_path_does_not_run_commands(tmp_path, name): +@@ -92,7 +237,7 @@ creator.bin_dir.rmdir() creator.dest.rmdir() result = subprocess.run( @@ -233,7 +235,7 @@ index 89a5dd4..980e0b1 100644 capture_output=True, text=True, cwd=str(tmp_path), -@@ -124,6 +269,10 @@ def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester): +@@ -124,6 +269,10 @@ return super().activate_call(script) + " || exit 1" def print_prompt(self): @@ -247,4 +249,3 @@ index 89a5dd4..980e0b1 100644 activation_tester(Bash) -- 2.45.4 - diff --git a/SPECS/python-virtualenv/CVE-2026-113725.patch b/SPECS/python-virtualenv/CVE-2026-113725.patch index 29320a475ed..afedc7798e1 100644 --- a/SPECS/python-virtualenv/CVE-2026-113725.patch +++ b/SPECS/python-virtualenv/CVE-2026-113725.patch @@ -1,40 +1,22 @@ From c3104e02474b652832a9bbaae79f6e1696296932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 11 Oct 2026 17:36:53 -0700 -Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(activation):=20escape=20the?= - =?UTF-8?q?=20bash=20prompt=20for=20both=20PS1=20passes=20(#3390)?= +Subject: [PATCH] test(activation): cover Bash two-pass prompt escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -bash handles `PS1` in two passes each time it draws the prompt: it first -decodes backslash prompt escapes, turning `\\` into `\` and `\$` into -`$` (or `#` for root), and then expands the result. The bash `activate` -script escaped the environment name for the expansion pass only (#3326), -so a backslash in the name undoes that escaping. 🔐 A name such as -`a\$(touch PWNED)`, from the directory, `--prompt` or -`VIRTUALENV_PROMPT`, runs the command each time bash draws the prompt. -For root, `\$` decodes to `#`, so the same name renders wrong but runs -nothing. - -Under bash, `activate` now escapes backslashes, dollar signs and -backticks a second time, so both passes leave the name literal. zsh does -no decode pass, so it keeps the single escaping added in 5b915367. The -prompt tests gain cases for a backslash before `$(...)` and before -backticks, and for names with a backslash before `$`, before a backtick, -doubled, and trailing; they render through bash 5.2 and zsh 5.9 and pass -as root and as an unprivileged user. - -Every release up to 21.14.6 is affected, and earlier releases before -21.13.0 do not escape the name at all. - Upstream-reference: https://github.com/pypa/virtualenv/commit/c3104e02474b652832a9bbaae79f6e1696296932.patch + +Backport-note: The production fix for CVE-2026-113725 is provided by +CVE-2026-113723.patch (5f48d711), whose prompt rewrite supersedes c3104e02, +with later prompt-state hardening in CVE-2026-115586.patch. This patch carries +only regression tests and the upstream changelog; it does not change the +activator or the stdlib creator. --- - docs/changelog/3390.bugfix.rst | 3 +++ - src/virtualenv/create/via_global_ref/venv.py | 7 +++++++ - tests/unit/activation/test_bash.py | 6 ++++++ - 3 files changed, 16 insertions(+) - create mode 100644 docs/changelog/3390.bugfix.rst + docs/changelog/3390.bugfix.rst | 3 +++ + tests/unit/activation/test_bash.py | 6 ++++++ + 2 files changed, 9 insertions(+) diff --git a/docs/changelog/3390.bugfix.rst b/docs/changelog/3390.bugfix.rst new file mode 100644 @@ -45,29 +27,11 @@ index 0000000..de64ad8 +Escape the environment name in the bash activator for both of bash's ``PS1`` passes, so a name or ``--prompt`` value +with a backslash before ``$(...)`` or backticks no longer runs as a command each time bash draws the prompt - by +:user:`gaborbernat`. -diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py -index e852b46..1de0d68 100644 ---- a/src/virtualenv/create/via_global_ref/venv.py -+++ b/src/virtualenv/create/via_global_ref/venv.py -@@ -1,6 +1,13 @@ - from __future__ import annotations - -+import csv -+import hashlib -+import io - import logging -+import os -+from base64 import urlsafe_b64encode -+from importlib.metadata import distributions -+from stat import S_IMODE - from argparse import ArgumentTypeError - - from virtualenv.create.via_global_ref.store import handle_store_python diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py index ee0c805..c5b4dce 100644 --- a/tests/unit/activation/test_bash.py +++ b/tests/unit/activation/test_bash.py -@@ -68,6 +68,8 @@ def prompt_after_activate(request, tmp_path, bash_activate_script): +@@ -68,6 +68,8 @@ pytest.param("x$(touch PWNED)y", "env", id="prompt-command-substitution"), pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"), pytest.param("x${$(touch PWNED)}y", "env", id="prompt-parameter-expansion"), @@ -76,7 +40,7 @@ index ee0c805..c5b4dce 100644 pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"), pytest.param(None, "A__VIRTUAL_NAME__B/x'$(touch PWNED)'y", id="placeholder-in-parent-dir"), ], -@@ -84,6 +86,10 @@ def test_prompt_does_not_run_commands(prompt_after_activate, prompt, name): +@@ -84,6 +86,10 @@ [ pytest.param("myenv", id="plain"), pytest.param(r"a%n$HOME\b`c`", id="special-characters"), @@ -89,4 +53,3 @@ index ee0c805..c5b4dce 100644 def test_prompt_renders_name_literally(prompt_after_activate, prompt): -- 2.45.4 - diff --git a/SPECS/python-virtualenv/CVE-2026-113726.patch b/SPECS/python-virtualenv/CVE-2026-113726.patch index cc9583eedaf..7cd0aff44ce 100644 --- a/SPECS/python-virtualenv/CVE-2026-113726.patch +++ b/SPECS/python-virtualenv/CVE-2026-113726.patch @@ -1,23 +1,26 @@ -The bash activate script, which zsh also sources, escaped backslashes, -dollar signs and backticks in the environment name only under bash. -zsh with PROMPT_SUBST re-expands PS1 at every prompt draw, so a name -such as x$(touch PWNED)y ran as a command each time the prompt showed. - -Apply the same escaping under zsh, also when PROMPT_SUBST is off since -it can be set after activation, and double % so zsh shows the name -instead of reading it as prompt escapes. The prompt tests now render -PS1 through zsh as well as bash. +From 5b915367da82bd024b2a0d28d2f98007034b4aa2 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Fri, 2 Oct 2026 06:40:23 -0700 +Subject: [PATCH] test(activation): cover Zsh prompt substitution +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit Upstream-reference: https://github.com/pypa/virtualenv/commit/5b915367da82bd024b2a0d28d2f98007034b4aa2.patch + +Backport-note: The production fix for CVE-2026-113726 is provided by +CVE-2026-113723.patch (5f48d711), with later prompt-state hardening in +CVE-2026-115586.patch. This patch carries only the Bash/Zsh regression +coverage; keep these prompt patches together. --- - tests/unit/activation/test_bash.py | 55 +++++++++++++++++++++++++++--- + tests/unit/activation/test_bash.py | 55 +++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py index 980e0b1..1c081ab 100644 --- a/tests/unit/activation/test_bash.py +++ b/tests/unit/activation/test_bash.py -@@ -32,9 +32,13 @@ def bash_activate_script(tmp_path): +@@ -32,9 +32,13 @@ return generate @@ -33,10 +36,12 @@ index 980e0b1..1c081ab 100644 input=f"{before}\nPS1=__END__\n. {BashActivator.quote(str(script))}\n{after}\nexit\n", cwd=tmp_path, env={**os.environ, "PS1": "", "PROMPT_COMMAND": ""}, -@@ -47,6 +51,47 @@ def _interactive_bash_prompt(script, tmp_path, before="", after=""): +@@ -45,6 +49,47 @@ + check=True, + ) return result.stderr - - ++ ++ +@pytest.fixture(params=["bash", "zsh"]) +def prompt_after_activate(request, tmp_path, bash_activate_script): + def build(prompt, name): @@ -76,12 +81,10 @@ index 980e0b1..1c081ab 100644 + + assert f"({prompt}) __END__" in rendered + assert not (work_dir / "PWNED").exists() -+ -+ + + @pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") - @pytest.mark.parametrize( - ("setup", "expected"), -@@ -98,7 +143,7 @@ def test_bash_prompt_renders_name_literally(tmp_path, bash_activate_script, prom +@@ -98,7 +143,7 @@ script = bash_activate_script(prompt=prompt) before = f"BASH_COMPAT={compatibility}\nshopt {'-s' if promptvars else '-u'} promptvars" @@ -90,7 +93,7 @@ index 980e0b1..1c081ab 100644 assert not (tmp_path / "PWNED").exists() assert f"({prompt}) __END__" in rendered -@@ -109,7 +154,7 @@ def test_bash_prompt_renders_name_literally(tmp_path, bash_activate_script, prom +@@ -109,7 +154,7 @@ def test_bash_prompt_from_directory_name(tmp_path, bash_activate_script, name): script = bash_activate_script(name=name) @@ -99,7 +102,7 @@ index 980e0b1..1c081ab 100644 assert not (tmp_path / "PWNED").exists() assert f"({name}) __END__" in rendered -@@ -129,7 +174,7 @@ def test_bash_prompt_from_directory_name(tmp_path, bash_activate_script, name): +@@ -129,7 +174,7 @@ def test_bash_prompt_options(tmp_path, bash_activate_script, before, after, expected): script = bash_activate_script(prompt="x$(touch PWNED)y") @@ -110,4 +113,3 @@ index 980e0b1..1c081ab 100644 assert expected in rendered -- 2.45.4 - diff --git a/SPECS/python-virtualenv/CVE-2026-113732.patch b/SPECS/python-virtualenv/CVE-2026-113732.patch index 98bf29ca948..5f0b41260ed 100644 --- a/SPECS/python-virtualenv/CVE-2026-113732.patch +++ b/SPECS/python-virtualenv/CVE-2026-113732.patch @@ -1,26 +1,25 @@ From 3bddeaf8d02c3ee7f576503efb082f8f494cfcab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 30 Sep 2026 22:59:45 -0700 -Subject: [PATCH] Merge commit from fork - -FishActivator inherited shlex.quote, whose POSIX rules leave a backslash -literal inside single quotes. fish reads \' there as an escaped quote, so a -path or --prompt with a backslash before a quote ended the literal early and -ran the rest as commands, without any error from fish. - -Give FishActivator its own quote() that escapes backslashes and quotes. +Subject: [PATCH] fix(activation): quote Fish values using Fish rules +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit Upstream-reference: https://github.com/pypa/virtualenv/commit/3bddeaf8d02c3ee7f576503efb082f8f494cfcab.patch + +Backport-note: Include the imports and Fish executable lookup missing from the +older test module, using its type-checking conventions. --- - src/virtualenv/activation/fish/__init__.py | 5 +++ - tests/unit/activation/test_fish.py | 41 ++++++++++++++++++++-- - 2 files changed, 44 insertions(+), 2 deletions(-) + src/virtualenv/activation/fish/__init__.py | 5 +++ + tests/unit/activation/test_fish.py | 46 ++++++++++++++++++++++++++-- + 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/virtualenv/activation/fish/__init__.py b/src/virtualenv/activation/fish/__init__.py index 2626356..d91da4f 100644 --- a/src/virtualenv/activation/fish/__init__.py +++ b/src/virtualenv/activation/fish/__init__.py -@@ -6,6 +6,11 @@ from virtualenv.activation.via_template import ViaTemplateActivator +@@ -6,6 +6,11 @@ class FishActivator(ViaTemplateActivator): def templates(self): yield "activate.fish" @@ -33,15 +32,20 @@ index 2626356..d91da4f 100644 def replacements(self, creator, dest): data = super().replacements(creator, dest) diff --git a/tests/unit/activation/test_fish.py b/tests/unit/activation/test_fish.py -index cd901f4..6e7fe5b 100644 +index cd901f4..fc3a65d 100644 --- a/tests/unit/activation/test_fish.py +++ b/tests/unit/activation/test_fish.py -@@ -6,12 +6,16 @@ import shutil +@@ -1,17 +1,23 @@ + from __future__ import annotations + + import os +-import shlex + import shutil import subprocess import sys from argparse import Namespace -+from pathlib import Path from types import SimpleNamespace ++from typing import TYPE_CHECKING import pytest @@ -49,11 +53,14 @@ index cd901f4..6e7fe5b 100644 from virtualenv.info import IS_WIN +from virtualenv.run import cli_run + ++if TYPE_CHECKING: ++ from pathlib import Path ++ +FISH = shutil.which("fish") @pytest.mark.parametrize( -@@ -50,12 +54,45 @@ def test_fish_tkinter_generation(tmp_path, tcl_lib, tk_lib, present): +@@ -50,11 +56,45 @@ # THEN if present: @@ -64,7 +71,8 @@ index cd901f4..6e7fe5b 100644 else: assert "if test -n ''\n if set -q TCL_LIBRARY;" in content assert "if test -n ''\n if set -q TK_LIBRARY;" in content - ++ ++ +@pytest.mark.skipif(IS_WIN, reason="fish is not available on Windows") +@pytest.mark.skipif(FISH is None, reason="fish is not installed") +@pytest.mark.parametrize( @@ -97,9 +105,8 @@ index cd901f4..6e7fe5b 100644 + + assert not (work_dir / "PWNED").exists() + assert result.stdout == str(dest), result.stderr -+ + @pytest.mark.skipif(IS_WIN, reason="fish is not available on Windows") - @pytest.mark.skipif(shutil.which("fish") is None, reason="fish is not installed") -- 2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-115586.patch b/SPECS/python-virtualenv/CVE-2026-115586.patch new file mode 100644 index 00000000000..a4fc6e0d111 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-115586.patch @@ -0,0 +1,325 @@ +From 3598e201f6f044e389136b2347a8a919ac03c5f4 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Thu, 22 Oct 2026 13:06:22 -0700 +Subject: [PATCH] fix(activation): escape prompts for every prompt state +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/3598e201f6f044e389136b2347a8a919ac03c5f4.patch + +Backport-note: Backport the prompt-state runtime changes, ordinary regression +tests, and related changelog only. Exclude unrelated CI, CodeQL, property- +test, and redirect changes from the upstream commit. Preserve the older PS1 +export behavior while escaping the stored prompt for inherited child shells. +--- + src/virtualenv/activation/bash/activate.sh | 43 ++++++--- + tests/unit/activation/test_bash.py | 129 +++++++++++++++++++++++++--- + tests/unit/activation/conftest.py | 5 + + docs/changelog/3466.bugfix.rst | 6 + + 4 files changed, 151 insertions(+), 32 deletions(-) + +diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh +index 49825ec..b89f018 100644 +--- a/src/virtualenv/activation/bash/activate.sh ++++ b/src/virtualenv/activation/bash/activate.sh +@@ -43,6 +43,11 @@ + PS1="$_OLD_VIRTUAL_PS1" + export PS1 + unset _OLD_VIRTUAL_PS1 ++ fi ++ ++ if [ -n "${_OLD_VIRTUAL_PSVAR+x}" ] ; then ++ psvar[9]="$_OLD_VIRTUAL_PSVAR" ++ unset _OLD_VIRTUAL_PSVAR + fi + + unset VIRTUAL_ENV +@@ -106,25 +111,29 @@ + if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] && + { [ -n "${BASH_VERSION-}" ] || [ -n "${ZSH_VERSION-}" ]; }; then + _OLD_VIRTUAL_PS1="${PS1-}" +- # Expand the value only after bash decodes prompt escapes. Without expansion, only backslashes need doubling. +- # zsh also needs dollar signs, backticks and percent signs escaped, including when PROMPT_SUBST is enabled later. +- if [ -n "${BASH_VERSION-}" ] && { shopt -q promptvars || shopt -qo posix; }; then +- PS1='(${VIRTUAL_ENV_PROMPT-}) '"${PS1-}" ++ if [ -n "${BASH_VERSION-}" ]; then ++ # Escape for both prompt passes regardless of current options or a child shell's options. Concatenation avoids ++ # compatibility-dependent backslash removal in parameter-substitution replacement strings. ++ _VIRTUAL_REST=$VIRTUAL_ENV_PROMPT ++ _VIRTUAL_PROMPT= ++ while [ -n "$_VIRTUAL_REST" ]; do ++ _VIRTUAL_CHAR=${_VIRTUAL_REST%"${_VIRTUAL_REST#?}"} ++ _VIRTUAL_REST=${_VIRTUAL_REST#?} ++ case $_VIRTUAL_CHAR in ++ \\) _VIRTUAL_PROMPT="$_VIRTUAL_PROMPT\\\\\\\\" ;; ++ \$ | \`) _VIRTUAL_PROMPT="$_VIRTUAL_PROMPT\\$_VIRTUAL_CHAR" ;; ++ *) _VIRTUAL_PROMPT="$_VIRTUAL_PROMPT$_VIRTUAL_CHAR" ;; ++ esac ++ done ++ unset _VIRTUAL_REST _VIRTUAL_CHAR + else +- if [ -n "${BASH_VERSION-}" ]; then +- # A literal replacement value avoids compatibility-dependent quote removal. +- _VIRTUAL_BACKSLASH='\\\\' +- _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/$_VIRTUAL_BACKSLASH}" +- unset _VIRTUAL_BACKSLASH +- else +- _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/\\\\}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}" +- fi +- PS1="(${_VIRTUAL_PROMPT}) ${PS1-}" +- unset _VIRTUAL_PROMPT ++ # zsh inserts psvar entries as data rather than parsing their contents as prompt syntax. ++ _OLD_VIRTUAL_PSVAR="${psvar[9]-}" ++ psvar[9]="$VIRTUAL_ENV_PROMPT" ++ _VIRTUAL_PROMPT='%9v' + fi ++ PS1="(${_VIRTUAL_PROMPT}) ${PS1-}" ++ unset _VIRTUAL_PROMPT + export PS1 + fi + +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index c5b4dce..f00f8c1 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -13,6 +13,16 @@ + from virtualenv.info import IS_WIN + + _BASH = shutil.which("bash") ++ ++ ++def _expected_bash_prompt(prompt, promptvars=True): ++ if not IS_WIN and os.geteuid() == 0: ++ prompt = prompt.replace("$", "#") ++ return prompt if promptvars else prompt.replace("\\", "\\\\").replace("`", "\\`") ++ ++ ++def _last_prompt(rendered): ++ return rendered.rsplit("__END__", 1)[0].rsplit("\n", 1)[-1] + "__END__" + + + @pytest.fixture +@@ -56,7 +66,7 @@ + def build(prompt, name): + script = bash_activate_script(prompt=prompt, name=name) + before = "setopt prompt_subst" if request.param == "zsh" else "" +- return tmp_path, _interactive_prompt(script, tmp_path, before=before, shell=request.param) ++ return tmp_path, _interactive_prompt(script, tmp_path, before=before, shell=request.param), request.param + + return build + +@@ -75,7 +85,7 @@ + ], + ) + def test_prompt_does_not_run_commands(prompt_after_activate, prompt, name): +- work_dir, _ = prompt_after_activate(prompt, name) ++ work_dir, _, _ = prompt_after_activate(prompt, name) + + assert not (work_dir / "PWNED").exists() + +@@ -93,9 +103,10 @@ + ], + ) + def test_prompt_renders_name_literally(prompt_after_activate, prompt): +- work_dir, rendered = prompt_after_activate(prompt, "env") +- +- assert f"({prompt}) __END__" in rendered ++ work_dir, rendered, shell = prompt_after_activate(prompt, "env") ++ ++ expected = _expected_bash_prompt(prompt) if shell == "bash" else prompt ++ assert f"({expected}) __END__" in rendered + assert not (work_dir / "PWNED").exists() + + +@@ -103,7 +114,7 @@ + @pytest.mark.parametrize( + ("setup", "expected"), + [ +- pytest.param("", "(${VIRTUAL_ENV_PROMPT-}) base ", id="bash"), ++ pytest.param("", "(example) base ", id="bash"), + pytest.param("unset BASH_VERSION ZSH_VERSION;", "base ", id="unrecognized-shell"), + pytest.param("VIRTUAL_ENV_DISABLE_PROMPT=1;", "base ", id="disabled-prompt"), + ], +@@ -153,7 +164,7 @@ + rendered = _interactive_prompt(script, tmp_path, before=before) + + assert not (tmp_path / "PWNED").exists() +- assert f"({prompt}) __END__" in rendered ++ assert f"({_expected_bash_prompt(prompt, promptvars)}) __END__" in rendered + + + @pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") +@@ -164,7 +175,7 @@ + rendered = _interactive_prompt(script, tmp_path) + + assert not (tmp_path / "PWNED").exists() +- assert f"({name}) __END__" in rendered ++ assert f"({_expected_bash_prompt(name)}) __END__" in rendered + + + @pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") +@@ -175,7 +186,7 @@ + pytest.param( + "shopt -u promptvars; set -o posix", "", "(x$(touch PWNED)y) __END__", id="promptvars-disabled-posix" + ), +- pytest.param("", "set -u; unset VIRTUAL_ENV_PROMPT", "() __END__", id="unset-with-nounset"), ++ pytest.param("", "set -u; unset VIRTUAL_ENV_PROMPT", "(x$(touch PWNED)y) __END__", id="unset-with-nounset"), + ], + ) + def test_bash_prompt_options(tmp_path, bash_activate_script, before, after, expected): +@@ -184,8 +195,99 @@ + rendered = _interactive_prompt(script, tmp_path, before=before, after=after) + + assert not (tmp_path / "PWNED").exists() ++ if not IS_WIN and os.geteuid() == 0: ++ expected = expected.replace("$", "#") + assert expected in rendered + assert "unbound variable" not in rendered ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize("compatibility", ["31", "42", "51"], ids=["bash31", "bash42", "bash51"]) ++def test_bash_prompt_stored_form(tmp_path, bash_activate_script, compatibility): ++ script = bash_activate_script(prompt="a\\b$c`d") ++ result = subprocess.run( ++ [ ++ _BASH, ++ "--noprofile", ++ "--norc", ++ "-c", ++ f'BASH_COMPAT={compatibility}; PS1=__END__; . "$1"; printf "%s" "$PS1"', ++ "test", ++ str(script), ++ ], ++ cwd=tmp_path, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ ++ assert result.stdout == "(a\\\\\\\\b\\$c\\`d) __END__" ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize("prompt", ["x$(printf EXPANDED)y", "x`printf EXPANDED`y", r"x\$(printf EXPANDED)y"]) ++@pytest.mark.parametrize("compatibility", ["31", "42", "51"], ids=["bash31", "bash42", "bash51"]) ++@pytest.mark.parametrize("promptvars", [True, False], ids=["promptvars-on", "promptvars-off"]) ++@pytest.mark.parametrize( ++ ("after", "expanded"), ++ [ ++ pytest.param("shopt -s promptvars", True, id="enable-promptvars"), ++ pytest.param("set -o posix", True, id="enable-posix"), ++ pytest.param("shopt -u promptvars", False, id="disable-promptvars"), ++ ], ++) ++def test_bash_prompt_state_changes( # noqa: PLR0913 ++ tmp_path, bash_activate_script, prompt, compatibility, promptvars, after, expanded ++): ++ script = bash_activate_script(prompt=prompt) ++ before = f"BASH_COMPAT={compatibility}\nshopt {'-s' if promptvars else '-u'} promptvars" ++ ++ rendered = _interactive_prompt(script, tmp_path, before=before, after=after) ++ ++ assert _last_prompt(rendered) == f"({_expected_bash_prompt(prompt, expanded)}) __END__" ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize("prompt", ["x$(printf EXPANDED)y", "x`printf EXPANDED`y", r"x\$(printf EXPANDED)y"]) ++@pytest.mark.parametrize("compatibility", ["31", "42", "51"], ids=["bash31", "bash42", "bash51"]) ++@pytest.mark.parametrize("promptvars", [True, False], ids=["promptvars-on", "promptvars-off"]) ++def test_bash_child_shell_keeps_prompt_literal(tmp_path, bash_activate_script, prompt, compatibility, promptvars): ++ script = bash_activate_script(prompt=prompt) ++ before = f"BASH_COMPAT={compatibility}\nshopt {'-s' if promptvars else '-u'} promptvars" ++ after = ( ++ f"{shlex.quote(_BASH)} --noprofile --norc -i 2> child-prompt.txt <<'VIRTUALENV_CHILD'\nexit\nVIRTUALENV_CHILD" ++ ) ++ ++ _interactive_prompt(script, tmp_path, before=before, after=after) ++ ++ rendered = (tmp_path / "child-prompt.txt").read_text(encoding="utf-8") ++ assert _last_prompt(rendered) == f"({_expected_bash_prompt(prompt)}) __END__" ++ ++ ++@pytest.mark.skipif(IS_WIN or shutil.which("zsh") is None, reason="requires POSIX zsh") ++def test_zsh_deactivate_restores_psvar(tmp_path, bash_activate_script): ++ script = bash_activate_script(prompt="plain") ++ result = subprocess.run( ++ [ ++ shutil.which("zsh"), ++ "-f", ++ "-c", ++ 'psvar[9]=original; . "$1"; print -r -- "$psvar[9]"; ' ++ 'deactivate; print -r -- "$psvar[9]"; print -r -- "${_OLD_VIRTUAL_PSVAR-unset}"', ++ "test", ++ str(script), ++ ], ++ cwd=tmp_path, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ ++ assert result.stdout == "plain\noriginal\nunset\n" + + + @pytest.mark.skipif(IS_WIN, reason="requires POSIX shells") +@@ -321,10 +423,9 @@ + return super().activate_call(script) + " || exit 1" + + def print_prompt(self): +- return ( +- "case \"$PS1\" in '(${VIRTUAL_ENV_PROMPT-}) '*) " +- "printf '(%s) %s\\n' \"$VIRTUAL_ENV_PROMPT\" \"${PS1#'(${VIRTUAL_ENV_PROMPT-}) '}\";; " +- "*) printf '%s\\n' \"$PS1\";; esac" +- ) ++ return 'printf "exit\\n" | PS1="$PS1" bash --noprofile --norc -i 2>&1 >/dev/null' ++ ++ def expected_prompt(self): ++ return f"({_expected_bash_prompt(self._creator.env_name)}) " + + activation_tester(Bash) +diff --git a/tests/unit/activation/conftest.py b/tests/unit/activation/conftest.py +index 53a819f..aa58273 100644 +--- a/tests/unit/activation/conftest.py ++++ b/tests/unit/activation/conftest.py +@@ -141,7 +141,7 @@ + assert out[5] == self._creator.env_name + # Some attempts to test the prompt output print more than 1 line. + # So we need to check if the prompt exists on any of them. +- prompt_text = f"({self._creator.env_name}) " ++ prompt_text = self.expected_prompt() + assert any(prompt_text in line for line in out[6:-4]), raw + + assert out[-4] == "wrote pydoc_test.html", raw +@@ -167,6 +167,9 @@ + + def print_prompt(self): + return NotImplemented ++ ++ def expected_prompt(self): ++ return f"({self._creator.env_name}) " + + def activate_call(self, script): + cmd = self.quote(str(self.activate_cmd)) +diff --git a/docs/changelog/3466.bugfix.rst b/docs/changelog/3466.bugfix.rst +new file mode 100644 +index 0000000..7cb71b7 +--- /dev/null ++++ b/docs/changelog/3466.bugfix.rst +@@ -0,0 +1,6 @@ ++Escape the Bash activation prompt independently of current prompt options, including later changes to ``promptvars``, ++POSIX mode, and child shells inheriting ``PS1`` (CVE-2026-115586). Zsh inserts the name through ``psvar[9]`` as data and ++restores its previous value on deactivation. ++ ++With ``promptvars`` disabled, Bash displays doubled backslashes and a leading backslash before backticks. Under root, ++its dollar-sign prompt escape displays ``#``. These display differences preserve escaping for later option changes. +-- +2.45.4 diff --git a/SPECS/python-virtualenv/python-virtualenv.spec b/SPECS/python-virtualenv/python-virtualenv.spec index 697f6975e67..da10c54af83 100644 --- a/SPECS/python-virtualenv/python-virtualenv.spec +++ b/SPECS/python-virtualenv/python-virtualenv.spec @@ -39,6 +39,7 @@ Patch1025: CVE-2026-113720.patch Patch1026: CVE-2026-113719.patch Patch1027: CVE-2026-113721.patch Patch1028: CVE-2026-102938.patch +Patch1029: CVE-2026-115586.patch BuildArch: noarch %description @@ -87,6 +88,7 @@ virtualenv is a tool to create isolated Python environment. %patch -P 1026 -p1 %patch -P 1027 -p1 %patch -P 1028 -p1 +%patch -P 1029 -p1 # Manual patching for CVE-2025-50181 and CVE-2026-1703v0 # For CVE-2025-50181, poolmanager.py file is located in 2 different places and each is of different version so the same patch cannot be applied to all of them. @@ -246,8 +248,7 @@ export PYTHONWARNDEFAULTENCODING=1 - Patch for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, and CVE-2026-113716. - Patch for CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, and CVE-2026-113722. - Patch for CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, and CVE-2026-113732. -- Patch for CVE-2026-113776. -- Fix missing imports and executable detection in the Fish activation regression test in CVE-2026-113732. +- Patch for CVE-2026-113776 and CVE-2026-115586. * Fri Oct 23 2026 Jon Slobodzian - 20.36.1-8 - Remove affected eMudhra and AAA roots from both embedded pip wheels.