mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-11-12 03:21:04 +00:00
python3-virtualenv: fix CVE-2026-113726
Backport the upstream Zsh prompt fix [1] identified by advisory [3]. Include prerequisite [2] to provide the prompt-escaping block and regression-test fixture that the fix uses but 21.2.1 does not contain. Escape prompt values before Zsh expands PS1, including when PROMPT_SUBST is enabled after activation, and preserve literal percent signs in the displayed environment name. [1] https://github.com/pypa/virtualenv/commit/5b915367da82bd024b2a0d28d2f98007034b4aa2 [2] https://github.com/pypa/virtualenv/commit/a70c1dac43e2ee7e618132b572072395af5d8a9b [3] https://github.com/pypa/virtualenv/security/advisories/GHSA-5vjq-rrrf-7h2q Signed-off-by: Hetvi Thakar <hthakar@cisco.com> Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
1 parent
936c732252
commit
46b8c6fa8f
3 files changed
+319
No files matched your search
+168
@@ -0,0 +1,168 @@
|
||||
From a70c1dac43e2ee7e618132b572072395af5d8a9b Mon Sep 17 00:00:00 2001
|
||||
From: Bernát Gábor <gaborjbernat@gmail.com>
|
||||
Date: Sat, 26 Sep 2026 09:35:09 -0700
|
||||
Subject: [PATCH] 🐛 fix(activation): stop bash re-expanding the prompt in PS1
|
||||
(#3326)
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
CVE: CVE-2026-113726
|
||||
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/a70c1dac43e2ee7e618132b572072395af5d8a9b]
|
||||
|
||||
Backport Changes:
|
||||
- Update the 21.2.1 activation tester's print_os_env_var("PS1") call
|
||||
to use upstream's rendered-prompt check instead of its later raw-printf
|
||||
implementation.
|
||||
- Omit the release-only changelog fragment; the release documentation
|
||||
directory is absent from the 21.2.1 source distribution.
|
||||
|
||||
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
|
||||
---
|
||||
src/virtualenv/activation/bash/activate.sh | 11 ++-
|
||||
tests/unit/activation/test_bash.py | 102 ++++++++++++++++++++-
|
||||
2 files changed, 111 insertions(+), 2 deletions(-)
|
||||
|
||||
diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh
|
||||
index 18a78e5..76b9c39 100644
|
||||
--- a/src/virtualenv/activation/bash/activate.sh
|
||||
+++ b/src/virtualenv/activation/bash/activate.sh
|
||||
@@ -115,7 +115,16 @@ fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1-}"
|
||||
- PS1="(${VIRTUAL_ENV_PROMPT}) ${PS1-}"
|
||||
+ _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT}"
|
||||
+ # bash re-expands backslashes, dollar signs and backticks in PS1 on every redraw; the guard keeps the
|
||||
+ # bash-only substitution away from POSIX shells such as dash, which reject it with "Bad substitution"
|
||||
+ if [ -n "${BASH_VERSION-}" ]; then
|
||||
+ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\\/\\\\}"
|
||||
+ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}"
|
||||
+ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}"
|
||||
+ 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 df3aaf3..e3608f1 100644
|
||||
--- a/tests/unit/activation/test_bash.py
|
||||
+++ b/tests/unit/activation/test_bash.py
|
||||
@@ -141,6 +141,101 @@ def test_bash_activate_relocation_does_not_run_path_commands(
|
||||
assert not (work_dir / "PWNED").exists()
|
||||
|
||||
|
||||
+@pytest.fixture
|
||||
+def bash_prompt_after_activate(tmp_path: Path, current_fastest: str) -> Callable[[str | None, str], tuple[Path, str]]:
|
||||
+ version = subprocess.run(
|
||||
+ ["bash", "-c", 'printf "%s" "$((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1]))"'],
|
||||
+ capture_output=True,
|
||||
+ text=True,
|
||||
+ check=True,
|
||||
+ ).stdout
|
||||
+ if int(version) < 404:
|
||||
+ pytest.skip("${PS1@P} needs bash 4.4 or later")
|
||||
+
|
||||
+ def build(prompt: str | None, env_name: str) -> tuple[Path, str]:
|
||||
+ dest = tmp_path / env_name
|
||||
+ args = [
|
||||
+ "--without-pip",
|
||||
+ str(dest),
|
||||
+ "--creator",
|
||||
+ current_fastest,
|
||||
+ "--no-periodic-update",
|
||||
+ "--activators",
|
||||
+ "bash",
|
||||
+ ]
|
||||
+ if prompt is not None:
|
||||
+ args += ["--prompt", prompt]
|
||||
+ cli_run(args)
|
||||
+ activate = dest / "bin" / "activate"
|
||||
+ work_dir = tmp_path / "workdir"
|
||||
+ work_dir.mkdir(exist_ok=True)
|
||||
+ # the path is passed as $1 so the outer shell cannot expand a payload dir name while locating the script;
|
||||
+ # ${PS1@P} then forces bash to render the prompt exactly as it does before each interactive command
|
||||
+ result = subprocess.run(
|
||||
+ ["bash", "--norc", "--noprofile", "-c", 'source "$1"; printf "%s" "${PS1@P}"', "bash", str(activate)],
|
||||
+ capture_output=True,
|
||||
+ text=True,
|
||||
+ cwd=str(work_dir),
|
||||
+ encoding="utf-8",
|
||||
+ check=False,
|
||||
+ )
|
||||
+ return work_dir, result.stdout
|
||||
+
|
||||
+ return build
|
||||
+
|
||||
+
|
||||
+@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
|
||||
+@pytest.mark.parametrize(
|
||||
+ ("prompt", "env_name"),
|
||||
+ [
|
||||
+ pytest.param("x$(touch PWNED)y", "env", id="prompt-command-substitution"),
|
||||
+ pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"),
|
||||
+ pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"),
|
||||
+ ],
|
||||
+)
|
||||
+def test_bash_prompt_does_not_run_commands(
|
||||
+ bash_prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str | None, env_name: str
|
||||
+) -> None:
|
||||
+ work_dir, _ = bash_prompt_after_activate(prompt, env_name)
|
||||
+
|
||||
+ assert not (work_dir / "PWNED").exists()
|
||||
+
|
||||
+
|
||||
+@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
|
||||
+def test_bash_prompt_keeps_plain_name_visible(
|
||||
+ bash_prompt_after_activate: Callable[[str | None, str], tuple[Path, str]],
|
||||
+) -> None:
|
||||
+ _, rendered = bash_prompt_after_activate("myenv", "env")
|
||||
+
|
||||
+ assert rendered.startswith("(myenv) ")
|
||||
+
|
||||
+
|
||||
+@pytest.mark.skipif(IS_WIN or shutil.which("dash") is None, reason="needs dash as a POSIX shell")
|
||||
+def test_bash_activate_sources_under_dash(tmp_path: Path, current_fastest: str) -> None:
|
||||
+ dest = tmp_path / "env"
|
||||
+ cli_run([
|
||||
+ "--without-pip",
|
||||
+ str(dest),
|
||||
+ "--creator",
|
||||
+ current_fastest,
|
||||
+ "--no-periodic-update",
|
||||
+ "--activators",
|
||||
+ "bash",
|
||||
+ "--prompt",
|
||||
+ "x$y",
|
||||
+ ])
|
||||
+
|
||||
+ result = subprocess.run(
|
||||
+ ["dash", "-c", '. "$1" && printf "%s" "$VIRTUAL_ENV"', "dash", str(dest / "bin" / "activate")],
|
||||
+ capture_output=True,
|
||||
+ text=True,
|
||||
+ encoding="utf-8",
|
||||
+ check=False,
|
||||
+ )
|
||||
+
|
||||
+ assert (result.returncode, result.stdout) == (0, str(dest))
|
||||
+
|
||||
+
|
||||
@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
|
||||
@pytest.mark.parametrize("hashing_enabled", [True, False])
|
||||
def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester) -> None:
|
||||
@@ -161,6 +256,11 @@ 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")
|
||||
+ # render PS1 the way bash draws it, since the activator escapes the prompt for that expansion; bash before
|
||||
+ # 4.4 lacks ${PS1@P}, so undo the escapes the way its prompt expansion would
|
||||
+ return (
|
||||
+ 'if ((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 404)); then printf "%s\\n" "${PS1@P}"; '
|
||||
+ "else printf '%s\\n' \"$PS1\" | sed 's/\\\\\\([$`\\\\]\\)/\\1/g'; fi"
|
||||
+ )
|
||||
|
||||
activation_tester(Bash)
|
||||
--
|
||||
2.35.6
|
||||
@@ -0,0 +1,149 @@
|
||||
From 5b915367da82bd024b2a0d28d2f98007034b4aa2 Mon Sep 17 00:00:00 2001
|
||||
From: Bernát Gábor <gaborjbernat@gmail.com>
|
||||
Date: Fri, 2 Oct 2026 06:40:23 -0700
|
||||
Subject: [PATCH] Merge commit from fork
|
||||
|
||||
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.
|
||||
|
||||
CVE: CVE-2026-113726
|
||||
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/5b915367da82bd024b2a0d28d2f98007034b4aa2]
|
||||
|
||||
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
|
||||
---
|
||||
src/virtualenv/activation/bash/activate.sh | 11 +++--
|
||||
tests/unit/activation/test_bash.py | 57 ++++++++++++++--------
|
||||
2 files changed, 45 insertions(+), 23 deletions(-)
|
||||
|
||||
diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh
|
||||
index 76b9c39..0a372ff 100644
|
||||
--- a/src/virtualenv/activation/bash/activate.sh
|
||||
+++ b/src/virtualenv/activation/bash/activate.sh
|
||||
@@ -116,13 +116,18 @@ fi
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1-}"
|
||||
_VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT}"
|
||||
- # bash re-expands backslashes, dollar signs and backticks in PS1 on every redraw; the guard keeps the
|
||||
- # bash-only substitution away from POSIX shells such as dash, which reject it with "Bad substitution"
|
||||
- if [ -n "${BASH_VERSION-}" ]; then
|
||||
+ # bash, and zsh under PROMPT_SUBST, re-expand backslashes, dollar signs and backticks in PS1 on every redraw;
|
||||
+ # zsh gets the escaping even without the option, since a user can set it after activation. The guards keep
|
||||
+ # the substitution away from POSIX shells such as dash, which reject it with "Bad substitution"
|
||||
+ if [ -n "${BASH_VERSION-}${ZSH_VERSION-}" ]; then
|
||||
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\\/\\\\}"
|
||||
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}"
|
||||
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}"
|
||||
fi
|
||||
+ # zsh expands % sequences in PS1 as prompt escapes
|
||||
+ if [ -n "${ZSH_VERSION-}" ]; then
|
||||
+ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}"
|
||||
+ fi
|
||||
PS1="(${_VIRTUAL_PROMPT}) ${PS1-}"
|
||||
unset _VIRTUAL_PROMPT
|
||||
export PS1
|
||||
diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py
|
||||
index e3608f1..defd2ed 100644
|
||||
--- a/tests/unit/activation/test_bash.py
|
||||
+++ b/tests/unit/activation/test_bash.py
|
||||
@@ -141,16 +141,21 @@ def test_bash_activate_relocation_does_not_run_path_commands(
|
||||
assert not (work_dir / "PWNED").exists()
|
||||
|
||||
|
||||
-@pytest.fixture
|
||||
-def bash_prompt_after_activate(tmp_path: Path, current_fastest: str) -> Callable[[str | None, str], tuple[Path, str]]:
|
||||
- version = subprocess.run(
|
||||
- ["bash", "-c", 'printf "%s" "$((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1]))"'],
|
||||
- capture_output=True,
|
||||
- text=True,
|
||||
- check=True,
|
||||
- ).stdout
|
||||
- if int(version) < 404:
|
||||
- pytest.skip("${PS1@P} needs bash 4.4 or later")
|
||||
+@pytest.fixture(params=["bash", "zsh"])
|
||||
+def prompt_after_activate(
|
||||
+ request: pytest.FixtureRequest, tmp_path: Path, current_fastest: str
|
||||
+) -> Callable[[str | None, str], tuple[Path, str]]:
|
||||
+ if shutil.which(shell := request.param) is None:
|
||||
+ pytest.skip(f"{shell} is not installed")
|
||||
+ if shell == "bash":
|
||||
+ version = subprocess.run(
|
||||
+ ["bash", "-c", 'printf "%s" "$((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1]))"'],
|
||||
+ capture_output=True,
|
||||
+ text=True,
|
||||
+ check=True,
|
||||
+ ).stdout
|
||||
+ if int(version) < 404:
|
||||
+ pytest.skip("${PS1@P} needs bash 4.4 or later")
|
||||
|
||||
def build(prompt: str | None, env_name: str) -> tuple[Path, str]:
|
||||
dest = tmp_path / env_name
|
||||
@@ -166,13 +171,17 @@ def bash_prompt_after_activate(tmp_path: Path, current_fastest: str) -> Callable
|
||||
if prompt is not None:
|
||||
args += ["--prompt", prompt]
|
||||
cli_run(args)
|
||||
- activate = dest / "bin" / "activate"
|
||||
work_dir = tmp_path / "workdir"
|
||||
work_dir.mkdir(exist_ok=True)
|
||||
# the path is passed as $1 so the outer shell cannot expand a payload dir name while locating the script;
|
||||
- # ${PS1@P} then forces bash to render the prompt exactly as it does before each interactive command
|
||||
+ # ${PS1@P} renders the prompt exactly as bash does before each interactive command, and in zsh (e) applies
|
||||
+ # what PROMPT_SUBST does at each draw before (%) expands the prompt escapes
|
||||
+ render = {
|
||||
+ "bash": ["bash", "--norc", "--noprofile", "-c", 'source "$1"; printf "%s" "${PS1@P}"'],
|
||||
+ "zsh": ["zsh", "-f", "-c", 'setopt prompt_subst; source "$1"; print -rn -- "${(%)${(e)PS1}}"'],
|
||||
+ }[shell]
|
||||
result = subprocess.run(
|
||||
- ["bash", "--norc", "--noprofile", "-c", 'source "$1"; printf "%s" "${PS1@P}"', "bash", str(activate)],
|
||||
+ [*render, shell, str(dest / "bin" / "activate")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(work_dir),
|
||||
@@ -190,24 +199,32 @@ def bash_prompt_after_activate(tmp_path: Path, current_fastest: str) -> Callable
|
||||
[
|
||||
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_bash_prompt_does_not_run_commands(
|
||||
- bash_prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str | None, env_name: str
|
||||
+def test_prompt_does_not_run_commands(
|
||||
+ prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str | None, env_name: str
|
||||
) -> None:
|
||||
- work_dir, _ = bash_prompt_after_activate(prompt, env_name)
|
||||
+ work_dir, _ = prompt_after_activate(prompt, env_name)
|
||||
|
||||
assert not (work_dir / "PWNED").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
|
||||
-def test_bash_prompt_keeps_plain_name_visible(
|
||||
- bash_prompt_after_activate: Callable[[str | None, str], tuple[Path, str]],
|
||||
+@pytest.mark.parametrize(
|
||||
+ "prompt",
|
||||
+ [
|
||||
+ pytest.param("myenv", id="plain"),
|
||||
+ pytest.param("a%n$HOME\\b`c`", id="special-characters"),
|
||||
+ ],
|
||||
+)
|
||||
+def test_prompt_renders_name_literally(
|
||||
+ prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str
|
||||
) -> None:
|
||||
- _, rendered = bash_prompt_after_activate("myenv", "env")
|
||||
+ _, rendered = prompt_after_activate(prompt, "env")
|
||||
|
||||
- assert rendered.startswith("(myenv) ")
|
||||
+ assert rendered.startswith(f"({prompt}) ")
|
||||
|
||||
|
||||
@pytest.mark.skipif(IS_WIN or shutil.which("dash") is None, reason="needs dash as a POSIX shell")
|
||||
--
|
||||
2.35.6
|
||||
@@ -18,6 +18,8 @@ SRC_URI += " \
|
||||
file://CVE-2026-113732_p1.patch \
|
||||
file://CVE-2026-113732_p2.patch \
|
||||
file://CVE-2026-113776.patch \
|
||||
file://CVE-2026-113726-dependent.patch \
|
||||
file://CVE-2026-113726.patch \
|
||||
"
|
||||
|
||||
BBCLASSEXTEND = "native nativesdk"
|
||||
|
||||
Reference in new issue
Block a user