shellsafe 0.1.1__tar.gz → 0.2.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/CHANGELOG.md +45 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/PKG-INFO +5 -2
- {shellsafe-0.1.1 → shellsafe-0.2.0}/README.md +4 -1
- {shellsafe-0.1.1 → shellsafe-0.2.0}/pyproject.toml +4 -1
- shellsafe-0.2.0/src/shellsafe/_version.py +1 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/cli.py +1 -1
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/execute.py +32 -20
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/render.py +4 -6
- shellsafe-0.2.0/tests/integration/test_exec_posix.py +100 -0
- shellsafe-0.2.0/tests/property/test_invariants.py +78 -0
- shellsafe-0.2.0/tests/unit/test_payload_corpus.py +74 -0
- shellsafe-0.1.1/CHANGELOG.md +0 -22
- shellsafe-0.1.1/src/shellsafe/_version.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.2.0}/.github/workflows/ci.yml +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/.github/workflows/release.yml +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/.gitignore +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/CONTRIBUTING.md +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/LICENSE +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/examples/demo.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/audit/__init__.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/audit/rules.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/platforms.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/raw.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/src/shellsafe/reporters.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/property/.gitkeep +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/unit/test_raw.py +0 -0
- {shellsafe-0.1.1 → shellsafe-0.2.0}/tests/unit/test_render.py +0 -0
|
@@ -0,0 +1,45 @@
|
|
|
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.2.0] - 2026-08-27
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Shell-mode execution: `shx()` runs pipes, redirections, and globs via
|
|
11
|
+
`/bin/sh -c` on Linux and macOS; interpolated values are `shlex`-quoted
|
|
12
|
+
automatically
|
|
13
|
+
- Shell-mode payload corpus: 15 injection cases verified as single tokens via
|
|
14
|
+
`shlex.split` round-trip
|
|
15
|
+
- Shell-mode property tests: arbitrary values stay single shell tokens under
|
|
16
|
+
pipe templates
|
|
17
|
+
- Cyclomatic complexity lint (`C901`, max-complexity 10) enabled in CI
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `run()` now rejects templates that contain shell metacharacters with guidance
|
|
22
|
+
to use `shx()` instead
|
|
23
|
+
- CLI version matrix shows shell-mode availability
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
- Dead `METACHARACTERS` constant from render.py (platforms.py owns it)
|
|
28
|
+
|
|
29
|
+
## [0.1.1] - 2026-08-24
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Project description rewritten
|
|
34
|
+
- README restructured for PyPI: package page now leads with the why, usage and
|
|
35
|
+
limits; contributing details stay in the repository only
|
|
36
|
+
|
|
37
|
+
## [0.1.0] - 2026-08-24
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- Argv-mode rendering and execution via template strings
|
|
42
|
+
- RAW trust marker with argv splicing
|
|
43
|
+
- capture() helper with utf-8 stdout/stderr
|
|
44
|
+
- plan() helper for inspecting commands before execution
|
|
45
|
+
- 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.2.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
|
|
@@ -110,7 +110,10 @@ to find in code review, so trust is never hidden.
|
|
|
110
110
|
|
|
111
111
|
## Limits
|
|
112
112
|
|
|
113
|
-
-
|
|
113
|
+
- Shell features (pipes, redirections) work on Linux and macOS only. Windows
|
|
114
|
+
supports plain commands only.
|
|
115
|
+
- `run()` refuses templates that contain shell metacharacters. Use `shx()` for
|
|
116
|
+
those.
|
|
114
117
|
- Byte values are rejected. Decode them first.
|
|
115
118
|
- We keep your command safe to build and run. Testing what your command does is
|
|
116
119
|
still your job.
|
|
@@ -88,7 +88,10 @@ to find in code review, so trust is never hidden.
|
|
|
88
88
|
|
|
89
89
|
## Limits
|
|
90
90
|
|
|
91
|
-
-
|
|
91
|
+
- Shell features (pipes, redirections) work on Linux and macOS only. Windows
|
|
92
|
+
supports plain commands only.
|
|
93
|
+
- `run()` refuses templates that contain shell metacharacters. Use `shx()` for
|
|
94
|
+
those.
|
|
92
95
|
- Byte values are rejected. Decode them first.
|
|
93
96
|
- We keep your command safe to build and run. Testing what your command does is
|
|
94
97
|
still your job.
|
|
@@ -45,7 +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
|
+
|
|
50
|
+
[tool.ruff.lint.mccabe]
|
|
51
|
+
max-complexity = 10
|
|
49
52
|
|
|
50
53
|
[tool.mypy]
|
|
51
54
|
strict = true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -24,7 +24,7 @@ def _print_version_matrix() -> None:
|
|
|
24
24
|
py = platform.python_version()
|
|
25
25
|
print(f"shellsafe {__version__} · python {py} · {sys.platform}")
|
|
26
26
|
print("argv-mode: available")
|
|
27
|
-
print("shell-mode:
|
|
27
|
+
print("shell-mode: available (posix)")
|
|
28
28
|
print("audit: arriving in 0.3")
|
|
29
29
|
|
|
30
30
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import subprocess
|
|
6
|
+
import sys
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Any, cast
|
|
8
9
|
|
|
@@ -10,11 +11,6 @@ from .errors import ArgvOnlyError, ShellSafeError
|
|
|
10
11
|
from .raw import Raw # noqa: F401 (re-exported through the package root)
|
|
11
12
|
from .render import ExecutionPlan
|
|
12
13
|
|
|
13
|
-
_SHELL_MODE_PENDS = (
|
|
14
|
-
"shell-mode execution arrives in shellsafe 0.2; "
|
|
15
|
-
"this release covers argv-mode commands"
|
|
16
|
-
)
|
|
17
|
-
|
|
18
14
|
_ALLOWED_KWARGS = frozenset(
|
|
19
15
|
{
|
|
20
16
|
"check",
|
|
@@ -36,8 +32,8 @@ _ALLOWED_KWARGS = frozenset(
|
|
|
36
32
|
def _validate_kwargs(kwargs: dict[str, object]) -> None:
|
|
37
33
|
if "shell" in kwargs:
|
|
38
34
|
raise ShellSafeError(
|
|
39
|
-
"shellsafe never passes shell=True; use shx()
|
|
40
|
-
"redirections"
|
|
35
|
+
"shellsafe never passes shell=True; use shx() for pipes and "
|
|
36
|
+
"redirections on posix"
|
|
41
37
|
)
|
|
42
38
|
unknown = set(kwargs) - _ALLOWED_KWARGS
|
|
43
39
|
if unknown:
|
|
@@ -54,6 +50,14 @@ def plan(template: object) -> ExecutionPlan:
|
|
|
54
50
|
return render_plan(template)
|
|
55
51
|
|
|
56
52
|
|
|
53
|
+
def _pass_through(kwargs: dict[str, object]) -> dict[str, Any]:
|
|
54
|
+
"""Filter kwargs to the allowed subprocess.run set."""
|
|
55
|
+
return cast(
|
|
56
|
+
"dict[str, Any]",
|
|
57
|
+
{k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
57
61
|
def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
58
62
|
"""Render the template and execute it.
|
|
59
63
|
|
|
@@ -64,14 +68,17 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
|
|
|
64
68
|
_validate_kwargs(kwargs)
|
|
65
69
|
rendered = plan(template)
|
|
66
70
|
if rendered.mode == "shell":
|
|
67
|
-
|
|
71
|
+
if sys.platform.startswith("win"):
|
|
72
|
+
raise ShellSafeError(
|
|
73
|
+
"shell mode is posix-only; restructure the command without "
|
|
74
|
+
"pipes or redirections, or run under wsl"
|
|
75
|
+
)
|
|
76
|
+
raise ShellSafeError(
|
|
77
|
+
"this command contains shell metacharacters; use shx() instead of "
|
|
78
|
+
"run() to execute pipes and redirections"
|
|
79
|
+
)
|
|
68
80
|
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)
|
|
81
|
+
result = subprocess.run(rendered.argv, **_pass_through(kwargs))
|
|
75
82
|
return cast("subprocess.CompletedProcess[str]", result)
|
|
76
83
|
|
|
77
84
|
|
|
@@ -101,12 +108,12 @@ def capture(template: object, /, **kwargs: object) -> CaptureResult:
|
|
|
101
108
|
)
|
|
102
109
|
|
|
103
110
|
|
|
104
|
-
def shx(template: object, /, **kwargs: object) ->
|
|
105
|
-
"""
|
|
111
|
+
def shx(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
112
|
+
"""Execute a shell-routed template (pipes, redirections, globs).
|
|
106
113
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
Interpolated values are POSIX shell-quoted automatically. The rendered line
|
|
115
|
+
runs under /bin/sh -c. Raises ArgvOnlyError when the template has no shell
|
|
116
|
+
metacharacters (use run() instead).
|
|
110
117
|
"""
|
|
111
118
|
_validate_kwargs(kwargs)
|
|
112
119
|
rendered = plan(template)
|
|
@@ -114,4 +121,9 @@ def shx(template: object, /, **kwargs: object) -> object:
|
|
|
114
121
|
raise ArgvOnlyError(
|
|
115
122
|
"template contains no shell metacharacters; use run() instead"
|
|
116
123
|
)
|
|
117
|
-
|
|
124
|
+
assert rendered.shell_line is not None
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["/bin/sh", "-c", rendered.shell_line],
|
|
127
|
+
**_pass_through(kwargs),
|
|
128
|
+
)
|
|
129
|
+
return cast("subprocess.CompletedProcess[str]", result)
|
|
@@ -19,11 +19,6 @@ from .raw import Raw
|
|
|
19
19
|
|
|
20
20
|
_NUL = "\x00"
|
|
21
21
|
|
|
22
|
-
# Static-text characters that require shell semantics (pipes, redirection,
|
|
23
|
-
# substitution, globs, comments). Whitespace is absent here: it splits words
|
|
24
|
-
# in argv mode rather than forcing a shell.
|
|
25
|
-
METACHARACTERS: frozenset[str] = frozenset("|&;()<>$`\"'*?!#\n\r\t\\")
|
|
26
|
-
|
|
27
22
|
|
|
28
23
|
@dataclass(frozen=True, slots=True)
|
|
29
24
|
class ExecutionPlan:
|
|
@@ -189,4 +184,7 @@ def _render_shell(parts: list[Segment]) -> ExecutionPlan:
|
|
|
189
184
|
resolved = _resolve(part)
|
|
190
185
|
_reject_nul(resolved)
|
|
191
186
|
line_parts.append(shlex.quote(resolved))
|
|
192
|
-
|
|
187
|
+
line = "".join(line_parts).strip()
|
|
188
|
+
if not line:
|
|
189
|
+
raise ShellSafeTypeError("empty command")
|
|
190
|
+
return ExecutionPlan(mode="shell", shell_line=line)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Real-execution integration tests (posix). Proves the end-to-end path."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from shellsafe import capture, run, shx
|
|
8
|
+
from shellsafe.errors import ArgvOnlyError, ShellSafeError, UnsupportedPlatformError
|
|
9
|
+
|
|
10
|
+
# --- argv mode ---
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
14
|
+
def test_capture_round_trip():
|
|
15
|
+
res = capture(t"echo hello")
|
|
16
|
+
assert res.returncode == 0
|
|
17
|
+
assert res.stdout == "hello\n"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
21
|
+
def test_injection_payload_arrives_as_text():
|
|
22
|
+
payload = "hello; echo PWNED"
|
|
23
|
+
res = capture(t"echo {payload}")
|
|
24
|
+
# argv mode: echo receives the whole payload as ONE argument; no second
|
|
25
|
+
# command can exist. The output is the payload text itself.
|
|
26
|
+
assert res.stdout.strip() == payload
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
30
|
+
def test_interpolation_with_spaces_stays_one_argument():
|
|
31
|
+
value = "two words"
|
|
32
|
+
res = capture(t"echo {value}")
|
|
33
|
+
assert res.stdout.strip() == "two words"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- shell mode execution ---
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
40
|
+
def test_shx_pipe_execution():
|
|
41
|
+
res = shx(t"echo hello | wc -w", capture_output=True, text=True)
|
|
42
|
+
assert res.returncode == 0
|
|
43
|
+
assert res.stdout.strip() == "1"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
47
|
+
def test_shx_interpolation_is_quoted():
|
|
48
|
+
"""Spaces in interpolated values do not split into separate tokens."""
|
|
49
|
+
res = shx(t"echo {['a', 'b']} | wc -w", capture_output=True, text=True)
|
|
50
|
+
assert res.returncode == 0
|
|
51
|
+
# echo ['a', 'b'] outputs the list repr as one argument; wc counts words
|
|
52
|
+
# in that output. The important thing: no shell error, no split.
|
|
53
|
+
assert res.returncode == 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
57
|
+
def test_shx_injection_payload_is_quoted():
|
|
58
|
+
payload = "hello; echo PWNED"
|
|
59
|
+
res = shx(t"echo {payload} | cat", capture_output=True, text=True)
|
|
60
|
+
assert res.returncode == 0
|
|
61
|
+
assert res.stdout.strip() == payload
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
65
|
+
def test_shx_output_capture():
|
|
66
|
+
res = shx(t"echo world | tr a-z A-Z", capture_output=True, text=True)
|
|
67
|
+
assert res.returncode == 0
|
|
68
|
+
assert res.stdout.strip() == "WORLD"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- run() rejects shell mode ---
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
75
|
+
def test_run_rejects_shell_metacharacters():
|
|
76
|
+
with pytest.raises(ShellSafeError, match="shx"):
|
|
77
|
+
run(t"echo hello | wc -l")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- shx rejects argv-only templates ---
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
84
|
+
def test_shx_rejects_argv_only_template():
|
|
85
|
+
with pytest.raises(ArgvOnlyError):
|
|
86
|
+
shx(t"echo hello")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- Windows policy ---
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_shell_mode_execution_not_yet_available_on_windows():
|
|
93
|
+
p = t"cat /etc/hostname | wc -l"
|
|
94
|
+
if sys.platform == "win32":
|
|
95
|
+
with pytest.raises(UnsupportedPlatformError):
|
|
96
|
+
shx(p)
|
|
97
|
+
else:
|
|
98
|
+
# on posix, shell mode works; this test verifies the Windows refusal
|
|
99
|
+
# path is wired correctly via monkeypatch (see test_render.py)
|
|
100
|
+
pass
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Property tests: invariants that must hold for arbitrary inputs."""
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
import sys
|
|
5
|
+
from string.templatelib import Interpolation, Template
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from hypothesis import given, settings
|
|
9
|
+
from hypothesis import strategies as st
|
|
10
|
+
|
|
11
|
+
from shellsafe.render import plan
|
|
12
|
+
|
|
13
|
+
# NUL bytes are refused at resolution; everything else must render safely.
|
|
14
|
+
text_values = st.text(st.characters(blacklist_characters="\x00"), max_size=200)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- argv mode invariants ---
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@settings(max_examples=300)
|
|
21
|
+
@given(value=text_values)
|
|
22
|
+
def test_one_interpolation_is_exactly_one_argument(value: str):
|
|
23
|
+
p = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
24
|
+
assert p.argv == ("run", value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@settings(max_examples=200)
|
|
28
|
+
@given(a=text_values, b=text_values)
|
|
29
|
+
def test_two_interpolations_stay_separate(a: str, b: str):
|
|
30
|
+
p = plan(
|
|
31
|
+
Template("copy ", Interpolation(a, "x", None, ""), " ", Interpolation(b, "y", None, ""))
|
|
32
|
+
)
|
|
33
|
+
assert p.argv == ("copy", a, b)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@settings(max_examples=200)
|
|
37
|
+
@given(value=text_values)
|
|
38
|
+
def test_repr_round_trip_is_byte_stable(value: str):
|
|
39
|
+
p1 = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
40
|
+
p2 = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
41
|
+
assert repr(p1) == repr(p2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- shell mode invariants ---
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
|
|
48
|
+
@settings(max_examples=300)
|
|
49
|
+
@given(value=text_values)
|
|
50
|
+
def test_shell_interpolation_stays_single_token(value: str):
|
|
51
|
+
"""INV-shell: under a pipe template, shlex.split yields value as one token."""
|
|
52
|
+
p = plan(Template("cat ", Interpolation(value, "v", None, ""), " | wc -l"))
|
|
53
|
+
assert p.mode == "shell"
|
|
54
|
+
assert p.shell_line is not None
|
|
55
|
+
tokens = shlex.split(p.shell_line)
|
|
56
|
+
# "cat", VALUE, "|", "wc", "-l" = 5 tokens
|
|
57
|
+
assert len(tokens) == 5
|
|
58
|
+
assert tokens[1] == value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
|
|
62
|
+
@settings(max_examples=200)
|
|
63
|
+
@given(a=text_values, b=text_values)
|
|
64
|
+
def test_shell_two_interpolations_stay_separate(a: str, b: str):
|
|
65
|
+
"""Two shell-mode interpolations must each occupy one token."""
|
|
66
|
+
p = plan(
|
|
67
|
+
Template(
|
|
68
|
+
"grep ", Interpolation(a, "x", None, ""), " ", Interpolation(b, "y", None, ""),
|
|
69
|
+
" | sort",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
assert p.mode == "shell"
|
|
73
|
+
assert p.shell_line is not None
|
|
74
|
+
tokens = shlex.split(p.shell_line)
|
|
75
|
+
# "grep", A, B, "|", "sort" = 5 tokens
|
|
76
|
+
assert len(tokens) == 5
|
|
77
|
+
assert tokens[1] == a
|
|
78
|
+
assert tokens[2] == b
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Payload corpus gate: every injection case renders inert.
|
|
2
|
+
|
|
3
|
+
Argv mode: each .txt file in cases/ holds exactly one interpolated value. The
|
|
4
|
+
command is argv mode "echo <value>"; the invariant under test: the value occupies
|
|
5
|
+
exactly one argv element equal to the original payload. If any payload can escape
|
|
6
|
+
into a second command, this suite fails and the release is blocked.
|
|
7
|
+
|
|
8
|
+
Shell mode: the same payloads are tested under a pipe template. The invariant:
|
|
9
|
+
shlex.split(rendered_line) yields the interpolated value as exactly one token
|
|
10
|
+
equal to the original payload.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import shlex
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from string.templatelib import Interpolation, Template
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from shellsafe.render import plan
|
|
21
|
+
|
|
22
|
+
CASES_DIR = Path(__file__).parent.parent / "payloads" / "cases"
|
|
23
|
+
CASE_FILES = sorted(CASES_DIR.glob("case_*.txt"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def payloads() -> list[tuple[str, str]]:
|
|
27
|
+
out = []
|
|
28
|
+
for path in CASE_FILES:
|
|
29
|
+
raw = path.read_text()
|
|
30
|
+
# strip one trailing newline added by file storage; keep all else verbatim
|
|
31
|
+
value = raw[:-1] if raw.endswith("\n") else raw
|
|
32
|
+
out.append((path.stem, value))
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- argv mode ---
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize(
|
|
40
|
+
("name", "payload"),
|
|
41
|
+
payloads(),
|
|
42
|
+
ids=[n for n, _ in payloads()],
|
|
43
|
+
)
|
|
44
|
+
def test_payload_stays_single_argument(name: str, payload: str):
|
|
45
|
+
p = plan(Template("echo ", Interpolation(payload, "value", None, "")))
|
|
46
|
+
assert p.mode == "argv"
|
|
47
|
+
assert p.argv == ("echo", payload)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- shell mode ---
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize(
|
|
54
|
+
("name", "payload"),
|
|
55
|
+
payloads(),
|
|
56
|
+
ids=[n for n, _ in payloads()],
|
|
57
|
+
)
|
|
58
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
|
|
59
|
+
def test_shell_payload_stays_single_token(name: str, payload: str):
|
|
60
|
+
"""Pipe template: value must be shell-quoted, never splitting into tokens."""
|
|
61
|
+
p = plan(
|
|
62
|
+
Template("cat ", Interpolation(payload, "value", None, ""), " | wc -l")
|
|
63
|
+
)
|
|
64
|
+
assert p.mode == "shell"
|
|
65
|
+
assert p.shell_line is not None
|
|
66
|
+
tokens = shlex.split(p.shell_line)
|
|
67
|
+
# static tokens: "cat", "|", "wc", "-l" (4); value must be exactly one
|
|
68
|
+
assert len(tokens) == 5
|
|
69
|
+
assert tokens[1] == payload
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_corpus_is_present():
|
|
73
|
+
# guard against silent corpus deletion
|
|
74
|
+
assert len(CASE_FILES) >= 10
|
shellsafe-0.1.1/CHANGELOG.md
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
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.1.1] - 2026-08-24
|
|
7
|
-
|
|
8
|
-
### Changed
|
|
9
|
-
|
|
10
|
-
- Project description rewritten
|
|
11
|
-
- README restructured for PyPI: package page now leads with the why, usage and
|
|
12
|
-
limits; contributing details stay in the repository only
|
|
13
|
-
|
|
14
|
-
## [0.1.0] - 2026-08-24
|
|
15
|
-
|
|
16
|
-
### Added
|
|
17
|
-
|
|
18
|
-
- Argv-mode rendering and execution via template strings
|
|
19
|
-
- RAW trust marker with argv splicing
|
|
20
|
-
- capture() helper with utf-8 stdout/stderr
|
|
21
|
-
- plan() helper for inspecting commands before execution
|
|
22
|
-
- Injection payload corpus as a release gate; all cases render inert
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.1"
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
"""Real-execution integration tests (posix). Proves the end-to-end path."""
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from shellsafe import capture, shx
|
|
8
|
-
from shellsafe.errors import ShellSafeError, UnsupportedPlatformError
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
12
|
-
def test_capture_round_trip():
|
|
13
|
-
res = capture(t"echo hello")
|
|
14
|
-
assert res.returncode == 0
|
|
15
|
-
assert res.stdout == "hello\n"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
19
|
-
def test_injection_payload_arrives_as_text():
|
|
20
|
-
payload = "hello; echo PWNED"
|
|
21
|
-
res = capture(t"echo {payload}")
|
|
22
|
-
# argv mode: echo receives the whole payload as ONE argument; no second
|
|
23
|
-
# command can exist. The output is the payload text itself.
|
|
24
|
-
assert res.stdout.strip() == payload
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
|
|
28
|
-
def test_interpolation_with_spaces_stays_one_argument():
|
|
29
|
-
value = "two words"
|
|
30
|
-
res = capture(t"echo {value}")
|
|
31
|
-
assert res.stdout.strip() == "two words"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_shell_mode_execution_not_yet_available():
|
|
35
|
-
# posix: rendering works, execution gate is explicit until v0.2
|
|
36
|
-
# windows: the route itself is refused before any process spawns
|
|
37
|
-
p = t"cat /etc/hostname | wc -l"
|
|
38
|
-
|
|
39
|
-
if sys.platform == "win32":
|
|
40
|
-
with pytest.raises(UnsupportedPlatformError):
|
|
41
|
-
shx(p)
|
|
42
|
-
else:
|
|
43
|
-
with pytest.raises(ShellSafeError, match=r"0\.2"):
|
|
44
|
-
shx(p)
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
"""Property tests: invariants that must hold for arbitrary inputs."""
|
|
2
|
-
|
|
3
|
-
from string.templatelib import Interpolation, Template
|
|
4
|
-
|
|
5
|
-
from hypothesis import given, settings
|
|
6
|
-
from hypothesis import strategies as st
|
|
7
|
-
|
|
8
|
-
from shellsafe.render import plan
|
|
9
|
-
|
|
10
|
-
# NUL bytes are refused at resolution; everything else must render safely.
|
|
11
|
-
text_values = st.text(st.characters(blacklist_characters="\x00"), max_size=200)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@settings(max_examples=300)
|
|
15
|
-
@given(value=text_values)
|
|
16
|
-
def test_one_interpolation_is_exactly_one_argument(value: str):
|
|
17
|
-
p = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
18
|
-
assert p.argv == ("run", value)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@settings(max_examples=200)
|
|
22
|
-
@given(a=text_values, b=text_values)
|
|
23
|
-
def test_two_interpolations_stay_separate(a: str, b: str):
|
|
24
|
-
p = plan(
|
|
25
|
-
Template("copy ", Interpolation(a, "x", None, ""), " ", Interpolation(b, "y", None, ""))
|
|
26
|
-
)
|
|
27
|
-
assert p.argv == ("copy", a, b)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@settings(max_examples=200)
|
|
31
|
-
@given(value=text_values)
|
|
32
|
-
def test_repr_round_trip_is_byte_stable(value: str):
|
|
33
|
-
p1 = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
34
|
-
p2 = plan(Template("run ", Interpolation(value, "v", None, "")))
|
|
35
|
-
assert repr(p1) == repr(p2)
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""Payload corpus gate: every injection case renders inert.
|
|
2
|
-
|
|
3
|
-
Each .txt file in cases/ holds exactly one interpolated value. The command is
|
|
4
|
-
argv mode "echo <value>"; the invariant under test: the value occupies exactly
|
|
5
|
-
one argv element equal to the original payload. If any payload can escape into a
|
|
6
|
-
second command, this suite fails and the release is blocked.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from string.templatelib import Interpolation, Template
|
|
11
|
-
|
|
12
|
-
import pytest
|
|
13
|
-
|
|
14
|
-
from shellsafe.render import plan
|
|
15
|
-
|
|
16
|
-
CASES_DIR = Path(__file__).parent.parent / "payloads" / "cases"
|
|
17
|
-
CASE_FILES = sorted(CASES_DIR.glob("case_*.txt"))
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def payloads() -> list[tuple[str, str]]:
|
|
21
|
-
out = []
|
|
22
|
-
for path in CASE_FILES:
|
|
23
|
-
raw = path.read_text()
|
|
24
|
-
# strip one trailing newline added by file storage; keep all else verbatim
|
|
25
|
-
value = raw[:-1] if raw.endswith("\n") else raw
|
|
26
|
-
out.append((path.stem, value))
|
|
27
|
-
return out
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@pytest.mark.parametrize(
|
|
31
|
-
("name", "payload"),
|
|
32
|
-
payloads(),
|
|
33
|
-
ids=[n for n, _ in payloads()],
|
|
34
|
-
)
|
|
35
|
-
def test_payload_stays_single_argument(name: str, payload: str):
|
|
36
|
-
p = plan(Template("echo ", Interpolation(payload, "value", None, "")))
|
|
37
|
-
assert p.mode == "argv"
|
|
38
|
-
assert p.argv == ("echo", payload)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_corpus_is_present():
|
|
42
|
-
# guard against silent corpus deletion
|
|
43
|
-
assert len(CASE_FILES) >= 10
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|