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.
- {shellsafe-0.3.0 → shellsafe-0.3.2}/CHANGELOG.md +26 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/PKG-INFO +7 -5
- {shellsafe-0.3.0 → shellsafe-0.3.2}/README.md +5 -4
- {shellsafe-0.3.0 → shellsafe-0.3.2}/pyproject.toml +1 -0
- shellsafe-0.3.2/src/shellsafe/_version.py +1 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/audit/scanner.py +192 -1
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/cli.py +1 -1
- shellsafe-0.3.2/tests/fixtures/audit/au002_sample.py +41 -0
- shellsafe-0.3.2/tests/fixtures/audit/au003_sample.py +37 -0
- shellsafe-0.3.2/tests/fixtures/audit/au004_sample.py +28 -0
- shellsafe-0.3.2/tests/integration/test_cli_audit.py +205 -0
- shellsafe-0.3.2/tests/unit/test_audit.py +167 -0
- shellsafe-0.3.0/src/shellsafe/_version.py +0 -1
- shellsafe-0.3.0/tests/integration/test_cli_audit.py +0 -66
- shellsafe-0.3.0/tests/unit/test_audit.py +0 -56
- {shellsafe-0.3.0 → shellsafe-0.3.2}/.github/workflows/ci.yml +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/.github/workflows/release.yml +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/.gitignore +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/CONTRIBUTING.md +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/LICENSE +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/examples/demo.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/audit/__init__.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/execute.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/platforms.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/raw.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/src/shellsafe/render.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/fixtures/audit/au001_sample.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/integration/test_exec_posix.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/property/.gitkeep +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/property/test_invariants.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_payload_corpus.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.2}/tests/unit/test_raw.py +0 -0
- {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.
|
|
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
|
|
51
|
-
So today there is no standard way to run shell commands
|
|
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
|
|
29
|
-
So today there is no standard way to run shell commands
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|