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.
- {shellsafe-0.3.1 → shellsafe-0.3.2}/CHANGELOG.md +16 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/PKG-INFO +2 -1
- {shellsafe-0.3.1 → shellsafe-0.3.2}/pyproject.toml +1 -0
- shellsafe-0.3.2/src/shellsafe/_version.py +1 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/audit/scanner.py +134 -1
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/cli.py +1 -1
- 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.1 → shellsafe-0.3.2}/tests/integration/test_cli_audit.py +70 -1
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_audit.py +69 -6
- shellsafe-0.3.1/src/shellsafe/_version.py +0 -1
- {shellsafe-0.3.1 → shellsafe-0.3.2}/.github/workflows/ci.yml +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/.github/workflows/release.yml +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/.gitignore +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/CONTRIBUTING.md +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/LICENSE +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/README.md +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/examples/demo.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/audit/__init__.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/execute.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/platforms.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/raw.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/src/shellsafe/render.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/fixtures/audit/au001_sample.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/fixtures/audit/au002_sample.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/integration/test_exec_posix.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/property/.gitkeep +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/property/test_invariants.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_payload_corpus.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.2}/tests/unit/test_raw.py +0 -0
- {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.
|
|
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
|
|
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"]
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
assert errors +
|
|
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
|
|
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
|