/
ump-team
/
ump-infra
Обзор
Документация
Войти
/
ump-team
/
ump-infra
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
dev/test
tests/test_subprocess_security.py
178 строк
9 KB
Dmitry Kochenov
v0.0.5: рефакторинг валидации фаз и улучшение PR-логики
19 июл 2026, 23:40
19 июл 2026, 23:40
a686387
Код
Авторство
О чём код?
"""Тесты для P0-7: безопасность subprocess — нет shell=True, нет инъекций. P0-7 regression-guards: проверяют, что ни один скрипт в кодовой базе не использует `subprocess.run(cmd, shell=True, ...)` с интерполируемыми переменными. Это закрывает риск shell-инъекций через YAML-планы (branch name, verify command, files_to_commit, и т.д.). Если кто-то добавит новый `shell=True` — эти тесты упадут. """ from __future__ import annotations import ast import re from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parent.parent SCRIPTS_DIR = PROJECT_ROOT / 'scripts' def _find_subprocess_calls_with_shell(pyfile: Path) -> list[tuple[int, str]]: """Найти все subprocess.run/Popen вызовы с shell=True через AST. Возвращает [(line_number, snippet)]. """ source = pyfile.read_text(encoding='utf-8') tree = ast.parse(source) findings: list[tuple[int, str]] = [] for node in ast.walk(tree): # Ищем ast.Call где func — subprocess.run / subprocess.Popen / subprocess.check_output if not isinstance(node, ast.Call): continue func = node.func if isinstance(func, ast.Attribute): if not (isinstance(func.value, ast.Name) and func.value.id == 'subprocess'): continue if func.attr not in ('run', 'Popen', 'check_output', 'check_call', 'call'): continue else: continue # Ищем kwarg shell=True for kw in node.keywords: if kw.arg == 'shell' and isinstance(kw.value, ast.Constant) and kw.value.value is True: line = node.lineno # Извлечь строку исходника для контекста src_line = source.splitlines()[line - 1] findings.append((line, src_line.strip())) return findings def _find_f_string_subprocess_calls(pyfile: Path) -> list[tuple[int, str]]: """Найти f-string в первом аргументе subprocess.run — это подозрительно. Если cmd = f'git push {branch}' — это инъекция, даже без shell=True. После P0-7 фикса все такие вызовы должны быть переписаны на list-form. """ source = pyfile.read_text(encoding='utf-8') tree = ast.parse(source) findings: list[tuple[int, str]] = [] for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func # Ищем только если первый positional arg — JoinedStr (f-string) if not node.args: continue first_arg = node.args[0] if not isinstance(first_arg, ast.JoinedStr): continue # Проверяем, что это subprocess.run / run / run_cmd / _run func_name = '' if isinstance(func, ast.Name): func_name = func.id elif isinstance(func, ast.Attribute): func_name = func.attr if func_name not in ('run', 'run_cmd', '_run', 'run_str'): continue line = node.lineno src_line = source.splitlines()[line - 1] findings.append((line, src_line.strip())) return findings class TestNoShellTrue: """P0-7: ни один subprocess вызов не должен использовать shell=True. P0-security-fix (audit v0.0.2): ранее subprocess_utils.py имел fallback на shell=True — это High-severity Bandit finding (B602). Fallback удалён; теперь run_str() поднимает ShellInjectionError при обнаружении shell-метасимволов. Исключений в кодовой базе нет. """ @pytest.mark.parametrize('pyfile', sorted(SCRIPTS_DIR.glob('*.py')) + sorted(SCRIPTS_DIR.glob('ump/*.py'))) def test_no_subprocess_shell_true(self, pyfile: Path) -> None: findings = _find_subprocess_calls_with_shell(pyfile) assert not findings, ( f'{pyfile.name}: найден subprocess.run(..., shell=True). ' f'P0-7 fix требует list-form. Нарушения:\n' + '\n'.join(f' L{line}: {snippet}' for line, snippet in findings) ) class TestNoFStringInSubprocessArgs: """P0-7: в subprocess.run первым аргументом не должно быть f-string. f-string с интерполяцией переменных — это инъекция, даже без shell=True: shlex.split разъединит аргументы, но если переменная содержит пробелы, семантика меняется (превратится в несколько argv). Вместо: run(f'git push origin {branch}') Надо: run(['git', 'push', 'origin', branch]) """ @pytest.mark.parametrize('pyfile', sorted(SCRIPTS_DIR.glob('*.py'))) def test_no_fstring_in_run_calls(self, pyfile: Path) -> None: findings = _find_f_string_subprocess_calls(pyfile) # subprocess_utils.run_str — это легитимный случай (она парсит f-string # через shlex.split), но даже там лучше list-form. # Фильтруем: если в строке есть 'run_str' или 'shell_forbidden' — это helper. real_findings = [ (line, snip) for line, snip in findings if 'run_str' not in snip and 'shell_forbidden' not in snip ] assert not real_findings, ( f'{pyfile.name}: найден subprocess.run с f-string аргументом. ' f'P0-7 fix требует list-form: ["cmd", "arg", variable]. Нарушения:\n' + '\n'.join(f' L{line}: {snippet}' for line, snippet in real_findings) ) class TestSubprocessUtilsHelper: """Проверка helper-модуля subprocess_utils.py.""" def test_shell_forbidden_detects_metachars(self) -> None: from subprocess_utils import shell_forbidden assert shell_forbidden('git push origin main') == [] assert '>' in shell_forbidden('git stash 2>/dev/null') assert '||' in shell_forbidden('git stash || true') assert '|' in shell_forbidden('echo foo | grep bar') def test_run_list_executes_without_shell(self, tmp_path: Path) -> None: """list-form действительно использует execve, а не /bin/sh.""" from subprocess_utils import run_list # `echo $$` в shell-form дал бы PID shell'а; в list-form — PID python'а result = run_list(['sh', '-c', 'echo $PPID'], check=False, capture=True) # PPID процесса sh должен быть PID текущего python'а assert result.stdout.strip() == str(__import__('os').getpid()) def test_run_list_with_glob_arg_is_literal(self, tmp_path: Path) -> None: """Аргумент '*.yaml' передаётся как literal, не раскрывается как glob.""" from subprocess_utils import run_list (tmp_path / 'a.yaml').write_text('x', encoding='utf-8') (tmp_path / 'b.yaml').write_text('y', encoding='utf-8') # echo '*.yaml' в list-form — это literal *.yaml, а не раскрытие result = run_list(['echo', '*.yaml'], check=False, capture=True, cwd=tmp_path) assert result.stdout.strip() == '*.yaml' def test_run_str_rejects_shell_metachars(self) -> None: """P0-security-fix (audit v0.0.2): run_str больше не fallback'ает на shell=True при обнаружении метасимволов — поднимает ShellInjectionError. Это закрывает High-severity Bandit B602. """ from subprocess_utils import ShellInjectionError, run_str with pytest.raises(ShellInjectionError): run_str('git stash 2>/dev/null || true') with pytest.raises(ShellInjectionError): run_str('echo foo | grep bar') with pytest.raises(ShellInjectionError): run_str('cat *.yaml') def test_run_str_accepts_safe_command(self) -> None: """Безопасные команды по-прежнему выполняются через shlex.split.""" from subprocess_utils import run_str result = run_str('echo hello', check=False, capture=True) assert result.stdout.strip() == 'hello'