shellsafe 0.1.0__py3-none-any.whl
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/__init__.py +18 -0
- shellsafe/__main__.py +3 -0
- shellsafe/_version.py +1 -0
- shellsafe/audit/__init__.py +10 -0
- shellsafe/audit/rules.py +0 -0
- shellsafe/cli.py +44 -0
- shellsafe/errors.py +36 -0
- shellsafe/execute.py +117 -0
- shellsafe/exitcodes.py +6 -0
- shellsafe/platforms.py +35 -0
- shellsafe/py.typed +0 -0
- shellsafe/raw.py +30 -0
- shellsafe/render.py +192 -0
- shellsafe/reporters.py +1 -0
- shellsafe-0.1.0.dist-info/METADATA +126 -0
- shellsafe-0.1.0.dist-info/RECORD +19 -0
- shellsafe-0.1.0.dist-info/WHEEL +4 -0
- shellsafe-0.1.0.dist-info/entry_points.txt +2 -0
- shellsafe-0.1.0.dist-info/licenses/LICENSE +21 -0
shellsafe/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""shellsafe: safe shell commands via Python 3.14 template strings.
|
|
2
|
+
|
|
3
|
+
Public surface (stable names, additive only through 1.0):
|
|
4
|
+
run, capture, shx, RAW, CaptureResult.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
from .execute import CaptureResult, capture, run, shx
|
|
9
|
+
from .raw import RAW
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"RAW",
|
|
13
|
+
"CaptureResult",
|
|
14
|
+
"__version__",
|
|
15
|
+
"capture",
|
|
16
|
+
"run",
|
|
17
|
+
"shx",
|
|
18
|
+
]
|
shellsafe/__main__.py
ADDED
shellsafe/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,10 @@
|
|
|
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")
|
shellsafe/audit/rules.py
ADDED
|
File without changes
|
shellsafe/cli.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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: arriving in 0.2")
|
|
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
|
shellsafe/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Exception hierarchy for shellsafe.
|
|
2
|
+
|
|
3
|
+
Every error raised publicly inherits from ShellSafeError so callers can catch
|
|
4
|
+
broadly. Messages are lowercase, state got and expected, and include the fix.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ShellSafeError(Exception):
|
|
9
|
+
"""Base class for every error shellsafe raises."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ShellSafeTypeError(ShellSafeError):
|
|
13
|
+
"""A template or interpolated value has an unsupported type."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedPlatformError(ShellSafeError):
|
|
17
|
+
"""The requested shell route cannot be made safe on this platform.
|
|
18
|
+
|
|
19
|
+
Interpolated shell routes are refused on Windows by policy; restructure the
|
|
20
|
+
command as argv mode (no pipes or redirections) instead.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ArgvOnlyError(ShellSafeError):
|
|
25
|
+
"""shx() was called with a template that needs no shell at all.
|
|
26
|
+
|
|
27
|
+
Use run() for plain commands; shx() exists for pipes and redirections.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RawUsageError(ShellSafeError):
|
|
32
|
+
"""RAW() was used incorrectly.
|
|
33
|
+
|
|
34
|
+
RAW takes exactly one argument: a list of strings in argv mode or a string
|
|
35
|
+
in shell mode. RAW values are never nested and never modified.
|
|
36
|
+
"""
|
shellsafe/execute.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Subprocess wrappers over rendered ExecutionPlans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from .errors import ArgvOnlyError, ShellSafeError
|
|
10
|
+
from .raw import Raw # noqa: F401 (re-exported through the package root)
|
|
11
|
+
from .render import ExecutionPlan
|
|
12
|
+
|
|
13
|
+
_SHELL_MODE_PENDS = (
|
|
14
|
+
"shell-mode execution arrives in shellsafe 0.2; "
|
|
15
|
+
"this release covers argv-mode commands"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_ALLOWED_KWARGS = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"check",
|
|
21
|
+
"timeout",
|
|
22
|
+
"env",
|
|
23
|
+
"cwd",
|
|
24
|
+
"capture_output",
|
|
25
|
+
"input",
|
|
26
|
+
"stdin",
|
|
27
|
+
"stdout",
|
|
28
|
+
"stderr",
|
|
29
|
+
"start_new_session",
|
|
30
|
+
"text",
|
|
31
|
+
"encoding",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_kwargs(kwargs: dict[str, object]) -> None:
|
|
37
|
+
if "shell" in kwargs:
|
|
38
|
+
raise ShellSafeError(
|
|
39
|
+
"shellsafe never passes shell=True; use shx() on posix for pipes and "
|
|
40
|
+
"redirections"
|
|
41
|
+
)
|
|
42
|
+
unknown = set(kwargs) - _ALLOWED_KWARGS
|
|
43
|
+
if unknown:
|
|
44
|
+
valid = ", ".join(sorted(_ALLOWED_KWARGS))
|
|
45
|
+
raise ShellSafeError(
|
|
46
|
+
f"unsupported keyword(s) {sorted(unknown)}; valid keywords: {valid}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def plan(template: object) -> ExecutionPlan:
|
|
51
|
+
"""Render a t-string into an inspectable ExecutionPlan without executing."""
|
|
52
|
+
from .render import plan as render_plan
|
|
53
|
+
|
|
54
|
+
return render_plan(template)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
58
|
+
"""Render the template and execute it.
|
|
59
|
+
|
|
60
|
+
Interpolated values always arrive as single argv elements. Keyword arguments
|
|
61
|
+
pass through to subprocess.run with one exception: shell is rejected by
|
|
62
|
+
design.
|
|
63
|
+
"""
|
|
64
|
+
_validate_kwargs(kwargs)
|
|
65
|
+
rendered = plan(template)
|
|
66
|
+
if rendered.mode == "shell":
|
|
67
|
+
raise ShellSafeError(_SHELL_MODE_PENDS)
|
|
68
|
+
assert rendered.argv is not None
|
|
69
|
+
# passthrough boundary: values are caller-owned subprocess kwargs
|
|
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)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class CaptureResult:
|
|
80
|
+
"""Text-captured execution result plus the plan that produced it."""
|
|
81
|
+
|
|
82
|
+
stdout: str
|
|
83
|
+
stderr: str
|
|
84
|
+
returncode: int
|
|
85
|
+
plan: ExecutionPlan
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def capture(template: object, /, **kwargs: object) -> CaptureResult:
|
|
89
|
+
"""Run with captured utf-8 stdout/stderr and return a CaptureResult."""
|
|
90
|
+
kwargs["capture_output"] = True
|
|
91
|
+
kwargs["text"] = True
|
|
92
|
+
kwargs["encoding"] = "utf-8"
|
|
93
|
+
completed = run(template, **kwargs)
|
|
94
|
+
assert isinstance(completed.stdout, str)
|
|
95
|
+
assert isinstance(completed.stderr, str)
|
|
96
|
+
return CaptureResult(
|
|
97
|
+
stdout=completed.stdout,
|
|
98
|
+
stderr=completed.stderr,
|
|
99
|
+
returncode=completed.returncode,
|
|
100
|
+
plan=plan(template),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def shx(template: object, /, **kwargs: object) -> object:
|
|
105
|
+
"""Shell-route alias for templates that need pipes or redirections.
|
|
106
|
+
|
|
107
|
+
Raises ArgvOnlyError when the template needs no shell at all, so a missing
|
|
108
|
+
pipe is never silently ignored. Full shell execution ships in shellsafe 0.2;
|
|
109
|
+
rendering and inspection work today via shellsafe.plan().
|
|
110
|
+
"""
|
|
111
|
+
_validate_kwargs(kwargs)
|
|
112
|
+
rendered = plan(template)
|
|
113
|
+
if rendered.mode != "shell":
|
|
114
|
+
raise ArgvOnlyError(
|
|
115
|
+
"template contains no shell metacharacters; use run() instead"
|
|
116
|
+
)
|
|
117
|
+
raise ShellSafeError(_SHELL_MODE_PENDS)
|
shellsafe/exitcodes.py
ADDED
shellsafe/platforms.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
)
|
shellsafe/py.typed
ADDED
|
File without changes
|
shellsafe/raw.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""RAW marker: the single explicit trust boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .errors import RawUsageError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Raw:
|
|
9
|
+
"""Wraps pre-trusted content that rendering must not modify.
|
|
10
|
+
|
|
11
|
+
In argv mode the wrapped value must be a list of strings and is spliced
|
|
12
|
+
into the argv positionally. In POSIX shell mode a str value is inserted
|
|
13
|
+
verbatim into the shell line. Nothing else is accepted, nesting is refused,
|
|
14
|
+
and every use site is greppable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("value",)
|
|
18
|
+
|
|
19
|
+
def __init__(self, value: list[str] | str) -> None:
|
|
20
|
+
if isinstance(value, Raw):
|
|
21
|
+
raise RawUsageError("raw cannot wrap another raw")
|
|
22
|
+
self.value = value
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
return f"<RAW {self.value!r}>"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def RAW(value: list[str] | str) -> Raw:
|
|
29
|
+
"""Mark content as pre-trusted. See Raw for mode-specific rules."""
|
|
30
|
+
return Raw(value)
|
shellsafe/render.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Template rendering: t-string in, ExecutionPlan out. Pure and deterministic.
|
|
2
|
+
|
|
3
|
+
Security invariants (property-tested, see docs/07_rendering_engine_spec.md):
|
|
4
|
+
- INV-1: every interpolation contributes exactly one argv element equal to its
|
|
5
|
+
resolved string.
|
|
6
|
+
- INV-2: the executable comes from static template text only.
|
|
7
|
+
- INV-3: element count equals static words plus interpolations plus RAW splices.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shlex
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from string.templatelib import Interpolation, Template
|
|
15
|
+
|
|
16
|
+
from .errors import RawUsageError, ShellSafeTypeError
|
|
17
|
+
from .platforms import route_for
|
|
18
|
+
from .raw import Raw
|
|
19
|
+
|
|
20
|
+
_NUL = "\x00"
|
|
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
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class ExecutionPlan:
|
|
30
|
+
"""The exact thing that will execute. Inspect via repr before running."""
|
|
31
|
+
|
|
32
|
+
mode: str # "argv" or "shell"
|
|
33
|
+
argv: tuple[str, ...] | None = None
|
|
34
|
+
shell_line: str | None = None
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
if self.mode == "argv":
|
|
38
|
+
assert self.argv is not None
|
|
39
|
+
body = ",".join(repr(a) for a in self.argv)
|
|
40
|
+
return f"argv: [{body}]"
|
|
41
|
+
assert self.shell_line is not None
|
|
42
|
+
return f"shell: {self.shell_line}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
Segment = str | Interpolation[str] | Raw
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _walk(
|
|
49
|
+
template: Template, seen: frozenset[int]
|
|
50
|
+
) -> list[Segment]:
|
|
51
|
+
"""Flatten a template into an ordered list of static text and interpolations.
|
|
52
|
+
|
|
53
|
+
Nested templates (a t-string interpolated inside a t-string) splice their
|
|
54
|
+
segments at the interpolation position. Cycle-guarded via object ids.
|
|
55
|
+
"""
|
|
56
|
+
parts: list[Segment] = []
|
|
57
|
+
for element in template:
|
|
58
|
+
if isinstance(element, str):
|
|
59
|
+
parts.append(element)
|
|
60
|
+
elif isinstance(element, Interpolation):
|
|
61
|
+
if isinstance(element.value, Raw):
|
|
62
|
+
parts.append(Raw(element.value.value))
|
|
63
|
+
elif isinstance(element.value, Template):
|
|
64
|
+
if id(element.value) in seen:
|
|
65
|
+
raise ShellSafeTypeError("template references itself")
|
|
66
|
+
parts.extend(_walk(element.value, seen | {id(element.value)}))
|
|
67
|
+
else:
|
|
68
|
+
parts.append(element)
|
|
69
|
+
else:
|
|
70
|
+
raise ShellSafeTypeError(
|
|
71
|
+
f"unexpected template element {type(element).__name__}"
|
|
72
|
+
)
|
|
73
|
+
return parts
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve(interpolation: Interpolation[str]) -> str:
|
|
77
|
+
"""Resolve one interpolation exactly as an f-string would."""
|
|
78
|
+
value = interpolation.value
|
|
79
|
+
if isinstance(value, (bytes, bytearray)):
|
|
80
|
+
raise ShellSafeTypeError(
|
|
81
|
+
"bytes interpolation is ambiguous; decode it explicitly first"
|
|
82
|
+
)
|
|
83
|
+
conversion = interpolation.conversion
|
|
84
|
+
format_spec = interpolation.format_spec
|
|
85
|
+
|
|
86
|
+
converted: object
|
|
87
|
+
if conversion is None:
|
|
88
|
+
converted = value
|
|
89
|
+
elif conversion == "r":
|
|
90
|
+
converted = repr(value)
|
|
91
|
+
elif conversion == "s":
|
|
92
|
+
converted = str(value)
|
|
93
|
+
elif conversion == "a":
|
|
94
|
+
converted = ascii(value)
|
|
95
|
+
else:
|
|
96
|
+
raise ShellSafeTypeError(
|
|
97
|
+
f"unsupported conversion {conversion!r}; expected 'r', 's', 'a', or None"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if format_spec:
|
|
101
|
+
return format(converted, format_spec)
|
|
102
|
+
if conversion is None:
|
|
103
|
+
return str(converted)
|
|
104
|
+
assert isinstance(converted, str)
|
|
105
|
+
return converted
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _reject_nul(resolved: str) -> None:
|
|
109
|
+
if _NUL in resolved:
|
|
110
|
+
raise ShellSafeTypeError(
|
|
111
|
+
"interpolated value contains a NUL byte after formatting; "
|
|
112
|
+
"executables cannot receive NUL bytes"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def plan(template: object) -> ExecutionPlan:
|
|
117
|
+
"""Render a t-string into an inspectable ExecutionPlan."""
|
|
118
|
+
if not isinstance(template, Template):
|
|
119
|
+
raise ShellSafeTypeError(
|
|
120
|
+
f"expected a t-string template, got {type(template).__name__}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
parts = _walk(template, frozenset({id(template)}))
|
|
124
|
+
route = route_for("".join(p for p in parts if isinstance(p, str)))
|
|
125
|
+
|
|
126
|
+
if route == "argv":
|
|
127
|
+
return _render_argv(parts)
|
|
128
|
+
return _render_shell(parts)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _render_argv(parts: list[Segment]) -> ExecutionPlan:
|
|
132
|
+
argv: list[str] = []
|
|
133
|
+
origins: list[str] = []
|
|
134
|
+
|
|
135
|
+
for part in parts:
|
|
136
|
+
if isinstance(part, str):
|
|
137
|
+
for word in part.split():
|
|
138
|
+
argv.append(word)
|
|
139
|
+
origins.append("static")
|
|
140
|
+
elif isinstance(part, Raw):
|
|
141
|
+
raw_value = part.value
|
|
142
|
+
if not isinstance(raw_value, list):
|
|
143
|
+
raise RawUsageError(
|
|
144
|
+
"RAW str values are only valid in shell mode; "
|
|
145
|
+
"in argv mode pass RAW(['one', 'argv', 'element'])"
|
|
146
|
+
)
|
|
147
|
+
for element in raw_value:
|
|
148
|
+
if not isinstance(element, str):
|
|
149
|
+
raise RawUsageError(
|
|
150
|
+
"RAW lists in argv mode contain strings only; got "
|
|
151
|
+
f"{type(element).__name__}"
|
|
152
|
+
)
|
|
153
|
+
_reject_nul(element)
|
|
154
|
+
argv.append(element)
|
|
155
|
+
origins.append("raw")
|
|
156
|
+
else:
|
|
157
|
+
resolved = _resolve(part)
|
|
158
|
+
_reject_nul(resolved)
|
|
159
|
+
argv.append(resolved)
|
|
160
|
+
origins.append("interpolation")
|
|
161
|
+
|
|
162
|
+
if not argv:
|
|
163
|
+
raise ShellSafeTypeError("empty command")
|
|
164
|
+
|
|
165
|
+
if origins[0] != "static":
|
|
166
|
+
raise ShellSafeTypeError(
|
|
167
|
+
"the executable must come from static template text; "
|
|
168
|
+
"interpolate arguments instead of commands"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return ExecutionPlan(mode="argv", argv=tuple(argv))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _render_shell(parts: list[Segment]) -> ExecutionPlan:
|
|
175
|
+
line_parts: list[str] = []
|
|
176
|
+
for part in parts:
|
|
177
|
+
if isinstance(part, str):
|
|
178
|
+
line_parts.append(part)
|
|
179
|
+
elif isinstance(part, Raw):
|
|
180
|
+
raw_value = part.value
|
|
181
|
+
if not isinstance(raw_value, str):
|
|
182
|
+
raise RawUsageError(
|
|
183
|
+
"RAW list values are only valid in argv mode; "
|
|
184
|
+
"in shell mode pass a single string"
|
|
185
|
+
)
|
|
186
|
+
_reject_nul(raw_value)
|
|
187
|
+
line_parts.append(raw_value)
|
|
188
|
+
else:
|
|
189
|
+
resolved = _resolve(part)
|
|
190
|
+
_reject_nul(resolved)
|
|
191
|
+
line_parts.append(shlex.quote(resolved))
|
|
192
|
+
return ExecutionPlan(mode="shell", shell_line="".join(line_parts))
|
shellsafe/reporters.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Finding reporters: terminal and json. Arrives in shellsafe 0.3."""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shellsafe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Safe shell commands via Python 3.14 template strings. Injection-proof by construction.
|
|
5
|
+
Project-URL: Repository, https://github.com/rahulXs/shellsafe
|
|
6
|
+
Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Rahul Sharma <rahulxsh@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: automation,command-injection,injection,pep750,security,shell,subprocess,t-strings,template-strings
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.14
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# shellsafe
|
|
24
|
+
|
|
25
|
+
> Safe shell commands via Python 3.14 template strings. Injection-proof by
|
|
26
|
+
> construction.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from shellsafe import run
|
|
30
|
+
|
|
31
|
+
message = get_user_input() # "fix; rm -rf ~"
|
|
32
|
+
run(t"git commit -m {message}")
|
|
33
|
+
# argv: ["git", "commit", "-m", "fix; rm -rf ~"]
|
|
34
|
+
# one command; the scary text is just an argument
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Why
|
|
38
|
+
|
|
39
|
+
The dominant pattern in scripts and automation is still this:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
subprocess.run(f"git commit -m {message}", shell=True) # injection waiting to happen
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Python 3.14 template strings (`t"..."`) separate static text from interpolated
|
|
46
|
+
values. shellsafe turns that structure into argv lists where interpolated values
|
|
47
|
+
are always data, never commands. When you genuinely need pipes, shell mode quotes
|
|
48
|
+
every value with POSIX rules first.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install shellsafe
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python 3.14+ (template strings).
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
Run a command. Interpolated values are always single arguments:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from shellsafe import run
|
|
64
|
+
|
|
65
|
+
run(t"mkdir {path}")
|
|
66
|
+
run(t"docker build -t {tag} .", check=True, timeout=300)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Capture output as text:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from shellsafe import capture
|
|
73
|
+
|
|
74
|
+
res = capture(t"grep {pattern} {file}")
|
|
75
|
+
print(res.stdout, res.returncode)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Pipes and redirections on POSIX (values are quoted with `shlex.quote` first):
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from shellsafe import shx
|
|
82
|
+
|
|
83
|
+
shx(t"cat {file} | wc -l")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Inspect exactly what will execute:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from shellsafe import plan # lower-level: render without running
|
|
90
|
+
|
|
91
|
+
print(plan(t"git commit -m {message}"))
|
|
92
|
+
# argv: ["git","commit","-m","fix; rm -rf ~"]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## What it refuses
|
|
96
|
+
|
|
97
|
+
| Case | Behavior |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Interpolation as the executable | error: the command comes from static text only |
|
|
100
|
+
| Shell route on Windows | error: cmd.exe quoting cannot be made injection-safe; use argv mode |
|
|
101
|
+
| RAW misuse | error: one argument, verbatim, nesting refused |
|
|
102
|
+
|
|
103
|
+
`RAW("...")` / `RAW(["a", "b"])` is the single explicit trust boundary for
|
|
104
|
+
pre-quoted content. Every use site is greppable.
|
|
105
|
+
|
|
106
|
+
## Limits
|
|
107
|
+
|
|
108
|
+
- Windows: interpolated shell routes are refused rather than approximated;
|
|
109
|
+
argv-mode commands work fully.
|
|
110
|
+
- Bytes interpolations are rejected: decode explicitly first.
|
|
111
|
+
- Runtime behavior after import is your test suite's job, same trust model as
|
|
112
|
+
calling subprocess yourself.
|
|
113
|
+
|
|
114
|
+
## Contributing
|
|
115
|
+
|
|
116
|
+
Issues and PRs welcome. Security reports go privately to the maintainer, never
|
|
117
|
+
through public issues.
|
|
118
|
+
|
|
119
|
+
## Requirements
|
|
120
|
+
|
|
121
|
+
- CPython >= 3.14 (uses template strings from PEP 750)
|
|
122
|
+
- Linux, macOS, Windows (Windows supports argv mode; POSIX-only shell mode)
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
shellsafe/__init__.py,sha256=nTpsWvya8AVgO1OoY2CKfGjllbeUJvAJYkKKPFqJxdk,383
|
|
2
|
+
shellsafe/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
shellsafe/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
4
|
+
shellsafe/cli.py,sha256=TVjCrcPuS01JLd5DBAnDrovnlVDJYwjFyGBU0r5I_dY,1337
|
|
5
|
+
shellsafe/errors.py,sha256=chDBp9q221IneE2El1jOwKNYi4c55fovvhJkDTjDYps,1092
|
|
6
|
+
shellsafe/execute.py,sha256=RFzzsbz-7Jp94gJbEq2gPUyAZdxRByX9EVkinYH2oe8,3561
|
|
7
|
+
shellsafe/exitcodes.py,sha256=XtYF6a0AWUu13UI0SMij8fh3MnrDTTkWboiN3hPyIF0,121
|
|
8
|
+
shellsafe/platforms.py,sha256=s-tM-R3HARXSdhwgaUgE6Mn-Hc0FxzvKcoVc6e6Ny9U,1176
|
|
9
|
+
shellsafe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
shellsafe/raw.py,sha256=qgx-AW7N1owB65u8lR3thMncAXZqnLvmMQyinEcb3nY,898
|
|
11
|
+
shellsafe/render.py,sha256=yy29C6_NPtKF6nbywbbxWimcD5h2yYPKGnnG6QUiZdE,6528
|
|
12
|
+
shellsafe/reporters.py,sha256=iHjEoqrwG665eOon9Yy6QK19mIwou42BkmZYrl2xc2M,70
|
|
13
|
+
shellsafe/audit/__init__.py,sha256=NLJA1Fg7zRqno4HSoyNOWHHJmxVOZgxvnF31PYuiOUc,341
|
|
14
|
+
shellsafe/audit/rules.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
shellsafe-0.1.0.dist-info/METADATA,sha256=tiaMAuC2Iyp9CINJ8CBUa4-uIvlmbsQDVJE9qqju84A,3543
|
|
16
|
+
shellsafe-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
17
|
+
shellsafe-0.1.0.dist-info/entry_points.txt,sha256=DmSHwYR25TXX7V-tOAz-WN3o8XRA5wb6tguDBaQq9Z0,49
|
|
18
|
+
shellsafe-0.1.0.dist-info/licenses/LICENSE,sha256=IP9ZsQ9Wh-8yPf3q3yVB83H9MqyWinWhSgU5bTQb_ko,1079
|
|
19
|
+
shellsafe-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rahul Sharma (rahulXs)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|