server-management-lib 0.2.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.
- server_management_lib/__init__.py +124 -0
- server_management_lib/audit.py +80 -0
- server_management_lib/base.py +55 -0
- server_management_lib/config.py +385 -0
- server_management_lib/executor.py +53 -0
- server_management_lib/http_clients.py +636 -0
- server_management_lib/local_executor.py +200 -0
- server_management_lib/operations.py +33 -0
- server_management_lib/output.py +61 -0
- server_management_lib/py.typed +0 -0
- server_management_lib/rate_limit.py +118 -0
- server_management_lib/redaction.py +191 -0
- server_management_lib/responses.py +126 -0
- server_management_lib/security.py +1552 -0
- server_management_lib/ssh_manager.py +447 -0
- server_management_lib/transport.py +128 -0
- server_management_lib/tunnel.py +380 -0
- server_management_lib-0.2.0.dist-info/METADATA +46 -0
- server_management_lib-0.2.0.dist-info/RECORD +21 -0
- server_management_lib-0.2.0.dist-info/WHEEL +5 -0
- server_management_lib-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local command executor.
|
|
3
|
+
|
|
4
|
+
Runs the same validated operation catalog as the SSH executor, but on the host
|
|
5
|
+
where the MCP server runs. Commands are executed as argument vectors (never
|
|
6
|
+
through a shell), and file access is confined to the configured service base.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import shlex
|
|
12
|
+
import stat
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .base import (
|
|
18
|
+
ExecutorBase,
|
|
19
|
+
RemoteDirEntry,
|
|
20
|
+
RemoteFileError,
|
|
21
|
+
RemotePathError,
|
|
22
|
+
is_within,
|
|
23
|
+
)
|
|
24
|
+
from .output import sanitize_output
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
SHELL_OPERATORS = {
|
|
29
|
+
"&",
|
|
30
|
+
"&&",
|
|
31
|
+
";",
|
|
32
|
+
"|",
|
|
33
|
+
"||",
|
|
34
|
+
">",
|
|
35
|
+
">>",
|
|
36
|
+
"<",
|
|
37
|
+
"<<",
|
|
38
|
+
"2>",
|
|
39
|
+
"2>&1",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_regular_file(path: Path, limit: int) -> tuple[bytes, int]:
|
|
44
|
+
"""Read a regular file without following special files (FIFOs, devices)."""
|
|
45
|
+
info = path.stat()
|
|
46
|
+
if not stat.S_ISREG(info.st_mode):
|
|
47
|
+
raise RemoteFileError(f"Not a regular file: {path}")
|
|
48
|
+
with path.open("rb") as handle:
|
|
49
|
+
return handle.read(limit + 1), info.st_size
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LocalExecutor(ExecutorBase):
|
|
53
|
+
"""Execute validated commands and file operations on the local host."""
|
|
54
|
+
|
|
55
|
+
async def connect(self) -> None:
|
|
56
|
+
"""No-op; kept for interface parity with the SSH executor."""
|
|
57
|
+
|
|
58
|
+
async def disconnect(self) -> None:
|
|
59
|
+
"""No-op; kept for interface parity with the SSH executor."""
|
|
60
|
+
|
|
61
|
+
async def execute_safe_command(self, command: str, timeout: int = 30) -> str:
|
|
62
|
+
"""Execute a validated command as an argv vector (no shell)."""
|
|
63
|
+
if not self.security.is_command_safe(command):
|
|
64
|
+
logger.error("Attempted to execute unsafe command: %s", command)
|
|
65
|
+
self.audit.log("command_blocked", command=command, executor="local")
|
|
66
|
+
return "❌ Security violation: Command blocked by security policy"
|
|
67
|
+
|
|
68
|
+
argv = shlex.split(command)
|
|
69
|
+
if not argv:
|
|
70
|
+
return "❌ Empty command"
|
|
71
|
+
|
|
72
|
+
if any(token in SHELL_OPERATORS for token in argv):
|
|
73
|
+
self.audit.log("command_blocked", command=command, reason="shell_operator")
|
|
74
|
+
return (
|
|
75
|
+
"❌ Shell operators are not supported by the local executor; "
|
|
76
|
+
"the command must be a plain argv vector."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
started = time.monotonic()
|
|
80
|
+
try:
|
|
81
|
+
process = await asyncio.create_subprocess_exec(
|
|
82
|
+
*argv,
|
|
83
|
+
stdout=asyncio.subprocess.PIPE,
|
|
84
|
+
stderr=asyncio.subprocess.PIPE,
|
|
85
|
+
)
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
self.audit.log("command_error", command=command, error="not_found")
|
|
88
|
+
return f"❌ Command not found: {argv[0]}"
|
|
89
|
+
except OSError as e:
|
|
90
|
+
self.audit.log("command_error", command=command, error=str(e))
|
|
91
|
+
return f"❌ Error executing command: {e}"
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
stdout, stderr = await asyncio.wait_for(
|
|
95
|
+
process.communicate(), timeout=timeout
|
|
96
|
+
)
|
|
97
|
+
except TimeoutError:
|
|
98
|
+
process.kill()
|
|
99
|
+
await process.wait()
|
|
100
|
+
self.audit.log("command_timeout", command=command, timeout=timeout)
|
|
101
|
+
return f"❌ Command timed out after {timeout}s"
|
|
102
|
+
|
|
103
|
+
output = stdout.decode("utf-8", errors="replace")
|
|
104
|
+
error_output = stderr.decode("utf-8", errors="replace")
|
|
105
|
+
if error_output:
|
|
106
|
+
output += ("\n--- STDERR ---\n" if output else "") + error_output
|
|
107
|
+
if process.returncode:
|
|
108
|
+
output += f"\n\n⚠️ Exit code: {process.returncode}"
|
|
109
|
+
|
|
110
|
+
sanitized = sanitize_output(
|
|
111
|
+
output, self.max_output_bytes, label="command output"
|
|
112
|
+
)
|
|
113
|
+
self.audit.log(
|
|
114
|
+
"command",
|
|
115
|
+
command=command,
|
|
116
|
+
executor="local",
|
|
117
|
+
exit_status=process.returncode,
|
|
118
|
+
duration_ms=round((time.monotonic() - started) * 1000, 1),
|
|
119
|
+
output_bytes=len(output.encode("utf-8")),
|
|
120
|
+
truncated=sanitized != output,
|
|
121
|
+
)
|
|
122
|
+
return sanitized
|
|
123
|
+
|
|
124
|
+
async def check_service_exists(self, service: str) -> bool:
|
|
125
|
+
"""Check the local service directory."""
|
|
126
|
+
return (Path(self.services_base_path) / service).is_dir()
|
|
127
|
+
|
|
128
|
+
async def read_remote_file(
|
|
129
|
+
self,
|
|
130
|
+
path: str,
|
|
131
|
+
*,
|
|
132
|
+
allowed_base: str | None = None,
|
|
133
|
+
max_bytes: int | None = None,
|
|
134
|
+
) -> str:
|
|
135
|
+
"""Read a local file with the same confinement rules as SFTP reads."""
|
|
136
|
+
limit = max_bytes or self.max_output_bytes
|
|
137
|
+
real_path = Path(path).resolve()
|
|
138
|
+
real_base = Path(allowed_base).resolve() if allowed_base else None
|
|
139
|
+
if real_base is not None and not is_within(str(real_path), str(real_base)):
|
|
140
|
+
self.audit.log("file_read_blocked", path=path, resolved=str(real_path))
|
|
141
|
+
raise RemotePathError(f"Path escapes the allowed directory: {real_path}")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
# File I/O must not block the event loop; a FIFO would otherwise
|
|
145
|
+
# hang the whole MCP server.
|
|
146
|
+
data, size = await asyncio.to_thread(_read_regular_file, real_path, limit)
|
|
147
|
+
except OSError as e:
|
|
148
|
+
raise RemoteFileError(f"Cannot read {real_path}: {e}") from e
|
|
149
|
+
|
|
150
|
+
text = data.decode("utf-8", errors="replace")
|
|
151
|
+
sanitized = sanitize_output(text, limit, label="file contents")
|
|
152
|
+
self.audit.log(
|
|
153
|
+
"file_read",
|
|
154
|
+
path=str(real_path),
|
|
155
|
+
size=size,
|
|
156
|
+
executor="local",
|
|
157
|
+
truncated=len(data) > limit or sanitized != text,
|
|
158
|
+
)
|
|
159
|
+
return sanitized
|
|
160
|
+
|
|
161
|
+
async def list_remote_dir(
|
|
162
|
+
self,
|
|
163
|
+
path: str,
|
|
164
|
+
*,
|
|
165
|
+
allowed_base: str | None = None,
|
|
166
|
+
) -> list[RemoteDirEntry]:
|
|
167
|
+
"""List a local directory with the same confinement rules."""
|
|
168
|
+
real_path = Path(path).resolve()
|
|
169
|
+
real_base = Path(allowed_base).resolve() if allowed_base else None
|
|
170
|
+
if real_base is not None and not is_within(str(real_path), str(real_base)):
|
|
171
|
+
self.audit.log("dir_list_blocked", path=path, resolved=str(real_path))
|
|
172
|
+
raise RemotePathError(f"Path escapes the allowed directory: {real_path}")
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
children = list(real_path.iterdir())
|
|
176
|
+
except OSError as e:
|
|
177
|
+
raise RemoteFileError(f"Cannot list {real_path}: {e}") from e
|
|
178
|
+
|
|
179
|
+
entries = []
|
|
180
|
+
for child in children:
|
|
181
|
+
try:
|
|
182
|
+
info = child.stat()
|
|
183
|
+
except OSError:
|
|
184
|
+
continue
|
|
185
|
+
modified = datetime.fromtimestamp(info.st_mtime).strftime("%b %e %H:%M")
|
|
186
|
+
longname = (
|
|
187
|
+
f"{stat.filemode(info.st_mode)} {info.st_size:>10} "
|
|
188
|
+
f"{modified} {child.name}"
|
|
189
|
+
)
|
|
190
|
+
entries.append(
|
|
191
|
+
RemoteDirEntry(name=child.name, longname=longname, size=info.st_size)
|
|
192
|
+
)
|
|
193
|
+
entries.sort(key=lambda entry: entry.name)
|
|
194
|
+
self.audit.log(
|
|
195
|
+
"dir_list",
|
|
196
|
+
path=str(real_path),
|
|
197
|
+
entries=len(entries),
|
|
198
|
+
executor="local",
|
|
199
|
+
)
|
|
200
|
+
return entries
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shell-safe command templates shared by the MCP tool catalogs.
|
|
3
|
+
|
|
4
|
+
Commands are argument vectors, never concatenated shell strings. ``render``
|
|
5
|
+
substitutes values literally and quotes the resulting vector with
|
|
6
|
+
:func:`shlex.join`, so a validated value cannot become shell syntax.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import shlex
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Operation:
|
|
15
|
+
"""A shell-safe command template.
|
|
16
|
+
|
|
17
|
+
``argv`` items may contain ``$name`` placeholders. Values are substituted
|
|
18
|
+
literally and the resulting vector is quoted by :func:`shlex.join`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
argv: tuple[str, ...]
|
|
22
|
+
read_only: bool = True
|
|
23
|
+
destructive: bool = False
|
|
24
|
+
|
|
25
|
+
def render(self, **params: str | int) -> str:
|
|
26
|
+
"""Render the template with ``params`` substituted literally."""
|
|
27
|
+
|
|
28
|
+
def substitute(part: str) -> str:
|
|
29
|
+
for key, value in params.items():
|
|
30
|
+
part = part.replace(f"${key}", str(value))
|
|
31
|
+
return part
|
|
32
|
+
|
|
33
|
+
return shlex.join(substitute(part) for part in self.argv)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Output sanitization and size limits.
|
|
3
|
+
|
|
4
|
+
MCP tool output is fed directly to a model. Remote command output is untrusted:
|
|
5
|
+
it can contain terminal escape sequences, control characters, or megabytes of
|
|
6
|
+
data. Everything returned by the SSH layer and the HTTP clients goes through
|
|
7
|
+
:func:`sanitize_output`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
DEFAULT_MAX_OUTPUT_BYTES = 64_000
|
|
13
|
+
|
|
14
|
+
# CSI/OSC-style terminal escapes (colors, cursor movement, hyperlinks, ...)
|
|
15
|
+
ANSI_ESCAPE_PATTERN = re.compile(
|
|
16
|
+
r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Control characters except tab, newline and carriage return.
|
|
20
|
+
CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
21
|
+
|
|
22
|
+
# A response that was already truncated at this limit must not be truncated
|
|
23
|
+
# again: composite tools assemble several capped blocks and then apply a final
|
|
24
|
+
# cap, which would otherwise append a second marker.
|
|
25
|
+
TRUNCATION_MARKER_PATTERN = re.compile(r"\n… \[[^\]]+ truncated to (\d+) bytes\]$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def strip_ansi(text: str) -> str:
|
|
29
|
+
"""Remove ANSI terminal escape sequences."""
|
|
30
|
+
return ANSI_ESCAPE_PATTERN.sub("", text)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sanitize_output(
|
|
34
|
+
text: str,
|
|
35
|
+
max_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
36
|
+
*,
|
|
37
|
+
label: str = "output",
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Strip escapes/control characters and cap the encoded size.
|
|
40
|
+
|
|
41
|
+
The cap is applied to the UTF-8 encoding so a single response cannot flood
|
|
42
|
+
the model context. A truncation marker is appended when data was dropped.
|
|
43
|
+
"""
|
|
44
|
+
if not text:
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
cleaned = CONTROL_CHAR_PATTERN.sub("", strip_ansi(text))
|
|
48
|
+
|
|
49
|
+
if max_bytes <= 0:
|
|
50
|
+
return cleaned
|
|
51
|
+
|
|
52
|
+
existing = TRUNCATION_MARKER_PATTERN.search(cleaned)
|
|
53
|
+
if existing is not None and int(existing.group(1)) == max_bytes:
|
|
54
|
+
return cleaned
|
|
55
|
+
|
|
56
|
+
encoded = cleaned.encode("utf-8")
|
|
57
|
+
if len(encoded) <= max_bytes:
|
|
58
|
+
return cleaned
|
|
59
|
+
|
|
60
|
+
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
|
|
61
|
+
return f"{truncated}\n… [{label} truncated to {max_bytes} bytes]"
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""A small in-memory rate limiter for state-changing tool calls.
|
|
2
|
+
|
|
3
|
+
The limiter is scoped to one MCP server process (one client session in
|
|
4
|
+
practice). It is a sliding window over call timestamps: at most ``max_calls``
|
|
5
|
+
calls are allowed in any ``window_seconds`` period. It is deliberately simple
|
|
6
|
+
and local; it is not a substitute for host-level protections.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RateLimitConfig:
|
|
21
|
+
"""Configuration for the destructive-action rate limiter."""
|
|
22
|
+
|
|
23
|
+
enabled: bool = True
|
|
24
|
+
max_calls: int = 10
|
|
25
|
+
window_seconds: float = 60.0
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_config(cls, config: dict) -> "RateLimitConfig":
|
|
29
|
+
section = config.get("rate_limit")
|
|
30
|
+
if not isinstance(section, dict):
|
|
31
|
+
return cls()
|
|
32
|
+
|
|
33
|
+
enabled = section.get("enabled", True)
|
|
34
|
+
if not isinstance(enabled, bool):
|
|
35
|
+
logger.warning("Ignoring non-boolean rate_limit.enabled")
|
|
36
|
+
enabled = True
|
|
37
|
+
|
|
38
|
+
max_calls = _positive_int(section.get("max_calls", 10), "rate_limit.max_calls")
|
|
39
|
+
window = _positive_float(
|
|
40
|
+
section.get("window_seconds", 60), "rate_limit.window_seconds"
|
|
41
|
+
)
|
|
42
|
+
return cls(enabled=enabled, max_calls=max_calls, window_seconds=window)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _positive_int(value: object, name: str) -> int:
|
|
46
|
+
try:
|
|
47
|
+
parsed = int(value) # type: ignore[arg-type]
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
logger.warning("Ignoring invalid %s: %r", name, value)
|
|
50
|
+
return 10
|
|
51
|
+
if parsed < 1:
|
|
52
|
+
logger.warning("Ignoring non-positive %s: %r", name, value)
|
|
53
|
+
return 10
|
|
54
|
+
return parsed
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _positive_float(value: object, name: str) -> float:
|
|
58
|
+
try:
|
|
59
|
+
parsed = float(value) # type: ignore[arg-type]
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
logger.warning("Ignoring invalid %s: %r", name, value)
|
|
62
|
+
return 60.0
|
|
63
|
+
if parsed <= 0:
|
|
64
|
+
logger.warning("Ignoring non-positive %s: %r", name, value)
|
|
65
|
+
return 60.0
|
|
66
|
+
return parsed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RateLimiter:
|
|
70
|
+
"""Sliding-window limiter with a monotonic clock (injectable for tests)."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
max_calls: int,
|
|
75
|
+
window_seconds: float,
|
|
76
|
+
*,
|
|
77
|
+
clock: Callable[[], float] = time.monotonic,
|
|
78
|
+
):
|
|
79
|
+
if max_calls < 1:
|
|
80
|
+
raise ValueError("max_calls must be at least 1")
|
|
81
|
+
if window_seconds <= 0:
|
|
82
|
+
raise ValueError("window_seconds must be positive")
|
|
83
|
+
self.max_calls = max_calls
|
|
84
|
+
self.window_seconds = float(window_seconds)
|
|
85
|
+
self._clock = clock
|
|
86
|
+
self._calls: deque[float] = deque()
|
|
87
|
+
self._lock = threading.Lock()
|
|
88
|
+
|
|
89
|
+
def _prune(self, now: float) -> None:
|
|
90
|
+
cutoff = now - self.window_seconds
|
|
91
|
+
while self._calls and self._calls[0] <= cutoff:
|
|
92
|
+
self._calls.popleft()
|
|
93
|
+
|
|
94
|
+
def check(self) -> float | None:
|
|
95
|
+
"""Record a call; return ``None`` when allowed or the retry delay."""
|
|
96
|
+
with self._lock:
|
|
97
|
+
now = self._clock()
|
|
98
|
+
self._prune(now)
|
|
99
|
+
if len(self._calls) >= self.max_calls:
|
|
100
|
+
return max(0.0, self.window_seconds - (now - self._calls[0]))
|
|
101
|
+
self._calls.append(now)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def reset(self) -> None:
|
|
105
|
+
"""Forget recorded calls (used by tests and session teardown)."""
|
|
106
|
+
with self._lock:
|
|
107
|
+
self._calls.clear()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def create_rate_limiter(config: dict) -> RateLimiter | None:
|
|
111
|
+
"""Build the destructive-action limiter, or ``None`` when disabled."""
|
|
112
|
+
settings = RateLimitConfig.from_config(config)
|
|
113
|
+
if not settings.enabled:
|
|
114
|
+
return None
|
|
115
|
+
return RateLimiter(
|
|
116
|
+
settings.max_calls,
|
|
117
|
+
settings.window_seconds,
|
|
118
|
+
)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redaction helpers for file contents returned to the model.
|
|
3
|
+
|
|
4
|
+
``.env`` files are useful for debugging a compose stack, but their values are
|
|
5
|
+
secrets. :func:`redact_env_content` keeps the keys, comments and structure so
|
|
6
|
+
the file stays readable while every assignment value is masked.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
REDACTED_VALUE = "***"
|
|
12
|
+
|
|
13
|
+
# ``KEY=value`` and ``export KEY=value`` with optional whitespace around ``=``.
|
|
14
|
+
_ASSIGNMENT_PATTERN = re.compile(
|
|
15
|
+
r"^(?P<prefix>\s*(?:export\s+)?)"
|
|
16
|
+
r"(?P<key>[A-Za-z_][A-Za-z0-9_.-]*)"
|
|
17
|
+
r"(?P<sep>\s*=\s*)(?P<value>.*)$"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def redact_env_content(text: str) -> str:
|
|
22
|
+
"""Mask the values of dotenv assignments, preserving the file structure.
|
|
23
|
+
|
|
24
|
+
Comments and blank lines are kept unchanged. Recognized assignments keep
|
|
25
|
+
their key and separator (``KEY=***``). Any other non-empty line is treated
|
|
26
|
+
as a value or a continuation of one (for example a multi-line quoted
|
|
27
|
+
secret) and is masked entirely, so no value text can leak. A trailing
|
|
28
|
+
newline in the input is preserved.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
text: Raw file contents.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The contents with every assignment value replaced by ``***``.
|
|
35
|
+
"""
|
|
36
|
+
if not text:
|
|
37
|
+
return text
|
|
38
|
+
|
|
39
|
+
lines: list[str] = []
|
|
40
|
+
for line in text.splitlines():
|
|
41
|
+
stripped = line.lstrip()
|
|
42
|
+
if not stripped or stripped.startswith("#"):
|
|
43
|
+
lines.append(line)
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
match = _ASSIGNMENT_PATTERN.match(line)
|
|
47
|
+
if match is None:
|
|
48
|
+
lines.append(REDACTED_VALUE)
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
lines.append(
|
|
52
|
+
f"{match.group('prefix')}{match.group('key')}"
|
|
53
|
+
f"{match.group('sep')}{REDACTED_VALUE}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
redacted = "\n".join(lines)
|
|
57
|
+
if text.endswith("\n"):
|
|
58
|
+
redacted += "\n"
|
|
59
|
+
return redacted
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ============================================================================
|
|
63
|
+
# General secret redaction (service config files and host status/log output)
|
|
64
|
+
# ============================================================================
|
|
65
|
+
|
|
66
|
+
# Key names whose values are secrets. Matching is on the key itself, so
|
|
67
|
+
# ``POSTGRES_PASSWORD``, ``GITHUB_TOKEN`` and ``api_key`` are covered without
|
|
68
|
+
# treating unrelated ``*.key`` file names as secrets (that policy lives in
|
|
69
|
+
# ``security.BLOCKED_PATH_*``).
|
|
70
|
+
_SENSITIVE_KEY_WORD = re.compile(
|
|
71
|
+
r"(?:password|passwd|pwd|secret|token|api[_-]?key|private[_-]?key|"
|
|
72
|
+
r"authorization|access[_-]?key|client[_-]?secret|credential)",
|
|
73
|
+
re.IGNORECASE,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# JSON object member: "key": "value" or "key": value
|
|
77
|
+
_JSON_MEMBER = re.compile(
|
|
78
|
+
r'(?P<prefix>"(?P<key>[^"\n]+)"\s*:\s*)'
|
|
79
|
+
r'(?P<value>"(?:[^"\\]|\\.)*"|[^,}\n]+)'
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# YAML/INI/CLI assignment at the start of a line, including ``- KEY=value``.
|
|
83
|
+
_LINE_ASSIGNMENT = re.compile(
|
|
84
|
+
r"^(?P<prefix>\s*(?:-\s*)?)"
|
|
85
|
+
r'(?P<key>"?[A-Za-z0-9_.-]+"?)'
|
|
86
|
+
r"(?P<sep>\s*[:=]\s*)"
|
|
87
|
+
r"(?P<value>.*)$"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# KEY=value anywhere on the line (environment assignments in command lines).
|
|
91
|
+
_INLINE_ASSIGNMENT = re.compile(
|
|
92
|
+
r"(?P<key>\b[A-Za-z_][A-Za-z0-9_.-]*)(?P<sep>=)"
|
|
93
|
+
r'(?P<value>"[^"]*"|\'[^\']*\'|[^\s,;}]+)'
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# --token value and --password=value command-line flags.
|
|
97
|
+
_CLI_FLAG = re.compile(
|
|
98
|
+
r"(?P<key>--?[A-Za-z0-9_.-]+)(?P<sep>[=\s]+)"
|
|
99
|
+
r'(?P<value>"[^"]*"|\'[^\']*\'|[^\s,;}]+)'
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
_BEARER_VALUE = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+")
|
|
103
|
+
_JWT_VALUE = re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
|
|
104
|
+
_URL_PASSWORD = re.compile(
|
|
105
|
+
r"(?P<scheme>\b[a-z][a-z0-9+.-]*://)(?P<user>[^/\s:@]+):"
|
|
106
|
+
r"(?P<password>[^/\s@]+)@",
|
|
107
|
+
re.IGNORECASE,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
112
|
+
"""Return True when an assignment key names a credential."""
|
|
113
|
+
cleaned = key.strip().strip("\"'").lstrip("-")
|
|
114
|
+
return bool(cleaned) and _SENSITIVE_KEY_WORD.search(cleaned) is not None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _redact_line(line: str) -> str:
|
|
118
|
+
if not line:
|
|
119
|
+
return line
|
|
120
|
+
|
|
121
|
+
stripped = line.lstrip()
|
|
122
|
+
if stripped.startswith(("#", ";", "//")):
|
|
123
|
+
return line
|
|
124
|
+
|
|
125
|
+
json_changed = False
|
|
126
|
+
|
|
127
|
+
def json_replacement(match: re.Match[str]) -> str:
|
|
128
|
+
nonlocal json_changed
|
|
129
|
+
if _is_sensitive_key(match.group("key")):
|
|
130
|
+
json_changed = True
|
|
131
|
+
return f'{match.group("prefix")}"***"'
|
|
132
|
+
return match.group(0)
|
|
133
|
+
|
|
134
|
+
line = _JSON_MEMBER.sub(json_replacement, line)
|
|
135
|
+
|
|
136
|
+
if not json_changed:
|
|
137
|
+
line_match = _LINE_ASSIGNMENT.match(line)
|
|
138
|
+
if line_match is not None and _is_sensitive_key(line_match.group("key")):
|
|
139
|
+
line = (
|
|
140
|
+
f"{line_match.group('prefix')}{line_match.group('key')}"
|
|
141
|
+
f"{line_match.group('sep')}{REDACTED_VALUE}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def assignment_replacement(match: re.Match[str]) -> str:
|
|
145
|
+
if _is_sensitive_key(match.group("key")):
|
|
146
|
+
return f"{match.group('key')}{match.group('sep')}{REDACTED_VALUE}"
|
|
147
|
+
return match.group(0)
|
|
148
|
+
|
|
149
|
+
line = _INLINE_ASSIGNMENT.sub(assignment_replacement, line)
|
|
150
|
+
|
|
151
|
+
def flag_replacement(match: re.Match[str]) -> str:
|
|
152
|
+
if _is_sensitive_key(match.group("key")):
|
|
153
|
+
return f"{match.group('key')}{match.group('sep')}{REDACTED_VALUE}"
|
|
154
|
+
return match.group(0)
|
|
155
|
+
|
|
156
|
+
line = _CLI_FLAG.sub(flag_replacement, line)
|
|
157
|
+
line = _BEARER_VALUE.sub(f"Bearer {REDACTED_VALUE}", line)
|
|
158
|
+
line = _JWT_VALUE.sub(REDACTED_VALUE, line)
|
|
159
|
+
line = _URL_PASSWORD.sub(
|
|
160
|
+
lambda match: f"{match.group('scheme')}{match.group('user')}:{REDACTED_VALUE}@",
|
|
161
|
+
line,
|
|
162
|
+
)
|
|
163
|
+
return line
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def redact_secrets(text: str) -> str:
|
|
167
|
+
"""Mask credentials in service config files and host status/log output.
|
|
168
|
+
|
|
169
|
+
The redactor handles YAML/INI/dotenv assignments, JSON members, command-line
|
|
170
|
+
flags, inline ``KEY=value`` pairs, ``Authorization: Bearer`` values, JWT
|
|
171
|
+
shapes and credentials embedded in URLs. Keys, comments and the surrounding
|
|
172
|
+
structure stay readable; only the value is replaced with ``***``.
|
|
173
|
+
|
|
174
|
+
This is deliberately applied in addition to, not instead of, the sensitive
|
|
175
|
+
file policy. Real key material and credential stores are still refused
|
|
176
|
+
before any content is read.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
text: File contents or command output.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The text with recognized secret values masked.
|
|
183
|
+
"""
|
|
184
|
+
if not text:
|
|
185
|
+
return text
|
|
186
|
+
|
|
187
|
+
lines = [_redact_line(line) for line in text.splitlines()]
|
|
188
|
+
redacted = "\n".join(lines)
|
|
189
|
+
if text.endswith("\n"):
|
|
190
|
+
redacted += "\n"
|
|
191
|
+
return redacted
|