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.
Files changed (61) hide show
  1. {shellsafe-0.3.1 → shellsafe-0.3.3}/CHANGELOG.md +35 -0
  2. {shellsafe-0.3.1 → shellsafe-0.3.3}/PKG-INFO +2 -1
  3. {shellsafe-0.3.1 → shellsafe-0.3.3}/pyproject.toml +1 -0
  4. shellsafe-0.3.3/src/shellsafe/_version.py +1 -0
  5. shellsafe-0.3.3/src/shellsafe/audit/scanner.py +606 -0
  6. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/cli.py +16 -3
  7. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/execute.py +1 -2
  8. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/platforms.py +1 -1
  9. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/fixtures/audit/au001_sample.py +2 -2
  10. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/fixtures/audit/au002_sample.py +2 -2
  11. shellsafe-0.3.3/tests/fixtures/audit/au003_sample.py +37 -0
  12. shellsafe-0.3.3/tests/fixtures/audit/au004_sample.py +28 -0
  13. shellsafe-0.3.3/tests/fixtures/audit/suppress_sample.py +26 -0
  14. shellsafe-0.3.3/tests/integration/test_cli_audit.py +306 -0
  15. shellsafe-0.3.3/tests/unit/test_audit.py +251 -0
  16. shellsafe-0.3.1/src/shellsafe/_version.py +0 -1
  17. shellsafe-0.3.1/src/shellsafe/audit/scanner.py +0 -290
  18. shellsafe-0.3.1/tests/integration/test_cli_audit.py +0 -136
  19. shellsafe-0.3.1/tests/unit/test_audit.py +0 -104
  20. {shellsafe-0.3.1 → shellsafe-0.3.3}/.github/workflows/ci.yml +0 -0
  21. {shellsafe-0.3.1 → shellsafe-0.3.3}/.github/workflows/release.yml +0 -0
  22. {shellsafe-0.3.1 → shellsafe-0.3.3}/.gitignore +0 -0
  23. {shellsafe-0.3.1 → shellsafe-0.3.3}/CONTRIBUTING.md +0 -0
  24. {shellsafe-0.3.1 → shellsafe-0.3.3}/LICENSE +0 -0
  25. {shellsafe-0.3.1 → shellsafe-0.3.3}/README.md +0 -0
  26. {shellsafe-0.3.1 → shellsafe-0.3.3}/examples/demo.py +0 -0
  27. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/__init__.py +0 -0
  28. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/__main__.py +0 -0
  29. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/audit/__init__.py +0 -0
  30. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/errors.py +0 -0
  31. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/exitcodes.py +0 -0
  32. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/py.typed +0 -0
  33. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/raw.py +0 -0
  34. {shellsafe-0.3.1 → shellsafe-0.3.3}/src/shellsafe/render.py +0 -0
  35. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/golden/.gitkeep +0 -0
  36. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/integration/.gitkeep +0 -0
  37. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/integration/test_exec_posix.py +0 -0
  38. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/README.md +0 -0
  39. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  40. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_02_substitution.txt +0 -0
  41. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_03_backticks.txt +0 -0
  42. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  43. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  44. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  45. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  46. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  47. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  48. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  49. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  50. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_12_newline.txt +0 -0
  51. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  52. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  53. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  54. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/property/.gitkeep +0 -0
  55. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/property/test_invariants.py +0 -0
  56. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_cli.py +0 -0
  57. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_errors.py +0 -0
  58. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_payload_corpus.py +0 -0
  59. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_public_surface.py +0 -0
  60. {shellsafe-0.3.1 → shellsafe-0.3.3}/tests/unit/test_raw.py +0 -0
  61. {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.1
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
+ }