shellsafe 0.3.1__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 (57) hide show
  1. {shellsafe-0.3.1 → shellsafe-0.3.2}/CHANGELOG.md +16 -0
  2. {shellsafe-0.3.1 → shellsafe-0.3.2}/PKG-INFO +2 -1
  3. {shellsafe-0.3.1 → shellsafe-0.3.2}/pyproject.toml +1 -0
  4. shellsafe-0.3.2/src/shellsafe/_version.py +1 -0
  5. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/audit/scanner.py +134 -1
  6. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/cli.py +1 -1
  7. shellsafe-0.3.2/tests/fixtures/audit/au003_sample.py +37 -0
  8. shellsafe-0.3.2/tests/fixtures/audit/au004_sample.py +28 -0
  9. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/integration/test_cli_audit.py +70 -1
  10. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_audit.py +69 -6
  11. shellsafe-0.3.1/src/shellsafe/_version.py +0 -1
  12. {shellsafe-0.3.1 → shellsafe-0.3.2}/.github/workflows/ci.yml +0 -0
  13. {shellsafe-0.3.1 → shellsafe-0.3.2}/.github/workflows/release.yml +0 -0
  14. {shellsafe-0.3.1 → shellsafe-0.3.2}/.gitignore +0 -0
  15. {shellsafe-0.3.1 → shellsafe-0.3.2}/CONTRIBUTING.md +0 -0
  16. {shellsafe-0.3.1 → shellsafe-0.3.2}/LICENSE +0 -0
  17. {shellsafe-0.3.1 → shellsafe-0.3.2}/README.md +0 -0
  18. {shellsafe-0.3.1 → shellsafe-0.3.2}/examples/demo.py +0 -0
  19. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/__init__.py +0 -0
  20. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/__main__.py +0 -0
  21. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/audit/__init__.py +0 -0
  22. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/errors.py +0 -0
  23. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/execute.py +0 -0
  24. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/exitcodes.py +0 -0
  25. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/platforms.py +0 -0
  26. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/py.typed +0 -0
  27. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/raw.py +0 -0
  28. {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/render.py +0 -0
  29. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/fixtures/audit/au001_sample.py +0 -0
  30. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/fixtures/audit/au002_sample.py +0 -0
  31. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/golden/.gitkeep +0 -0
  32. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/integration/.gitkeep +0 -0
  33. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/integration/test_exec_posix.py +0 -0
  34. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/README.md +0 -0
  35. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  36. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_02_substitution.txt +0 -0
  37. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_03_backticks.txt +0 -0
  38. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  39. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  40. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  41. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  42. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  43. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  44. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  45. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  46. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_12_newline.txt +0 -0
  47. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  48. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  49. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  50. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/property/.gitkeep +0 -0
  51. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/property/test_invariants.py +0 -0
  52. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_cli.py +0 -0
  53. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_errors.py +0 -0
  54. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_payload_corpus.py +0 -0
  55. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_public_surface.py +0 -0
  56. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_raw.py +0 -0
  57. {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_render.py +0 -0
@@ -3,6 +3,22 @@
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
+
6
22
  ## [0.3.1] - 2026-08-30
7
23
 
8
24
  ### Added
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shellsafe
3
- Version: 0.3.1
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
@@ -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"
@@ -155,9 +155,142 @@ def au002(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
155
155
  return None
156
156
 
157
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
+
158
289
  RULES: list[tuple[str, Callable[..., Finding | None]]] = [
159
290
  ("AU001", au001),
160
291
  ("AU002", au002),
292
+ ("AU003", au003),
293
+ ("AU004", au004),
161
294
  ]
162
295
 
163
296
 
@@ -178,7 +311,7 @@ def _discover(paths: list[str]) -> list[Path]:
178
311
 
179
312
  def _import_aliases(tree: ast.Module) -> dict[str, str]:
180
313
  """Map local names to real module.function for executor imports."""
181
- aliases: dict[str, str] = {}
314
+ aliases = {}
182
315
  for node in ast.walk(tree):
183
316
  if isinstance(node, ast.Import):
184
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,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"])
@@ -46,7 +46,7 @@ def test_audit_cli_json_output():
46
46
  assert report["schema_version"] == 1
47
47
  au001_findings = [f for f in report["findings"] if f["rule_id"] == "AU001"]
48
48
  assert len(au001_findings) == 6
49
- assert report["summary"]["errors"] == 6
49
+ assert report["summary"]["errors"] >= 6
50
50
 
51
51
 
52
52
  def test_audit_cli_severity_filter():
@@ -134,3 +134,72 @@ def test_audit_cli_both_rules():
134
134
  rule_ids = {f["rule_id"] for f in report["findings"]}
135
135
  assert "AU001" in rule_ids
136
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
@@ -1,4 +1,4 @@
1
- """Audit scanner tests: AU001 and AU002 rule detection against fixture corpus."""
1
+ """Audit scanner tests: AU001-AU004 rule detection against fixture corpus."""
2
2
 
3
3
  from pathlib import Path
4
4
 
@@ -20,10 +20,9 @@ def test_au001_detects_positive_cases():
20
20
 
21
21
  def test_au001_ignores_safe_cases():
22
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
23
+ au001_lines = {f.lineno for f in findings if f.rule_id == "AU001"}
25
24
  for line in range(26, 33):
26
- assert line not in flagged_lines, f"false positive on line {line}"
25
+ assert line not in au001_lines, f"false positive on line {line}"
27
26
 
28
27
 
29
28
  def test_au001_confidence():
@@ -88,8 +87,8 @@ def test_scan_returns_sorted():
88
87
  findings = scan([str(FIXTURES / "au002_sample.py")])
89
88
  severities = [f.severity for f in findings]
90
89
  errors = [s for s in severities if s == "error"]
91
- warnings = [s for s in severities if s == "warning"]
92
- assert errors + warnings == severities
90
+ non_errors = [s for s in severities if s != "error"]
91
+ assert severities == errors + non_errors
93
92
 
94
93
 
95
94
  def test_scan_empty_dir(tmp_path: Path):
@@ -102,3 +101,67 @@ def test_scan_syntax_error_skipped(tmp_path: Path):
102
101
  bad.write_text("def x(\n")
103
102
  findings = scan([str(tmp_path)])
104
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.1"
File without changes
File without changes
File without changes
File without changes
File without changes