shellsafe 0.3.0__tar.gz → 0.3.1__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.1}/CHANGELOG.md +10 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/PKG-INFO +6 -5
- {shellsafe-0.3.0 → shellsafe-0.3.1}/README.md +5 -4
- shellsafe-0.3.1/src/shellsafe/_version.py +1 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/audit/scanner.py +58 -0
- shellsafe-0.3.1/tests/fixtures/audit/au002_sample.py +41 -0
- shellsafe-0.3.1/tests/integration/test_cli_audit.py +136 -0
- shellsafe-0.3.1/tests/unit/test_audit.py +104 -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.1}/.github/workflows/ci.yml +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/.github/workflows/release.yml +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/.gitignore +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/CONTRIBUTING.md +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/LICENSE +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/examples/demo.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/pyproject.toml +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/audit/__init__.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/cli.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/execute.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/platforms.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/raw.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/render.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/fixtures/audit/au001_sample.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/integration/test_exec_posix.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/property/.gitkeep +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/property/test_invariants.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_payload_corpus.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_raw.py +0 -0
- {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_render.py +0 -0
|
@@ -3,6 +3,16 @@
|
|
|
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.1] - 2026-08-30
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- AU002 rule: detect `shell=True` with dynamic content (.format(), string
|
|
11
|
+
concatenation, %-formatting, variables) in shell executor calls
|
|
12
|
+
- AU002 fixture corpus: 6 positive cases + 5 safe cases
|
|
13
|
+
- Unit tests for AU002 detection, confidence, and severity
|
|
14
|
+
- Integration tests for CLI audit with AU002
|
|
15
|
+
|
|
6
16
|
## [0.3.0] - 2026-08-29
|
|
7
17
|
|
|
8
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shellsafe
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Run shell commands safely using Python 3.14 template strings. Values can never turn into commands.
|
|
5
5
|
Project-URL: Repository, https://github.com/rahulXs/shellsafe
|
|
6
6
|
Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
|
|
@@ -47,9 +47,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
|
|
|
47
47
|
# if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
|
|
48
48
|
```
|
|
49
49
|
|
|
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.
|
|
50
|
+
Python planned to solve this officially (PEP 787), but that PEP was deferred to
|
|
51
|
+
at least Python 3.15. So today there is no standard way to run shell commands
|
|
52
|
+
safely with templates. This package fills that gap.
|
|
53
53
|
|
|
54
54
|
## Install
|
|
55
55
|
|
|
@@ -61,7 +61,8 @@ Needs Python 3.14 or newer.
|
|
|
61
61
|
|
|
62
62
|
## How to use
|
|
63
63
|
|
|
64
|
-
Run a command. Your values always stay one argument each
|
|
64
|
+
Run a command. Your values always stay one argument each. `run()` never invokes
|
|
65
|
+
a shell, so injection is impossible on any platform:
|
|
65
66
|
|
|
66
67
|
```python
|
|
67
68
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -98,8 +98,66 @@ 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
|
+
|
|
101
158
|
RULES: list[tuple[str, Callable[..., Finding | None]]] = [
|
|
102
159
|
("AU001", au001),
|
|
160
|
+
("AU002", au002),
|
|
103
161
|
]
|
|
104
162
|
|
|
105
163
|
|
|
@@ -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,136 @@
|
|
|
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
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Audit scanner tests: AU001 and AU002 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
|
+
au001 = [f for f in findings if f.rule_id == "AU001"]
|
|
32
|
+
for f in au001:
|
|
33
|
+
assert f.confidence == 0.95
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_au001_severity():
|
|
37
|
+
findings = scan([str(FIXTURES / "au001_sample.py")])
|
|
38
|
+
au001 = [f for f in findings if f.rule_id == "AU001"]
|
|
39
|
+
for f in au001:
|
|
40
|
+
assert f.severity == "error"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_au002_detects_positive_cases():
|
|
44
|
+
findings = scan([str(FIXTURES / "au002_sample.py")])
|
|
45
|
+
au002 = [f for f in findings if f.rule_id == "AU002"]
|
|
46
|
+
assert len(au002) == 6
|
|
47
|
+
kinds = {f.evidence["kind"] for f in au002}
|
|
48
|
+
assert "f-string" in kinds
|
|
49
|
+
assert ".format() call" in kinds
|
|
50
|
+
assert "string concatenation" in kinds
|
|
51
|
+
assert "%-format string" in kinds
|
|
52
|
+
assert "variable" in kinds
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_au002_ignores_safe_cases():
|
|
56
|
+
findings = scan([str(FIXTURES / "au002_sample.py")])
|
|
57
|
+
au002_lines = {f.lineno for f in findings if f.rule_id == "AU002"}
|
|
58
|
+
# safe cases are on lines 28-38; none should appear
|
|
59
|
+
for line in range(28, 39):
|
|
60
|
+
assert line not in au002_lines, f"false positive on line {line}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_au002_confidence():
|
|
64
|
+
findings = scan([str(FIXTURES / "au002_sample.py")])
|
|
65
|
+
au002 = [f for f in findings if f.rule_id == "AU002"]
|
|
66
|
+
for f in au002:
|
|
67
|
+
assert f.confidence == 0.90
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_au002_severity():
|
|
71
|
+
findings = scan([str(FIXTURES / "au002_sample.py")])
|
|
72
|
+
au002 = [f for f in findings if f.rule_id == "AU002"]
|
|
73
|
+
for f in au002:
|
|
74
|
+
assert f.severity == "warning"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_au002_does_not_flag_au001_cases():
|
|
78
|
+
findings = scan([str(FIXTURES / "au001_sample.py")])
|
|
79
|
+
au002 = [f for f in findings if f.rule_id == "AU002"]
|
|
80
|
+
# line 12 has shell=True with f-string, AU002 correctly flags it too
|
|
81
|
+
# but lines without shell=True should not be flagged
|
|
82
|
+
au002_lines = {f.lineno for f in au002}
|
|
83
|
+
assert 12 in au002_lines
|
|
84
|
+
assert len(au002) == 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_scan_returns_sorted():
|
|
88
|
+
findings = scan([str(FIXTURES / "au002_sample.py")])
|
|
89
|
+
severities = [f.severity for f in findings]
|
|
90
|
+
errors = [s for s in severities if s == "error"]
|
|
91
|
+
warnings = [s for s in severities if s == "warning"]
|
|
92
|
+
assert errors + warnings == severities
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_scan_empty_dir(tmp_path: Path):
|
|
96
|
+
findings = scan([str(tmp_path)])
|
|
97
|
+
assert findings == []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_scan_syntax_error_skipped(tmp_path: Path):
|
|
101
|
+
bad = tmp_path / "bad.py"
|
|
102
|
+
bad.write_text("def x(\n")
|
|
103
|
+
findings = scan([str(tmp_path)])
|
|
104
|
+
assert findings == []
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|