From 90f0e58d3d978554c354d9f84931435486e1e34c Mon Sep 17 00:00:00 2001 From: Hetvi Thakar Date: Thu, 5 Nov 2026 01:00:00 -0800 Subject: [PATCH] python3-virtualenv: fix CVE-2026-113719 This patch applies the upstream fix as referenced in [2], using the commit shown in [1]. Restrict seed-package metadata cleanup to entries inside the environment, without deleting the environment itself or directories containing the metadata. Unlink final symlinks without following their targets. [1] https://github.com/pypa/virtualenv/commit/e3cea6fa10a50fa29cf1fdddd772458fba076d5f [2] https://nvd.nist.gov/vuln/detail/CVE-2026-113719 Signed-off-by: Hetvi Thakar Signed-off-by: Anuj Mittal --- .../python3-virtualenv/CVE-2026-113719.patch | 207 ++++++++++++++++++ .../python/python3-virtualenv_21.2.1.bb | 1 + 2 files changed, 208 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113719.patch diff --git a/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113719.patch b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113719.patch new file mode 100644 index 0000000000..857b35b2bd --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113719.patch @@ -0,0 +1,207 @@ +From e3cea6fa10a50fa29cf1fdddd772458fba076d5f Mon Sep 17 00:00:00 2001 +From: Bernát Gábor +Date: Sat, 17 Oct 2026 03:19:58 -0700 +Subject: [PATCH] fix(seed): contain metadata cleanup (#3428) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-113719 +Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/e3cea6fa10a50fa29cf1fdddd772458fba076d5f] + +Backport Changes: +- Add the Final import required by the unchanged upstream regression + tests; the older test module does not already import it. +- Omit the upstream release changelog fragment, which is not shipped + in the 21.2.1 source archive. + +Signed-off-by: Hetvi Thakar +--- + .../embed/via_app_data/pip_install/base.py | 43 +++++---- + .../embed/test_bootstrap_link_via_app_data.py | 88 ++++++++++++++++++- + 2 files changed, 115 insertions(+), 16 deletions(-) + +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 89348aa..7697e73 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 +@@ -9,7 +9,7 @@ from configparser import ConfigParser + from itertools import chain + from pathlib import Path + from tempfile import mkdtemp +-from typing import TYPE_CHECKING ++from typing import TYPE_CHECKING, Final + + from distlib.scripts import ScriptMaker, enquote_executable + +@@ -154,35 +154,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: Path) -> None: +- dist_base = dist.parent ++ def _uninstall_dist(dist: Path, env_dir: Path) -> None: ++ dist_base: Final = 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 +- paths = ( ++ top_txt: Final = dist / "top_level.txt" ++ paths: Final = ( + {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: Final = paths.copy() ++ record: Final = 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 ++ path: Final = dist_base / name ++ 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: Final = env_dir.resolve() ++ metadata_parents: Final = set(dist.resolve().parents) ++ for entry in sorted(paths): ++ if entry.exists() or entry.is_symlink(): ++ linked: Final = entry.is_symlink() ++ # Unlinking the final link leaves its target intact; parent links still affect containment. ++ resolved: Final = 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) -> None: + 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 502f32a..278a48e 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 +@@ -6,7 +6,7 @@ import sys + from stat import S_IWGRP, S_IWOTH, S_IWUSR + from subprocess import Popen, check_call + from threading import Thread +-from typing import TYPE_CHECKING ++from typing import TYPE_CHECKING, Final + + import pytest + from python_discovery import PythonInfo +@@ -23,6 +23,92 @@ 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: Path, caplog: pytest.LogCaptureFixture, metadata_file: str, content: str ++) -> None: ++ args: Final = [str(tmp_path / "env"), "--pip", "embed", "--no-periodic-update"] ++ session: Final = cli_run(args, setup_logging=False) ++ purelib: Final = session.creator.purelib ++ markers: Final = [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: Final = 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: Path, caplog: pytest.LogCaptureFixture) -> None: ++ args: Final = [str(tmp_path / "env"), "--pip", "embed", "--no-periodic-update"] ++ session: Final = cli_run(args, setup_logging=False) ++ outside: Final = tmp_path / "outside" ++ outside.mkdir() ++ (outside / "keep.txt").write_text("keep", encoding="utf-8") ++ link: Final = session.creator.purelib / "linked" ++ link.symlink_to(outside, target_is_directory=True) ++ record: Final = 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: Path, linked: bool, caplog: pytest.LogCaptureFixture ++) -> None: ++ args: Final = [ ++ str(tmp_path / "env"), ++ "--pip", ++ "embed", ++ "--no-periodic-update", ++ *(["--symlink-app-data"] if linked else []), ++ ] ++ cli_run(args, setup_logging=False) ++ session: Final = cli_run(args, setup_logging=False) ++ package: Final = session.creator.purelib / "pip" ++ assert ((package / "__init__.py").is_file(), package.is_symlink(), "skip removal of" in caplog.text) == ( ++ True, ++ linked, ++ False, ++ ) ++ ++ + @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) -> None: # noqa: PLR0915 +-- +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 bd92f92555..01b2125df7 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 @@ -25,6 +25,7 @@ SRC_URI += " \ file://CVE-2026-115586.patch \ file://CVE-2026-113715.patch \ file://CVE-2026-113720.patch \ + file://CVE-2026-113719.patch \ " BBCLASSEXTEND = "native nativesdk"