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.
Files changed (57) hide show
  1. {shellsafe-0.3.0 → shellsafe-0.3.1}/CHANGELOG.md +10 -0
  2. {shellsafe-0.3.0 → shellsafe-0.3.1}/PKG-INFO +6 -5
  3. {shellsafe-0.3.0 → shellsafe-0.3.1}/README.md +5 -4
  4. shellsafe-0.3.1/src/shellsafe/_version.py +1 -0
  5. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/audit/scanner.py +58 -0
  6. shellsafe-0.3.1/tests/fixtures/audit/au002_sample.py +41 -0
  7. shellsafe-0.3.1/tests/integration/test_cli_audit.py +136 -0
  8. shellsafe-0.3.1/tests/unit/test_audit.py +104 -0
  9. shellsafe-0.3.0/src/shellsafe/_version.py +0 -1
  10. shellsafe-0.3.0/tests/integration/test_cli_audit.py +0 -66
  11. shellsafe-0.3.0/tests/unit/test_audit.py +0 -56
  12. {shellsafe-0.3.0 → shellsafe-0.3.1}/.github/workflows/ci.yml +0 -0
  13. {shellsafe-0.3.0 → shellsafe-0.3.1}/.github/workflows/release.yml +0 -0
  14. {shellsafe-0.3.0 → shellsafe-0.3.1}/.gitignore +0 -0
  15. {shellsafe-0.3.0 → shellsafe-0.3.1}/CONTRIBUTING.md +0 -0
  16. {shellsafe-0.3.0 → shellsafe-0.3.1}/LICENSE +0 -0
  17. {shellsafe-0.3.0 → shellsafe-0.3.1}/examples/demo.py +0 -0
  18. {shellsafe-0.3.0 → shellsafe-0.3.1}/pyproject.toml +0 -0
  19. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/__init__.py +0 -0
  20. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/__main__.py +0 -0
  21. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/audit/__init__.py +0 -0
  22. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/cli.py +0 -0
  23. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/errors.py +0 -0
  24. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/execute.py +0 -0
  25. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/exitcodes.py +0 -0
  26. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/platforms.py +0 -0
  27. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/py.typed +0 -0
  28. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/raw.py +0 -0
  29. {shellsafe-0.3.0 → shellsafe-0.3.1}/src/shellsafe/render.py +0 -0
  30. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/fixtures/audit/au001_sample.py +0 -0
  31. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/golden/.gitkeep +0 -0
  32. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/integration/.gitkeep +0 -0
  33. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/integration/test_exec_posix.py +0 -0
  34. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/README.md +0 -0
  35. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  36. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_02_substitution.txt +0 -0
  37. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_03_backticks.txt +0 -0
  38. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  39. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  40. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  41. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  42. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  43. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  44. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  45. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  46. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_12_newline.txt +0 -0
  47. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  48. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  49. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  50. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/property/.gitkeep +0 -0
  51. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/property/test_invariants.py +0 -0
  52. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_cli.py +0 -0
  53. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_errors.py +0 -0
  54. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_payload_corpus.py +0 -0
  55. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_public_surface.py +0 -0
  56. {shellsafe-0.3.0 → shellsafe-0.3.1}/tests/unit/test_raw.py +0 -0
  57. {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.0
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 plan was postponed.
51
- So today there is no standard way to run shell commands safely with templates.
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 plan was postponed.
29
- So today there is no standard way to run shell commands safely with templates.
30
- This package fills that gap.
28
+ Python planned to solve this officially (PEP 787), but that PEP was deferred to
29
+ at least Python 3.15. So today there is no standard way to run shell commands
30
+ safely with templates. This package fills that gap.
31
31
 
32
32
  ## Install
33
33
 
@@ -39,7 +39,8 @@ Needs Python 3.14 or newer.
39
39
 
40
40
  ## How to use
41
41
 
42
- Run a command. Your values always stay one argument each:
42
+ Run a command. Your values always stay one argument each. `run()` never invokes
43
+ a shell, so injection is impossible on any platform:
43
44
 
44
45
  ```python
45
46
  from shellsafe import run
@@ -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