shellsafe 0.1.1__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.1.1 → shellsafe-0.3.0}/.github/workflows/ci.yml +0 -1
- shellsafe-0.3.0/CHANGELOG.md +72 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/PKG-INFO +26 -2
- {shellsafe-0.1.1 → shellsafe-0.3.0}/README.md +25 -1
- {shellsafe-0.1.1 → shellsafe-0.3.0}/pyproject.toml +5 -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.1.1 → shellsafe-0.3.0}/src/shellsafe/execute.py +29 -27
- shellsafe-0.3.0/src/shellsafe/platforms.py +25 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/raw.py +0 -1
- {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/render.py +9 -22
- 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.3.0/tests/integration/test_exec_posix.py +94 -0
- shellsafe-0.3.0/tests/property/test_invariants.py +76 -0
- shellsafe-0.3.0/tests/unit/test_audit.py +56 -0
- shellsafe-0.3.0/tests/unit/test_payload_corpus.py +72 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/unit/test_render.py +0 -2
- shellsafe-0.1.1/CHANGELOG.md +0 -22
- shellsafe-0.1.1/src/shellsafe/_version.py +0 -1
- shellsafe-0.1.1/src/shellsafe/audit/__init__.py +0 -10
- shellsafe-0.1.1/src/shellsafe/audit/rules.py +0 -0
- shellsafe-0.1.1/src/shellsafe/cli.py +0 -44
- shellsafe-0.1.1/src/shellsafe/platforms.py +0 -35
- shellsafe-0.1.1/src/shellsafe/reporters.py +0 -1
- shellsafe-0.1.1/tests/integration/test_exec_posix.py +0 -44
- shellsafe-0.1.1/tests/property/test_invariants.py +0 -35
- shellsafe-0.1.1/tests/unit/test_payload_corpus.py +0 -43
- {shellsafe-0.1.1 → shellsafe-0.3.0}/.github/workflows/release.yml +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/.gitignore +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/CONTRIBUTING.md +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/LICENSE +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/examples/demo.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/property/.gitkeep +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.3.0}/tests/unit/test_raw.py +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format follows
|
|
4
|
+
Keep a Changelog; versioning follows SemVer.
|
|
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
|
+
|
|
33
|
+
## [0.2.0] - 2026-08-27
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- Shell-mode execution: `shx()` runs pipes, redirections, and globs via
|
|
38
|
+
`/bin/sh -c` on Linux and macOS; interpolated values are `shlex`-quoted
|
|
39
|
+
automatically
|
|
40
|
+
- Shell-mode payload corpus: 15 injection cases verified as single tokens via
|
|
41
|
+
`shlex.split` round-trip
|
|
42
|
+
- Shell-mode property tests: arbitrary values stay single shell tokens under
|
|
43
|
+
pipe templates
|
|
44
|
+
- Cyclomatic complexity lint (`C901`, max-complexity 10) enabled in CI
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- `run()` now rejects templates that contain shell metacharacters with guidance
|
|
49
|
+
to use `shx()` instead
|
|
50
|
+
- CLI version matrix shows shell-mode availability
|
|
51
|
+
|
|
52
|
+
### Removed
|
|
53
|
+
|
|
54
|
+
- Dead `METACHARACTERS` constant from render.py (platforms.py owns it)
|
|
55
|
+
|
|
56
|
+
## [0.1.1] - 2026-08-24
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- Project description rewritten
|
|
61
|
+
- README restructured for PyPI: package page now leads with the why, usage and
|
|
62
|
+
limits; contributing details stay in the repository only
|
|
63
|
+
|
|
64
|
+
## [0.1.0] - 2026-08-24
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- Argv-mode rendering and execution via template strings
|
|
69
|
+
- RAW trust marker with argv splicing
|
|
70
|
+
- capture() helper with utf-8 stdout/stderr
|
|
71
|
+
- plan() helper for inspecting commands before execution
|
|
72
|
+
- Injection payload corpus as a release gate; all cases render inert
|
|
@@ -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,9 +108,33 @@ 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
|
|
135
|
+
supports plain commands only.
|
|
136
|
+
- `run()` refuses templates that contain shell metacharacters. Use `shx()` for
|
|
137
|
+
those.
|
|
114
138
|
- Byte values are rejected. Decode them first.
|
|
115
139
|
- We keep your command safe to build and run. Testing what your command does is
|
|
116
140
|
still your job.
|
|
@@ -86,9 +86,33 @@ 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
|
|
113
|
+
supports plain commands only.
|
|
114
|
+
- `run()` refuses templates that contain shell metacharacters. Use `shx()` for
|
|
115
|
+
those.
|
|
92
116
|
- Byte values are rejected. Decode them first.
|
|
93
117
|
- We keep your command safe to build and run. Testing what your command does is
|
|
94
118
|
still your job.
|
|
@@ -45,11 +45,10 @@ target-version = "py314"
|
|
|
45
45
|
line-length = 100
|
|
46
46
|
|
|
47
47
|
[tool.ruff.lint]
|
|
48
|
-
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
|
|
48
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF", "C901"]
|
|
49
49
|
|
|
50
|
-
[tool.
|
|
51
|
-
|
|
52
|
-
python_version = "3.14"
|
|
50
|
+
[tool.ruff.lint.mccabe]
|
|
51
|
+
max-complexity = 10
|
|
53
52
|
|
|
54
53
|
[tool.pytest.ini_options]
|
|
55
54
|
testpaths = ["tests"]
|
|
@@ -59,12 +58,13 @@ addopts = "-q --tb=short"
|
|
|
59
58
|
# RAW() is a deliberate loud trust marker; the uppercase name is the API.
|
|
60
59
|
"src/shellsafe/raw.py" = ["N802"]
|
|
61
60
|
"src/shellsafe/__init__.py" = ["N999"]
|
|
61
|
+
# Fixture files contain planted violations and undefined names on purpose.
|
|
62
|
+
"tests/fixtures/**" = ["F821", "F841"]
|
|
62
63
|
|
|
63
64
|
[dependency-groups]
|
|
64
65
|
dev = [
|
|
65
66
|
"pytest>=8",
|
|
66
67
|
"ruff>=0.5",
|
|
67
|
-
"mypy>=1.10",
|
|
68
68
|
"hypothesis>=6",
|
|
69
69
|
"build>=1.2",
|
|
70
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,20 +1,14 @@
|
|
|
1
1
|
"""Subprocess wrappers over rendered ExecutionPlans."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
import subprocess
|
|
4
|
+
import sys
|
|
6
5
|
from dataclasses import dataclass
|
|
7
|
-
from typing import Any
|
|
6
|
+
from typing import Any
|
|
8
7
|
|
|
9
8
|
from .errors import ArgvOnlyError, ShellSafeError
|
|
10
9
|
from .raw import Raw # noqa: F401 (re-exported through the package root)
|
|
11
10
|
from .render import ExecutionPlan
|
|
12
11
|
|
|
13
|
-
_SHELL_MODE_PENDS = (
|
|
14
|
-
"shell-mode execution arrives in shellsafe 0.2; "
|
|
15
|
-
"this release covers argv-mode commands"
|
|
16
|
-
)
|
|
17
|
-
|
|
18
12
|
_ALLOWED_KWARGS = frozenset(
|
|
19
13
|
{
|
|
20
14
|
"check",
|
|
@@ -33,11 +27,11 @@ _ALLOWED_KWARGS = frozenset(
|
|
|
33
27
|
)
|
|
34
28
|
|
|
35
29
|
|
|
36
|
-
def _validate_kwargs(kwargs: dict[str, object])
|
|
30
|
+
def _validate_kwargs(kwargs: dict[str, object]):
|
|
37
31
|
if "shell" in kwargs:
|
|
38
32
|
raise ShellSafeError(
|
|
39
|
-
"shellsafe never passes shell=True; use shx()
|
|
40
|
-
"redirections"
|
|
33
|
+
"shellsafe never passes shell=True; use shx() for pipes and "
|
|
34
|
+
"redirections on posix"
|
|
41
35
|
)
|
|
42
36
|
unknown = set(kwargs) - _ALLOWED_KWARGS
|
|
43
37
|
if unknown:
|
|
@@ -54,6 +48,10 @@ def plan(template: object) -> ExecutionPlan:
|
|
|
54
48
|
return render_plan(template)
|
|
55
49
|
|
|
56
50
|
|
|
51
|
+
def _pass_through(kwargs: dict[str, object]) -> dict[str, Any]:
|
|
52
|
+
return {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS}
|
|
53
|
+
|
|
54
|
+
|
|
57
55
|
def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
58
56
|
"""Render the template and execute it.
|
|
59
57
|
|
|
@@ -64,15 +62,17 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
|
|
|
64
62
|
_validate_kwargs(kwargs)
|
|
65
63
|
rendered = plan(template)
|
|
66
64
|
if rendered.mode == "shell":
|
|
67
|
-
|
|
65
|
+
if sys.platform.startswith("win"):
|
|
66
|
+
raise ShellSafeError(
|
|
67
|
+
"shell mode is posix-only; restructure the command without "
|
|
68
|
+
"pipes or redirections, or run under wsl"
|
|
69
|
+
)
|
|
70
|
+
raise ShellSafeError(
|
|
71
|
+
"this command contains shell metacharacters; use shx() instead of "
|
|
72
|
+
"run() to execute pipes and redirections"
|
|
73
|
+
)
|
|
68
74
|
assert rendered.argv is not None
|
|
69
|
-
|
|
70
|
-
typed_kwargs = cast(
|
|
71
|
-
"dict[str, Any]",
|
|
72
|
-
{k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS},
|
|
73
|
-
)
|
|
74
|
-
result = subprocess.run(rendered.argv, **typed_kwargs)
|
|
75
|
-
return cast("subprocess.CompletedProcess[str]", result)
|
|
75
|
+
return subprocess.run(rendered.argv, **_pass_through(kwargs))
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
@dataclass(frozen=True, slots=True)
|
|
@@ -91,8 +91,6 @@ def capture(template: object, /, **kwargs: object) -> CaptureResult:
|
|
|
91
91
|
kwargs["text"] = True
|
|
92
92
|
kwargs["encoding"] = "utf-8"
|
|
93
93
|
completed = run(template, **kwargs)
|
|
94
|
-
assert isinstance(completed.stdout, str)
|
|
95
|
-
assert isinstance(completed.stderr, str)
|
|
96
94
|
return CaptureResult(
|
|
97
95
|
stdout=completed.stdout,
|
|
98
96
|
stderr=completed.stderr,
|
|
@@ -101,12 +99,12 @@ def capture(template: object, /, **kwargs: object) -> CaptureResult:
|
|
|
101
99
|
)
|
|
102
100
|
|
|
103
101
|
|
|
104
|
-
def shx(template: object, /, **kwargs: object) ->
|
|
105
|
-
"""
|
|
102
|
+
def shx(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
103
|
+
"""Execute a shell-routed template (pipes, redirections, globs).
|
|
106
104
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
Interpolated values are POSIX shell-quoted automatically. The rendered line
|
|
106
|
+
runs under /bin/sh -c. Raises ArgvOnlyError when the template has no shell
|
|
107
|
+
metacharacters (use run() instead).
|
|
110
108
|
"""
|
|
111
109
|
_validate_kwargs(kwargs)
|
|
112
110
|
rendered = plan(template)
|
|
@@ -114,4 +112,8 @@ def shx(template: object, /, **kwargs: object) -> object:
|
|
|
114
112
|
raise ArgvOnlyError(
|
|
115
113
|
"template contains no shell metacharacters; use run() instead"
|
|
116
114
|
)
|
|
117
|
-
|
|
115
|
+
assert rendered.shell_line is not None
|
|
116
|
+
return subprocess.run(
|
|
117
|
+
["/bin/sh", "-c", rendered.shell_line],
|
|
118
|
+
**_pass_through(kwargs),
|
|
119
|
+
)
|
|
@@ -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"
|