From f11354004d9bcd95f22b5c75d97fcfa295e501cf Mon Sep 17 00:00:00 2001 From: Hetvi Thakar Date: Thu, 5 Nov 2026 00:59:59 -0800 Subject: [PATCH] python3-virtualenv: fix CVE-2026-113720 This patch applies the upstream fix as referenced in [2], using the commit shown in [1]. Apply the current umask when copying interpreter and seed-package files instead of inheriting cached permission bits. Do not grant group or world write access to copied files. [1] https://github.com/pypa/virtualenv/commit/35f0b33562790176dd186d0a3c517af3acc7a0e1 [2] https://github.com/pypa/virtualenv/security/advisories/GHSA-fc2g-4w5p-6whm Signed-off-by: Hetvi Thakar Signed-off-by: Anuj Mittal --- .../python3-virtualenv/CVE-2026-113720.patch | 156 ++++++++++++++++++ .../python/python3-virtualenv_21.2.1.bb | 1 + 2 files changed, 157 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113720.patch diff --git a/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113720.patch b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113720.patch new file mode 100644 index 0000000000..48156c8c32 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113720.patch @@ -0,0 +1,156 @@ +From 35f0b33562790176dd186d0a3c517af3acc7a0e1 Mon Sep 17 00:00:00 2001 +From: Bernát Gábor +Date: Sat, 17 Oct 2026 02:57:36 -0700 +Subject: [PATCH] fix(path): apply current permissions to copied files + (#3425) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-113720 +Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/35f0b33562790176dd186d0a3c517af3acc7a0e1] + +Backport Changes: +- Import Final without TYPE_CHECKING and remove the now-empty type-only + import block: moving Path to runtime leaves no type-only imports in + 21.2.1's copy helper. +- Add the copy and Final imports needed by the unchanged upstream + regression tests; these imports are absent from 21.2.1's test file. +- Omit the upstream release changelog fragment. + +Signed-off-by: Hetvi Thakar +--- + src/virtualenv/util/path/_sync.py | 35 +++++++++++++---------- + tests/unit/test_util.py | 47 ++++++++++++++++++++++++++++++- + 2 files changed, 66 insertions(+), 16 deletions(-) + +diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py +index 6d701b9..a94466f 100644 +--- a/src/virtualenv/util/path/_sync.py ++++ b/src/virtualenv/util/path/_sync.py +@@ -4,11 +4,9 @@ import logging + import os + import shutil + import sys +-from stat import S_IWUSR +-from typing import TYPE_CHECKING +- +-if TYPE_CHECKING: +- from pathlib import Path ++from pathlib import Path ++from stat import S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR ++from typing import Final + + LOGGER = logging.getLogger(__name__) + +@@ -40,22 +38,29 @@ def symlink(src: Path, dest: Path) -> None: + + + def copy(src: Path, dest: Path) -> None: +- 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: str, dest: str) -> None: + 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: Final = 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: Path, dest: Path) -> None: ++ ensure_safe_to_do(src, dest) ++ mode: Final = 0o755 if src.stat().st_mode & (S_IXUSR | S_IXGRP | S_IXOTH) else 0o644 ++ flags: Final = 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: Path) -> None: +diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py +index 2cdc038..ea7030b 100644 +--- a/tests/unit/test_util.py ++++ b/tests/unit/test_util.py +@@ -3,18 +3,63 @@ from __future__ import annotations + import concurrent.futures + import os + import traceback +-from typing import TYPE_CHECKING ++from typing import TYPE_CHECKING, Final + + 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.subprocess import run_cmd + + if TYPE_CHECKING: + from pathlib import Path + + ++@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: Path, directory: bool, source_mode: int, mask: int, expected: int) -> None: ++ source: Final = tmp_path / "source" ++ source.mkdir() ++ payload: Final = source / "entry" ++ payload.write_bytes(b"copied bytes") ++ payload.chmod(source_mode) ++ destination: Final = tmp_path / "destination" ++ previous: Final = os.umask(mask) ++ try: ++ copy(source if directory else payload, destination) ++ finally: ++ os.umask(previous) ++ result: Final = 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: Path, directory: bool) -> None: ++ source: Final = tmp_path / "source" ++ source.mkdir() ++ (source / "entry").write_bytes(b"new") ++ destination: Final = 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" ++ ++ + 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 d514e79358..bd92f92555 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 @@ -24,6 +24,7 @@ SRC_URI += " \ file://CVE-2026-113723.patch \ file://CVE-2026-115586.patch \ file://CVE-2026-113715.patch \ + file://CVE-2026-113720.patch \ " BBCLASSEXTEND = "native nativesdk"