shellsafe 0.3.4__tar.gz → 0.3.5__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 (58) hide show
  1. {shellsafe-0.3.4 → shellsafe-0.3.5}/CHANGELOG.md +18 -0
  2. {shellsafe-0.3.4 → shellsafe-0.3.5}/PKG-INFO +6 -4
  3. {shellsafe-0.3.4 → shellsafe-0.3.5}/README.md +4 -3
  4. {shellsafe-0.3.4 → shellsafe-0.3.5}/pyproject.toml +1 -0
  5. shellsafe-0.3.5/src/shellsafe/_version.py +1 -0
  6. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/audit/scanner.py +56 -49
  7. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/execute.py +27 -10
  8. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/render.py +14 -4
  9. shellsafe-0.3.4/src/shellsafe/_version.py +0 -1
  10. {shellsafe-0.3.4 → shellsafe-0.3.5}/.github/workflows/ci.yml +0 -0
  11. {shellsafe-0.3.4 → shellsafe-0.3.5}/.github/workflows/release.yml +0 -0
  12. {shellsafe-0.3.4 → shellsafe-0.3.5}/.gitignore +0 -0
  13. {shellsafe-0.3.4 → shellsafe-0.3.5}/CONTRIBUTING.md +0 -0
  14. {shellsafe-0.3.4 → shellsafe-0.3.5}/LICENSE +0 -0
  15. {shellsafe-0.3.4 → shellsafe-0.3.5}/examples/demo.py +0 -0
  16. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/__init__.py +0 -0
  17. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/__main__.py +0 -0
  18. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/audit/__init__.py +0 -0
  19. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/cli.py +0 -0
  20. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/errors.py +0 -0
  21. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/exitcodes.py +0 -0
  22. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/platforms.py +0 -0
  23. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/py.typed +0 -0
  24. {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/raw.py +0 -0
  25. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/au001_sample.py +0 -0
  26. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/au002_sample.py +0 -0
  27. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/au003_sample.py +0 -0
  28. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/au004_sample.py +0 -0
  29. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/suppress_sample.py +0 -0
  30. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/golden/.gitkeep +0 -0
  31. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/integration/.gitkeep +0 -0
  32. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/integration/test_cli_audit.py +0 -0
  33. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/integration/test_exec_posix.py +0 -0
  34. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/README.md +0 -0
  35. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  36. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_02_substitution.txt +0 -0
  37. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_03_backticks.txt +0 -0
  38. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  39. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  40. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  41. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  42. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  43. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  44. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  45. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  46. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_12_newline.txt +0 -0
  47. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  48. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  49. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  50. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/property/.gitkeep +0 -0
  51. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/property/test_invariants.py +0 -0
  52. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_audit.py +0 -0
  53. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_cli.py +0 -0
  54. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_errors.py +0 -0
  55. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_payload_corpus.py +0 -0
  56. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_public_surface.py +0 -0
  57. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_raw.py +0 -0
  58. {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_render.py +0 -0
@@ -3,6 +3,24 @@
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.5] - 2026-09-11
7
+
8
+ ### Fixed
9
+
10
+ - `capture()` no longer renders template twice
11
+ - `_resolve` always returns `str` (defense-in-depth)
12
+ - Suppression comment check extended to 5 lines back (was 1)
13
+ - AU010 now flags file-level suppressions without reasons
14
+ - Audit scanner follows symlinks
15
+ - Walrus operator (`:=`) tracked by AU003
16
+
17
+ ### Changed
18
+
19
+ - Shell line length guard (128KB max)
20
+ - Suppression `reason:` is now case-insensitive
21
+ - `_apply_suppression` uses `dataclasses.replace` (internal cleanup)
22
+ - PEP 787 status updated to reflect ongoing deferral
23
+
6
24
  ## [0.3.4] - 2026-09-03
7
25
 
8
26
  ### Fixed
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shellsafe
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Run shell commands safely using Python 3.14 template strings. Values can never turn into commands.
5
5
  Project-URL: Homepage, https://github.com/rahulXs/shellsafe
6
6
  Project-URL: Repository, https://github.com/rahulXs/shellsafe
7
7
  Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
8
8
  Project-URL: Changelog, https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md
9
+ Project-URL: Blog, https://rahulxs.github.io/shellsafe/
9
10
  Author-email: Rahul Sharma <rahulxsh@gmail.com>
10
11
  License-Expression: MIT
11
12
  License-File: LICENSE
@@ -48,9 +49,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
48
49
  # if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
49
50
  ```
50
51
 
51
- Python planned to solve this officially (PEP 787), but that PEP was deferred to
52
- at least Python 3.15. So today there is no standard way to run shell commands
53
- safely with templates. This package fills that gap.
52
+ Python planned to solve this officially (PEP 787), but that PEP is still deferred.
53
+ It is not in the Python 3.15 release candidates. So today there is no standard
54
+ way to run shell commands safely with templates. This package fills that gap.
54
55
 
55
56
  ## Install
56
57
 
@@ -162,6 +163,7 @@ shellsafe audit src/ --ignore AU004
162
163
  ## More
163
164
 
164
165
  - Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
166
+ - Blog: [rahulxs.github.io/shellsafe](https://rahulxs.github.io/shellsafe/)
165
167
  - Want to help? See CONTRIBUTING.md in the repository.
166
168
 
167
169
  License: MIT
@@ -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 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.
28
+ Python planned to solve this officially (PEP 787), but that PEP is still deferred.
29
+ It is not in the Python 3.15 release candidates. So today there is no standard
30
+ way to run shell commands safely with templates. This package fills that gap.
31
31
 
32
32
  ## Install
33
33
 
@@ -139,6 +139,7 @@ shellsafe audit src/ --ignore AU004
139
139
  ## More
140
140
 
141
141
  - Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
142
+ - Blog: [rahulxs.github.io/shellsafe](https://rahulxs.github.io/shellsafe/)
142
143
  - Want to help? See CONTRIBUTING.md in the repository.
143
144
 
144
145
  License: MIT
@@ -34,6 +34,7 @@ Homepage = "https://github.com/rahulXs/shellsafe"
34
34
  Repository = "https://github.com/rahulXs/shellsafe"
35
35
  Issues = "https://github.com/rahulXs/shellsafe/issues"
36
36
  Changelog = "https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md"
37
+ Blog = "https://rahulxs.github.io/shellsafe/"
37
38
 
38
39
  [tool.hatch.version]
39
40
  path = "src/shellsafe/_version.py"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.5"
@@ -7,7 +7,7 @@ import ast
7
7
  import json
8
8
  import sys
9
9
  import time
10
- from dataclasses import dataclass, field
10
+ from dataclasses import dataclass, field, replace
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
@@ -158,13 +158,15 @@ def _track_dynamic_vars(tree: ast.Module):
158
158
  """Find variables assigned dynamic string expressions."""
159
159
  dynamic = set()
160
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)
161
+ if isinstance(node, ast.Assign):
162
+ if not _is_dynamic_string(node.value):
163
+ continue
164
+ for target in node.targets:
165
+ if isinstance(target, ast.Name):
166
+ dynamic.add(target.id)
167
+ elif isinstance(node, ast.NamedExpr):
168
+ if _is_dynamic_string(node.value) and isinstance(node.target, ast.Name):
169
+ dynamic.add(node.target.id)
168
170
  return dynamic
169
171
 
170
172
 
@@ -300,11 +302,17 @@ def _discover(paths: list[str]):
300
302
  if p.is_file() and p.suffix == ".py":
301
303
  files.append(p)
302
304
  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
- )
305
+ import os
306
+
307
+ for dirpath, dirnames, filenames in os.walk(p, followlinks=True):
308
+ dirnames[:] = [
309
+ d for d in dirnames
310
+ if not d.startswith(".") and d != "__pycache__"
311
+ ]
312
+ for name in filenames:
313
+ if name.endswith(".py"):
314
+ files.append(Path(dirpath) / name)
315
+ files.sort()
308
316
  return files
309
317
 
310
318
 
@@ -341,10 +349,11 @@ def _parse_suppression_comment(comment: str) -> tuple[list[str], str | None]:
341
349
  return [], None
342
350
  rest = directive[len("ignore "):]
343
351
  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
352
+ reason_lower = rest.lower()
353
+ reason_idx = reason_lower.find(" reason:")
354
+ if reason_idx != -1:
355
+ reason = rest[reason_idx + 8:].strip() or None
356
+ rest = rest[:reason_idx]
348
357
  rule_ids = [r.strip().upper() for r in rest.split(",") if r.strip()]
349
358
  return rule_ids, reason
350
359
 
@@ -386,11 +395,14 @@ def _find_suppressed_line(
386
395
  ) -> tuple[set[str], str | None] | None:
387
396
  """Find which rules suppress findings at target_line.
388
397
 
389
- Checks previous non-blank, non-comment line, then target line itself.
398
+ Checks up to 5 previous non-blank lines, then the target line itself.
390
399
  """
391
- for candidate in (target_line - 1, target_line):
400
+ for offset in range(1, 6):
401
+ candidate = target_line - offset
392
402
  if candidate in line_suppressions:
393
403
  return line_suppressions[candidate]
404
+ if target_line in line_suppressions:
405
+ return line_suppressions[target_line]
394
406
  return None
395
407
 
396
408
 
@@ -402,24 +414,13 @@ def _apply_suppression(
402
414
  ) -> Finding:
403
415
  """Apply suppression rules to a finding. Returns new Finding with ignored flag."""
404
416
  rule_id = result.rule_id
417
+
418
+ if ignore and rule_id in ignore:
419
+ return replace(result, ignored=True, ignore_reason="CLI --ignore")
420
+
405
421
  suppressed = False
406
422
  ignore_reason = None
407
423
 
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
424
  if rule_id in file_rules or "*" in file_rules:
424
425
  suppressed = True
425
426
  ignore_reason = "file-level suppression"
@@ -433,24 +434,12 @@ def _apply_suppression(
433
434
 
434
435
  if not suppressed:
435
436
  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
- )
437
+ return replace(result, ignored=True, ignore_reason=ignore_reason)
450
438
 
451
439
 
452
440
  def _emit_au010(
453
441
  line_suppressions: dict[int, tuple[set[str], str | None]],
442
+ file_rules: set[str],
454
443
  rel: str,
455
444
  ) -> list[Finding]:
456
445
  """Emit AU010 warnings for suppression comments without reasons."""
@@ -477,6 +466,24 @@ def _emit_au010(
477
466
  evidence={"rules": sorted(suppressed_rules)},
478
467
  )
479
468
  )
469
+ if file_rules and "*" not in file_rules:
470
+ for rule in sorted(file_rules):
471
+ results.append(
472
+ Finding(
473
+ rule_id="AU010",
474
+ title="suppression comment without reason",
475
+ severity="info",
476
+ confidence=1.0,
477
+ path=rel,
478
+ lineno=1,
479
+ col=0,
480
+ message=(
481
+ f"File-level suppression for {rule} "
482
+ f"has no reason. Add: reason: <why>"
483
+ ),
484
+ evidence={"rules": [rule], "file_level": True},
485
+ )
486
+ )
480
487
  return results
481
488
 
482
489
 
@@ -506,7 +513,7 @@ def scan(paths: list[str], ignore: set[str] | None = None) -> list[Finding]:
506
513
  findings.append(
507
514
  _apply_suppression(result, ignore, file_rules, line_suppressions)
508
515
  )
509
- findings.extend(_emit_au010(line_suppressions, rel))
516
+ findings.extend(_emit_au010(line_suppressions, file_rules, rel))
510
517
 
511
518
  findings.sort(key=lambda f: (f.severity != "error", f.path, f.lineno))
512
519
  return findings
@@ -51,15 +51,14 @@ def _pass_through(kwargs: dict[str, object]):
51
51
  return {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS}
52
52
 
53
53
 
54
- def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
55
- """Render the template and execute it.
54
+ def _render(
55
+ template: object, kwargs: dict[str, object]
56
+ ) -> tuple[ExecutionPlan, subprocess.CompletedProcess[str]]:
57
+ """Render template and execute, returning both plan and result."""
58
+ from .render import plan as render_plan
56
59
 
57
- Interpolated values always arrive as single argv elements. Keyword arguments
58
- pass through to subprocess.run with one exception: shell is rejected by
59
- design.
60
- """
61
60
  _validate_kwargs(kwargs)
62
- rendered = plan(template)
61
+ rendered = render_plan(template)
63
62
  if rendered.mode == "shell":
64
63
  if sys.platform.startswith("win"):
65
64
  raise ShellSafeError(
@@ -71,7 +70,19 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
71
70
  "run() to execute pipes and redirections"
72
71
  )
73
72
  assert rendered.argv is not None
74
- return subprocess.run(rendered.argv, **_pass_through(kwargs))
73
+ completed = subprocess.run(rendered.argv, **_pass_through(kwargs))
74
+ return rendered, completed
75
+
76
+
77
+ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
78
+ """Render the template and execute it.
79
+
80
+ Interpolated values always arrive as single argv elements. Keyword arguments
81
+ pass through to subprocess.run with one exception: shell is rejected by
82
+ design.
83
+ """
84
+ _, completed = _render(template, kwargs)
85
+ return completed
75
86
 
76
87
 
77
88
  @dataclass(frozen=True, slots=True)
@@ -83,18 +94,24 @@ class CaptureResult:
83
94
  returncode: int
84
95
  plan: ExecutionPlan
85
96
 
97
+ def __repr__(self) -> str:
98
+ return (
99
+ f"CaptureResult(returncode={self.returncode}, "
100
+ f"stdout={self.stdout!r}, stderr={self.stderr!r})"
101
+ )
102
+
86
103
 
87
104
  def capture(template: object, /, **kwargs: object) -> CaptureResult:
88
105
  """Run with captured utf-8 stdout/stderr and return a CaptureResult."""
89
106
  kwargs["capture_output"] = True
90
107
  kwargs["text"] = True
91
108
  kwargs["encoding"] = "utf-8"
92
- completed = run(template, **kwargs)
109
+ rendered, completed = _render(template, kwargs)
93
110
  return CaptureResult(
94
111
  stdout=completed.stdout,
95
112
  stderr=completed.stderr,
96
113
  returncode=completed.returncode,
97
- plan=plan(template),
114
+ plan=rendered,
98
115
  )
99
116
 
100
117
 
@@ -83,10 +83,15 @@ def _resolve(interpolation: Interpolation[str]) -> str:
83
83
  )
84
84
 
85
85
  if format_spec:
86
- return format(converted, format_spec)
87
- if conversion is None:
88
- return str(converted)
89
- return converted
86
+ result = format(converted, format_spec)
87
+ elif conversion is None:
88
+ result = str(converted)
89
+ else:
90
+ result = converted
91
+
92
+ if not isinstance(result, str):
93
+ result = str(result)
94
+ return result
90
95
 
91
96
 
92
97
  def _reject_nul(resolved: str):
@@ -176,4 +181,9 @@ def _render_shell(parts: list[Segment]) -> ExecutionPlan:
176
181
  line = "".join(line_parts).strip()
177
182
  if not line:
178
183
  raise ShellSafeTypeError("empty command")
184
+ if len(line) > 131072:
185
+ raise ShellSafeTypeError(
186
+ f"shell line exceeds 128KB ({len(line)} bytes); "
187
+ "use argv mode or shorten the command"
188
+ )
179
189
  return ExecutionPlan(mode="shell", shell_line=line)
@@ -1 +0,0 @@
1
- __version__ = "0.3.4"
File without changes
File without changes
File without changes
File without changes