diff --git a/debian/changelog b/debian/changelog index d186d22..e231758 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,14 @@ +python-click (8.1.6-1deepin1) unstable; urgency=medium + + [ deepin-ci-robot ] + * fix(cve): CVE-2026-7246 + + [ lichenggang ] + * fix: add missing tempfile import in _termui_impl.py to resolve + NameError in _tempfilepager + + -- deepin-ci-robot Tue, 21 Jul 2026 18:59:13 +0800 + python-click (8.1.6-1) unstable; urgency=medium * Team upload diff --git a/debian/patches/CVE-2026-7246.patch b/debian/patches/CVE-2026-7246.patch new file mode 100644 index 0000000..e26362f --- /dev/null +++ b/debian/patches/CVE-2026-7246.patch @@ -0,0 +1,282 @@ +Description: CVE-2026-7246 - 安全修复 +Author: Kevin Deldycke +Origin: https://github.com/pallets/click/commit/b96c2601af4e01341b4d2c0db494ebee4aef8f42 +Bug: https://nvd.nist.gov/vuln/detail/CVE-2026-7246 +Last-Update: 2026-03-04 +--- + +diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py +index f744657..dc794e3 100644 +--- a/src/click/_termui_impl.py ++++ b/src/click/_termui_impl.py +@@ -6,6 +6,10 @@ placed in this module and only imported as needed. + import contextlib + import math + import os ++import shlex ++import shutil ++import subprocess ++import tempfile + import sys + import time + import typing as t +@@ -358,7 +361,20 @@ class ProgressBar(t.Generic[V]): + + + def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None: +- """Decide what method to use for paging through text.""" ++ """Decide what method to use for paging through text. ++ ++ The ``PAGER`` environment variable is split into an ``argv`` list with ++ :func:`shlex.split` in its default POSIX mode so that quotes are ++ stripped from tokens and quoted Windows paths are preserved. ++ ++ .. note:: ++ ``posix=False`` was considered but rejected ++ because it retains quote characters in tokens. The ++ :func:`shlex.quote` approach was also reverted. ++ ++ .. seealso:: ++ :issue:`1026`, :pr:`1477` and :pr:`2775`. ++ """ + stdout = _default_text_stdout() + + # There are no standard streams attached to write to. For example, +@@ -368,17 +384,26 @@ def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None: + + if not isatty(sys.stdin) or not isatty(stdout): + return _nullpager(stdout, generator, color) +- pager_cmd = (os.environ.get("PAGER", None) or "").strip() +- if pager_cmd: ++ ++ # Split and normalize the pager command into parts. ++ pager_cmd_parts = shlex.split(os.environ.get("PAGER", "")) ++ if pager_cmd_parts: + if WIN: +- return _tempfilepager(generator, pager_cmd, color) +- return _pipepager(generator, pager_cmd, color) ++ if _tempfilepager(generator, pager_cmd_parts, color): ++ return ++ else: ++ if _pipepager(generator, pager_cmd_parts, color): ++ return ++ + if os.environ.get("TERM") in ("dumb", "emacs"): + return _nullpager(stdout, generator, color) ++ + if WIN or sys.platform.startswith("os2"): +- return _tempfilepager(generator, "more <", color) +- if hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0: +- return _pipepager(generator, "less", color) ++ if _tempfilepager(generator, ["more", "<"], color): ++ return ++ elif hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0: ++ if _pipepager(generator, ["less"], color): ++ return + + import tempfile + +@@ -386,23 +411,40 @@ def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None: + os.close(fd) + try: + if hasattr(os, "system") and os.system(f'more "{filename}"') == 0: +- return _pipepager(generator, "more", color) ++ if _pipepager(generator, ["more"], color): ++ return + return _nullpager(stdout, generator, color) + finally: + os.unlink(filename) + + +-def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> None: +- """Page through text by feeding it to another program. Invoking a +- pager through this might support colors. +- """ +- import subprocess ++def _pipepager( ++ generator: t.Iterable[str], cmd_parts: list[str], color: t.Optional[bool] ++) -> bool: ++ """Page through text by feeding it to another program. ++ ++ Invokes the pager via :class:`subprocess.Popen` with an ``argv`` list ++ produced by :func:`shlex.split`. The command is resolved to an absolute ++ path with :func:`shutil.which` as recommended by the ++ :mod:`subprocess` docs for Windows compatibility. + ++ Invoking a pager through this might support colors: if piping to ++ ``less`` and the user hasn't decided on colors, ``LESS=-R`` is set ++ automatically. ++ ++ Returns ``True`` if the command was found and executed, ``False`` ++ otherwise so another pager can be attempted. ++ ++ .. seealso:: ++ :pr:`2775` improved error handling: :exc:`BrokenPipeError` is ++ caught specifically, generator exceptions terminate the pager, and ++ ``stdin.close()`` is always called in a ``finally`` block. ++ """ + env = dict(os.environ) + + # If we're piping to less we might support colors under the + # condition that +- cmd_detail = cmd.rsplit("/", 1)[-1].split() ++ cmd_detail = cmd_parts[-1].rsplit("/", 1)[-1].split() + if color is None and cmd_detail[0] == "less": + less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_detail[1:])}" + if not less_flags: +@@ -411,7 +453,15 @@ def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> + elif "r" in less_flags or "R" in less_flags: + color = True + +- c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env) ++ cmd_path = shutil.which(cmd_parts[0]) ++ if cmd_path is None: ++ return False ++ ++ c = subprocess.Popen( ++ [cmd_path] + cmd_parts[1:], ++ stdin=subprocess.PIPE, ++ env=env, ++ ) + stdin = t.cast(t.BinaryIO, c.stdin) + encoding = get_best_encoding(stdin) + try: +@@ -420,7 +470,7 @@ def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> + text = strip_ansi(text) + + stdin.write(text.encode(encoding, "replace")) +- except (OSError, KeyboardInterrupt): ++ except (OSError, KeyboardInterrupt, BrokenPipeError): + pass + else: + stdin.close() +@@ -441,13 +491,22 @@ def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> + else: + break + ++ return True ++ + + def _tempfilepager( +- generator: t.Iterable[str], cmd: str, color: t.Optional[bool] +-) -> None: +- """Page through text by invoking a program on a temporary file.""" +- import tempfile ++ generator: t.Iterable[str], cmd_parts: list[str], color: t.Optional[bool] ++) -> bool: ++ """Page through text by invoking a program on a temporary file. ++ ++ Used as the primary pager strategy on Windows (where piping to ++ ``more`` adds spurious ``\\r\\n``), and as a fallback on other ++ platforms. The command is resolved to an absolute path with ++ :func:`shutil.which`. + ++ Returns ``True`` if the command was found and executed, ``False`` ++ otherwise so another pager can be attempted. ++ """ + fd, filename = tempfile.mkstemp() + # TODO: This never terminates if the passed generator never terminates. + text = "".join(generator) +@@ -457,7 +516,11 @@ def _tempfilepager( + with open_stream(filename, "wb")[0] as f: + f.write(text.encode(encoding)) + try: +- os.system(f'{cmd} "{filename}"') ++ cmd_path = shutil.which(cmd_parts[0]) ++ if cmd_path is not None: ++ subprocess.Popen([cmd_path] + cmd_parts[1:] + [filename]).wait() ++ return True ++ return False + finally: + os.close(fd) + os.unlink(filename) +@@ -501,7 +564,15 @@ class Editor: + return "vi" + + def edit_file(self, filename: str) -> None: +- import subprocess ++ """Open a file in the user's editor. ++ ++ The editor command is split into an ``argv`` list with ++ :func:`shlex.split` in POSIX mode; see :func:`pager` for rationale. ++ ++ .. seealso:: ++ :issue:`1026` and :pr:`1477`. ++ """ ++ import shlex + + editor = self.get_editor() + environ: t.Optional[t.Dict[str, str]] = None +@@ -511,7 +582,10 @@ class Editor: + environ.update(self.env) + + try: +- c = subprocess.Popen(f'{editor} "{filename}"', env=environ, shell=True) ++ c = subprocess.Popen( ++ shlex.split(editor) + [filename], ++ env=environ, ++ ) + exit_code = c.wait() + if exit_code != 0: + raise ClickException( +diff --git a/tests/test_termui.py b/tests/test_termui.py +index 7cfa939..24b0acb 100644 +--- a/tests/test_termui.py ++++ b/tests/test_termui.py +@@ -447,3 +447,56 @@ def test_confirmation_prompt(runner, prompt, input, default, expect): + + if prompt == "Confirm Password": + assert "Confirm Password: " in result.output ++ ++ ++# Tests for CVE-2026-7246: Shell injection prevention in editor/pager commands ++from unittest.mock import patch, MagicMock ++ ++ ++def test_editor_uses_shlex_split(): ++ """Verify that Editor.edit_file uses shlex.split instead of shell=True.""" ++ from click._termui_impl import Editor ++ ++ with patch("subprocess.Popen") as mock_popen: ++ mock_popen.return_value.wait.return_value = 0 ++ editor = Editor(editor="myeditor --wait --flag") ++ editor.edit_file("test.txt") ++ ++ mock_popen.assert_called_once() ++ call_args = mock_popen.call_args ++ # Verify shell is not True ++ assert call_args[1].get("shell") is not True ++ # Verify args is a list, not a string ++ args = call_args[1].get("args") or call_args[0][0] ++ assert isinstance(args, list) ++ assert args == ["myeditor", "--wait", "--flag", "test.txt"] ++ ++ ++def test_editor_with_shell_metacharacters(): ++ """Verify that shell metacharacters in filenames are properly escaped.""" ++ from click._termui_impl import Editor ++ ++ with patch("subprocess.Popen") as mock_popen: ++ mock_popen.return_value.wait.return_value = 0 ++ editor = Editor(editor="vi") ++ editor.edit_file('file"; rm -rf / ; echo "') ++ ++ mock_popen.assert_called_once() ++ call_args = mock_popen.call_args ++ args = call_args[1].get("args") or call_args[0][0] ++ # The filename should be a separate argument, not interpreted by shell ++ assert isinstance(args, list) ++ assert 'file"; rm -rf / ; echo "' in args ++ ++ ++def test_pager_uses_list_args(): ++ """Verify that pager functions use list arguments instead of shell strings.""" ++ from click._termui_impl import _pipepager, _tempfilepager ++ ++ # Test _pipepager returns False when command not found ++ result = _pipepager(iter(["test"]), ["nonexistent_command_xyz"], None) ++ assert result is False ++ ++ # Test _tempfilepager returns False when command not found ++ result = _tempfilepager(iter(["test"]), ["nonexistent_command_xyz"], None) ++ assert result is False diff --git a/debian/patches/series b/debian/patches/series index 84ceced..fe0689f 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -1 +1,2 @@ docs-Use-local-Python-intersphinx-inventary.patch +CVE-2026-7246.patch