From 15567524b8fcf070dd4ea853ca54ab665a2485e1 Mon Sep 17 00:00:00 2001 From: Hetvi Thakar Date: Thu, 5 Nov 2026 01:00:01 -0800 Subject: [PATCH] python3-virtualenv: fix CVE-2026-113721 This patch applies the upstream fix as referenced in [2], using the commit shown in [1]. Replace existing symbolic and hard links before writing generated files, and create the replacement files exclusively. Prepare the stdlib venv executable directory before it writes files. [1] https://github.com/pypa/virtualenv/commit/d25f28699eefb1fc34a251e5adf2b5de844c36cd [2] https://nvd.nist.gov/vuln/detail/CVE-2026-113721 Signed-off-by: Hetvi Thakar Signed-off-by: Anuj Mittal --- .../python3-virtualenv/CVE-2026-113721.patch | 477 ++++++++++++++++++ .../python/python3-virtualenv_21.2.1.bb | 1 + 2 files changed, 478 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113721.patch diff --git a/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113721.patch b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113721.patch new file mode 100644 index 0000000000..f9dc57f6cb --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113721.patch @@ -0,0 +1,477 @@ +From d25f28699eefb1fc34a251e5adf2b5de844c36cd Mon Sep 17 00:00:00 2001 +From: Bernát Gábor +Date: Sat, 17 Oct 2026 03:25:09 -0700 +Subject: [PATCH] fix(create): replace generated file links (#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> + +CVE: CVE-2026-113721 +Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/d25f28699eefb1fc34a251e5adf2b5de844c36cd] + +Backport Changes: +- Replace PyEnvCfg's older write_text() call instead of write_bytes(); + retain _one_line() serialization and separate the new helper import + from the type-only imports, without the later util.text refactor. +- Add the Windows junction-constant guard absent from 21.2.1's helper + module and retain S_IWUSR for its existing deletion callback; do not + import the later read-only deletion refactor. +- Add the missing fs_supports_symlink test import and extend the older + test_util path-import list with write_file. +- Omit the release-only changelog fragment; the release documentation + directory is absent from the 21.2.1 source distribution. + +Signed-off-by: Hetvi Thakar +--- + src/virtualenv/activation/via_template.py | 21 ++- + 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 | 8 +- + .../embed/via_app_data/pip_install/base.py | 4 + + src/virtualenv/util/path/__init__.py | 4 +- + src/virtualenv/util/path/_sync.py | 46 ++++++- + tests/unit/create/test_creator.py | 127 +++++++++++++++++- + tests/unit/test_util.py | 11 +- + 10 files changed, 213 insertions(+), 23 deletions(-) + +diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py +index 3af9dc5..3fba6ad 100644 +--- a/src/virtualenv/activation/via_template.py ++++ b/src/virtualenv/activation/via_template.py +@@ -5,7 +5,9 @@ import re + import shlex + import sys + from abc import ABC, abstractmethod +-from typing import TYPE_CHECKING ++from typing import TYPE_CHECKING, Final ++ ++from virtualenv.util.path import write_file + + from .activator import Activator + +@@ -63,18 +65,13 @@ class ViaTemplateActivator(Activator, ABC): + def _generate( + self, replacements: dict[str, str], templates: Iterable[str], to_folder: Path, creator: Creator + ) -> list[Path]: +- generated = [] ++ generated: Final[list[Path]] = [] + 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 +- 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)) ++ text: Final = self.instantiate_template(replacements, template, creator) ++ dest: Final = to_folder / self.as_name(template) ++ # PowerShell assumes Windows 1252 when the UTF-8 BOM is absent. ++ encoding: Final = "utf-8-sig" if str(template).endswith(".ps1") else "utf-8" ++ 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 9fa60d3..855d5a1 100644 +--- a/src/virtualenv/create/creator.py ++++ b/src/virtualenv/create/creator.py +@@ -23,7 +23,7 @@ if TYPE_CHECKING: + + from os.path import commonpath + +-from virtualenv.util.path import safe_delete ++from virtualenv.util.path import safe_delete, write_file + from virtualenv.util.subprocess import LogCmd, run_cmd + from virtualenv.version import __version__ + +@@ -215,7 +215,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) -> None: + self.pyenv_cfg.content = OrderedDict() +@@ -237,7 +237,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 52411fb..c0fb345 100644 +--- a/src/virtualenv/create/pyenv_cfg.py ++++ b/src/virtualenv/create/pyenv_cfg.py +@@ -5,6 +5,8 @@ import os + from collections import OrderedDict + from typing import TYPE_CHECKING, Final + ++from virtualenv.util.path import write_file ++ + if TYPE_CHECKING: + from pathlib import Path + +@@ -67,7 +69,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) -> OrderedDict[str, str]: + 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 5cb8e12..5382419 100644 +--- a/src/virtualenv/create/via_global_ref/api.py ++++ b/src/virtualenv/create/via_global_ref/api.py +@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING + + from virtualenv.create.creator import Creator, CreatorMeta + from virtualenv.info import fs_supports_symlink ++from virtualenv.util.path import write_file + + if TYPE_CHECKING: + from argparse import ArgumentParser +@@ -116,10 +117,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) -> str: + """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 2bfd1b4..8b704eb 100644 +--- a/src/virtualenv/create/via_global_ref/venv.py ++++ b/src/virtualenv/create/via_global_ref/venv.py +@@ -9,7 +9,7 @@ from python_discovery import PythonInfo + from virtualenv.create.pyenv_cfg import _one_line as collapse_line_boundaries + from virtualenv.create.via_global_ref.store import handle_store_python + from virtualenv.util.error import ProcessCallFailedError +-from virtualenv.util.path import ensure_dir ++from virtualenv.util.path import ensure_dir, ensure_within + from virtualenv.util.subprocess import run_cmd + + from .api import ViaGlobalRefApi, ViaGlobalRefMeta +@@ -50,6 +50,12 @@ class Venv(ViaGlobalRefApi): + return None + + def create(self) -> None: ++ # stdlib venv writes interpreter files, configuration and activation scripts in place, so a planted symlink or ++ # hard link would receive them outside the environment ++ ensure_within(self.dest, self.bin_dir) ++ 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 7697e73..086ae0c 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): + self, names: set[str], shebang: bytes, script_bytes: bytes, filenames: list[str], ext: str + ) -> None: + names.add(f"{self._name}{self.version_info[0]}.{self.version_info[1]}") ++ for name in names: ++ path: Final = 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 c144d8d..451630f 100644 +--- a/src/virtualenv/util/path/__init__.py ++++ b/src/virtualenv/util/path/__init__.py +@@ -1,16 +1,18 @@ + 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, write_file + from ._win import get_short_path_name + + __all__ = [ + "copy", + "copytree", + "ensure_dir", ++ "ensure_within", + "get_short_path_name", + "make_exe", + "safe_delete", + "set_tree", + "symlink", ++ "write_file", + ] +diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py +index a94466f..fae3859 100644 +--- a/src/virtualenv/util/path/_sync.py ++++ b/src/virtualenv/util/path/_sync.py +@@ -5,9 +5,12 @@ import os + import shutil + import sys + from pathlib import Path +-from stat import S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR ++from stat import S_ISDIR, S_ISLNK, S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR + from typing import Final + ++if os.name == "nt": ++ from stat import IO_REPARSE_TAG_MOUNT_POINT ++ + LOGGER = logging.getLogger(__name__) + + +@@ -17,6 +20,45 @@ def ensure_dir(path: Path) -> None: + os.makedirs(str(path)) + + ++def ensure_within(root: Path, path: Path) -> None: ++ parts: Final = path.relative_to(root).parts ++ if ".." in parts: ++ msg: Final = f"environment directory {path} must remain inside {root}" ++ raise ValueError(msg) ++ ensure_dir(root) ++ folder = root ++ for part in parts: ++ folder /= part ++ try: ++ metadata: Final = 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: Final = folder.lstat() ++ if not S_ISDIR(created.st_mode) or ( ++ os.name == "nt" and created.st_reparse_tag == IO_REPARSE_TAG_MOUNT_POINT ++ ): ++ msg: Final = f"environment directory is not a real directory: {folder}" ++ raise NotADirectoryError(msg) from None ++ ++ ++def write_file(path: Path, data: bytes) -> None: ++ 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: Path, dest: Path) -> None: + if src == dest: + msg = f"source and destination is the same {src}" +@@ -90,6 +132,8 @@ __all__ = [ + "copy", + "copytree", + "ensure_dir", ++ "ensure_within", + "safe_delete", + "symlink", ++ "write_file", + ] +diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py +index bd0f071..bc02679 100644 +--- a/tests/unit/create/test_creator.py ++++ b/tests/unit/create/test_creator.py +@@ -30,13 +30,101 @@ 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_mac_os_framework, is_macos_brew + from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix +-from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive ++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 + from virtualenv.run.plugin.creators import CreatorSelector + + CURRENT = PythonInfo.current_system() + + ++@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: Path, ++ creator_name: str, ++ existing: bool, ++ generated: str, ++) -> None: ++ command: Final = [ ++ str(tmp_path / "env"), ++ "--creator", ++ creator_name, ++ "--pip", ++ "embed", ++ "--no-periodic-update", ++ ] ++ if generated.startswith("_virtualenv"): ++ if (python39 := shutil.which("python3.9")) is None: ++ pytest.skip("Python 3.9 is unavailable") ++ command.extend(["--python", python39]) ++ session: Final = session_via_cli(command) ++ session.creator.bin_dir.mkdir(parents=True) ++ target: Final = 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: Path, kind: str) -> None: ++ session: Final = 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: Final = session.creator.dest / "empty" ++ if kind != "dangling": ++ target.mkdir() ++ if kind == "junction": ++ if not IS_WIN: ++ pytest.skip("requires Windows junctions") ++ 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 ++ ++ + def test_os_path_sep_not_allowed(tmp_path, capsys) -> None: + target = str(tmp_path / f"a{os.pathsep}b") + err = _non_success_exit_code(capsys, target) +@@ -326,6 +414,43 @@ def test_venv_configuration_uses_selected_options(tmp_path: Path, global_package + assert {key: session.creator.pyenv_cfg.content.get(key) for key in expected} == expected + + ++@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: Path) -> None: ++ args: Final = [str(tmp_path / "env"), "--creator", "venv", "--no-seed"] ++ cli_run([*args, "--symlinks"]) ++ session: Final = 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: Path, mode: str) -> None: ++ 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: Final = session_via_cli([str(tmp_path / "env"), "--creator", "venv", "--no-seed", "--activators=", mode]) ++ session.creator.bin_dir.mkdir(parents=True) ++ names: Final = ( ++ ("python.exe", "pythonw.exe") if IS_WIN else ("python", "python3", f"python3.{CURRENT.version_info.minor}") ++ ) ++ planted: Final = [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(not CURRENT.has_venv, reason="requires interpreter with venv") + def test_venv_fails_not_inline(tmp_path, capsys, mocker) -> None: + if hasattr(os, "geteuid") and os.geteuid() == 0: +diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py +index ea7030b..c4c448a 100644 +--- a/tests/unit/test_util.py ++++ b/tests/unit/test_util.py +@@ -9,7 +9,7 @@ import pytest + + from virtualenv.app_data import _cache_dir_with_migration, _default_app_data_dir + from virtualenv.util.lock import ReentrantFileLock +-from virtualenv.util.path import copy ++from virtualenv.util.path import copy, write_file + from virtualenv.util.subprocess import run_cmd + + if TYPE_CHECKING: +@@ -60,6 +60,15 @@ def test_copy_replaces_contents(tmp_path: Path, directory: bool) -> None: + assert (destination / "entry" if directory else destination).read_bytes() == b"new" + + ++@pytest.mark.parametrize("existing", [pytest.param(False, id="new"), pytest.param(True, id="replace")]) ++def test_write_file_replaces_content(tmp_path: Path, existing: bool) -> None: ++ path: Final = 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) -> None: + code, out, err = run_cmd([str(tmp_path)]) + assert err +-- +2.35.6 diff --git a/meta-python/recipes-devtools/python/python3-virtualenv_21.2.1.bb b/meta-python/recipes-devtools/python/python3-virtualenv_21.2.1.bb index 01b2125df7..bc31885a02 100644 --- a/meta-python/recipes-devtools/python/python3-virtualenv_21.2.1.bb +++ b/meta-python/recipes-devtools/python/python3-virtualenv_21.2.1.bb @@ -26,6 +26,7 @@ SRC_URI += " \ file://CVE-2026-113715.patch \ file://CVE-2026-113720.patch \ file://CVE-2026-113719.patch \ + file://CVE-2026-113721.patch \ " BBCLASSEXTEND = "native nativesdk"