shellsafe 0.2.0__tar.gz → 0.3.0__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.0}/.github/workflows/ci.yml +0 -1
- {shellsafe-0.2.0 → shellsafe-0.3.0}/CHANGELOG.md +27 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/PKG-INFO +22 -1
- {shellsafe-0.2.0 → shellsafe-0.3.0}/README.md +21 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/pyproject.toml +2 -5
- shellsafe-0.3.0/src/shellsafe/_version.py +1 -0
- shellsafe-0.3.0/src/shellsafe/audit/__init__.py +6 -0
- shellsafe-0.3.0/src/shellsafe/audit/scanner.py +232 -0
- shellsafe-0.3.0/src/shellsafe/cli.py +70 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/execute.py +5 -15
- shellsafe-0.3.0/src/shellsafe/platforms.py +25 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/raw.py +0 -1
- {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/render.py +5 -16
- shellsafe-0.3.0/tests/fixtures/audit/au001_sample.py +31 -0
- shellsafe-0.3.0/tests/integration/test_cli_audit.py +66 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/integration/test_exec_posix.py +0 -6
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/property/test_invariants.py +0 -2
- shellsafe-0.3.0/tests/unit/test_audit.py +56 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_payload_corpus.py +0 -2
- {shellsafe-0.2.0 → shellsafe-0.3.0}/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.0}/.github/workflows/release.yml +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/.gitignore +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/CONTRIBUTING.md +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/LICENSE +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/examples/demo.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/property/.gitkeep +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.2.0 → shellsafe-0.3.0}/tests/unit/test_raw.py +0 -0
|
@@ -3,6 +3,33 @@
|
|
|
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.0] - 2026-08-29
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Offline audit scanner: AST-based detection of f-strings passed to shell
|
|
11
|
+
executors (`os.system`, `subprocess.run`, `subprocess.call`, etc.)
|
|
12
|
+
- AU001 rule: flags f-string interpolation in shell executor arguments with
|
|
13
|
+
fix-hint suggesting template strings
|
|
14
|
+
- Import alias tracking: handles `import subprocess as sp`, `from os import
|
|
15
|
+
system`, etc.
|
|
16
|
+
- CLI `shellsafe audit` command with terminal table and JSON output
|
|
17
|
+
- `--json` flag for machine-readable findings
|
|
18
|
+
- `--severity` flag to filter by minimum severity level
|
|
19
|
+
- Audit fixture corpus: 6 positive cases + 4 safe cases
|
|
20
|
+
- Unit tests for scanner, integration tests for CLI audit
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Collapse `audit/rules.py` and `reporters.py` into single `audit/scanner.py`
|
|
25
|
+
- CLI refactored with dedicated `_run_audit` handler
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
29
|
+
- mypy (ruff catches real bugs)
|
|
30
|
+
- `cast()` calls on values that already have the right type
|
|
31
|
+
|
|
32
|
+
|
|
6
33
|
## [0.2.0] - 2026-08-27
|
|
7
34
|
|
|
8
35
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shellsafe
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -108,6 +108,27 @@ print(plan(t"git commit -m {message}"))
|
|
|
108
108
|
`RAW(...)` marks content you have already made safe by hand. It is loud and easy
|
|
109
109
|
to find in code review, so trust is never hidden.
|
|
110
110
|
|
|
111
|
+
## Find old dangerous patterns
|
|
112
|
+
|
|
113
|
+
Already have code using f-strings in shell commands? The scanner finds them:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
shellsafe audit src/
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
It detects f-strings passed to `os.system`, `subprocess.run(shell=True)`, and
|
|
120
|
+
other shell executors. Get machine-readable output:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
shellsafe audit src/ --json
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Filter by severity:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
shellsafe audit src/ --severity warning
|
|
130
|
+
```
|
|
131
|
+
|
|
111
132
|
## Limits
|
|
112
133
|
|
|
113
134
|
- Shell features (pipes, redirections) work on Linux and macOS only. Windows
|
|
@@ -86,6 +86,27 @@ print(plan(t"git commit -m {message}"))
|
|
|
86
86
|
`RAW(...)` marks content you have already made safe by hand. It is loud and easy
|
|
87
87
|
to find in code review, so trust is never hidden.
|
|
88
88
|
|
|
89
|
+
## Find old dangerous patterns
|
|
90
|
+
|
|
91
|
+
Already have code using f-strings in shell commands? The scanner finds them:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
shellsafe audit src/
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
It detects f-strings passed to `os.system`, `subprocess.run(shell=True)`, and
|
|
98
|
+
other shell executors. Get machine-readable output:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
shellsafe audit src/ --json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Filter by severity:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
shellsafe audit src/ --severity warning
|
|
108
|
+
```
|
|
109
|
+
|
|
89
110
|
## Limits
|
|
90
111
|
|
|
91
112
|
- 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.0"
|
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
RULES: list[tuple[str, Callable[..., Finding | None]]] = [
|
|
102
|
+
("AU001", au001),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _discover(paths: list[str]) -> list[Path]:
|
|
107
|
+
files: list[Path] = []
|
|
108
|
+
for raw in paths:
|
|
109
|
+
p = Path(raw)
|
|
110
|
+
if p.is_file() and p.suffix == ".py":
|
|
111
|
+
files.append(p)
|
|
112
|
+
elif p.is_dir():
|
|
113
|
+
files.extend(
|
|
114
|
+
f
|
|
115
|
+
for f in sorted(p.rglob("*.py"))
|
|
116
|
+
if not any(part.startswith(".") or part == "__pycache__" for part in f.parts)
|
|
117
|
+
)
|
|
118
|
+
return files
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _import_aliases(tree: ast.Module) -> dict[str, str]:
|
|
122
|
+
"""Map local names to real module.function for executor imports."""
|
|
123
|
+
aliases: dict[str, str] = {}
|
|
124
|
+
for node in ast.walk(tree):
|
|
125
|
+
if isinstance(node, ast.Import):
|
|
126
|
+
for alias in node.names:
|
|
127
|
+
local = alias.asname or alias.name
|
|
128
|
+
aliases[local] = alias.name
|
|
129
|
+
elif isinstance(node, ast.ImportFrom):
|
|
130
|
+
module = node.module or ""
|
|
131
|
+
for alias in node.names:
|
|
132
|
+
local = alias.asname or alias.name
|
|
133
|
+
aliases[local] = f"{module}.{alias.name}"
|
|
134
|
+
return aliases
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def scan(paths: list[str]) -> list[Finding]:
|
|
138
|
+
"""Scan Python files for audit findings."""
|
|
139
|
+
findings: list[Finding] = []
|
|
140
|
+
for path in _discover(paths):
|
|
141
|
+
try:
|
|
142
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
143
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
144
|
+
continue
|
|
145
|
+
aliases = _import_aliases(tree)
|
|
146
|
+
rel = str(path)
|
|
147
|
+
for node in ast.walk(tree):
|
|
148
|
+
if isinstance(node, ast.Call):
|
|
149
|
+
for _, rule in RULES:
|
|
150
|
+
result = rule(node, aliases, rel)
|
|
151
|
+
if result is not None:
|
|
152
|
+
findings.append(result)
|
|
153
|
+
findings.sort(key=lambda f: (f.severity != "error", f.path, f.lineno))
|
|
154
|
+
return findings
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_COLORS = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m", "reset": "\033[0m"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _color(text: str, severity: str) -> str:
|
|
161
|
+
c = _COLORS.get(severity, "")
|
|
162
|
+
return f"{c}{text}{_COLORS['reset']}" if c else text
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def report_terminal(findings: list[Finding]) -> str:
|
|
166
|
+
"""Render findings as a terminal table."""
|
|
167
|
+
if not findings:
|
|
168
|
+
return "no findings"
|
|
169
|
+
use_color = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
170
|
+
lines = [
|
|
171
|
+
f"{'SEV':<9} {'RULE':<7} {'FILE':<40} {'LINE':>5} MESSAGE",
|
|
172
|
+
"-" * 90,
|
|
173
|
+
]
|
|
174
|
+
for f in findings:
|
|
175
|
+
sev = _color(f.severity.upper(), f.severity) if use_color else f.severity.upper()
|
|
176
|
+
path = f.path if len(f.path) <= 40 else "..." + f.path[-37:]
|
|
177
|
+
lines.append(f"{sev:<18} {f.rule_id:<7} {path:<40} {f.lineno:>5} {f.message}")
|
|
178
|
+
return "\n".join(lines)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def report_json(report: dict[str, object]) -> str:
|
|
182
|
+
"""Render the full report as JSON."""
|
|
183
|
+
return json.dumps(report, indent=2)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def scan_report(paths: list[str]) -> dict[str, object]:
|
|
187
|
+
"""Scan and return a report dict matching schema v1."""
|
|
188
|
+
start = time.monotonic()
|
|
189
|
+
findings = scan(paths)
|
|
190
|
+
duration_ms = round((time.monotonic() - start) * 1000, 1)
|
|
191
|
+
|
|
192
|
+
errors = sum(1 for f in findings if f.severity == "error" and not f.ignored)
|
|
193
|
+
warnings = sum(1 for f in findings if f.severity == "warning" and not f.ignored)
|
|
194
|
+
info = sum(1 for f in findings if f.severity == "info" and not f.ignored)
|
|
195
|
+
ignored = sum(1 for f in findings if f.ignored)
|
|
196
|
+
|
|
197
|
+
from .._version import __version__
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
"schema_version": 1,
|
|
201
|
+
"tool": {"name": "shellsafe", "version": __version__},
|
|
202
|
+
"run": {
|
|
203
|
+
"duration_ms": duration_ms,
|
|
204
|
+
"host_python": f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
205
|
+
f".{sys.version_info.micro}",
|
|
206
|
+
"paths": paths,
|
|
207
|
+
},
|
|
208
|
+
"summary": {
|
|
209
|
+
"errors": errors,
|
|
210
|
+
"warnings": warnings,
|
|
211
|
+
"info": info,
|
|
212
|
+
"ignored": ignored,
|
|
213
|
+
"verdict": "fail" if errors > 0 else "pass",
|
|
214
|
+
},
|
|
215
|
+
"findings": [
|
|
216
|
+
{
|
|
217
|
+
"rule_id": f.rule_id,
|
|
218
|
+
"title": f.title,
|
|
219
|
+
"severity": f.severity,
|
|
220
|
+
"confidence": f.confidence,
|
|
221
|
+
"path": f.path,
|
|
222
|
+
"lineno": f.lineno,
|
|
223
|
+
"col": f.col,
|
|
224
|
+
"message": f.message,
|
|
225
|
+
"evidence": f.evidence,
|
|
226
|
+
"fix_hint": f.fix_hint,
|
|
227
|
+
"ignored": f.ignored,
|
|
228
|
+
"ignore_reason": f.ignore_reason,
|
|
229
|
+
}
|
|
230
|
+
for f in findings
|
|
231
|
+
],
|
|
232
|
+
}
|
|
@@ -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"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Template rendering: t-string in, ExecutionPlan out.
|
|
1
|
+
"""Template rendering: t-string in, ExecutionPlan out.
|
|
2
2
|
|
|
3
3
|
Security invariants (property-tested, see docs/07_rendering_engine_spec.md):
|
|
4
4
|
- INV-1: every interpolation contributes exactly one argv element equal to its
|
|
@@ -7,8 +7,6 @@ Security invariants (property-tested, see docs/07_rendering_engine_spec.md):
|
|
|
7
7
|
- INV-3: element count equals static words plus interpolations plus RAW splices.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
10
|
import shlex
|
|
13
11
|
from dataclasses import dataclass
|
|
14
12
|
from string.templatelib import Interpolation, Template
|
|
@@ -29,25 +27,17 @@ class ExecutionPlan:
|
|
|
29
27
|
shell_line: str | None = None
|
|
30
28
|
|
|
31
29
|
def __repr__(self) -> str:
|
|
32
|
-
if self.mode == "argv":
|
|
33
|
-
assert self.argv is not None
|
|
30
|
+
if self.mode == "argv" and self.argv is not None:
|
|
34
31
|
body = ",".join(repr(a) for a in self.argv)
|
|
35
32
|
return f"argv: [{body}]"
|
|
36
|
-
assert self.shell_line is not None
|
|
37
33
|
return f"shell: {self.shell_line}"
|
|
38
34
|
|
|
39
35
|
|
|
40
36
|
Segment = str | Interpolation[str] | Raw
|
|
41
37
|
|
|
42
38
|
|
|
43
|
-
def _walk(
|
|
44
|
-
|
|
45
|
-
) -> list[Segment]:
|
|
46
|
-
"""Flatten a template into an ordered list of static text and interpolations.
|
|
47
|
-
|
|
48
|
-
Nested templates (a t-string interpolated inside a t-string) splice their
|
|
49
|
-
segments at the interpolation position. Cycle-guarded via object ids.
|
|
50
|
-
"""
|
|
39
|
+
def _walk(template: Template, seen: frozenset[int]) -> list[Segment]:
|
|
40
|
+
"""Flatten a Template into segments, recursing into nested templates."""
|
|
51
41
|
parts: list[Segment] = []
|
|
52
42
|
for element in template:
|
|
53
43
|
if isinstance(element, str):
|
|
@@ -96,11 +86,10 @@ def _resolve(interpolation: Interpolation[str]) -> str:
|
|
|
96
86
|
return format(converted, format_spec)
|
|
97
87
|
if conversion is None:
|
|
98
88
|
return str(converted)
|
|
99
|
-
assert isinstance(converted, str)
|
|
100
89
|
return converted
|
|
101
90
|
|
|
102
91
|
|
|
103
|
-
def _reject_nul(resolved: str)
|
|
92
|
+
def _reject_nul(resolved: str):
|
|
104
93
|
if _NUL in resolved:
|
|
105
94
|
raise ShellSafeTypeError(
|
|
106
95
|
"interpolated value contains a NUL byte after formatting; "
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""AU001 fixture: planted violations and safe twins for the f-string rule."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from os import system
|
|
6
|
+
from subprocess import run as subprocess_run
|
|
7
|
+
|
|
8
|
+
# --- positive cases (should produce AU001 findings) ---
|
|
9
|
+
|
|
10
|
+
os.system(f"echo {user_input}")
|
|
11
|
+
|
|
12
|
+
subprocess.run(f"echo {user_input}", shell=True)
|
|
13
|
+
|
|
14
|
+
subprocess.call(f"cat {filename}")
|
|
15
|
+
|
|
16
|
+
system(f"rm -rf {path}")
|
|
17
|
+
|
|
18
|
+
subprocess_run(f"grep {pattern} {file}")
|
|
19
|
+
|
|
20
|
+
subprocess.getoutput(f"echo {user_input}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --- safe cases (should NOT produce findings) ---
|
|
24
|
+
|
|
25
|
+
subprocess.run(["echo", user_input])
|
|
26
|
+
|
|
27
|
+
os.system("echo hello")
|
|
28
|
+
|
|
29
|
+
subprocess.run(["cat", filename], shell=True)
|
|
30
|
+
|
|
31
|
+
subprocess.run("echo hello")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""CLI integration tests for the audit command."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
FIXTURES = Path(__file__).parent.parent / "fixtures" / "audit"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_audit_cli_finds_issues():
|
|
12
|
+
out = subprocess.run(
|
|
13
|
+
[sys.executable, "-m", "shellsafe", "audit", str(FIXTURES / "au001_sample.py")],
|
|
14
|
+
capture_output=True,
|
|
15
|
+
text=True,
|
|
16
|
+
)
|
|
17
|
+
assert out.returncode == 1
|
|
18
|
+
assert "AU001" in out.stdout
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_audit_cli_clean_dir(tmp_path: Path):
|
|
22
|
+
out = subprocess.run(
|
|
23
|
+
[sys.executable, "-m", "shellsafe", "audit", str(tmp_path)],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
)
|
|
27
|
+
assert out.returncode == 0
|
|
28
|
+
assert "no findings" in out.stdout
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_audit_cli_json_output():
|
|
32
|
+
out = subprocess.run(
|
|
33
|
+
[
|
|
34
|
+
sys.executable,
|
|
35
|
+
"-m",
|
|
36
|
+
"shellsafe",
|
|
37
|
+
"audit",
|
|
38
|
+
str(FIXTURES / "au001_sample.py"),
|
|
39
|
+
"--json",
|
|
40
|
+
],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
)
|
|
44
|
+
assert out.returncode == 1
|
|
45
|
+
report = json.loads(out.stdout)
|
|
46
|
+
assert report["schema_version"] == 1
|
|
47
|
+
assert report["summary"]["errors"] == 6
|
|
48
|
+
assert len(report["findings"]) == 6
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_audit_cli_severity_filter():
|
|
52
|
+
out = subprocess.run(
|
|
53
|
+
[
|
|
54
|
+
sys.executable,
|
|
55
|
+
"-m",
|
|
56
|
+
"shellsafe",
|
|
57
|
+
"audit",
|
|
58
|
+
str(FIXTURES / "au001_sample.py"),
|
|
59
|
+
"--severity",
|
|
60
|
+
"info",
|
|
61
|
+
],
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
)
|
|
65
|
+
assert out.returncode == 1
|
|
66
|
+
assert "AU001" in out.stdout
|
|
@@ -7,8 +7,6 @@ import pytest
|
|
|
7
7
|
from shellsafe import capture, run, shx
|
|
8
8
|
from shellsafe.errors import ArgvOnlyError, ShellSafeError, UnsupportedPlatformError
|
|
9
9
|
|
|
10
|
-
# --- argv mode ---
|
|
11
|
-
|
|
12
10
|
|
|
13
11
|
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
14
12
|
def test_capture_round_trip():
|
|
@@ -33,7 +31,6 @@ def test_interpolation_with_spaces_stays_one_argument():
|
|
|
33
31
|
assert res.stdout.strip() == "two words"
|
|
34
32
|
|
|
35
33
|
|
|
36
|
-
# --- shell mode execution ---
|
|
37
34
|
|
|
38
35
|
|
|
39
36
|
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
@@ -68,7 +65,6 @@ def test_shx_output_capture():
|
|
|
68
65
|
assert res.stdout.strip() == "WORLD"
|
|
69
66
|
|
|
70
67
|
|
|
71
|
-
# --- run() rejects shell mode ---
|
|
72
68
|
|
|
73
69
|
|
|
74
70
|
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
@@ -77,7 +73,6 @@ def test_run_rejects_shell_metacharacters():
|
|
|
77
73
|
run(t"echo hello | wc -l")
|
|
78
74
|
|
|
79
75
|
|
|
80
|
-
# --- shx rejects argv-only templates ---
|
|
81
76
|
|
|
82
77
|
|
|
83
78
|
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
@@ -86,7 +81,6 @@ def test_shx_rejects_argv_only_template():
|
|
|
86
81
|
shx(t"echo hello")
|
|
87
82
|
|
|
88
83
|
|
|
89
|
-
# --- Windows policy ---
|
|
90
84
|
|
|
91
85
|
|
|
92
86
|
def test_shell_mode_execution_not_yet_available_on_windows():
|
|
@@ -14,7 +14,6 @@ from shellsafe.render import plan
|
|
|
14
14
|
text_values = st.text(st.characters(blacklist_characters="\x00"), max_size=200)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
# --- argv mode invariants ---
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
@settings(max_examples=300)
|
|
@@ -41,7 +40,6 @@ def test_repr_round_trip_is_byte_stable(value: str):
|
|
|
41
40
|
assert repr(p1) == repr(p2)
|
|
42
41
|
|
|
43
42
|
|
|
44
|
-
# --- shell mode invariants ---
|
|
45
43
|
|
|
46
44
|
|
|
47
45
|
@pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Audit scanner tests: AU001 rule detection against fixture corpus."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from shellsafe.audit.scanner import scan
|
|
6
|
+
|
|
7
|
+
FIXTURES = Path(__file__).parent.parent / "fixtures" / "audit"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_au001_detects_positive_cases():
|
|
11
|
+
findings = scan([str(FIXTURES / "au001_sample.py")])
|
|
12
|
+
au001 = [f for f in findings if f.rule_id == "AU001"]
|
|
13
|
+
assert len(au001) == 6
|
|
14
|
+
callees = {f.evidence["callee"] for f in au001}
|
|
15
|
+
assert "os.system" in callees
|
|
16
|
+
assert "subprocess.run" in callees
|
|
17
|
+
assert "subprocess.call" in callees
|
|
18
|
+
assert "subprocess.getoutput" in callees
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_au001_ignores_safe_cases():
|
|
22
|
+
findings = scan([str(FIXTURES / "au001_sample.py")])
|
|
23
|
+
flagged_lines = {f.lineno for f in findings}
|
|
24
|
+
# safe cases are on lines 26-32; none should appear
|
|
25
|
+
for line in range(26, 33):
|
|
26
|
+
assert line not in flagged_lines, f"false positive on line {line}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_au001_confidence():
|
|
30
|
+
findings = scan([str(FIXTURES / "au001_sample.py")])
|
|
31
|
+
for f in findings:
|
|
32
|
+
assert f.confidence == 0.95
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_au001_severity():
|
|
36
|
+
findings = scan([str(FIXTURES / "au001_sample.py")])
|
|
37
|
+
for f in findings:
|
|
38
|
+
assert f.severity == "error"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_scan_returns_sorted():
|
|
42
|
+
findings = scan([str(FIXTURES / "au001_sample.py")])
|
|
43
|
+
severities = [f.severity for f in findings]
|
|
44
|
+
assert severities == ["error"] * len(findings)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_scan_empty_dir(tmp_path: Path):
|
|
48
|
+
findings = scan([str(tmp_path)])
|
|
49
|
+
assert findings == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_scan_syntax_error_skipped(tmp_path: Path):
|
|
53
|
+
bad = tmp_path / "bad.py"
|
|
54
|
+
bad.write_text("def x(\n")
|
|
55
|
+
findings = scan([str(tmp_path)])
|
|
56
|
+
assert findings == []
|
|
@@ -33,7 +33,6 @@ def payloads() -> list[tuple[str, str]]:
|
|
|
33
33
|
return out
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
# --- argv mode ---
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
@pytest.mark.parametrize(
|
|
@@ -47,7 +46,6 @@ def test_payload_stays_single_argument(name: str, payload: str):
|
|
|
47
46
|
assert p.argv == ("echo", payload)
|
|
48
47
|
|
|
49
48
|
|
|
50
|
-
# --- shell mode ---
|
|
51
49
|
|
|
52
50
|
|
|
53
51
|
@pytest.mark.parametrize(
|
|
@@ -59,7 +59,6 @@ def test_nested_template_splices():
|
|
|
59
59
|
assert p.argv == ("git", "commit", "-m", "msg with spaces")
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
# --- refusals ---
|
|
63
62
|
|
|
64
63
|
|
|
65
64
|
def test_non_template_refused():
|
|
@@ -134,7 +133,6 @@ def test_raw_inside_real_tstring_syntax():
|
|
|
134
133
|
assert p.argv == ("echo", "a", "b", "done")
|
|
135
134
|
|
|
136
135
|
|
|
137
|
-
# --- shell route: rendering works now; execution arrives in 0.2 ---
|
|
138
136
|
|
|
139
137
|
|
|
140
138
|
@pytest.mark.skipif(
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
"""Offline AST audit for dangerous command construction. Ships with v0.3.0."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from ..errors import ShellSafeError
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def scan(paths: list[str]) -> list[dict[str, object]]:
|
|
9
|
-
"""Scan paths for AU001-AU004 findings. Arrives in shellsafe 0.3."""
|
|
10
|
-
raise ShellSafeError("audit arrives in shellsafe 0.3")
|
|
File without changes
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
"""CLI wiring: audit, demo, version. Business logic lives in the stages."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import argparse
|
|
6
|
-
import platform
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
from . import exitcodes
|
|
10
|
-
from ._version import __version__
|
|
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
|
-
sub.add_parser("audit", help="scan code for dangerous command construction (v0.3)")
|
|
19
|
-
sub.add_parser("version", help="detailed version and capability matrix")
|
|
20
|
-
return parser
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _print_version_matrix() -> None:
|
|
24
|
-
py = platform.python_version()
|
|
25
|
-
print(f"shellsafe {__version__} · python {py} · {sys.platform}")
|
|
26
|
-
print("argv-mode: available")
|
|
27
|
-
print("shell-mode: available (posix)")
|
|
28
|
-
print("audit: arriving in 0.3")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def main(argv: list[str] | None = None) -> int:
|
|
32
|
-
args = _build_parser().parse_args(argv)
|
|
33
|
-
|
|
34
|
-
if args.version:
|
|
35
|
-
print(f"shellsafe {__version__}")
|
|
36
|
-
return exitcodes.OK
|
|
37
|
-
if args.command == "version":
|
|
38
|
-
_print_version_matrix()
|
|
39
|
-
return exitcodes.OK
|
|
40
|
-
if args.command == "audit":
|
|
41
|
-
print("audit arrives in shellsafe 0.3", file=sys.stderr)
|
|
42
|
-
return exitcodes.USAGE
|
|
43
|
-
_build_parser().print_help()
|
|
44
|
-
return exitcodes.OK
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
"""Platform policy tables and helpers. This module IS the security policy."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
# Static template text containing any of these routes the command to shell mode.
|
|
8
|
-
# Whitespace is not here: it splits words in argv mode.
|
|
9
|
-
METACHARACTERS: frozenset[str] = frozenset("|&;()<>$`\"'*?!#\\\n\r\t")
|
|
10
|
-
|
|
11
|
-
IS_WINDOWS = sys.platform.startswith("win")
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def route_for(static_text: str) -> str:
|
|
15
|
-
"""Return "argv" or "shell" for the given static template text.
|
|
16
|
-
|
|
17
|
-
Raises UnsupportedPlatformError when shell features are requested on Windows;
|
|
18
|
-
cmd.exe quoting cannot be made injection-safe, so the refusal is the feature.
|
|
19
|
-
"""
|
|
20
|
-
has_meta = any(ch in METACHARACTERS for ch in static_text)
|
|
21
|
-
if not has_meta:
|
|
22
|
-
return "argv"
|
|
23
|
-
if IS_WINDOWS:
|
|
24
|
-
raise _windows_error()
|
|
25
|
-
return "shell"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _windows_error() -> Exception:
|
|
29
|
-
from .errors import UnsupportedPlatformError
|
|
30
|
-
|
|
31
|
-
return UnsupportedPlatformError(
|
|
32
|
-
"shell metacharacters in a shellsafe template require posix sh; "
|
|
33
|
-
"windows cmd.exe quoting cannot be made injection-safe. "
|
|
34
|
-
"restructure without pipes/redirections, or run under wsl."
|
|
35
|
-
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Finding reporters: terminal and json. Arrives in shellsafe 0.3."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|