shellsafe 0.3.1__tar.gz → 0.3.3__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.3}/CHANGELOG.md +35 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/PKG-INFO +2 -1
- {shellsafe-0.3.1 → shellsafe-0.3.3}/pyproject.toml +1 -0
- shellsafe-0.3.3/src/shellsafe/_version.py +1 -0
- shellsafe-0.3.3/src/shellsafe/audit/scanner.py +606 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/cli.py +16 -3
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/execute.py +1 -2
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/platforms.py +1 -1
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/fixtures/audit/au001_sample.py +2 -2
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/fixtures/audit/au002_sample.py +2 -2
- shellsafe-0.3.3/tests/fixtures/audit/au003_sample.py +37 -0
- shellsafe-0.3.3/tests/fixtures/audit/au004_sample.py +28 -0
- shellsafe-0.3.3/tests/fixtures/audit/suppress_sample.py +26 -0
- shellsafe-0.3.3/tests/integration/test_cli_audit.py +306 -0
- shellsafe-0.3.3/tests/unit/test_audit.py +251 -0
- shellsafe-0.3.1/src/shellsafe/_version.py +0 -1
- shellsafe-0.3.1/src/shellsafe/audit/scanner.py +0 -290
- shellsafe-0.3.1/tests/integration/test_cli_audit.py +0 -136
- shellsafe-0.3.1/tests/unit/test_audit.py +0 -104
- {shellsafe-0.3.1 → shellsafe-0.3.3}/.github/workflows/ci.yml +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/.github/workflows/release.yml +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/.gitignore +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/CONTRIBUTING.md +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/LICENSE +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/README.md +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/examples/demo.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/audit/__init__.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/raw.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/render.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/integration/test_exec_posix.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/property/.gitkeep +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/property/test_invariants.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_payload_corpus.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_raw.py +0 -0
- {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_render.py +0 -0
|
@@ -3,6 +3,41 @@
|
|
|
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.3] - 2026-09-01
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Inline suppression: `# shellsafe: ignore AU001 reason: <why>` comments
|
|
11
|
+
- File-level suppression: `# shellsafe: ignore-file AU001` at top of file
|
|
12
|
+
- Multi-rule suppression: `# shellsafe: ignore AU001, AU002 reason: <why>`
|
|
13
|
+
- AU010 meta-warning: flags suppression comments without a reason
|
|
14
|
+
- CLI `--show-ignored` flag to display suppressed findings
|
|
15
|
+
- CLI `--ignore RULE` flag to suppress rules from command line (repeatable)
|
|
16
|
+
- Suppression fixture corpus: inline, file-level, multi-rule, AU010 cases
|
|
17
|
+
- Unit tests for suppression parsing and application
|
|
18
|
+
- Integration tests for CLI suppression flags
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Terminal reporter shows suppressed count and muted display for ignored findings
|
|
23
|
+
- CLI version string shows "AU001-AU004, suppression"
|
|
24
|
+
|
|
25
|
+
## [0.3.2] - 2026-08-30
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- AU003 rule: detect command-string assembly (variable, concatenation,
|
|
30
|
+
.format(), f-string passed as command to shell executor)
|
|
31
|
+
- AU004 rule: detect subprocess calls without timeout (info severity)
|
|
32
|
+
- AU003 fixture corpus: 6 positive cases + 3 safe cases
|
|
33
|
+
- AU004 fixture corpus: 5 positive cases + 4 safe cases
|
|
34
|
+
- Unit tests for AU003 and AU004 detection
|
|
35
|
+
- Integration tests for CLI audit with AU003 and AU004
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- CLI version string shows "AU001-AU004" instead of "AU001"
|
|
40
|
+
|
|
6
41
|
## [0.3.1] - 2026-08-30
|
|
7
42
|
|
|
8
43
|
### Added
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shellsafe
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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.3"
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""Offline AST scanner for dangerous command construction.
|
|
2
|
+
|
|
3
|
+
Never imports from the scanned project. Reads code only.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Finding:
|
|
17
|
+
"""One audit finding."""
|
|
18
|
+
|
|
19
|
+
rule_id: str
|
|
20
|
+
title: str
|
|
21
|
+
severity: str
|
|
22
|
+
confidence: float
|
|
23
|
+
path: str
|
|
24
|
+
lineno: int
|
|
25
|
+
col: int
|
|
26
|
+
message: str
|
|
27
|
+
evidence: dict[str, Any] = field(default_factory=dict) # why: heterogeneous value types
|
|
28
|
+
fix_hint: str = ""
|
|
29
|
+
ignored: bool = False
|
|
30
|
+
ignore_reason: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_EXECUTORS = {
|
|
34
|
+
"system": "os.system",
|
|
35
|
+
"popen": "os.popen",
|
|
36
|
+
"run": "subprocess.run",
|
|
37
|
+
"call": "subprocess.call",
|
|
38
|
+
"check_call": "subprocess.check_call",
|
|
39
|
+
"check_output": "subprocess.check_output",
|
|
40
|
+
"Popen": "subprocess.Popen",
|
|
41
|
+
"getoutput": "subprocess.getoutput",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_IMPORT_ALIAS = {
|
|
45
|
+
"os": "os",
|
|
46
|
+
"subprocess": "subprocess",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_callee(node: ast.expr, aliases: dict[str, str]):
|
|
51
|
+
"""Resolve a Call.func to an executor label like 'os.system'."""
|
|
52
|
+
if (
|
|
53
|
+
isinstance(node, ast.Attribute)
|
|
54
|
+
and isinstance(node.value, ast.Name)
|
|
55
|
+
and node.value.id in _IMPORT_ALIAS
|
|
56
|
+
):
|
|
57
|
+
module = _IMPORT_ALIAS[node.value.id]
|
|
58
|
+
if node.attr in _EXECUTORS:
|
|
59
|
+
return f"{module}.{node.attr}"
|
|
60
|
+
if isinstance(node, ast.Name):
|
|
61
|
+
real = aliases.get(node.id, node.id)
|
|
62
|
+
if real in _EXECUTORS:
|
|
63
|
+
return _EXECUTORS[real]
|
|
64
|
+
if "." in real:
|
|
65
|
+
module, func = real.rsplit(".", 1)
|
|
66
|
+
if func in _EXECUTORS:
|
|
67
|
+
return f"{module}.{func}"
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def au001(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
|
|
72
|
+
"""f-string passed to a shell-executing call."""
|
|
73
|
+
callee = _resolve_callee(node.func, aliases)
|
|
74
|
+
if callee is None:
|
|
75
|
+
return None
|
|
76
|
+
for arg in node.args:
|
|
77
|
+
if isinstance(arg, ast.JoinedStr) and any(
|
|
78
|
+
isinstance(v, ast.FormattedValue) for v in arg.values
|
|
79
|
+
):
|
|
80
|
+
return Finding(
|
|
81
|
+
rule_id="AU001",
|
|
82
|
+
title="f-string passed to shell-executing call",
|
|
83
|
+
severity="error",
|
|
84
|
+
confidence=0.95,
|
|
85
|
+
path=path,
|
|
86
|
+
lineno=node.lineno,
|
|
87
|
+
col=node.col_offset,
|
|
88
|
+
message=f"{callee} receives an f-string with interpolated value(s)",
|
|
89
|
+
evidence={
|
|
90
|
+
"callee": callee,
|
|
91
|
+
"interpolations": sum(
|
|
92
|
+
1 for v in arg.values if isinstance(v, ast.FormattedValue)
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
fix_hint='use shellsafe.run(t"...") or pass an argv list',
|
|
96
|
+
)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _has_shell_true(node: ast.Call):
|
|
101
|
+
for kw in node.keywords:
|
|
102
|
+
if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_dynamic_string(node: ast.expr):
|
|
108
|
+
if isinstance(node, ast.JoinedStr):
|
|
109
|
+
return any(isinstance(v, ast.FormattedValue) for v in node.values)
|
|
110
|
+
if (
|
|
111
|
+
isinstance(node, ast.Call)
|
|
112
|
+
and isinstance(node.func, ast.Attribute)
|
|
113
|
+
and node.func.attr == "format"
|
|
114
|
+
):
|
|
115
|
+
return True
|
|
116
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
|
|
117
|
+
return True
|
|
118
|
+
return isinstance(node, (ast.Name, ast.Attribute))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def au002(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
|
|
122
|
+
"""shell=True with dynamic content in shell-executing call."""
|
|
123
|
+
callee = _resolve_callee(node.func, aliases)
|
|
124
|
+
if callee is None:
|
|
125
|
+
return None
|
|
126
|
+
if not _has_shell_true(node):
|
|
127
|
+
return None
|
|
128
|
+
for arg in node.args:
|
|
129
|
+
if _is_dynamic_string(arg):
|
|
130
|
+
kind = "f-string" if isinstance(arg, ast.JoinedStr) else "dynamic content"
|
|
131
|
+
if isinstance(arg, ast.Call) and isinstance(arg.func, ast.Attribute):
|
|
132
|
+
kind = ".format() call"
|
|
133
|
+
elif isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add):
|
|
134
|
+
kind = "string concatenation"
|
|
135
|
+
elif isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Mod):
|
|
136
|
+
kind = "%-format string"
|
|
137
|
+
elif isinstance(arg, (ast.Name, ast.Attribute)):
|
|
138
|
+
kind = "variable"
|
|
139
|
+
return Finding(
|
|
140
|
+
rule_id="AU002",
|
|
141
|
+
title="shell=True with dynamic content",
|
|
142
|
+
severity="warning",
|
|
143
|
+
confidence=0.90,
|
|
144
|
+
path=path,
|
|
145
|
+
lineno=node.lineno,
|
|
146
|
+
col=node.col_offset,
|
|
147
|
+
message=f"{callee} uses shell=True with {kind}",
|
|
148
|
+
evidence={
|
|
149
|
+
"callee": callee,
|
|
150
|
+
"kind": kind,
|
|
151
|
+
},
|
|
152
|
+
fix_hint='use shellsafe.run(t"...") or pass an argv list without shell=True',
|
|
153
|
+
)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _track_dynamic_vars(tree: ast.Module):
|
|
158
|
+
"""Find variables assigned dynamic string expressions."""
|
|
159
|
+
dynamic = set()
|
|
160
|
+
for node in ast.walk(tree):
|
|
161
|
+
if not isinstance(node, ast.Assign):
|
|
162
|
+
continue
|
|
163
|
+
if not _is_dynamic_string(node.value):
|
|
164
|
+
continue
|
|
165
|
+
for target in node.targets:
|
|
166
|
+
if isinstance(target, ast.Name):
|
|
167
|
+
dynamic.add(target.id)
|
|
168
|
+
return dynamic
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def au003(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
|
|
172
|
+
"""Command string built by assembly passed to shell executor."""
|
|
173
|
+
callee = _resolve_callee(node.func, aliases)
|
|
174
|
+
if callee is None:
|
|
175
|
+
return None
|
|
176
|
+
for arg in node.args:
|
|
177
|
+
if isinstance(arg, ast.Name):
|
|
178
|
+
return Finding(
|
|
179
|
+
rule_id="AU003",
|
|
180
|
+
title="command string passed to shell executor",
|
|
181
|
+
severity="warning",
|
|
182
|
+
confidence=0.80,
|
|
183
|
+
path=path,
|
|
184
|
+
lineno=node.lineno,
|
|
185
|
+
col=node.col_offset,
|
|
186
|
+
message=f"{callee} receives variable '{arg.id}' as command string",
|
|
187
|
+
evidence={
|
|
188
|
+
"callee": callee,
|
|
189
|
+
"var": arg.id,
|
|
190
|
+
},
|
|
191
|
+
fix_hint='use shellsafe.run(t"...") or pass an argv list',
|
|
192
|
+
)
|
|
193
|
+
if isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add):
|
|
194
|
+
return Finding(
|
|
195
|
+
rule_id="AU003",
|
|
196
|
+
title="command string passed to shell executor",
|
|
197
|
+
severity="warning",
|
|
198
|
+
confidence=0.85,
|
|
199
|
+
path=path,
|
|
200
|
+
lineno=node.lineno,
|
|
201
|
+
col=node.col_offset,
|
|
202
|
+
message=f"{callee} receives concatenated string as command",
|
|
203
|
+
evidence={
|
|
204
|
+
"callee": callee,
|
|
205
|
+
"kind": "concatenation",
|
|
206
|
+
},
|
|
207
|
+
fix_hint='use shellsafe.run(t"...") or pass an argv list',
|
|
208
|
+
)
|
|
209
|
+
if (
|
|
210
|
+
isinstance(arg, ast.Call)
|
|
211
|
+
and isinstance(arg.func, ast.Attribute)
|
|
212
|
+
and arg.func.attr == "format"
|
|
213
|
+
):
|
|
214
|
+
return Finding(
|
|
215
|
+
rule_id="AU003",
|
|
216
|
+
title="command string passed to shell executor",
|
|
217
|
+
severity="warning",
|
|
218
|
+
confidence=0.85,
|
|
219
|
+
path=path,
|
|
220
|
+
lineno=node.lineno,
|
|
221
|
+
col=node.col_offset,
|
|
222
|
+
message=f"{callee} receives .format() result as command string",
|
|
223
|
+
evidence={
|
|
224
|
+
"callee": callee,
|
|
225
|
+
"kind": ".format()",
|
|
226
|
+
},
|
|
227
|
+
fix_hint='use shellsafe.run(t"...") or pass an argv list',
|
|
228
|
+
)
|
|
229
|
+
if (
|
|
230
|
+
isinstance(arg, ast.JoinedStr)
|
|
231
|
+
and any(isinstance(v, ast.FormattedValue) for v in arg.values)
|
|
232
|
+
):
|
|
233
|
+
return Finding(
|
|
234
|
+
rule_id="AU003",
|
|
235
|
+
title="command string passed to shell executor",
|
|
236
|
+
severity="error",
|
|
237
|
+
confidence=0.95,
|
|
238
|
+
path=path,
|
|
239
|
+
lineno=node.lineno,
|
|
240
|
+
col=node.col_offset,
|
|
241
|
+
message=f"{callee} receives f-string as command string",
|
|
242
|
+
evidence={
|
|
243
|
+
"callee": callee,
|
|
244
|
+
"kind": "f-string",
|
|
245
|
+
},
|
|
246
|
+
fix_hint='use shellsafe.run(t"...") or pass an argv list',
|
|
247
|
+
)
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _has_timeout(node: ast.Call):
|
|
252
|
+
return any(kw.arg == "timeout" for kw in node.keywords)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_EXECUTORS_WITH_TIMEOUT = {
|
|
256
|
+
"subprocess.run",
|
|
257
|
+
"subprocess.call",
|
|
258
|
+
"subprocess.check_call",
|
|
259
|
+
"subprocess.check_output",
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def au004(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
|
|
264
|
+
"""Subprocess call without timeout."""
|
|
265
|
+
callee = _resolve_callee(node.func, aliases)
|
|
266
|
+
if callee is None:
|
|
267
|
+
return None
|
|
268
|
+
if callee not in _EXECUTORS_WITH_TIMEOUT:
|
|
269
|
+
return None
|
|
270
|
+
if _has_timeout(node):
|
|
271
|
+
return None
|
|
272
|
+
return Finding(
|
|
273
|
+
rule_id="AU004",
|
|
274
|
+
title="subprocess call without timeout",
|
|
275
|
+
severity="info",
|
|
276
|
+
confidence=0.95,
|
|
277
|
+
path=path,
|
|
278
|
+
lineno=node.lineno,
|
|
279
|
+
col=node.col_offset,
|
|
280
|
+
message=f"{callee} has no timeout parameter",
|
|
281
|
+
evidence={
|
|
282
|
+
"callee": callee,
|
|
283
|
+
},
|
|
284
|
+
fix_hint="add timeout= to prevent hanging",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
RULES = [
|
|
289
|
+
("AU001", au001),
|
|
290
|
+
("AU002", au002),
|
|
291
|
+
("AU003", au003),
|
|
292
|
+
("AU004", au004),
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _discover(paths: list[str]):
|
|
297
|
+
files = []
|
|
298
|
+
for raw in paths:
|
|
299
|
+
p = Path(raw)
|
|
300
|
+
if p.is_file() and p.suffix == ".py":
|
|
301
|
+
files.append(p)
|
|
302
|
+
elif p.is_dir():
|
|
303
|
+
files.extend(
|
|
304
|
+
f
|
|
305
|
+
for f in sorted(p.rglob("*.py"))
|
|
306
|
+
if not any(part.startswith(".") or part == "__pycache__" for part in f.parts)
|
|
307
|
+
)
|
|
308
|
+
return files
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _import_aliases(tree: ast.Module):
|
|
312
|
+
"""Map local names to real module.function for executor imports."""
|
|
313
|
+
aliases = {}
|
|
314
|
+
for node in ast.walk(tree):
|
|
315
|
+
if isinstance(node, ast.Import):
|
|
316
|
+
for alias in node.names:
|
|
317
|
+
local = alias.asname or alias.name
|
|
318
|
+
aliases[local] = alias.name
|
|
319
|
+
elif isinstance(node, ast.ImportFrom):
|
|
320
|
+
module = node.module or ""
|
|
321
|
+
for alias in node.names:
|
|
322
|
+
local = alias.asname or alias.name
|
|
323
|
+
aliases[local] = f"{module}.{alias.name}"
|
|
324
|
+
return aliases
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _parse_suppression_comment(comment: str) -> tuple[list[str], str | None]:
|
|
328
|
+
"""Parse a shellsafe suppression comment.
|
|
329
|
+
|
|
330
|
+
Returns (rule_ids, reason_or_None).
|
|
331
|
+
Accepts: # shellsafe: ignore RULE[,RULE...] [reason: text]
|
|
332
|
+
"""
|
|
333
|
+
stripped = comment.strip()
|
|
334
|
+
if not stripped.startswith("#"):
|
|
335
|
+
return [], None
|
|
336
|
+
stripped = stripped[1:].strip()
|
|
337
|
+
if not stripped.lower().startswith("shellsafe:"):
|
|
338
|
+
return [], None
|
|
339
|
+
directive = stripped[len("shellsafe:"):].strip()
|
|
340
|
+
if not directive.lower().startswith("ignore "):
|
|
341
|
+
return [], None
|
|
342
|
+
rest = directive[len("ignore "):]
|
|
343
|
+
reason = None
|
|
344
|
+
reason_match = rest.split(" reason:", 1)
|
|
345
|
+
if len(reason_match) == 2:
|
|
346
|
+
rest = reason_match[0]
|
|
347
|
+
reason = reason_match[1].strip() or None
|
|
348
|
+
rule_ids = [r.strip().upper() for r in rest.split(",") if r.strip()]
|
|
349
|
+
return rule_ids, reason
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _parse_suppressions(
|
|
353
|
+
source_lines: list[str],
|
|
354
|
+
) -> tuple[set[int], dict[int, tuple[set[str], str | None]]]:
|
|
355
|
+
"""Parse source lines for suppression directives.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
file_level_rules: rule ids suppressed file-wide (from ignore-file)
|
|
359
|
+
line_suppressions: line_no -> (set of rule ids, reason)
|
|
360
|
+
"""
|
|
361
|
+
file_level_rules = set()
|
|
362
|
+
line_suppressions = {}
|
|
363
|
+
|
|
364
|
+
for i, line in enumerate(source_lines, 1):
|
|
365
|
+
stripped = line.strip()
|
|
366
|
+
if not stripped.startswith("#"):
|
|
367
|
+
continue
|
|
368
|
+
rule_ids, reason = _parse_suppression_comment(stripped)
|
|
369
|
+
if not rule_ids:
|
|
370
|
+
if stripped.lower() == "# shellsafe: ignore-file":
|
|
371
|
+
file_level_rules.add("*")
|
|
372
|
+
elif stripped.lower().startswith("# shellsafe: ignore-file "):
|
|
373
|
+
for r in stripped.split(None, 4)[-1].split(","):
|
|
374
|
+
r = r.strip().upper()
|
|
375
|
+
if r:
|
|
376
|
+
file_level_rules.add(r)
|
|
377
|
+
continue
|
|
378
|
+
line_suppressions[i] = (set(rule_ids), reason)
|
|
379
|
+
|
|
380
|
+
return file_level_rules, line_suppressions
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _find_suppressed_line(
|
|
384
|
+
target_line: int,
|
|
385
|
+
line_suppressions: dict[int, tuple[set[str], str | None]],
|
|
386
|
+
) -> tuple[set[str], str | None] | None:
|
|
387
|
+
"""Find which rules suppress findings at target_line.
|
|
388
|
+
|
|
389
|
+
Checks previous non-blank, non-comment line, then target line itself.
|
|
390
|
+
"""
|
|
391
|
+
for candidate in (target_line - 1, target_line):
|
|
392
|
+
if candidate in line_suppressions:
|
|
393
|
+
return line_suppressions[candidate]
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _apply_suppression(
|
|
398
|
+
result: Finding,
|
|
399
|
+
ignore: set[str] | None,
|
|
400
|
+
file_rules: set[str],
|
|
401
|
+
line_suppressions: dict[int, tuple[set[str], str | None]],
|
|
402
|
+
) -> Finding:
|
|
403
|
+
"""Apply suppression rules to a finding. Returns new Finding with ignored flag."""
|
|
404
|
+
rule_id = result.rule_id
|
|
405
|
+
suppressed = False
|
|
406
|
+
ignore_reason = None
|
|
407
|
+
|
|
408
|
+
if ignore and rule_id in ignore:
|
|
409
|
+
return Finding(
|
|
410
|
+
rule_id=result.rule_id,
|
|
411
|
+
title=result.title,
|
|
412
|
+
severity=result.severity,
|
|
413
|
+
confidence=result.confidence,
|
|
414
|
+
path=result.path,
|
|
415
|
+
lineno=result.lineno,
|
|
416
|
+
col=result.col,
|
|
417
|
+
message=result.message,
|
|
418
|
+
evidence=result.evidence,
|
|
419
|
+
fix_hint=result.fix_hint,
|
|
420
|
+
ignored=True,
|
|
421
|
+
ignore_reason="CLI --ignore",
|
|
422
|
+
)
|
|
423
|
+
if rule_id in file_rules or "*" in file_rules:
|
|
424
|
+
suppressed = True
|
|
425
|
+
ignore_reason = "file-level suppression"
|
|
426
|
+
else:
|
|
427
|
+
match = _find_suppressed_line(result.lineno, line_suppressions)
|
|
428
|
+
if match:
|
|
429
|
+
rules, reason = match
|
|
430
|
+
if rule_id in rules:
|
|
431
|
+
suppressed = True
|
|
432
|
+
ignore_reason = reason
|
|
433
|
+
|
|
434
|
+
if not suppressed:
|
|
435
|
+
return result
|
|
436
|
+
return Finding(
|
|
437
|
+
rule_id=result.rule_id,
|
|
438
|
+
title=result.title,
|
|
439
|
+
severity=result.severity,
|
|
440
|
+
confidence=result.confidence,
|
|
441
|
+
path=result.path,
|
|
442
|
+
lineno=result.lineno,
|
|
443
|
+
col=result.col,
|
|
444
|
+
message=result.message,
|
|
445
|
+
evidence=result.evidence,
|
|
446
|
+
fix_hint=result.fix_hint,
|
|
447
|
+
ignored=True,
|
|
448
|
+
ignore_reason=ignore_reason,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _emit_au010(
|
|
453
|
+
line_suppressions: dict[int, tuple[set[str], str | None]],
|
|
454
|
+
rel: str,
|
|
455
|
+
) -> list[Finding]:
|
|
456
|
+
"""Emit AU010 warnings for suppression comments without reasons."""
|
|
457
|
+
results = []
|
|
458
|
+
for suppressed_rules, reason in line_suppressions.values():
|
|
459
|
+
if not reason and suppressed_rules:
|
|
460
|
+
first_line = next(
|
|
461
|
+
(ln for ln, (r, _) in line_suppressions.items() if r == suppressed_rules),
|
|
462
|
+
1,
|
|
463
|
+
)
|
|
464
|
+
results.append(
|
|
465
|
+
Finding(
|
|
466
|
+
rule_id="AU010",
|
|
467
|
+
title="suppression comment without reason",
|
|
468
|
+
severity="info",
|
|
469
|
+
confidence=1.0,
|
|
470
|
+
path=rel,
|
|
471
|
+
lineno=first_line,
|
|
472
|
+
col=0,
|
|
473
|
+
message=(
|
|
474
|
+
f"Suppression for {', '.join(sorted(suppressed_rules))} "
|
|
475
|
+
f"has no reason. Add: reason: <why>"
|
|
476
|
+
),
|
|
477
|
+
evidence={"rules": sorted(suppressed_rules)},
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
return results
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def scan(paths: list[str], ignore: set[str] | None = None) -> list[Finding]:
|
|
484
|
+
"""Scan Python files for audit findings.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
paths: files or directories to scan.
|
|
488
|
+
ignore: rule ids to suppress from CLI --ignore flag.
|
|
489
|
+
"""
|
|
490
|
+
findings = []
|
|
491
|
+
for path in _discover(paths):
|
|
492
|
+
try:
|
|
493
|
+
source = path.read_text(encoding="utf-8")
|
|
494
|
+
tree = ast.parse(source, filename=str(path))
|
|
495
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
496
|
+
continue
|
|
497
|
+
aliases = _import_aliases(tree)
|
|
498
|
+
source_lines = source.splitlines()
|
|
499
|
+
file_rules, line_suppressions = _parse_suppressions(source_lines)
|
|
500
|
+
rel = str(path)
|
|
501
|
+
for node in ast.walk(tree):
|
|
502
|
+
if isinstance(node, ast.Call):
|
|
503
|
+
for _, rule in RULES:
|
|
504
|
+
result = rule(node, aliases, rel)
|
|
505
|
+
if result is not None:
|
|
506
|
+
findings.append(
|
|
507
|
+
_apply_suppression(result, ignore, file_rules, line_suppressions)
|
|
508
|
+
)
|
|
509
|
+
findings.extend(_emit_au010(line_suppressions, rel))
|
|
510
|
+
|
|
511
|
+
findings.sort(key=lambda f: (f.severity != "error", f.path, f.lineno))
|
|
512
|
+
return findings
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
_COLORS = {
|
|
516
|
+
"error": "\033[31m",
|
|
517
|
+
"warning": "\033[33m",
|
|
518
|
+
"info": "\033[36m",
|
|
519
|
+
"ignored": "\033[90m",
|
|
520
|
+
"reset": "\033[0m",
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _color(text: str, severity: str):
|
|
525
|
+
c = _COLORS.get(severity, "")
|
|
526
|
+
return f"{c}{text}{_COLORS['reset']}" if c else text
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def report_terminal(findings: list[Finding], show_ignored: bool = False) -> str:
|
|
530
|
+
"""Render findings as a terminal table."""
|
|
531
|
+
if not findings:
|
|
532
|
+
return "no findings"
|
|
533
|
+
use_color = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
534
|
+
visible = findings if show_ignored else [f for f in findings if not f.ignored]
|
|
535
|
+
if not visible:
|
|
536
|
+
return "no findings"
|
|
537
|
+
suppressed = sum(1 for f in findings if f.ignored)
|
|
538
|
+
lines = [
|
|
539
|
+
f"{'SEV':<9} {'RULE':<7} {'FILE':<40} {'LINE':>5} MESSAGE",
|
|
540
|
+
"-" * 90,
|
|
541
|
+
]
|
|
542
|
+
for f in visible:
|
|
543
|
+
if f.ignored:
|
|
544
|
+
sev = _color("IGNORED", "ignored") if use_color else "IGNORED"
|
|
545
|
+
else:
|
|
546
|
+
sev = _color(f.severity.upper(), f.severity) if use_color else f.severity.upper()
|
|
547
|
+
path = f.path if len(f.path) <= 40 else "..." + f.path[-37:]
|
|
548
|
+
reason = f" [{f.ignore_reason}]" if f.ignore_reason else ""
|
|
549
|
+
lines.append(f"{sev:<18} {f.rule_id:<7} {path:<40} {f.lineno:>5} {f.message}{reason}")
|
|
550
|
+
if suppressed and not show_ignored:
|
|
551
|
+
lines.append(f"\n {suppressed} finding(s) suppressed (use --show-ignored to display)")
|
|
552
|
+
return "\n".join(lines)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def report_json(report: dict[str, object]) -> str:
|
|
556
|
+
"""Render the full report as JSON."""
|
|
557
|
+
return json.dumps(report, indent=2)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def scan_report(paths: list[str], ignore: set[str] | None = None) -> dict[str, object]:
|
|
561
|
+
"""Scan and return a report dict matching schema v1."""
|
|
562
|
+
start = time.monotonic()
|
|
563
|
+
findings = scan(paths, ignore=ignore)
|
|
564
|
+
duration_ms = round((time.monotonic() - start) * 1000, 1)
|
|
565
|
+
|
|
566
|
+
errors = sum(1 for f in findings if f.severity == "error" and not f.ignored)
|
|
567
|
+
warnings = sum(1 for f in findings if f.severity == "warning" and not f.ignored)
|
|
568
|
+
info = sum(1 for f in findings if f.severity == "info" and not f.ignored)
|
|
569
|
+
ignored = sum(1 for f in findings if f.ignored)
|
|
570
|
+
|
|
571
|
+
from .._version import __version__
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
"schema_version": 1,
|
|
575
|
+
"tool": {"name": "shellsafe", "version": __version__},
|
|
576
|
+
"run": {
|
|
577
|
+
"duration_ms": duration_ms,
|
|
578
|
+
"host_python": f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
579
|
+
f".{sys.version_info.micro}",
|
|
580
|
+
"paths": paths,
|
|
581
|
+
},
|
|
582
|
+
"summary": {
|
|
583
|
+
"errors": errors,
|
|
584
|
+
"warnings": warnings,
|
|
585
|
+
"info": info,
|
|
586
|
+
"ignored": ignored,
|
|
587
|
+
"verdict": "fail" if errors > 0 else "pass",
|
|
588
|
+
},
|
|
589
|
+
"findings": [
|
|
590
|
+
{
|
|
591
|
+
"rule_id": f.rule_id,
|
|
592
|
+
"title": f.title,
|
|
593
|
+
"severity": f.severity,
|
|
594
|
+
"confidence": f.confidence,
|
|
595
|
+
"path": f.path,
|
|
596
|
+
"lineno": f.lineno,
|
|
597
|
+
"col": f.col,
|
|
598
|
+
"message": f.message,
|
|
599
|
+
"evidence": f.evidence,
|
|
600
|
+
"fix_hint": f.fix_hint,
|
|
601
|
+
"ignored": f.ignored,
|
|
602
|
+
"ignore_reason": f.ignore_reason,
|
|
603
|
+
}
|
|
604
|
+
for f in findings
|
|
605
|
+
],
|
|
606
|
+
}
|