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 <hthakar@cisco.com>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
Hetvi Thakar authored and Anuj Mittal committed 2026-11-09 09:03:45 +05:30
1 parent 2d3e0c1b5d
commit f11354004d
2 files changed
+157

No files matched your search

@@ -0,0 +1,156 @@
From 35f0b33562790176dd186d0a3c517af3acc7a0e1 Mon Sep 17 00:00:00 2001
From: Bernát Gábor <gaborjbernat@gmail.com>
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 <hthakar@cisco.com>
---
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
@@ -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"