shellsafe 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {shellsafe-0.3.0 → shellsafe-0.3.2}/CHANGELOG.md +26 -0
  2. {shellsafe-0.3.0 → shellsafe-0.3.2}/PKG-INFO +7 -5
  3. {shellsafe-0.3.0 → shellsafe-0.3.2}/README.md +5 -4
  4. {shellsafe-0.3.0 → shellsafe-0.3.2}/pyproject.toml +1 -0
  5. shellsafe-0.3.2/src/shellsafe/_version.py +1 -0
  6. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/audit/scanner.py +192 -1
  7. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/cli.py +1 -1
  8. shellsafe-0.3.2/tests/fixtures/audit/au002_sample.py +41 -0
  9. shellsafe-0.3.2/tests/fixtures/audit/au003_sample.py +37 -0
  10. shellsafe-0.3.2/tests/fixtures/audit/au004_sample.py +28 -0
  11. shellsafe-0.3.2/tests/integration/test_cli_audit.py +205 -0
  12. shellsafe-0.3.2/tests/unit/test_audit.py +167 -0
  13. shellsafe-0.3.0/src/shellsafe/_version.py +0 -1
  14. shellsafe-0.3.0/tests/integration/test_cli_audit.py +0 -66
  15. shellsafe-0.3.0/tests/unit/test_audit.py +0 -56
  16. {shellsafe-0.3.0 → shellsafe-0.3.2}/.github/workflows/ci.yml +0 -0
  17. {shellsafe-0.3.0 → shellsafe-0.3.2}/.github/workflows/release.yml +0 -0
  18. {shellsafe-0.3.0 → shellsafe-0.3.2}/.gitignore +0 -0
  19. {shellsafe-0.3.0 → shellsafe-0.3.2}/CONTRIBUTING.md +0 -0
  20. {shellsafe-0.3.0 → shellsafe-0.3.2}/LICENSE +0 -0
  21. {shellsafe-0.3.0 → shellsafe-0.3.2}/examples/demo.py +0 -0
  22. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/__init__.py +0 -0
  23. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/__main__.py +0 -0
  24. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/audit/__init__.py +0 -0
  25. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/errors.py +0 -0
  26. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/execute.py +0 -0
  27. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/exitcodes.py +0 -0
  28. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/platforms.py +0 -0
  29. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/py.typed +0 -0
  30. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/raw.py +0 -0
  31. {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/render.py +0 -0
  32. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/fixtures/audit/au001_sample.py +0 -0
  33. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/golden/.gitkeep +0 -0
  34. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/integration/.gitkeep +0 -0
  35. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/integration/test_exec_posix.py +0 -0
  36. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/README.md +0 -0
  37. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  38. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_02_substitution.txt +0 -0
  39. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_03_backticks.txt +0 -0
  40. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  41. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  42. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  43. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  44. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  45. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  46. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  47. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  48. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_12_newline.txt +0 -0
  49. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  50. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  51. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  52. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/property/.gitkeep +0 -0
  53. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/property/test_invariants.py +0 -0
  54. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_cli.py +0 -0
  55. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_errors.py +0 -0
  56. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_payload_corpus.py +0 -0
  57. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_public_surface.py +0 -0
  58. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_raw.py +0 -0
  59. {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_render.py +0 -0
@@ -3,6 +3,32 @@
3
3
  All notable changes to this project are documented here. Format follows
4
4
  Keep a Changelog; versioning follows SemVer.
5
5
 
6
+ ## [0.3.2] - 2026-08-30
7
+
8
+ ### Added
9
+
10
+ - AU003 rule: detect command-string assembly (variable, concatenation,
11
+ .format(), f-string passed as command to shell executor)
12
+ - AU004 rule: detect subprocess calls without timeout (info severity)
13
+ - AU003 fixture corpus: 6 positive cases + 3 safe cases
14
+ - AU004 fixture corpus: 5 positive cases + 4 safe cases
15
+ - Unit tests for AU003 and AU004 detection
16
+ - Integration tests for CLI audit with AU003 and AU004
17
+
18
+ ### Changed
19
+
20
+ - CLI version string shows "AU001-AU004" instead of "AU001"
21
+
22
+ ## [0.3.1] - 2026-08-30
23
+
24
+ ### Added
25
+
26
+ - AU002 rule: detect `shell=True` with dynamic content (.format(), string
27
+ concatenation, %-formatting, variables) in shell executor calls
28
+ - AU002 fixture corpus: 6 positive cases + 5 safe cases
29
+ - Unit tests for AU002 detection, confidence, and severity
30
+ - Integration tests for CLI audit with AU002
31
+
6
32
  ## [0.3.0] - 2026-08-29
7
33
 
8
34
  ### Added
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shellsafe
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Run shell commands safely using Python 3.14 template strings. Values can never turn into commands.
5
+ Project-URL: Homepage, https://github.com/rahulXs/shellsafe
5
6
  Project-URL: Repository, https://github.com/rahulXs/shellsafe
6
7
  Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
7
8
  Project-URL: Changelog, https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md
@@ -47,9 +48,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
47
48
  # if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
48
49
  ```
49
50
 
50
- Python planned to solve this officially (PEP 787), but that plan was postponed.
51
- So today there is no standard way to run shell commands safely with templates.
52
- This package fills that gap.
51
+ Python planned to solve this officially (PEP 787), but that PEP was deferred to
52
+ at least Python 3.15. So today there is no standard way to run shell commands
53
+ safely with templates. This package fills that gap.
53
54
 
54
55
  ## Install
55
56
 
@@ -61,7 +62,8 @@ Needs Python 3.14 or newer.
61
62
 
62
63
  ## How to use
63
64
 
64
- Run a command. Your values always stay one argument each:
65
+ Run a command. Your values always stay one argument each. `run()` never invokes
66
+ a shell, so injection is impossible on any platform:
65
67
 
66
68
  ```python
67
69
  from shellsafe import run
@@ -25,9 +25,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
25
25
  # if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
26
26
  ```
27
27
 
28
- Python planned to solve this officially (PEP 787), but that plan was postponed.
29
- So today there is no standard way to run shell commands safely with templates.
30
- This package fills that gap.
28
+ Python planned to solve this officially (PEP 787), but that PEP was deferred to
29
+ at least Python 3.15. So today there is no standard way to run shell commands
30
+ safely with templates. This package fills that gap.
31
31
 
32
32
  ## Install
33
33
 
@@ -39,7 +39,8 @@ Needs Python 3.14 or newer.
39
39
 
40
40
  ## How to use
41
41
 
42
- Run a command. Your values always stay one argument each:
42
+ Run a command. Your values always stay one argument each. `run()` never invokes
43
+ a shell, so injection is impossible on any platform:
43
44
 
44
45
  ```python
45
46
  from shellsafe import run
@@ -30,6 +30,7 @@ dependencies = []
30
30
  shellsafe = "shellsafe.cli:main"
31
31
 
32
32
  [project.urls]
33
+ Homepage = "https://github.com/rahulXs/shellsafe"
33
34
  Repository = "https://github.com/rahulXs/shellsafe"
34
35
  Issues = "https://github.com/rahulXs/shellsafe/issues"
35
36
  Changelog = "https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.2"
@@ -98,8 +98,199 @@ def au001(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
98
98
  return None
99
99
 
100
100
 
101
+ def _has_shell_true(node: ast.Call) -> bool:
102
+ for kw in node.keywords:
103
+ if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
104
+ return True
105
+ return False
106
+
107
+
108
+ def _is_dynamic_string(node: ast.expr) -> bool:
109
+ if isinstance(node, ast.JoinedStr):
110
+ return any(isinstance(v, ast.FormattedValue) for v in node.values)
111
+ if (
112
+ isinstance(node, ast.Call)
113
+ and isinstance(node.func, ast.Attribute)
114
+ and node.func.attr == "format"
115
+ ):
116
+ return True
117
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
118
+ return True
119
+ return isinstance(node, (ast.Name, ast.Attribute))
120
+
121
+
122
+ def au002(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
123
+ """shell=True with dynamic content in shell-executing call."""
124
+ callee = _resolve_callee(node.func, aliases)
125
+ if callee is None:
126
+ return None
127
+ if not _has_shell_true(node):
128
+ return None
129
+ for arg in node.args:
130
+ if _is_dynamic_string(arg):
131
+ kind = "f-string" if isinstance(arg, ast.JoinedStr) else "dynamic content"
132
+ if isinstance(arg, ast.Call) and isinstance(arg.func, ast.Attribute):
133
+ kind = ".format() call"
134
+ elif isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add):
135
+ kind = "string concatenation"
136
+ elif isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Mod):
137
+ kind = "%-format string"
138
+ elif isinstance(arg, (ast.Name, ast.Attribute)):
139
+ kind = "variable"
140
+ return Finding(
141
+ rule_id="AU002",
142
+ title="shell=True with dynamic content",
143
+ severity="warning",
144
+ confidence=0.90,
145
+ path=path,
146
+ lineno=node.lineno,
147
+ col=node.col_offset,
148
+ message=f"{callee} uses shell=True with {kind}",
149
+ evidence={
150
+ "callee": callee,
151
+ "kind": kind,
152
+ },
153
+ fix_hint='use shellsafe.run(t"...") or pass an argv list without shell=True',
154
+ )
155
+ return None
156
+
157
+
158
+ def _track_dynamic_vars(tree: ast.Module) -> set[str]:
159
+ """Find variables assigned dynamic string expressions."""
160
+ dynamic: set[str] = {}
161
+ for node in ast.walk(tree):
162
+ if not isinstance(node, ast.Assign):
163
+ continue
164
+ if not _is_dynamic_string(node.value):
165
+ continue
166
+ for target in node.targets:
167
+ if isinstance(target, ast.Name):
168
+ dynamic.add(target.id)
169
+ return dynamic
170
+
171
+
172
+ def au003(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
173
+ """Command string built by assembly passed to shell executor."""
174
+ callee = _resolve_callee(node.func, aliases)
175
+ if callee is None:
176
+ return None
177
+ for arg in node.args:
178
+ if isinstance(arg, ast.Name):
179
+ return Finding(
180
+ rule_id="AU003",
181
+ title="command string passed to shell executor",
182
+ severity="warning",
183
+ confidence=0.80,
184
+ path=path,
185
+ lineno=node.lineno,
186
+ col=node.col_offset,
187
+ message=f"{callee} receives variable '{arg.id}' as command string",
188
+ evidence={
189
+ "callee": callee,
190
+ "var": arg.id,
191
+ },
192
+ fix_hint='use shellsafe.run(t"...") or pass an argv list',
193
+ )
194
+ if isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add):
195
+ return Finding(
196
+ rule_id="AU003",
197
+ title="command string passed to shell executor",
198
+ severity="warning",
199
+ confidence=0.85,
200
+ path=path,
201
+ lineno=node.lineno,
202
+ col=node.col_offset,
203
+ message=f"{callee} receives concatenated string as command",
204
+ evidence={
205
+ "callee": callee,
206
+ "kind": "concatenation",
207
+ },
208
+ fix_hint='use shellsafe.run(t"...") or pass an argv list',
209
+ )
210
+ if (
211
+ isinstance(arg, ast.Call)
212
+ and isinstance(arg.func, ast.Attribute)
213
+ and arg.func.attr == "format"
214
+ ):
215
+ return Finding(
216
+ rule_id="AU003",
217
+ title="command string passed to shell executor",
218
+ severity="warning",
219
+ confidence=0.85,
220
+ path=path,
221
+ lineno=node.lineno,
222
+ col=node.col_offset,
223
+ message=f"{callee} receives .format() result as command string",
224
+ evidence={
225
+ "callee": callee,
226
+ "kind": ".format()",
227
+ },
228
+ fix_hint='use shellsafe.run(t"...") or pass an argv list',
229
+ )
230
+ if (
231
+ isinstance(arg, ast.JoinedStr)
232
+ and any(isinstance(v, ast.FormattedValue) for v in arg.values)
233
+ ):
234
+ return Finding(
235
+ rule_id="AU003",
236
+ title="command string passed to shell executor",
237
+ severity="error",
238
+ confidence=0.95,
239
+ path=path,
240
+ lineno=node.lineno,
241
+ col=node.col_offset,
242
+ message=f"{callee} receives f-string as command string",
243
+ evidence={
244
+ "callee": callee,
245
+ "kind": "f-string",
246
+ },
247
+ fix_hint='use shellsafe.run(t"...") or pass an argv list',
248
+ )
249
+ return None
250
+
251
+
252
+ def _has_timeout(node: ast.Call) -> bool:
253
+ return any(kw.arg == "timeout" for kw in node.keywords)
254
+
255
+
256
+ _EXECUTORS_WITH_TIMEOUT = {
257
+ "subprocess.run",
258
+ "subprocess.call",
259
+ "subprocess.check_call",
260
+ "subprocess.check_output",
261
+ }
262
+
263
+
264
+ def au004(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
265
+ """Subprocess call without timeout."""
266
+ callee = _resolve_callee(node.func, aliases)
267
+ if callee is None:
268
+ return None
269
+ if callee not in _EXECUTORS_WITH_TIMEOUT:
270
+ return None
271
+ if _has_timeout(node):
272
+ return None
273
+ return Finding(
274
+ rule_id="AU004",
275
+ title="subprocess call without timeout",
276
+ severity="info",
277
+ confidence=0.95,
278
+ path=path,
279
+ lineno=node.lineno,
280
+ col=node.col_offset,
281
+ message=f"{callee} has no timeout parameter",
282
+ evidence={
283
+ "callee": callee,
284
+ },
285
+ fix_hint="add timeout= to prevent hanging",
286
+ )
287
+
288
+
101
289
  RULES: list[tuple[str, Callable[..., Finding | None]]] = [
102
290
  ("AU001", au001),
291
+ ("AU002", au002),
292
+ ("AU003", au003),
293
+ ("AU004", au004),
103
294
  ]
104
295
 
105
296
 
@@ -120,7 +311,7 @@ def _discover(paths: list[str]) -> list[Path]:
120
311
 
121
312
  def _import_aliases(tree: ast.Module) -> dict[str, str]:
122
313
  """Map local names to real module.function for executor imports."""
123
- aliases: dict[str, str] = {}
314
+ aliases = {}
124
315
  for node in ast.walk(tree):
125
316
  if isinstance(node, ast.Import):
126
317
  for alias in node.names:
@@ -62,7 +62,7 @@ def main(argv: list[str] | None = None) -> int:
62
62
  print(f"shellsafe {__version__} . python {py} . {sys.platform}")
63
63
  print("argv-mode: available")
64
64
  print("shell-mode: available (posix)")
65
- print("audit: available (AU001)")
65
+ print("audit: available (AU001-AU004)")
66
66
  return exitcodes.OK
67
67
  if args.command == "audit":
68
68
  return _run_audit(args)
@@ -0,0 +1,41 @@
1
+ """AU002 fixture: planted violations and safe twins for shell=True rule."""
2
+
3
+ import subprocess
4
+
5
+ # --- positive cases (should produce AU002 findings) ---
6
+
7
+ # f-string with shell=True (also caught by AU001)
8
+ subprocess.run(f"echo {user_input}", shell=True)
9
+
10
+ # .format() with shell=True
11
+ subprocess.run("echo {}".format(name), shell=True) # noqa: UP032
12
+
13
+ # string concatenation with shell=True
14
+ subprocess.run("echo " + name, shell=True)
15
+
16
+ # %-format with shell=True
17
+ subprocess.run("echo %s" % name, shell=True) # noqa: UP031
18
+
19
+ # variable with shell=True
20
+ subprocess.run(cmd, shell=True)
21
+
22
+ # attribute with shell=True
23
+ subprocess.run(config.command, shell=True)
24
+
25
+
26
+ # --- safe cases (should NOT produce AU002 findings) ---
27
+
28
+ # shell=True with list (safe)
29
+ subprocess.run(["echo", user_input], shell=True)
30
+
31
+ # shell=True with static string (safe)
32
+ subprocess.run("echo hello", shell=True)
33
+
34
+ # no shell=True with f-string (AU001, not AU002)
35
+ subprocess.run(f"echo {user_input}")
36
+
37
+ # no shell=True with variable (safe)
38
+ subprocess.run(cmd)
39
+
40
+ # shell=False explicitly
41
+ subprocess.run("echo hello", shell=False)
@@ -0,0 +1,37 @@
1
+ """AU003 fixture: planted violations and safe twins for command-string assembly."""
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ # --- positive cases (should produce AU003 findings) ---
7
+
8
+ # variable passed to executor
9
+ cmd = "echo " + user_input
10
+ os.system(cmd)
11
+
12
+ # concatenation in call
13
+ os.system("echo " + user_input)
14
+
15
+ # .format() in call
16
+ os.system("echo {}".format(user_input)) # noqa: UP032
17
+
18
+ # f-string in call (also AU001)
19
+ os.system(f"echo {user_input}")
20
+
21
+ # variable with subprocess
22
+ subprocess.run(cmd)
23
+
24
+ # concatenation with subprocess
25
+ subprocess.run("echo " + user_input)
26
+
27
+
28
+ # --- safe cases (should NOT produce AU003 findings) ---
29
+
30
+ # list form (safe)
31
+ subprocess.run(["echo", user_input])
32
+
33
+ # static string
34
+ os.system("echo hello")
35
+
36
+ # argv list
37
+ subprocess.run(["git", "commit", "-m", "fix"])
@@ -0,0 +1,28 @@
1
+ """AU004 fixture: planted violations and safe twins for missing timeout."""
2
+
3
+ import subprocess
4
+ from subprocess import run as subprocess_run
5
+
6
+ # --- positive cases (should produce AU004 findings) ---
7
+
8
+ subprocess.run(["echo", "hello"])
9
+
10
+ subprocess.call(["echo", "hello"])
11
+
12
+ subprocess.check_call(["echo", "hello"])
13
+
14
+ subprocess.check_output(["echo", "hello"])
15
+
16
+ subprocess_run(["echo", "hello"])
17
+
18
+
19
+ # --- safe cases (should NOT produce AU004 findings) ---
20
+
21
+ subprocess.run(["echo", "hello"], timeout=10)
22
+
23
+ subprocess.call(["echo", "hello"], timeout=5)
24
+
25
+ subprocess.check_output(["echo", "hello"], timeout=30)
26
+
27
+ # Popen does not have timeout in the same way
28
+ subprocess.Popen(["echo", "hello"])
@@ -0,0 +1,205 @@
1
+ """CLI integration tests for the audit command."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ FIXTURES = Path(__file__).parent.parent / "fixtures" / "audit"
9
+
10
+
11
+ def test_audit_cli_finds_issues():
12
+ out = subprocess.run(
13
+ [sys.executable, "-m", "shellsafe", "audit", str(FIXTURES / "au001_sample.py")],
14
+ capture_output=True,
15
+ text=True,
16
+ )
17
+ assert out.returncode == 1
18
+ assert "AU001" in out.stdout
19
+
20
+
21
+ def test_audit_cli_clean_dir(tmp_path: Path):
22
+ out = subprocess.run(
23
+ [sys.executable, "-m", "shellsafe", "audit", str(tmp_path)],
24
+ capture_output=True,
25
+ text=True,
26
+ )
27
+ assert out.returncode == 0
28
+ assert "no findings" in out.stdout
29
+
30
+
31
+ def test_audit_cli_json_output():
32
+ out = subprocess.run(
33
+ [
34
+ sys.executable,
35
+ "-m",
36
+ "shellsafe",
37
+ "audit",
38
+ str(FIXTURES / "au001_sample.py"),
39
+ "--json",
40
+ ],
41
+ capture_output=True,
42
+ text=True,
43
+ )
44
+ assert out.returncode == 1
45
+ report = json.loads(out.stdout)
46
+ assert report["schema_version"] == 1
47
+ au001_findings = [f for f in report["findings"] if f["rule_id"] == "AU001"]
48
+ assert len(au001_findings) == 6
49
+ assert report["summary"]["errors"] >= 6
50
+
51
+
52
+ def test_audit_cli_severity_filter():
53
+ out = subprocess.run(
54
+ [
55
+ sys.executable,
56
+ "-m",
57
+ "shellsafe",
58
+ "audit",
59
+ str(FIXTURES / "au001_sample.py"),
60
+ "--severity",
61
+ "info",
62
+ ],
63
+ capture_output=True,
64
+ text=True,
65
+ )
66
+ assert out.returncode == 1
67
+ assert "AU001" in out.stdout
68
+
69
+
70
+ def test_audit_cli_au002_finds_issues():
71
+ out = subprocess.run(
72
+ [sys.executable, "-m", "shellsafe", "audit", str(FIXTURES / "au002_sample.py")],
73
+ capture_output=True,
74
+ text=True,
75
+ )
76
+ assert out.returncode == 1
77
+ assert "AU002" in out.stdout
78
+
79
+
80
+ def test_audit_cli_au002_json_output():
81
+ out = subprocess.run(
82
+ [
83
+ sys.executable,
84
+ "-m",
85
+ "shellsafe",
86
+ "audit",
87
+ str(FIXTURES / "au002_sample.py"),
88
+ "--json",
89
+ ],
90
+ capture_output=True,
91
+ text=True,
92
+ )
93
+ assert out.returncode == 1
94
+ report = json.loads(out.stdout)
95
+ au002_findings = [f for f in report["findings"] if f["rule_id"] == "AU002"]
96
+ assert len(au002_findings) == 6
97
+ assert report["summary"]["warnings"] >= 6
98
+
99
+
100
+ def test_audit_cli_au002_severity_filter():
101
+ out = subprocess.run(
102
+ [
103
+ sys.executable,
104
+ "-m",
105
+ "shellsafe",
106
+ "audit",
107
+ str(FIXTURES / "au002_sample.py"),
108
+ "--severity",
109
+ "error",
110
+ ],
111
+ capture_output=True,
112
+ text=True,
113
+ )
114
+ # au002_sample.py has an f-string with shell=True (line 12) caught by AU001
115
+ # but AU002 warnings should be excluded
116
+ assert "AU002" not in out.stdout
117
+
118
+
119
+ def test_audit_cli_both_rules():
120
+ out = subprocess.run(
121
+ [
122
+ sys.executable,
123
+ "-m",
124
+ "shellsafe",
125
+ "audit",
126
+ str(FIXTURES),
127
+ "--json",
128
+ ],
129
+ capture_output=True,
130
+ text=True,
131
+ )
132
+ assert out.returncode == 1
133
+ report = json.loads(out.stdout)
134
+ rule_ids = {f["rule_id"] for f in report["findings"]}
135
+ assert "AU001" in rule_ids
136
+ assert "AU002" in rule_ids
137
+
138
+
139
+ def test_audit_cli_au003_finds_issues():
140
+ out = subprocess.run(
141
+ [sys.executable, "-m", "shellsafe", "audit", str(FIXTURES / "au003_sample.py")],
142
+ capture_output=True,
143
+ text=True,
144
+ )
145
+ assert out.returncode == 1
146
+ assert "AU003" in out.stdout
147
+
148
+
149
+ def test_audit_cli_au004_finds_issues():
150
+ out = subprocess.run(
151
+ [
152
+ sys.executable,
153
+ "-m",
154
+ "shellsafe",
155
+ "audit",
156
+ str(FIXTURES / "au004_sample.py"),
157
+ "--severity",
158
+ "info",
159
+ ],
160
+ capture_output=True,
161
+ text=True,
162
+ )
163
+ # AU004 is info severity, exit code 0 (no errors)
164
+ assert "AU004" in out.stdout
165
+ assert "subprocess.run has no timeout" in out.stdout
166
+
167
+
168
+ def test_audit_cli_au004_severity_filter():
169
+ out = subprocess.run(
170
+ [
171
+ sys.executable,
172
+ "-m",
173
+ "shellsafe",
174
+ "audit",
175
+ str(FIXTURES / "au004_sample.py"),
176
+ "--severity",
177
+ "warning",
178
+ ],
179
+ capture_output=True,
180
+ text=True,
181
+ )
182
+ # AU004 is info severity, should be excluded when filtering by warning
183
+ assert "AU004" not in out.stdout
184
+
185
+
186
+ def test_audit_cli_all_rules():
187
+ out = subprocess.run(
188
+ [
189
+ sys.executable,
190
+ "-m",
191
+ "shellsafe",
192
+ "audit",
193
+ str(FIXTURES),
194
+ "--json",
195
+ ],
196
+ capture_output=True,
197
+ text=True,
198
+ )
199
+ assert out.returncode == 1
200
+ report = json.loads(out.stdout)
201
+ rule_ids = {f["rule_id"] for f in report["findings"]}
202
+ assert "AU001" in rule_ids
203
+ assert "AU002" in rule_ids
204
+ assert "AU003" in rule_ids
205
+ assert "AU004" in rule_ids
@@ -0,0 +1,167 @@
1
+ """Audit scanner tests: AU001-AU004 rule detection against fixture corpus."""
2
+
3
+ from pathlib import Path
4
+
5
+ from shellsafe.audit.scanner import scan
6
+
7
+ FIXTURES = Path(__file__).parent.parent / "fixtures" / "audit"
8
+
9
+
10
+ def test_au001_detects_positive_cases():
11
+ findings = scan([str(FIXTURES / "au001_sample.py")])
12
+ au001 = [f for f in findings if f.rule_id == "AU001"]
13
+ assert len(au001) == 6
14
+ callees = {f.evidence["callee"] for f in au001}
15
+ assert "os.system" in callees
16
+ assert "subprocess.run" in callees
17
+ assert "subprocess.call" in callees
18
+ assert "subprocess.getoutput" in callees
19
+
20
+
21
+ def test_au001_ignores_safe_cases():
22
+ findings = scan([str(FIXTURES / "au001_sample.py")])
23
+ au001_lines = {f.lineno for f in findings if f.rule_id == "AU001"}
24
+ for line in range(26, 33):
25
+ assert line not in au001_lines, f"false positive on line {line}"
26
+
27
+
28
+ def test_au001_confidence():
29
+ findings = scan([str(FIXTURES / "au001_sample.py")])
30
+ au001 = [f for f in findings if f.rule_id == "AU001"]
31
+ for f in au001:
32
+ assert f.confidence == 0.95
33
+
34
+
35
+ def test_au001_severity():
36
+ findings = scan([str(FIXTURES / "au001_sample.py")])
37
+ au001 = [f for f in findings if f.rule_id == "AU001"]
38
+ for f in au001:
39
+ assert f.severity == "error"
40
+
41
+
42
+ def test_au002_detects_positive_cases():
43
+ findings = scan([str(FIXTURES / "au002_sample.py")])
44
+ au002 = [f for f in findings if f.rule_id == "AU002"]
45
+ assert len(au002) == 6
46
+ kinds = {f.evidence["kind"] for f in au002}
47
+ assert "f-string" in kinds
48
+ assert ".format() call" in kinds
49
+ assert "string concatenation" in kinds
50
+ assert "%-format string" in kinds
51
+ assert "variable" in kinds
52
+
53
+
54
+ def test_au002_ignores_safe_cases():
55
+ findings = scan([str(FIXTURES / "au002_sample.py")])
56
+ au002_lines = {f.lineno for f in findings if f.rule_id == "AU002"}
57
+ # safe cases are on lines 28-38; none should appear
58
+ for line in range(28, 39):
59
+ assert line not in au002_lines, f"false positive on line {line}"
60
+
61
+
62
+ def test_au002_confidence():
63
+ findings = scan([str(FIXTURES / "au002_sample.py")])
64
+ au002 = [f for f in findings if f.rule_id == "AU002"]
65
+ for f in au002:
66
+ assert f.confidence == 0.90
67
+
68
+
69
+ def test_au002_severity():
70
+ findings = scan([str(FIXTURES / "au002_sample.py")])
71
+ au002 = [f for f in findings if f.rule_id == "AU002"]
72
+ for f in au002:
73
+ assert f.severity == "warning"
74
+
75
+
76
+ def test_au002_does_not_flag_au001_cases():
77
+ findings = scan([str(FIXTURES / "au001_sample.py")])
78
+ au002 = [f for f in findings if f.rule_id == "AU002"]
79
+ # line 12 has shell=True with f-string, AU002 correctly flags it too
80
+ # but lines without shell=True should not be flagged
81
+ au002_lines = {f.lineno for f in au002}
82
+ assert 12 in au002_lines
83
+ assert len(au002) == 1
84
+
85
+
86
+ def test_scan_returns_sorted():
87
+ findings = scan([str(FIXTURES / "au002_sample.py")])
88
+ severities = [f.severity for f in findings]
89
+ errors = [s for s in severities if s == "error"]
90
+ non_errors = [s for s in severities if s != "error"]
91
+ assert severities == errors + non_errors
92
+
93
+
94
+ def test_scan_empty_dir(tmp_path: Path):
95
+ findings = scan([str(tmp_path)])
96
+ assert findings == []
97
+
98
+
99
+ def test_scan_syntax_error_skipped(tmp_path: Path):
100
+ bad = tmp_path / "bad.py"
101
+ bad.write_text("def x(\n")
102
+ findings = scan([str(tmp_path)])
103
+ assert findings == []
104
+
105
+
106
+ def test_au003_detects_positive_cases():
107
+ findings = scan([str(FIXTURES / "au003_sample.py")])
108
+ au003 = [f for f in findings if f.rule_id == "AU003"]
109
+ assert len(au003) == 6
110
+ callees = {f.evidence["callee"] for f in au003}
111
+ assert "os.system" in callees
112
+ assert "subprocess.run" in callees
113
+
114
+
115
+ def test_au003_ignores_safe_cases():
116
+ findings = scan([str(FIXTURES / "au003_sample.py")])
117
+ au003_lines = {f.lineno for f in findings if f.rule_id == "AU003"}
118
+ for line in range(32, 40):
119
+ assert line not in au003_lines, f"false positive on line {line}"
120
+
121
+
122
+ def test_au003_severity():
123
+ findings = scan([str(FIXTURES / "au003_sample.py")])
124
+ au003 = [f for f in findings if f.rule_id == "AU003"]
125
+ for f in au003:
126
+ assert f.severity in ("warning", "error")
127
+
128
+
129
+ def test_au004_detects_positive_cases():
130
+ findings = scan([str(FIXTURES / "au004_sample.py")])
131
+ au004 = [f for f in findings if f.rule_id == "AU004"]
132
+ assert len(au004) == 5
133
+ callees = {f.evidence["callee"] for f in au004}
134
+ assert "subprocess.run" in callees
135
+ assert "subprocess.call" in callees
136
+ assert "subprocess.check_call" in callees
137
+ assert "subprocess.check_output" in callees
138
+
139
+
140
+ def test_au004_ignores_safe_cases():
141
+ findings = scan([str(FIXTURES / "au004_sample.py")])
142
+ au004_lines = {f.lineno for f in findings if f.rule_id == "AU004"}
143
+ for line in range(21, 29):
144
+ assert line not in au004_lines, f"false positive on line {line}"
145
+
146
+
147
+ def test_au004_severity():
148
+ findings = scan([str(FIXTURES / "au004_sample.py")])
149
+ au004 = [f for f in findings if f.rule_id == "AU004"]
150
+ for f in au004:
151
+ assert f.severity == "info"
152
+
153
+
154
+ def test_au004_confidence():
155
+ findings = scan([str(FIXTURES / "au004_sample.py")])
156
+ au004 = [f for f in findings if f.rule_id == "AU004"]
157
+ for f in au004:
158
+ assert f.confidence == 0.95
159
+
160
+
161
+ def test_all_rules_found():
162
+ findings = scan([str(FIXTURES)])
163
+ rule_ids = {f.rule_id for f in findings}
164
+ assert "AU001" in rule_ids
165
+ assert "AU002" in rule_ids
166
+ assert "AU003" in rule_ids
167
+ assert "AU004" in rule_ids
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
@@ -1,66 +0,0 @@
1
- """CLI integration tests for the audit command."""
2
-
3
- import json
4
- import subprocess
5
- import sys
6
- from pathlib import Path
7
-
8
- FIXTURES = Path(__file__).parent.parent / "fixtures" / "audit"
9
-
10
-
11
- def test_audit_cli_finds_issues():
12
- out = subprocess.run(
13
- [sys.executable, "-m", "shellsafe", "audit", str(FIXTURES / "au001_sample.py")],
14
- capture_output=True,
15
- text=True,
16
- )
17
- assert out.returncode == 1
18
- assert "AU001" in out.stdout
19
-
20
-
21
- def test_audit_cli_clean_dir(tmp_path: Path):
22
- out = subprocess.run(
23
- [sys.executable, "-m", "shellsafe", "audit", str(tmp_path)],
24
- capture_output=True,
25
- text=True,
26
- )
27
- assert out.returncode == 0
28
- assert "no findings" in out.stdout
29
-
30
-
31
- def test_audit_cli_json_output():
32
- out = subprocess.run(
33
- [
34
- sys.executable,
35
- "-m",
36
- "shellsafe",
37
- "audit",
38
- str(FIXTURES / "au001_sample.py"),
39
- "--json",
40
- ],
41
- capture_output=True,
42
- text=True,
43
- )
44
- assert out.returncode == 1
45
- report = json.loads(out.stdout)
46
- assert report["schema_version"] == 1
47
- assert report["summary"]["errors"] == 6
48
- assert len(report["findings"]) == 6
49
-
50
-
51
- def test_audit_cli_severity_filter():
52
- out = subprocess.run(
53
- [
54
- sys.executable,
55
- "-m",
56
- "shellsafe",
57
- "audit",
58
- str(FIXTURES / "au001_sample.py"),
59
- "--severity",
60
- "info",
61
- ],
62
- capture_output=True,
63
- text=True,
64
- )
65
- assert out.returncode == 1
66
- assert "AU001" in out.stdout
@@ -1,56 +0,0 @@
1
- """Audit scanner tests: AU001 rule detection against fixture corpus."""
2
-
3
- from pathlib import Path
4
-
5
- from shellsafe.audit.scanner import scan
6
-
7
- FIXTURES = Path(__file__).parent.parent / "fixtures" / "audit"
8
-
9
-
10
- def test_au001_detects_positive_cases():
11
- findings = scan([str(FIXTURES / "au001_sample.py")])
12
- au001 = [f for f in findings if f.rule_id == "AU001"]
13
- assert len(au001) == 6
14
- callees = {f.evidence["callee"] for f in au001}
15
- assert "os.system" in callees
16
- assert "subprocess.run" in callees
17
- assert "subprocess.call" in callees
18
- assert "subprocess.getoutput" in callees
19
-
20
-
21
- def test_au001_ignores_safe_cases():
22
- findings = scan([str(FIXTURES / "au001_sample.py")])
23
- flagged_lines = {f.lineno for f in findings}
24
- # safe cases are on lines 26-32; none should appear
25
- for line in range(26, 33):
26
- assert line not in flagged_lines, f"false positive on line {line}"
27
-
28
-
29
- def test_au001_confidence():
30
- findings = scan([str(FIXTURES / "au001_sample.py")])
31
- for f in findings:
32
- assert f.confidence == 0.95
33
-
34
-
35
- def test_au001_severity():
36
- findings = scan([str(FIXTURES / "au001_sample.py")])
37
- for f in findings:
38
- assert f.severity == "error"
39
-
40
-
41
- def test_scan_returns_sorted():
42
- findings = scan([str(FIXTURES / "au001_sample.py")])
43
- severities = [f.severity for f in findings]
44
- assert severities == ["error"] * len(findings)
45
-
46
-
47
- def test_scan_empty_dir(tmp_path: Path):
48
- findings = scan([str(tmp_path)])
49
- assert findings == []
50
-
51
-
52
- def test_scan_syntax_error_skipped(tmp_path: Path):
53
- bad = tmp_path / "bad.py"
54
- bad.write_text("def x(\n")
55
- findings = scan([str(tmp_path)])
56
- assert findings == []
File without changes
File without changes
File without changes
File without changes