shellsafe 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {shellsafe-0.2.0 → shellsafe-0.3.1}/.github/workflows/ci.yml +0 -1
- {shellsafe-0.2.0 → shellsafe-0.3.1}/CHANGELOG.md +37 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/PKG-INFO +27 -5
- {shellsafe-0.2.0 → shellsafe-0.3.1}/README.md +26 -4
- {shellsafe-0.2.0 → shellsafe-0.3.1}/pyproject.toml +2 -5
- shellsafe-0.3.1/src/shellsafe/_version.py +1 -0
- shellsafe-0.3.1/src/shellsafe/audit/__init__.py +6 -0
- shellsafe-0.3.1/src/shellsafe/audit/scanner.py +290 -0
- shellsafe-0.3.1/src/shellsafe/cli.py +70 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/execute.py +5 -15
- shellsafe-0.3.1/src/shellsafe/platforms.py +25 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/raw.py +0 -1
- {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/render.py +5 -16
- shellsafe-0.3.1/tests/fixtures/audit/au001_sample.py +31 -0
- shellsafe-0.3.1/tests/fixtures/audit/au002_sample.py +41 -0
- shellsafe-0.3.1/tests/integration/test_cli_audit.py +136 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/integration/test_exec_posix.py +0 -6
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/property/test_invariants.py +0 -2
- shellsafe-0.3.1/tests/unit/test_audit.py +104 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_payload_corpus.py +0 -2
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_render.py +0 -2
- shellsafe-0.2.0/src/shellsafe/_version.py +0 -1
- shellsafe-0.2.0/src/shellsafe/audit/__init__.py +0 -10
- shellsafe-0.2.0/src/shellsafe/audit/rules.py +0 -0
- shellsafe-0.2.0/src/shellsafe/cli.py +0 -44
- shellsafe-0.2.0/src/shellsafe/platforms.py +0 -35
- shellsafe-0.2.0/src/shellsafe/reporters.py +0 -1
- {shellsafe-0.2.0 → shellsafe-0.3.1}/.github/workflows/release.yml +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/.gitignore +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/CONTRIBUTING.md +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/LICENSE +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/examples/demo.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/property/.gitkeep +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.1}/tests/unit/test_raw.py +0 -0
|
@@ -3,6 +3,43 @@
|
|
|
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
|
+
|
|
16
|
+
## [0.3.0] - 2026-08-29
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Offline audit scanner: AST-based detection of f-strings passed to shell
|
|
21
|
+
executors (`os.system`, `subprocess.run`, `subprocess.call`, etc.)
|
|
22
|
+
- AU001 rule: flags f-string interpolation in shell executor arguments with
|
|
23
|
+
fix-hint suggesting template strings
|
|
24
|
+
- Import alias tracking: handles `import subprocess as sp`, `from os import
|
|
25
|
+
system`, etc.
|
|
26
|
+
- CLI `shellsafe audit` command with terminal table and JSON output
|
|
27
|
+
- `--json` flag for machine-readable findings
|
|
28
|
+
- `--severity` flag to filter by minimum severity level
|
|
29
|
+
- Audit fixture corpus: 6 positive cases + 4 safe cases
|
|
30
|
+
- Unit tests for scanner, integration tests for CLI audit
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Collapse `audit/rules.py` and `reporters.py` into single `audit/scanner.py`
|
|
35
|
+
- CLI refactored with dedicated `_run_audit` handler
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
- mypy (ruff catches real bugs)
|
|
40
|
+
- `cast()` calls on values that already have the right type
|
|
41
|
+
|
|
42
|
+
|
|
6
43
|
## [0.2.0] - 2026-08-27
|
|
7
44
|
|
|
8
45
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shellsafe
|
|
3
|
-
Version: 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
|
|
51
|
-
So today there is no standard way to run shell commands
|
|
52
|
-
This package fills that gap.
|
|
50
|
+
Python planned to solve this officially (PEP 787), but that PEP was deferred to
|
|
51
|
+
at least Python 3.15. So today there is no standard way to run shell commands
|
|
52
|
+
safely with templates. This package fills that gap.
|
|
53
53
|
|
|
54
54
|
## Install
|
|
55
55
|
|
|
@@ -61,7 +61,8 @@ Needs Python 3.14 or newer.
|
|
|
61
61
|
|
|
62
62
|
## How to use
|
|
63
63
|
|
|
64
|
-
Run a command. Your values always stay one argument each
|
|
64
|
+
Run a command. Your values always stay one argument each. `run()` never invokes
|
|
65
|
+
a shell, so injection is impossible on any platform:
|
|
65
66
|
|
|
66
67
|
```python
|
|
67
68
|
from shellsafe import run
|
|
@@ -108,6 +109,27 @@ print(plan(t"git commit -m {message}"))
|
|
|
108
109
|
`RAW(...)` marks content you have already made safe by hand. It is loud and easy
|
|
109
110
|
to find in code review, so trust is never hidden.
|
|
110
111
|
|
|
112
|
+
## Find old dangerous patterns
|
|
113
|
+
|
|
114
|
+
Already have code using f-strings in shell commands? The scanner finds them:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
shellsafe audit src/
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
It detects f-strings passed to `os.system`, `subprocess.run(shell=True)`, and
|
|
121
|
+
other shell executors. Get machine-readable output:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
shellsafe audit src/ --json
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Filter by severity:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
shellsafe audit src/ --severity warning
|
|
131
|
+
```
|
|
132
|
+
|
|
111
133
|
## Limits
|
|
112
134
|
|
|
113
135
|
- Shell features (pipes, redirections) work on Linux and macOS only. Windows
|
|
@@ -25,9 +25,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
|
|
|
25
25
|
# if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Python planned to solve this officially (PEP 787), but that
|
|
29
|
-
So today there is no standard way to run shell commands
|
|
30
|
-
This package fills that gap.
|
|
28
|
+
Python planned to solve this officially (PEP 787), but that PEP was deferred to
|
|
29
|
+
at least Python 3.15. So today there is no standard way to run shell commands
|
|
30
|
+
safely with templates. This package fills that gap.
|
|
31
31
|
|
|
32
32
|
## Install
|
|
33
33
|
|
|
@@ -39,7 +39,8 @@ Needs Python 3.14 or newer.
|
|
|
39
39
|
|
|
40
40
|
## How to use
|
|
41
41
|
|
|
42
|
-
Run a command. Your values always stay one argument each
|
|
42
|
+
Run a command. Your values always stay one argument each. `run()` never invokes
|
|
43
|
+
a shell, so injection is impossible on any platform:
|
|
43
44
|
|
|
44
45
|
```python
|
|
45
46
|
from shellsafe import run
|
|
@@ -86,6 +87,27 @@ print(plan(t"git commit -m {message}"))
|
|
|
86
87
|
`RAW(...)` marks content you have already made safe by hand. It is loud and easy
|
|
87
88
|
to find in code review, so trust is never hidden.
|
|
88
89
|
|
|
90
|
+
## Find old dangerous patterns
|
|
91
|
+
|
|
92
|
+
Already have code using f-strings in shell commands? The scanner finds them:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
shellsafe audit src/
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
It detects f-strings passed to `os.system`, `subprocess.run(shell=True)`, and
|
|
99
|
+
other shell executors. Get machine-readable output:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
shellsafe audit src/ --json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Filter by severity:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
shellsafe audit src/ --severity warning
|
|
109
|
+
```
|
|
110
|
+
|
|
89
111
|
## Limits
|
|
90
112
|
|
|
91
113
|
- Shell features (pipes, redirections) work on Linux and macOS only. Windows
|
|
@@ -50,10 +50,6 @@ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF", "C901"]
|
|
|
50
50
|
[tool.ruff.lint.mccabe]
|
|
51
51
|
max-complexity = 10
|
|
52
52
|
|
|
53
|
-
[tool.mypy]
|
|
54
|
-
strict = true
|
|
55
|
-
python_version = "3.14"
|
|
56
|
-
|
|
57
53
|
[tool.pytest.ini_options]
|
|
58
54
|
testpaths = ["tests"]
|
|
59
55
|
addopts = "-q --tb=short"
|
|
@@ -62,12 +58,13 @@ addopts = "-q --tb=short"
|
|
|
62
58
|
# RAW() is a deliberate loud trust marker; the uppercase name is the API.
|
|
63
59
|
"src/shellsafe/raw.py" = ["N802"]
|
|
64
60
|
"src/shellsafe/__init__.py" = ["N999"]
|
|
61
|
+
# Fixture files contain planted violations and undefined names on purpose.
|
|
62
|
+
"tests/fixtures/**" = ["F821", "F841"]
|
|
65
63
|
|
|
66
64
|
[dependency-groups]
|
|
67
65
|
dev = [
|
|
68
66
|
"pytest>=8",
|
|
69
67
|
"ruff>=0.5",
|
|
70
|
-
"mypy>=1.10",
|
|
71
68
|
"hypothesis>=6",
|
|
72
69
|
"build>=1.2",
|
|
73
70
|
"twine>=6",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -0,0 +1,290 @@
|
|
|
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 collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Finding:
|
|
18
|
+
"""One audit finding."""
|
|
19
|
+
|
|
20
|
+
rule_id: str
|
|
21
|
+
title: str
|
|
22
|
+
severity: str
|
|
23
|
+
confidence: float
|
|
24
|
+
path: str
|
|
25
|
+
lineno: int
|
|
26
|
+
col: int
|
|
27
|
+
message: str
|
|
28
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
fix_hint: str = ""
|
|
30
|
+
ignored: bool = False
|
|
31
|
+
ignore_reason: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_EXECUTORS = {
|
|
35
|
+
"system": "os.system",
|
|
36
|
+
"popen": "os.popen",
|
|
37
|
+
"run": "subprocess.run",
|
|
38
|
+
"call": "subprocess.call",
|
|
39
|
+
"check_call": "subprocess.check_call",
|
|
40
|
+
"check_output": "subprocess.check_output",
|
|
41
|
+
"Popen": "subprocess.Popen",
|
|
42
|
+
"getoutput": "subprocess.getoutput",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_IMPORT_ALIAS = {
|
|
46
|
+
"os": "os",
|
|
47
|
+
"subprocess": "subprocess",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_callee(node: ast.expr, aliases: dict[str, str]) -> str | None:
|
|
52
|
+
"""Resolve a Call.func to an executor label like 'os.system'."""
|
|
53
|
+
if (
|
|
54
|
+
isinstance(node, ast.Attribute)
|
|
55
|
+
and isinstance(node.value, ast.Name)
|
|
56
|
+
and node.value.id in _IMPORT_ALIAS
|
|
57
|
+
):
|
|
58
|
+
module = _IMPORT_ALIAS[node.value.id]
|
|
59
|
+
if node.attr in _EXECUTORS:
|
|
60
|
+
return f"{module}.{node.attr}"
|
|
61
|
+
if isinstance(node, ast.Name):
|
|
62
|
+
real = aliases.get(node.id, node.id)
|
|
63
|
+
if real in _EXECUTORS:
|
|
64
|
+
return _EXECUTORS[real]
|
|
65
|
+
if "." in real:
|
|
66
|
+
module, func = real.rsplit(".", 1)
|
|
67
|
+
if func in _EXECUTORS:
|
|
68
|
+
return f"{module}.{func}"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def au001(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
|
|
73
|
+
"""f-string passed to a shell-executing call."""
|
|
74
|
+
callee = _resolve_callee(node.func, aliases)
|
|
75
|
+
if callee is None:
|
|
76
|
+
return None
|
|
77
|
+
for arg in node.args:
|
|
78
|
+
if isinstance(arg, ast.JoinedStr) and any(
|
|
79
|
+
isinstance(v, ast.FormattedValue) for v in arg.values
|
|
80
|
+
):
|
|
81
|
+
return Finding(
|
|
82
|
+
rule_id="AU001",
|
|
83
|
+
title="f-string passed to shell-executing call",
|
|
84
|
+
severity="error",
|
|
85
|
+
confidence=0.95,
|
|
86
|
+
path=path,
|
|
87
|
+
lineno=node.lineno,
|
|
88
|
+
col=node.col_offset,
|
|
89
|
+
message=f"{callee} receives an f-string with interpolated value(s)",
|
|
90
|
+
evidence={
|
|
91
|
+
"callee": callee,
|
|
92
|
+
"interpolations": sum(
|
|
93
|
+
1 for v in arg.values if isinstance(v, ast.FormattedValue)
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
fix_hint='use shellsafe.run(t"...") or pass an argv list',
|
|
97
|
+
)
|
|
98
|
+
return None
|
|
99
|
+
|
|
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
|
+
|
|
158
|
+
RULES: list[tuple[str, Callable[..., Finding | None]]] = [
|
|
159
|
+
("AU001", au001),
|
|
160
|
+
("AU002", au002),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _discover(paths: list[str]) -> list[Path]:
|
|
165
|
+
files: list[Path] = []
|
|
166
|
+
for raw in paths:
|
|
167
|
+
p = Path(raw)
|
|
168
|
+
if p.is_file() and p.suffix == ".py":
|
|
169
|
+
files.append(p)
|
|
170
|
+
elif p.is_dir():
|
|
171
|
+
files.extend(
|
|
172
|
+
f
|
|
173
|
+
for f in sorted(p.rglob("*.py"))
|
|
174
|
+
if not any(part.startswith(".") or part == "__pycache__" for part in f.parts)
|
|
175
|
+
)
|
|
176
|
+
return files
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _import_aliases(tree: ast.Module) -> dict[str, str]:
|
|
180
|
+
"""Map local names to real module.function for executor imports."""
|
|
181
|
+
aliases: dict[str, str] = {}
|
|
182
|
+
for node in ast.walk(tree):
|
|
183
|
+
if isinstance(node, ast.Import):
|
|
184
|
+
for alias in node.names:
|
|
185
|
+
local = alias.asname or alias.name
|
|
186
|
+
aliases[local] = alias.name
|
|
187
|
+
elif isinstance(node, ast.ImportFrom):
|
|
188
|
+
module = node.module or ""
|
|
189
|
+
for alias in node.names:
|
|
190
|
+
local = alias.asname or alias.name
|
|
191
|
+
aliases[local] = f"{module}.{alias.name}"
|
|
192
|
+
return aliases
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def scan(paths: list[str]) -> list[Finding]:
|
|
196
|
+
"""Scan Python files for audit findings."""
|
|
197
|
+
findings: list[Finding] = []
|
|
198
|
+
for path in _discover(paths):
|
|
199
|
+
try:
|
|
200
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
201
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
202
|
+
continue
|
|
203
|
+
aliases = _import_aliases(tree)
|
|
204
|
+
rel = str(path)
|
|
205
|
+
for node in ast.walk(tree):
|
|
206
|
+
if isinstance(node, ast.Call):
|
|
207
|
+
for _, rule in RULES:
|
|
208
|
+
result = rule(node, aliases, rel)
|
|
209
|
+
if result is not None:
|
|
210
|
+
findings.append(result)
|
|
211
|
+
findings.sort(key=lambda f: (f.severity != "error", f.path, f.lineno))
|
|
212
|
+
return findings
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_COLORS = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m", "reset": "\033[0m"}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _color(text: str, severity: str) -> str:
|
|
219
|
+
c = _COLORS.get(severity, "")
|
|
220
|
+
return f"{c}{text}{_COLORS['reset']}" if c else text
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def report_terminal(findings: list[Finding]) -> str:
|
|
224
|
+
"""Render findings as a terminal table."""
|
|
225
|
+
if not findings:
|
|
226
|
+
return "no findings"
|
|
227
|
+
use_color = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
228
|
+
lines = [
|
|
229
|
+
f"{'SEV':<9} {'RULE':<7} {'FILE':<40} {'LINE':>5} MESSAGE",
|
|
230
|
+
"-" * 90,
|
|
231
|
+
]
|
|
232
|
+
for f in findings:
|
|
233
|
+
sev = _color(f.severity.upper(), f.severity) if use_color else f.severity.upper()
|
|
234
|
+
path = f.path if len(f.path) <= 40 else "..." + f.path[-37:]
|
|
235
|
+
lines.append(f"{sev:<18} {f.rule_id:<7} {path:<40} {f.lineno:>5} {f.message}")
|
|
236
|
+
return "\n".join(lines)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def report_json(report: dict[str, object]) -> str:
|
|
240
|
+
"""Render the full report as JSON."""
|
|
241
|
+
return json.dumps(report, indent=2)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def scan_report(paths: list[str]) -> dict[str, object]:
|
|
245
|
+
"""Scan and return a report dict matching schema v1."""
|
|
246
|
+
start = time.monotonic()
|
|
247
|
+
findings = scan(paths)
|
|
248
|
+
duration_ms = round((time.monotonic() - start) * 1000, 1)
|
|
249
|
+
|
|
250
|
+
errors = sum(1 for f in findings if f.severity == "error" and not f.ignored)
|
|
251
|
+
warnings = sum(1 for f in findings if f.severity == "warning" and not f.ignored)
|
|
252
|
+
info = sum(1 for f in findings if f.severity == "info" and not f.ignored)
|
|
253
|
+
ignored = sum(1 for f in findings if f.ignored)
|
|
254
|
+
|
|
255
|
+
from .._version import __version__
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
"schema_version": 1,
|
|
259
|
+
"tool": {"name": "shellsafe", "version": __version__},
|
|
260
|
+
"run": {
|
|
261
|
+
"duration_ms": duration_ms,
|
|
262
|
+
"host_python": f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
263
|
+
f".{sys.version_info.micro}",
|
|
264
|
+
"paths": paths,
|
|
265
|
+
},
|
|
266
|
+
"summary": {
|
|
267
|
+
"errors": errors,
|
|
268
|
+
"warnings": warnings,
|
|
269
|
+
"info": info,
|
|
270
|
+
"ignored": ignored,
|
|
271
|
+
"verdict": "fail" if errors > 0 else "pass",
|
|
272
|
+
},
|
|
273
|
+
"findings": [
|
|
274
|
+
{
|
|
275
|
+
"rule_id": f.rule_id,
|
|
276
|
+
"title": f.title,
|
|
277
|
+
"severity": f.severity,
|
|
278
|
+
"confidence": f.confidence,
|
|
279
|
+
"path": f.path,
|
|
280
|
+
"lineno": f.lineno,
|
|
281
|
+
"col": f.col,
|
|
282
|
+
"message": f.message,
|
|
283
|
+
"evidence": f.evidence,
|
|
284
|
+
"fix_hint": f.fix_hint,
|
|
285
|
+
"ignored": f.ignored,
|
|
286
|
+
"ignore_reason": f.ignore_reason,
|
|
287
|
+
}
|
|
288
|
+
for f in findings
|
|
289
|
+
],
|
|
290
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""CLI: audit, version. Business logic lives in the stages."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import exitcodes
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
|
|
10
|
+
_SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="shellsafe")
|
|
15
|
+
parser.add_argument("-V", "--version", action="store_true")
|
|
16
|
+
sub = parser.add_subparsers(dest="command")
|
|
17
|
+
|
|
18
|
+
audit = sub.add_parser("audit", help="scan code for dangerous command construction")
|
|
19
|
+
audit.add_argument("paths", nargs="*", default=["."], help="files or directories to scan")
|
|
20
|
+
audit.add_argument("--json", action="store_true", dest="json_output", help="output JSON report")
|
|
21
|
+
audit.add_argument(
|
|
22
|
+
"--severity",
|
|
23
|
+
choices=["error", "warning", "info"],
|
|
24
|
+
default="warning",
|
|
25
|
+
help="minimum severity to report (default: warning)",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
sub.add_parser("version", help="detailed version and capability matrix")
|
|
29
|
+
return parser
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run_audit(args: argparse.Namespace) -> int:
|
|
33
|
+
from .audit.scanner import Finding, report_json, report_terminal, scan_report
|
|
34
|
+
|
|
35
|
+
report = scan_report(args.paths)
|
|
36
|
+
findings = report.get("findings", [])
|
|
37
|
+
|
|
38
|
+
min_sev = _SEVERITY_ORDER.get(args.severity, 1)
|
|
39
|
+
filtered = [
|
|
40
|
+
f for f in findings
|
|
41
|
+
if _SEVERITY_ORDER.get(str(f.get("severity", "")), 9) <= min_sev
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
if args.json_output:
|
|
45
|
+
print(report_json(report))
|
|
46
|
+
else:
|
|
47
|
+
print(report_terminal([Finding(**f) for f in filtered]))
|
|
48
|
+
|
|
49
|
+
summary = report.get("summary", {})
|
|
50
|
+
return exitcodes.FINDINGS if summary.get("errors", 0) > 0 else exitcodes.OK
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv: list[str] | None = None) -> int:
|
|
54
|
+
parser = _build_parser()
|
|
55
|
+
args = parser.parse_args(argv)
|
|
56
|
+
|
|
57
|
+
if args.version:
|
|
58
|
+
print(f"shellsafe {__version__}")
|
|
59
|
+
return exitcodes.OK
|
|
60
|
+
if args.command == "version":
|
|
61
|
+
py = platform.python_version()
|
|
62
|
+
print(f"shellsafe {__version__} . python {py} . {sys.platform}")
|
|
63
|
+
print("argv-mode: available")
|
|
64
|
+
print("shell-mode: available (posix)")
|
|
65
|
+
print("audit: available (AU001)")
|
|
66
|
+
return exitcodes.OK
|
|
67
|
+
if args.command == "audit":
|
|
68
|
+
return _run_audit(args)
|
|
69
|
+
parser.print_help()
|
|
70
|
+
return exitcodes.OK
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
"""Subprocess wrappers over rendered ExecutionPlans."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
import subprocess
|
|
6
4
|
import sys
|
|
7
5
|
from dataclasses import dataclass
|
|
8
|
-
from typing import Any
|
|
6
|
+
from typing import Any
|
|
9
7
|
|
|
10
8
|
from .errors import ArgvOnlyError, ShellSafeError
|
|
11
9
|
from .raw import Raw # noqa: F401 (re-exported through the package root)
|
|
@@ -29,7 +27,7 @@ _ALLOWED_KWARGS = frozenset(
|
|
|
29
27
|
)
|
|
30
28
|
|
|
31
29
|
|
|
32
|
-
def _validate_kwargs(kwargs: dict[str, object])
|
|
30
|
+
def _validate_kwargs(kwargs: dict[str, object]):
|
|
33
31
|
if "shell" in kwargs:
|
|
34
32
|
raise ShellSafeError(
|
|
35
33
|
"shellsafe never passes shell=True; use shx() for pipes and "
|
|
@@ -51,11 +49,7 @@ def plan(template: object) -> ExecutionPlan:
|
|
|
51
49
|
|
|
52
50
|
|
|
53
51
|
def _pass_through(kwargs: dict[str, object]) -> dict[str, Any]:
|
|
54
|
-
|
|
55
|
-
return cast(
|
|
56
|
-
"dict[str, Any]",
|
|
57
|
-
{k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS},
|
|
58
|
-
)
|
|
52
|
+
return {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS}
|
|
59
53
|
|
|
60
54
|
|
|
61
55
|
def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
@@ -78,8 +72,7 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
|
|
|
78
72
|
"run() to execute pipes and redirections"
|
|
79
73
|
)
|
|
80
74
|
assert rendered.argv is not None
|
|
81
|
-
|
|
82
|
-
return cast("subprocess.CompletedProcess[str]", result)
|
|
75
|
+
return subprocess.run(rendered.argv, **_pass_through(kwargs))
|
|
83
76
|
|
|
84
77
|
|
|
85
78
|
@dataclass(frozen=True, slots=True)
|
|
@@ -98,8 +91,6 @@ def capture(template: object, /, **kwargs: object) -> CaptureResult:
|
|
|
98
91
|
kwargs["text"] = True
|
|
99
92
|
kwargs["encoding"] = "utf-8"
|
|
100
93
|
completed = run(template, **kwargs)
|
|
101
|
-
assert isinstance(completed.stdout, str)
|
|
102
|
-
assert isinstance(completed.stderr, str)
|
|
103
94
|
return CaptureResult(
|
|
104
95
|
stdout=completed.stdout,
|
|
105
96
|
stderr=completed.stderr,
|
|
@@ -122,8 +113,7 @@ def shx(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
|
|
|
122
113
|
"template contains no shell metacharacters; use run() instead"
|
|
123
114
|
)
|
|
124
115
|
assert rendered.shell_line is not None
|
|
125
|
-
|
|
116
|
+
return subprocess.run(
|
|
126
117
|
["/bin/sh", "-c", rendered.shell_line],
|
|
127
118
|
**_pass_through(kwargs),
|
|
128
119
|
)
|
|
129
|
-
return cast("subprocess.CompletedProcess[str]", result)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Platform policy tables and route decision."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
METACHARACTERS = frozenset("|&;()<>$`\"'*?!#\\\n\r\t")
|
|
6
|
+
|
|
7
|
+
IS_WINDOWS = sys.platform.startswith("win")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def route_for(static_text: str) -> str:
|
|
11
|
+
"""Return "argv" or "shell" for the given static template text.
|
|
12
|
+
|
|
13
|
+
Raises UnsupportedPlatformError on Windows when shell features are requested.
|
|
14
|
+
"""
|
|
15
|
+
if not any(ch in METACHARACTERS for ch in static_text):
|
|
16
|
+
return "argv"
|
|
17
|
+
if IS_WINDOWS:
|
|
18
|
+
from .errors import UnsupportedPlatformError
|
|
19
|
+
|
|
20
|
+
raise UnsupportedPlatformError(
|
|
21
|
+
"shell metacharacters in a shellsafe template require posix sh; "
|
|
22
|
+
"windows cmd.exe quoting cannot be made injection-safe. "
|
|
23
|
+
"restructure without pipes/redirections, or run under wsl."
|
|
24
|
+
)
|
|
25
|
+
return "shell"
|