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,1552 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security Validator for Server Management Library
|
|
3
|
+
|
|
4
|
+
Enforces strict security policies:
|
|
5
|
+
- No generic command execution
|
|
6
|
+
- Device name validation (disk-health-mcp)
|
|
7
|
+
- Service name validation (remote-server-mcp)
|
|
8
|
+
- Path traversal prevention (including Unicode/encoding bypasses)
|
|
9
|
+
- Command injection prevention
|
|
10
|
+
- Database query validation (read-only enforcement)
|
|
11
|
+
- Log search pattern and time-filter validation
|
|
12
|
+
|
|
13
|
+
Design: Whitelist specific safe operations, don't try to blacklist dangerous ones.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import shlex
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from pathlib import PurePosixPath
|
|
23
|
+
|
|
24
|
+
import sqlparse
|
|
25
|
+
from sqlparse import tokens as sql_tokens
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# ============================================================================
|
|
30
|
+
# Device name validation (disk-health-mcp)
|
|
31
|
+
# ============================================================================
|
|
32
|
+
|
|
33
|
+
# Valid block device name pattern: sdX, nvmeXnY, vdX, hdX, mmcblkXpY, dm-X
|
|
34
|
+
DEVICE_NAME_PATTERN = re.compile(
|
|
35
|
+
r"^(sd[a-z]{1,2}|nvme\d+n\d+|vd[a-z]|hd[a-z]|mmcblk\d+(p\d+)?|dm-\d+)$"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# ============================================================================
|
|
39
|
+
# Service name validation (remote-server-mcp)
|
|
40
|
+
# ============================================================================
|
|
41
|
+
|
|
42
|
+
# Strict service name pattern: must start with alphanumeric, then alphanumeric,
|
|
43
|
+
# hyphens, underscores. Prevents Docker option injection (names like "-help").
|
|
44
|
+
SERVICE_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
45
|
+
|
|
46
|
+
# ============================================================================
|
|
47
|
+
# Host service unit validation (remote-server-mcp)
|
|
48
|
+
# ============================================================================
|
|
49
|
+
|
|
50
|
+
# Systemd unit names may contain dots, @, colons, hyphens and underscores. The
|
|
51
|
+
# allowlist below is the real control; this pattern only rejects names that
|
|
52
|
+
# cannot be a plain systemd unit (paths, spaces, shell syntax).
|
|
53
|
+
HOST_UNIT_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.@:-]{0,99}$")
|
|
54
|
+
|
|
55
|
+
# Default units that may be inspected with systemctl/journalctl. Host
|
|
56
|
+
# infrastructure services are not Docker containers, so they need their own
|
|
57
|
+
# explicit allowlist. Override with security.host_units in config.yaml.
|
|
58
|
+
DEFAULT_HOST_UNITS: tuple[str, ...] = (
|
|
59
|
+
"telegraf",
|
|
60
|
+
"smartd",
|
|
61
|
+
"docker",
|
|
62
|
+
"snapraid",
|
|
63
|
+
"mergerfs",
|
|
64
|
+
"smbd",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Absolute filesystem paths reported by get_server_health when
|
|
68
|
+
# health.disk_paths is unset or contains no valid entry. NAS pools are separate
|
|
69
|
+
# mount points, so they must be configured explicitly instead of assuming that
|
|
70
|
+
# services_path is representative.
|
|
71
|
+
DEFAULT_HEALTH_DISK_PATHS: tuple[str, ...] = ("/",)
|
|
72
|
+
|
|
73
|
+
# ============================================================================
|
|
74
|
+
# Log search and time-filter validation (remote-server-mcp)
|
|
75
|
+
# ============================================================================
|
|
76
|
+
|
|
77
|
+
# Log search matches lines locally in Python, never in a shell. Shell syntax in
|
|
78
|
+
# the pattern is therefore harmless and must be preserved verbatim; only the
|
|
79
|
+
# length and control characters are bounded.
|
|
80
|
+
MAX_SEARCH_PATTERN_LENGTH = 200
|
|
81
|
+
SEARCH_CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
|
|
82
|
+
|
|
83
|
+
# ``docker logs --since/--until`` accept an RFC3339 timestamp (with an optional
|
|
84
|
+
# fractional part and offset), a date, or a Go-style relative duration such as
|
|
85
|
+
# ``42m`` or ``1h30m``. Keeping this strict prevents a value from being parsed
|
|
86
|
+
# as a Docker option.
|
|
87
|
+
LOG_TIME_PATTERN = re.compile(
|
|
88
|
+
r"^(?:"
|
|
89
|
+
r"\d{4}-\d{2}-\d{2}"
|
|
90
|
+
r"(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}))?"
|
|
91
|
+
r"|(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h)){1,4}"
|
|
92
|
+
r")$"
|
|
93
|
+
)
|
|
94
|
+
LOG_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}")
|
|
95
|
+
MAX_LOG_TIME_LENGTH = 64
|
|
96
|
+
|
|
97
|
+
# ============================================================================
|
|
98
|
+
# Path traversal patterns (remote-server-mcp)
|
|
99
|
+
# ============================================================================
|
|
100
|
+
|
|
101
|
+
PATH_TRAVERSAL_PATTERNS = [
|
|
102
|
+
"..", # Parent directory reference
|
|
103
|
+
"~", # Home directory
|
|
104
|
+
"$", # Variable expansion
|
|
105
|
+
"`", # Command substitution
|
|
106
|
+
";", # Command separator
|
|
107
|
+
"|", # Pipe
|
|
108
|
+
"&", # Background/AND
|
|
109
|
+
">", # Redirect
|
|
110
|
+
"<", # Redirect input
|
|
111
|
+
"(", # Subshell
|
|
112
|
+
")", # Subshell
|
|
113
|
+
"{", # Brace expansion
|
|
114
|
+
"}", # Brace expansion
|
|
115
|
+
"[", # Glob
|
|
116
|
+
"]", # Glob
|
|
117
|
+
"*", # Glob
|
|
118
|
+
"?", # Glob
|
|
119
|
+
"'", # Quote escape
|
|
120
|
+
'"', # Quote escape
|
|
121
|
+
"\\", # Escape character
|
|
122
|
+
"\n", # Newline injection
|
|
123
|
+
"\r", # Carriage return
|
|
124
|
+
"\t", # Tab injection
|
|
125
|
+
" ", # Space (shell word splitting)
|
|
126
|
+
"!", # Bash history expansion
|
|
127
|
+
"#", # Comment character
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
URL_ENCODED_TRAVERSAL = [
|
|
131
|
+
"%2e", # URL-encoded dot
|
|
132
|
+
"%2f", # URL-encoded slash
|
|
133
|
+
"%5c", # URL-encoded backslash
|
|
134
|
+
"%25", # URL-encoded percent (double encoding)
|
|
135
|
+
"%00", # Null byte
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
UNICODE_DOT_VARIANTS = [
|
|
139
|
+
"\uff0e", # Fullwidth full stop
|
|
140
|
+
"\u3002", # Ideographic full stop
|
|
141
|
+
"\u2024", # One dot leader
|
|
142
|
+
"\u2025", # Two dot leader
|
|
143
|
+
"\u2027", # Hyphenation point
|
|
144
|
+
"\u00b7", # Middle dot
|
|
145
|
+
"\u200b", # Zero-width space (used between dots)
|
|
146
|
+
"\u200c", # Zero-width non-joiner
|
|
147
|
+
"\u200d", # Zero-width joiner
|
|
148
|
+
"\u2060", # Word joiner
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
# ============================================================================
|
|
152
|
+
# Database query validation
|
|
153
|
+
# ============================================================================
|
|
154
|
+
|
|
155
|
+
# Keywords that turn a SELECT into a write or administrative statement.
|
|
156
|
+
# Validation is semantic: parse the SQL instead of blacklisting shell characters
|
|
157
|
+
# (the query is sent over HTTP, never through a shell).
|
|
158
|
+
WRITE_KEYWORDS = frozenset(
|
|
159
|
+
{
|
|
160
|
+
"ALTER",
|
|
161
|
+
"CREATE",
|
|
162
|
+
"DELETE",
|
|
163
|
+
"DROP",
|
|
164
|
+
"GRANT",
|
|
165
|
+
"INSERT",
|
|
166
|
+
"INTO",
|
|
167
|
+
"KILL",
|
|
168
|
+
"MERGE",
|
|
169
|
+
"RENAME",
|
|
170
|
+
"REPLACE",
|
|
171
|
+
"REVOKE",
|
|
172
|
+
"SET",
|
|
173
|
+
"TRUNCATE",
|
|
174
|
+
"UPDATE",
|
|
175
|
+
"UPSERT",
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# ============================================================================
|
|
180
|
+
# Sensitive file policy (remote-server-mcp)
|
|
181
|
+
# ============================================================================
|
|
182
|
+
#
|
|
183
|
+
# Matching is per path component (or its stem) and per extension, never as a
|
|
184
|
+
# substring of the whole path. That removes false positives such as
|
|
185
|
+
# ``monkey.txt`` matching ``.key`` or ``tokenizer.json`` matching ``token``.
|
|
186
|
+
|
|
187
|
+
# Components that are always refused: private keys, credential stores and
|
|
188
|
+
# version-control metadata that commonly embeds tokens in remote URLs.
|
|
189
|
+
BLOCKED_PATH_COMPONENTS: frozenset[str] = frozenset(
|
|
190
|
+
{
|
|
191
|
+
".git",
|
|
192
|
+
".ssh",
|
|
193
|
+
".gnupg",
|
|
194
|
+
".aws",
|
|
195
|
+
".kube",
|
|
196
|
+
".docker",
|
|
197
|
+
".netrc",
|
|
198
|
+
".pgpass",
|
|
199
|
+
".npmrc",
|
|
200
|
+
".pypirc",
|
|
201
|
+
".htpasswd",
|
|
202
|
+
"credentials",
|
|
203
|
+
"credential",
|
|
204
|
+
"secrets",
|
|
205
|
+
"secret",
|
|
206
|
+
"password",
|
|
207
|
+
"passwords",
|
|
208
|
+
"passwd",
|
|
209
|
+
"token",
|
|
210
|
+
"tokens",
|
|
211
|
+
"api_key",
|
|
212
|
+
"apikey",
|
|
213
|
+
"private_key",
|
|
214
|
+
"id_rsa",
|
|
215
|
+
"id_dsa",
|
|
216
|
+
"id_ecdsa",
|
|
217
|
+
"id_ed25519",
|
|
218
|
+
"shadow",
|
|
219
|
+
"gshadow",
|
|
220
|
+
"sudoers",
|
|
221
|
+
"htpasswd",
|
|
222
|
+
"wp-config",
|
|
223
|
+
"wp-config.php",
|
|
224
|
+
"database.yml",
|
|
225
|
+
"database.yaml",
|
|
226
|
+
"secrets.yml",
|
|
227
|
+
"secrets.yaml",
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Extensions that identify key material or an encrypted secret store.
|
|
232
|
+
BLOCKED_PATH_SUFFIXES: frozenset[str] = frozenset(
|
|
233
|
+
{".pem", ".key", ".p12", ".pfx", ".jks", ".keystore", ".kdbx"}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# ``.env`` files are readable because they are the primary way to debug a
|
|
237
|
+
# compose stack whose compose.yml only contains ``${VARS}``. Their values are
|
|
238
|
+
# masked before the contents are returned to the model.
|
|
239
|
+
REDACTED_PATH_NAMES: frozenset[str] = frozenset({".env", ".envrc"})
|
|
240
|
+
REDACTED_PATH_SUFFIXES: frozenset[str] = frozenset({".env"})
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class FileAccess(Enum):
|
|
244
|
+
"""Access decision for a file inside a service directory."""
|
|
245
|
+
|
|
246
|
+
ALLOW = "allow"
|
|
247
|
+
REDACT = "redact"
|
|
248
|
+
BLOCKED_INVALID = "blocked_invalid"
|
|
249
|
+
BLOCKED_SENSITIVE = "blocked_sensitive"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class ServiceFilePath:
|
|
254
|
+
"""Result of checking a requested service file path.
|
|
255
|
+
|
|
256
|
+
``path`` is the resolved absolute path when the file may be read, else
|
|
257
|
+
``None``. ``access`` tells callers whether the contents need redaction and
|
|
258
|
+
why a path was refused; ``reason`` is a short human-readable explanation.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
path: str | None
|
|
262
|
+
access: FileAccess = FileAccess.ALLOW
|
|
263
|
+
reason: str | None = None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _relative_file_access(file_path: str) -> tuple[FileAccess, str | None]:
|
|
267
|
+
"""Classify a relative path against the sensitive-file policy."""
|
|
268
|
+
access = FileAccess.ALLOW
|
|
269
|
+
for component in PurePosixPath(file_path).parts:
|
|
270
|
+
name = component.lower()
|
|
271
|
+
pure = PurePosixPath(name)
|
|
272
|
+
stem = pure.stem
|
|
273
|
+
|
|
274
|
+
if name in BLOCKED_PATH_COMPONENTS or stem in BLOCKED_PATH_COMPONENTS:
|
|
275
|
+
return (
|
|
276
|
+
FileAccess.BLOCKED_SENSITIVE,
|
|
277
|
+
f"path component {component!r} is sensitive",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if pure.suffix in BLOCKED_PATH_SUFFIXES:
|
|
281
|
+
return (
|
|
282
|
+
FileAccess.BLOCKED_SENSITIVE,
|
|
283
|
+
f"file extension {pure.suffix!r} may contain key material",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if (
|
|
287
|
+
name in REDACTED_PATH_NAMES
|
|
288
|
+
or name.startswith(".env.")
|
|
289
|
+
or pure.suffix in REDACTED_PATH_SUFFIXES
|
|
290
|
+
):
|
|
291
|
+
access = FileAccess.REDACT
|
|
292
|
+
|
|
293
|
+
return access, None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ============================================================================
|
|
297
|
+
# Command safety - argv allowlist
|
|
298
|
+
# ============================================================================
|
|
299
|
+
#
|
|
300
|
+
# Every command must be an argv vector produced by the operation catalog and
|
|
301
|
+
# match one of these prefixes. The string is parsed with shlex and must render
|
|
302
|
+
# back to the exact same canonical form, so hand-built shell syntax (chaining,
|
|
303
|
+
# redirection, command substitution) is rejected before the allowlist lookup.
|
|
304
|
+
|
|
305
|
+
# Supported container runtimes and the default used when unset.
|
|
306
|
+
DEFAULT_CONTAINER_RUNTIME = "docker"
|
|
307
|
+
CONTAINER_RUNTIMES: tuple[str, ...] = ("docker", "podman", "nerdctl")
|
|
308
|
+
# ``compose`` subcommands and the only arguments the catalog may append.
|
|
309
|
+
COMPOSE_ARGUMENTS: dict[str, tuple[tuple[str, ...], ...]] = {
|
|
310
|
+
"ps": ((), ("--format", "{{.Name}} {{.Status}}")),
|
|
311
|
+
"pull": ((),),
|
|
312
|
+
"up": (("-d",), ("-d", "--remove-orphans")),
|
|
313
|
+
"down": ((), ("--remove-orphans",)),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
ALLOWED_ARGV_PREFIXES: tuple[tuple[str, ...], ...] = (
|
|
317
|
+
# remote-server-mcp catalog
|
|
318
|
+
("echo",),
|
|
319
|
+
("uptime",),
|
|
320
|
+
("free", "-h"),
|
|
321
|
+
("df", "-h"),
|
|
322
|
+
("ls", "-1"),
|
|
323
|
+
("test", "-d"),
|
|
324
|
+
("docker", "ps"),
|
|
325
|
+
("docker", "logs"),
|
|
326
|
+
("docker", "inspect"),
|
|
327
|
+
("docker", "stats"),
|
|
328
|
+
("docker", "port"),
|
|
329
|
+
("docker", "restart"),
|
|
330
|
+
("docker", "start"),
|
|
331
|
+
("docker", "stop"),
|
|
332
|
+
# host infrastructure services (remote-server-mcp)
|
|
333
|
+
("systemctl", "status"),
|
|
334
|
+
("journalctl", "-u"),
|
|
335
|
+
# disk-health-mcp catalog
|
|
336
|
+
("lsblk", "-d"),
|
|
337
|
+
("iostat", "-x"),
|
|
338
|
+
("iostat", "-d"),
|
|
339
|
+
("zpool", "status"),
|
|
340
|
+
("zpool", "list"),
|
|
341
|
+
("zpool", "iostat"),
|
|
342
|
+
("zfs", "list"),
|
|
343
|
+
("zfs", "get"),
|
|
344
|
+
("cat", "/proc/mdstat"),
|
|
345
|
+
("mdadm", "--detail"),
|
|
346
|
+
("mdadm", "--examine"),
|
|
347
|
+
("snapraid", "status"),
|
|
348
|
+
("findmnt", "--real"),
|
|
349
|
+
("findmnt", "-t"),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
for _smartctl in (
|
|
353
|
+
"smartctl",
|
|
354
|
+
"/usr/sbin/smartctl",
|
|
355
|
+
"/sbin/smartctl",
|
|
356
|
+
"/usr/bin/smartctl",
|
|
357
|
+
):
|
|
358
|
+
ALLOWED_ARGV_PREFIXES += (
|
|
359
|
+
(_smartctl, "--version"),
|
|
360
|
+
(_smartctl, "-n", "standby"),
|
|
361
|
+
(_smartctl, "-j", "-a"),
|
|
362
|
+
(_smartctl, "-j", "-i"),
|
|
363
|
+
(_smartctl, "-a"),
|
|
364
|
+
(_smartctl, "-i"),
|
|
365
|
+
(_smartctl, "-c"),
|
|
366
|
+
(_smartctl, "-l", "error"),
|
|
367
|
+
(_smartctl, "-l", "selftest"),
|
|
368
|
+
(_smartctl, "-t"),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
for _nvme in ("nvme", "/usr/sbin/nvme", "/sbin/nvme"):
|
|
372
|
+
for _subcommand in (
|
|
373
|
+
"smart-log",
|
|
374
|
+
"smart-log-add",
|
|
375
|
+
"id-ctrl",
|
|
376
|
+
"id-ns",
|
|
377
|
+
"error-log",
|
|
378
|
+
):
|
|
379
|
+
ALLOWED_ARGV_PREFIXES += ((_nvme, _subcommand),)
|
|
380
|
+
|
|
381
|
+
# Podman and nerdctl expose a Docker-compatible command surface. Only the
|
|
382
|
+
# container subcommands the catalog uses are accepted; -compose commands are
|
|
383
|
+
# handled separately by ``_is_safe_compose`` (they carry a directory argument).
|
|
384
|
+
for _runtime in ("podman", "nerdctl"):
|
|
385
|
+
for _subcommand in (
|
|
386
|
+
"ps",
|
|
387
|
+
"logs",
|
|
388
|
+
"inspect",
|
|
389
|
+
"stats",
|
|
390
|
+
"port",
|
|
391
|
+
"restart",
|
|
392
|
+
"start",
|
|
393
|
+
"stop",
|
|
394
|
+
):
|
|
395
|
+
ALLOWED_ARGV_PREFIXES += ((_runtime, _subcommand),)
|
|
396
|
+
|
|
397
|
+
del _smartctl, _nvme, _subcommand, _runtime
|
|
398
|
+
|
|
399
|
+
# ``sudo`` is treated as a dangerous pattern below, with one exception. SnapRAID
|
|
400
|
+
# keeps its content file and its ``<content>.lock`` file as ``root:root 0600``,
|
|
401
|
+
# so the unprivileged executor cannot read them, and an ACL cannot fix that:
|
|
402
|
+
# SnapRAID rewrites the content file through a 0600 temporary file and renames
|
|
403
|
+
# it into place, which masks inherited default-ACL entries to nothing.
|
|
404
|
+
# ``snapraid status`` is read-only, so the catalog runs exactly this argv with
|
|
405
|
+
# sudo. The host sudoers rule matches the same literal, and sudoers matches
|
|
406
|
+
# arguments exactly, so any extra argument asks for a password and fails under
|
|
407
|
+
# ``sudo -n``.
|
|
408
|
+
SNAPRAID_STATUS_SUDO_ARGV: tuple[str, ...] = (
|
|
409
|
+
"sudo",
|
|
410
|
+
"-n",
|
|
411
|
+
"/usr/local/bin/snapraid",
|
|
412
|
+
"status",
|
|
413
|
+
)
|
|
414
|
+
SNAPRAID_STATUS_SUDO_COMMAND: str = shlex.join(SNAPRAID_STATUS_SUDO_ARGV)
|
|
415
|
+
|
|
416
|
+
# Bare shell syntax in an argument can only come from a hand-built command.
|
|
417
|
+
SHELL_METACHARACTERS = frozenset(";&|<>`$")
|
|
418
|
+
|
|
419
|
+
# Container names may contain dots (for example ``compose-app.1``) but never
|
|
420
|
+
# start with an option dash.
|
|
421
|
+
CONTAINER_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$")
|
|
422
|
+
|
|
423
|
+
# Docker inspect/stats format fields that can expose the process environment,
|
|
424
|
+
# command line, mounts or host configuration. The catalog only needs .Name,
|
|
425
|
+
# .State, .RestartCount and the stats fields, so these are refused outright.
|
|
426
|
+
SENSITIVE_DOCKER_FORMAT_PARTS = (
|
|
427
|
+
".Config",
|
|
428
|
+
".Args",
|
|
429
|
+
".Mounts",
|
|
430
|
+
".HostConfig",
|
|
431
|
+
".NetworkSettings",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# SMART self-test types accepted by the catalog and by smartctl itself.
|
|
435
|
+
SMART_TEST_TYPES = frozenset({"short", "long", "conveyance"})
|
|
436
|
+
|
|
437
|
+
# ============================================================================
|
|
438
|
+
# Command safety - disk-health-mcp injection patterns
|
|
439
|
+
# ============================================================================
|
|
440
|
+
|
|
441
|
+
INJECTION_PATTERNS = [
|
|
442
|
+
";", # Command separator
|
|
443
|
+
"|", # Pipe
|
|
444
|
+
"&", # Background (but allow 2>&1 redirect)
|
|
445
|
+
"`", # Command substitution
|
|
446
|
+
"$", # Variable expansion
|
|
447
|
+
"(", # Subshell
|
|
448
|
+
")", # Subshell
|
|
449
|
+
"{", # Brace expansion
|
|
450
|
+
"}", # Brace expansion
|
|
451
|
+
"<", # Redirect input
|
|
452
|
+
"\n", # Newline injection
|
|
453
|
+
"\r", # Carriage return
|
|
454
|
+
"\\", # Escape character
|
|
455
|
+
"'", # Quote escape
|
|
456
|
+
'"', # Quote escape
|
|
457
|
+
"!", # History expansion
|
|
458
|
+
"~", # Home directory
|
|
459
|
+
"..", # Path traversal
|
|
460
|
+
]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class SecurityValidator:
|
|
464
|
+
"""Validates all inputs for security before execution."""
|
|
465
|
+
|
|
466
|
+
def __init__(self, config: dict):
|
|
467
|
+
"""
|
|
468
|
+
Initialize security validator.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
config: Configuration dictionary
|
|
472
|
+
"""
|
|
473
|
+
self.config = config
|
|
474
|
+
self.security_config = config.get("security", {})
|
|
475
|
+
self.services_base_path = self.security_config.get("services_path", "/srv")
|
|
476
|
+
self._container_runtime = self._read_container_runtime(config)
|
|
477
|
+
# Kept for configuration compatibility only. It deliberately has no
|
|
478
|
+
# effect: there is no path that enables arbitrary command execution.
|
|
479
|
+
self.allow_generic_command_execution = False
|
|
480
|
+
|
|
481
|
+
@staticmethod
|
|
482
|
+
def _read_container_runtime(config: dict) -> str:
|
|
483
|
+
raw = config.get("container_runtime", DEFAULT_CONTAINER_RUNTIME)
|
|
484
|
+
if isinstance(raw, str) and raw.strip().lower() in CONTAINER_RUNTIMES:
|
|
485
|
+
return raw.strip().lower()
|
|
486
|
+
logger.warning(
|
|
487
|
+
"Ignoring container_runtime %r: expected one of %s",
|
|
488
|
+
raw,
|
|
489
|
+
", ".join(CONTAINER_RUNTIMES),
|
|
490
|
+
)
|
|
491
|
+
return DEFAULT_CONTAINER_RUNTIME
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def container_runtime(self) -> str:
|
|
495
|
+
"""Container CLI used by the remote-server operation catalog."""
|
|
496
|
+
return self._container_runtime
|
|
497
|
+
|
|
498
|
+
@property
|
|
499
|
+
def allowed_host_units(self) -> frozenset[str]:
|
|
500
|
+
"""Systemd units that may be inspected with systemctl/journalctl.
|
|
501
|
+
|
|
502
|
+
``security.host_units`` defaults to :data:`DEFAULT_HOST_UNITS`. Entries
|
|
503
|
+
are validated so a malformed value cannot become a systemd argument.
|
|
504
|
+
Setting the key to ``null`` or an empty list disables host-service
|
|
505
|
+
tools entirely.
|
|
506
|
+
"""
|
|
507
|
+
raw = self.security_config.get("host_units", DEFAULT_HOST_UNITS)
|
|
508
|
+
if raw is None:
|
|
509
|
+
return frozenset()
|
|
510
|
+
if isinstance(raw, str):
|
|
511
|
+
raw = [raw]
|
|
512
|
+
if not isinstance(raw, (list, tuple, set)):
|
|
513
|
+
logger.warning("Ignoring security.host_units: expected a list of names")
|
|
514
|
+
return frozenset()
|
|
515
|
+
|
|
516
|
+
allowed = set()
|
|
517
|
+
for unit in raw:
|
|
518
|
+
if isinstance(unit, str) and HOST_UNIT_PATTERN.match(unit):
|
|
519
|
+
allowed.add(unit)
|
|
520
|
+
else:
|
|
521
|
+
logger.warning("Ignoring invalid security.host_units entry: %r", unit)
|
|
522
|
+
return frozenset(allowed)
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def health_disk_paths(self) -> tuple[str, ...]:
|
|
526
|
+
"""Absolute filesystem paths ``get_server_health`` reports.
|
|
527
|
+
|
|
528
|
+
``health.disk_paths`` accepts a list of absolute mount points (for
|
|
529
|
+
example ``["/", "/mnt/storage"]``). Entries are validated, normalised
|
|
530
|
+
and deduplicated while preserving their configured order. Invalid
|
|
531
|
+
entries are ignored; when nothing valid remains the default ``["/"]``
|
|
532
|
+
is used so the health report never silently loses its disk section.
|
|
533
|
+
"""
|
|
534
|
+
health_config = self.config.get("health")
|
|
535
|
+
if health_config is None:
|
|
536
|
+
health_config = {}
|
|
537
|
+
if not isinstance(health_config, dict):
|
|
538
|
+
logger.warning("Ignoring health: expected a mapping")
|
|
539
|
+
return DEFAULT_HEALTH_DISK_PATHS
|
|
540
|
+
|
|
541
|
+
raw = health_config.get("disk_paths", DEFAULT_HEALTH_DISK_PATHS)
|
|
542
|
+
if raw is None:
|
|
543
|
+
raw = DEFAULT_HEALTH_DISK_PATHS
|
|
544
|
+
if isinstance(raw, str):
|
|
545
|
+
raw = [raw]
|
|
546
|
+
if not isinstance(raw, (list, tuple)):
|
|
547
|
+
logger.warning("Ignoring health.disk_paths: expected a list of paths")
|
|
548
|
+
return DEFAULT_HEALTH_DISK_PATHS
|
|
549
|
+
|
|
550
|
+
paths: list[str] = []
|
|
551
|
+
for candidate in raw:
|
|
552
|
+
path = self.validate_disk_path(candidate)
|
|
553
|
+
if path is not None and path not in paths:
|
|
554
|
+
paths.append(path)
|
|
555
|
+
|
|
556
|
+
if not paths:
|
|
557
|
+
if raw:
|
|
558
|
+
logger.warning(
|
|
559
|
+
"health.disk_paths contained no valid absolute path; using %s",
|
|
560
|
+
DEFAULT_HEALTH_DISK_PATHS,
|
|
561
|
+
)
|
|
562
|
+
return DEFAULT_HEALTH_DISK_PATHS
|
|
563
|
+
return tuple(paths)
|
|
564
|
+
|
|
565
|
+
@property
|
|
566
|
+
def allowed_containers(self) -> frozenset[str]:
|
|
567
|
+
"""Container names explicitly allowed outside a service directory.
|
|
568
|
+
|
|
569
|
+
``security.allowed_containers`` exists for containers that are not
|
|
570
|
+
part of a compose project below ``services_path`` (for example a
|
|
571
|
+
standalone `watchtower` container). Entries are validated with the
|
|
572
|
+
same pattern as service names, so they cannot inject Docker options.
|
|
573
|
+
"""
|
|
574
|
+
raw = self.security_config.get("allowed_containers", [])
|
|
575
|
+
if raw is None:
|
|
576
|
+
return frozenset()
|
|
577
|
+
if isinstance(raw, str):
|
|
578
|
+
raw = [raw]
|
|
579
|
+
if not isinstance(raw, (list, tuple, set)):
|
|
580
|
+
logger.warning(
|
|
581
|
+
"Ignoring security.allowed_containers: expected a list of names"
|
|
582
|
+
)
|
|
583
|
+
return frozenset()
|
|
584
|
+
|
|
585
|
+
allowed = set()
|
|
586
|
+
for name in raw:
|
|
587
|
+
if isinstance(name, str) and self.validate_service_name(name):
|
|
588
|
+
allowed.add(name)
|
|
589
|
+
else:
|
|
590
|
+
logger.warning(
|
|
591
|
+
"Ignoring invalid security.allowed_containers entry: %r", name
|
|
592
|
+
)
|
|
593
|
+
return frozenset(allowed)
|
|
594
|
+
|
|
595
|
+
# ========================================================================
|
|
596
|
+
# Device name validation (disk-health-mcp)
|
|
597
|
+
# ========================================================================
|
|
598
|
+
|
|
599
|
+
def validate_device_name(self, device: str) -> bool:
|
|
600
|
+
"""
|
|
601
|
+
Validate block device name against safe pattern.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
device: Device name (e.g., 'sda', 'nvme0n1')
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
True if valid, False otherwise
|
|
608
|
+
"""
|
|
609
|
+
if not device or not isinstance(device, str):
|
|
610
|
+
logger.warning(f"Invalid device name: {device}")
|
|
611
|
+
return False
|
|
612
|
+
|
|
613
|
+
if len(device) > 32:
|
|
614
|
+
logger.warning(f"Device name too long: {len(device)} chars")
|
|
615
|
+
return False
|
|
616
|
+
|
|
617
|
+
if not DEVICE_NAME_PATTERN.match(device):
|
|
618
|
+
logger.warning(f"Device name contains invalid chars: {device}")
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
for pattern in INJECTION_PATTERNS:
|
|
622
|
+
if pattern in device:
|
|
623
|
+
logger.warning(f"Device name contains injection pattern: {pattern}")
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
return True
|
|
627
|
+
|
|
628
|
+
def validate_smart_test_type(self, test_type: str) -> bool:
|
|
629
|
+
"""
|
|
630
|
+
Validate SMART self-test type.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
test_type: Test type string
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
True if valid, False otherwise
|
|
637
|
+
"""
|
|
638
|
+
if not test_type or not isinstance(test_type, str):
|
|
639
|
+
logger.warning(f"Invalid SMART test type: {test_type}")
|
|
640
|
+
return False
|
|
641
|
+
valid_tests = {"short", "long", "conveyance"}
|
|
642
|
+
if test_type.lower() not in valid_tests:
|
|
643
|
+
logger.warning(f"Invalid SMART test type: {test_type}")
|
|
644
|
+
return False
|
|
645
|
+
return True
|
|
646
|
+
|
|
647
|
+
# ========================================================================
|
|
648
|
+
# Service name validation (remote-server-mcp)
|
|
649
|
+
# ========================================================================
|
|
650
|
+
|
|
651
|
+
def validate_service_name(self, service_name: str) -> bool:
|
|
652
|
+
"""
|
|
653
|
+
Validate service name against strict pattern.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
service_name: Service name to validate
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
True if valid, False otherwise
|
|
660
|
+
"""
|
|
661
|
+
if not service_name or not isinstance(service_name, str):
|
|
662
|
+
logger.warning(f"Invalid service name: {service_name}")
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
if len(service_name) > 100:
|
|
666
|
+
logger.warning(f"Service name too long: {len(service_name)} chars")
|
|
667
|
+
return False
|
|
668
|
+
|
|
669
|
+
if not SERVICE_NAME_PATTERN.match(service_name):
|
|
670
|
+
logger.warning(f"Service name contains invalid characters: {service_name}")
|
|
671
|
+
return False
|
|
672
|
+
|
|
673
|
+
for pattern in PATH_TRAVERSAL_PATTERNS:
|
|
674
|
+
if pattern in service_name:
|
|
675
|
+
logger.warning(
|
|
676
|
+
f"Service name contains path traversal pattern: {pattern}"
|
|
677
|
+
)
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
return True
|
|
681
|
+
|
|
682
|
+
def check_service_file_path(self, service: str, file_path: str) -> ServiceFilePath:
|
|
683
|
+
"""Validate a file path and classify it for the sensitive-file policy.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
service: Service name (already validated)
|
|
687
|
+
file_path: Relative file path within the service directory
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
A :class:`ServiceFilePath`. ``path`` is set only when the file may
|
|
691
|
+
be read; ``access`` distinguishes invalid paths from files refused
|
|
692
|
+
by the sensitive-file policy and marks ``.env`` files for redaction.
|
|
693
|
+
"""
|
|
694
|
+
if (
|
|
695
|
+
not isinstance(service, str)
|
|
696
|
+
or not service
|
|
697
|
+
or service in {".", ".."}
|
|
698
|
+
or PurePosixPath(service).parts != (service,)
|
|
699
|
+
):
|
|
700
|
+
logger.warning("Invalid service name for file path: %r", service)
|
|
701
|
+
return ServiceFilePath(
|
|
702
|
+
None, FileAccess.BLOCKED_INVALID, "invalid service name"
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
if not file_path or not isinstance(file_path, str):
|
|
706
|
+
logger.warning(f"Invalid file path: {file_path}")
|
|
707
|
+
return ServiceFilePath(
|
|
708
|
+
None, FileAccess.BLOCKED_INVALID, "file path is empty"
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
if len(file_path) > 500:
|
|
712
|
+
logger.warning(f"File path too long: {len(file_path)} chars")
|
|
713
|
+
return ServiceFilePath(
|
|
714
|
+
None, FileAccess.BLOCKED_INVALID, "file path is too long"
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
if "\x00" in file_path:
|
|
718
|
+
logger.warning("File path contains null byte")
|
|
719
|
+
return ServiceFilePath(
|
|
720
|
+
None, FileAccess.BLOCKED_INVALID, "file path contains a null byte"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
file_path_lower = file_path.lower()
|
|
724
|
+
for pattern in URL_ENCODED_TRAVERSAL:
|
|
725
|
+
if pattern.lower() in file_path_lower:
|
|
726
|
+
logger.warning(f"File path contains URL-encoded traversal: {pattern}")
|
|
727
|
+
return ServiceFilePath(
|
|
728
|
+
None,
|
|
729
|
+
FileAccess.BLOCKED_INVALID,
|
|
730
|
+
"file path contains URL-encoded traversal",
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
for char in UNICODE_DOT_VARIANTS:
|
|
734
|
+
if char in file_path:
|
|
735
|
+
logger.warning("File path contains unicode dot variant: %r", char)
|
|
736
|
+
return ServiceFilePath(
|
|
737
|
+
None,
|
|
738
|
+
FileAccess.BLOCKED_INVALID,
|
|
739
|
+
"file path contains a Unicode dot variant",
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
for pattern in PATH_TRAVERSAL_PATTERNS:
|
|
743
|
+
if pattern in file_path:
|
|
744
|
+
logger.warning(f"File path contains dangerous pattern: {pattern}")
|
|
745
|
+
return ServiceFilePath(
|
|
746
|
+
None,
|
|
747
|
+
FileAccess.BLOCKED_INVALID,
|
|
748
|
+
"file path contains a traversal or shell pattern",
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
if file_path.startswith("/"):
|
|
752
|
+
logger.warning(f"File path must be relative, not absolute: {file_path}")
|
|
753
|
+
return ServiceFilePath(
|
|
754
|
+
None, FileAccess.BLOCKED_INVALID, "file path must be relative"
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
access, reason = _relative_file_access(file_path)
|
|
758
|
+
if access is FileAccess.BLOCKED_SENSITIVE:
|
|
759
|
+
logger.warning("File path blocked by sensitive-file policy: %s", file_path)
|
|
760
|
+
return ServiceFilePath(None, access, reason)
|
|
761
|
+
|
|
762
|
+
# The path belongs to the target host, not to the MCP host. Building it
|
|
763
|
+
# with PurePosixPath keeps it lexical: a symlink below services_path on
|
|
764
|
+
# the MCP host can neither change the target path nor break validation.
|
|
765
|
+
# The executor performs the authoritative realpath check on the target.
|
|
766
|
+
service_base = str(PurePosixPath(self.services_base_path) / service)
|
|
767
|
+
full_path = str(PurePosixPath(service_base) / file_path)
|
|
768
|
+
if full_path != service_base and not full_path.startswith(service_base + "/"):
|
|
769
|
+
logger.warning(
|
|
770
|
+
"Lexical path escaped service directory: %s (base %s)",
|
|
771
|
+
full_path,
|
|
772
|
+
service_base,
|
|
773
|
+
)
|
|
774
|
+
return ServiceFilePath(
|
|
775
|
+
None,
|
|
776
|
+
FileAccess.BLOCKED_INVALID,
|
|
777
|
+
"file path resolves outside the service directory",
|
|
778
|
+
)
|
|
779
|
+
return ServiceFilePath(full_path, access, reason)
|
|
780
|
+
|
|
781
|
+
def validate_service_file_path(self, service: str, file_path: str) -> str | None:
|
|
782
|
+
"""
|
|
783
|
+
Validate and construct a safe file path within /srv/{service}/.
|
|
784
|
+
|
|
785
|
+
Args:
|
|
786
|
+
service: Service name (already validated)
|
|
787
|
+
file_path: Relative file path within service directory
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
Full safe path, or None if invalid
|
|
791
|
+
"""
|
|
792
|
+
return self.check_service_file_path(service, file_path).path
|
|
793
|
+
|
|
794
|
+
def validate_disk_path(self, path: object) -> str | None:
|
|
795
|
+
"""Validate and normalise an absolute filesystem path for ``df``.
|
|
796
|
+
|
|
797
|
+
Only paths are accepted (no shell syntax, globs or traversal) and the
|
|
798
|
+
path must be absolute. ``PurePosixPath`` makes the result lexical, so
|
|
799
|
+
the path is still resolved on the target host, not on the MCP host.
|
|
800
|
+
|
|
801
|
+
Returns:
|
|
802
|
+
The normalised absolute path, or ``None`` when invalid.
|
|
803
|
+
"""
|
|
804
|
+
if not isinstance(path, str) or not path:
|
|
805
|
+
logger.warning("Invalid health disk path: %r", path)
|
|
806
|
+
return None
|
|
807
|
+
|
|
808
|
+
if len(path) > 500:
|
|
809
|
+
logger.warning("Health disk path too long: %d chars", len(path))
|
|
810
|
+
return None
|
|
811
|
+
|
|
812
|
+
if not path.startswith("/"):
|
|
813
|
+
logger.warning("Health disk path must be absolute: %s", path)
|
|
814
|
+
return None
|
|
815
|
+
|
|
816
|
+
if "\x00" in path or any(ord(char) < 0x20 for char in path):
|
|
817
|
+
logger.warning("Health disk path contains a control character: %r", path)
|
|
818
|
+
return None
|
|
819
|
+
|
|
820
|
+
for pattern in PATH_TRAVERSAL_PATTERNS:
|
|
821
|
+
if pattern in path:
|
|
822
|
+
logger.warning(
|
|
823
|
+
"Health disk path contains dangerous pattern %r: %s",
|
|
824
|
+
pattern,
|
|
825
|
+
path,
|
|
826
|
+
)
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
normalised = str(PurePosixPath(path))
|
|
831
|
+
except Exception as e: # pragma: no cover - PurePosixPath is defensive
|
|
832
|
+
logger.warning("Could not normalise health disk path %r: %s", path, e)
|
|
833
|
+
return None
|
|
834
|
+
|
|
835
|
+
if not normalised.startswith("/"):
|
|
836
|
+
return None
|
|
837
|
+
return normalised
|
|
838
|
+
|
|
839
|
+
def validate_host_unit(self, unit: str) -> bool:
|
|
840
|
+
"""Validate a systemd unit name against the configured allowlist.
|
|
841
|
+
|
|
842
|
+
Args:
|
|
843
|
+
unit: Unit name such as ``telegraf`` or ``docker``.
|
|
844
|
+
|
|
845
|
+
Returns:
|
|
846
|
+
True only when the name is well formed and explicitly allowed.
|
|
847
|
+
"""
|
|
848
|
+
if not unit or not isinstance(unit, str):
|
|
849
|
+
logger.warning("Invalid host unit: %r", unit)
|
|
850
|
+
return False
|
|
851
|
+
|
|
852
|
+
if not HOST_UNIT_PATTERN.match(unit):
|
|
853
|
+
logger.warning("Host unit contains invalid characters: %s", unit)
|
|
854
|
+
return False
|
|
855
|
+
|
|
856
|
+
if unit not in self.allowed_host_units:
|
|
857
|
+
logger.warning("Host unit is not in security.host_units: %s", unit)
|
|
858
|
+
return False
|
|
859
|
+
|
|
860
|
+
return True
|
|
861
|
+
|
|
862
|
+
# ========================================================================
|
|
863
|
+
# Log search and time-filter validation (remote-server-mcp)
|
|
864
|
+
# ========================================================================
|
|
865
|
+
|
|
866
|
+
def validate_search_pattern(self, pattern: str) -> str:
|
|
867
|
+
"""Validate a log search pattern and return it unchanged.
|
|
868
|
+
|
|
869
|
+
``search_service_logs`` filters lines locally in Python, so the pattern
|
|
870
|
+
is data rather than shell syntax. Characters such as quotes, ``!``,
|
|
871
|
+
``#`` and ``~`` must keep their literal meaning; only length and control
|
|
872
|
+
characters are rejected.
|
|
873
|
+
|
|
874
|
+
Args:
|
|
875
|
+
pattern: Raw search pattern
|
|
876
|
+
|
|
877
|
+
Returns:
|
|
878
|
+
The exact pattern, unchanged
|
|
879
|
+
|
|
880
|
+
Raises:
|
|
881
|
+
ValueError: If the pattern is empty, too long or contains control
|
|
882
|
+
characters
|
|
883
|
+
"""
|
|
884
|
+
if not pattern or not isinstance(pattern, str):
|
|
885
|
+
raise ValueError("Search pattern must not be empty")
|
|
886
|
+
|
|
887
|
+
if len(pattern) > MAX_SEARCH_PATTERN_LENGTH:
|
|
888
|
+
raise ValueError(
|
|
889
|
+
f"Search pattern too long ({len(pattern)} chars, "
|
|
890
|
+
f"max {MAX_SEARCH_PATTERN_LENGTH})"
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
if SEARCH_CONTROL_CHARACTER_PATTERN.search(pattern):
|
|
894
|
+
raise ValueError("Search pattern must not contain control characters")
|
|
895
|
+
|
|
896
|
+
return pattern
|
|
897
|
+
|
|
898
|
+
def validate_log_time(self, value: str, field: str = "time") -> bool:
|
|
899
|
+
"""Validate a ``docker logs --since/--until`` value.
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
value: RFC3339 timestamp, ``YYYY-MM-DD`` date or relative duration
|
|
903
|
+
field: Name used in the warning (``since`` or ``until``)
|
|
904
|
+
|
|
905
|
+
Returns:
|
|
906
|
+
True if Docker can safely receive the value
|
|
907
|
+
"""
|
|
908
|
+
if not value or not isinstance(value, str):
|
|
909
|
+
logger.warning(f"Invalid log {field}: {value!r}")
|
|
910
|
+
return False
|
|
911
|
+
|
|
912
|
+
if len(value) > MAX_LOG_TIME_LENGTH:
|
|
913
|
+
logger.warning(f"Log {field} too long: {len(value)} chars")
|
|
914
|
+
return False
|
|
915
|
+
|
|
916
|
+
if not LOG_TIME_PATTERN.match(value):
|
|
917
|
+
logger.warning(f"Invalid log {field} format: {value!r}")
|
|
918
|
+
return False
|
|
919
|
+
|
|
920
|
+
if LOG_DATE_PATTERN.match(value):
|
|
921
|
+
# The pattern only checks the shape; reject impossible dates such
|
|
922
|
+
# as 2026-13-99 instead of passing them on to Docker.
|
|
923
|
+
try:
|
|
924
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
925
|
+
except ValueError:
|
|
926
|
+
logger.warning(f"Invalid log {field} date: {value!r}")
|
|
927
|
+
return False
|
|
928
|
+
|
|
929
|
+
return True
|
|
930
|
+
|
|
931
|
+
# ========================================================================
|
|
932
|
+
# Database query validation (remote-server-mcp)
|
|
933
|
+
# ========================================================================
|
|
934
|
+
|
|
935
|
+
def validate_influxdb_query(self, query: str) -> str | None:
|
|
936
|
+
"""
|
|
937
|
+
Validate an InfluxDB v3 SQL query for read-only safety.
|
|
938
|
+
|
|
939
|
+
Args:
|
|
940
|
+
query: SQL query string to validate
|
|
941
|
+
|
|
942
|
+
Returns:
|
|
943
|
+
The query if valid, None if invalid
|
|
944
|
+
"""
|
|
945
|
+
if not query or not isinstance(query, str):
|
|
946
|
+
logger.warning("Empty or invalid InfluxDB query")
|
|
947
|
+
return None
|
|
948
|
+
|
|
949
|
+
if len(query) > 5000:
|
|
950
|
+
logger.warning(f"InfluxDB query too long: {len(query)} chars")
|
|
951
|
+
return None
|
|
952
|
+
|
|
953
|
+
if "\x00" in query:
|
|
954
|
+
logger.warning("InfluxDB query contains a null byte")
|
|
955
|
+
return None
|
|
956
|
+
|
|
957
|
+
try:
|
|
958
|
+
statements = [
|
|
959
|
+
statement
|
|
960
|
+
for statement in sqlparse.parse(query)
|
|
961
|
+
if str(statement).strip()
|
|
962
|
+
]
|
|
963
|
+
except Exception as e: # pragma: no cover - sqlparse is defensive
|
|
964
|
+
logger.warning(f"Could not parse InfluxDB query: {e}")
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
if len(statements) != 1:
|
|
968
|
+
logger.warning("InfluxDB query must be a single statement")
|
|
969
|
+
return None
|
|
970
|
+
|
|
971
|
+
statement = statements[0]
|
|
972
|
+
if statement.get_type().upper() != "SELECT":
|
|
973
|
+
logger.warning(
|
|
974
|
+
"InfluxDB query must be a SELECT, got %s", statement.get_type()
|
|
975
|
+
)
|
|
976
|
+
return None
|
|
977
|
+
|
|
978
|
+
for token in statement.flatten():
|
|
979
|
+
if token.ttype in sql_tokens.Comment:
|
|
980
|
+
logger.warning("InfluxDB query contains an SQL comment")
|
|
981
|
+
return None
|
|
982
|
+
if (
|
|
983
|
+
token.ttype in sql_tokens.Keyword
|
|
984
|
+
and token.value.upper() in WRITE_KEYWORDS
|
|
985
|
+
):
|
|
986
|
+
logger.warning("InfluxDB query contains write keyword: %s", token.value)
|
|
987
|
+
return None
|
|
988
|
+
|
|
989
|
+
# A single trailing semicolon is harmless; drop it before sending.
|
|
990
|
+
return query.strip().rstrip(";").rstrip()
|
|
991
|
+
|
|
992
|
+
def validate_prometheus_query(self, query: str) -> str | None:
|
|
993
|
+
"""
|
|
994
|
+
Validate a PromQL query for safety.
|
|
995
|
+
|
|
996
|
+
Args:
|
|
997
|
+
query: PromQL expression to validate
|
|
998
|
+
|
|
999
|
+
Returns:
|
|
1000
|
+
The query if valid, None if invalid
|
|
1001
|
+
"""
|
|
1002
|
+
if not query or not isinstance(query, str):
|
|
1003
|
+
logger.warning("Empty or Prometheus query")
|
|
1004
|
+
return None
|
|
1005
|
+
|
|
1006
|
+
if len(query) > 5000:
|
|
1007
|
+
logger.warning(f"PromQL query too long: {len(query)} chars")
|
|
1008
|
+
return None
|
|
1009
|
+
|
|
1010
|
+
if "\x00" in query:
|
|
1011
|
+
logger.warning("PromQL query contains a null byte")
|
|
1012
|
+
return None
|
|
1013
|
+
|
|
1014
|
+
# PromQL is sent over HTTP, never through a shell. Quotes, braces and
|
|
1015
|
+
# comparison operators are valid and required for real queries. Only
|
|
1016
|
+
# reject control characters that cannot appear in a PromQL expression.
|
|
1017
|
+
for char in query:
|
|
1018
|
+
if ord(char) < 0x20 and char not in "\n\r\t":
|
|
1019
|
+
logger.warning("PromQL query contains control character: %r", char)
|
|
1020
|
+
return None
|
|
1021
|
+
|
|
1022
|
+
return query
|
|
1023
|
+
|
|
1024
|
+
# ========================================================================
|
|
1025
|
+
# Command safety (merged: disk-health-mcp strict + remote-server-mcp permissive)
|
|
1026
|
+
# ========================================================================
|
|
1027
|
+
|
|
1028
|
+
def is_command_safe(self, command: str) -> bool:
|
|
1029
|
+
"""
|
|
1030
|
+
Check whether a catalog command is safe to execute.
|
|
1031
|
+
|
|
1032
|
+
The command must be a canonical argv vector (as rendered by
|
|
1033
|
+
:func:`shlex.join`) whose executable and leading arguments match the
|
|
1034
|
+
explicit allowlist. Unknown executables and hand-built shell syntax are
|
|
1035
|
+
rejected. ``security.allow_generic_commands`` has no effect.
|
|
1036
|
+
|
|
1037
|
+
Args:
|
|
1038
|
+
command: Full command string
|
|
1039
|
+
|
|
1040
|
+
Returns:
|
|
1041
|
+
True if safe
|
|
1042
|
+
"""
|
|
1043
|
+
if not isinstance(command, str) or not command.strip():
|
|
1044
|
+
logger.warning("Invalid command: %r", command)
|
|
1045
|
+
return False
|
|
1046
|
+
|
|
1047
|
+
if len(command) > 4096:
|
|
1048
|
+
logger.warning("Command too long: %d chars", len(command))
|
|
1049
|
+
return False
|
|
1050
|
+
|
|
1051
|
+
# The only privileged command in the catalog. The comparison is a full
|
|
1052
|
+
# string equality against a constant built from the argv vector, so no
|
|
1053
|
+
# caller-supplied text can use this branch to reach sudo, and every
|
|
1054
|
+
# dangerous-pattern and argv-shape check below is bypassed only for
|
|
1055
|
+
# this fixed literal.
|
|
1056
|
+
if command == SNAPRAID_STATUS_SUDO_COMMAND:
|
|
1057
|
+
return True
|
|
1058
|
+
|
|
1059
|
+
command_lower = command.lower()
|
|
1060
|
+
|
|
1061
|
+
# Block truly dangerous operations first, before any whitelist check
|
|
1062
|
+
dangerous_patterns = [
|
|
1063
|
+
"sudo",
|
|
1064
|
+
"su ",
|
|
1065
|
+
"passwd",
|
|
1066
|
+
"useradd",
|
|
1067
|
+
"usermod",
|
|
1068
|
+
"userdel",
|
|
1069
|
+
"groupadd",
|
|
1070
|
+
"chmod",
|
|
1071
|
+
"chown",
|
|
1072
|
+
"mount ",
|
|
1073
|
+
"umount",
|
|
1074
|
+
"iptables",
|
|
1075
|
+
"kill -9",
|
|
1076
|
+
"rm -rf /",
|
|
1077
|
+
"mkfs",
|
|
1078
|
+
"fdisk",
|
|
1079
|
+
"/etc/shadow",
|
|
1080
|
+
"/etc/passwd",
|
|
1081
|
+
"wget ",
|
|
1082
|
+
"curl ",
|
|
1083
|
+
"bash ",
|
|
1084
|
+
"sh ",
|
|
1085
|
+
"zsh ",
|
|
1086
|
+
"python ",
|
|
1087
|
+
"python3 ",
|
|
1088
|
+
"perl ",
|
|
1089
|
+
"ruby ",
|
|
1090
|
+
]
|
|
1091
|
+
|
|
1092
|
+
for pattern in dangerous_patterns:
|
|
1093
|
+
if pattern in command_lower:
|
|
1094
|
+
logger.warning(f"Command contains dangerous pattern: {pattern}")
|
|
1095
|
+
return False
|
|
1096
|
+
|
|
1097
|
+
# Block dd separately (handles "dd " at start or " dd " in middle)
|
|
1098
|
+
if command_lower.startswith("dd ") or " dd " in command_lower:
|
|
1099
|
+
logger.warning("Command contains dangerous pattern: dd")
|
|
1100
|
+
return False
|
|
1101
|
+
|
|
1102
|
+
# Block network exfiltration tools
|
|
1103
|
+
net_tools = ["nc ", "ncat ", "netcat ", "socat ", "telnet "]
|
|
1104
|
+
for tool in net_tools:
|
|
1105
|
+
if tool in command_lower or command_lower.startswith(tool):
|
|
1106
|
+
logger.warning(f"Command contains dangerous pattern: {tool.strip()}")
|
|
1107
|
+
return False
|
|
1108
|
+
|
|
1109
|
+
# Parse the command and require canonical shlex.join output. A string
|
|
1110
|
+
# with bare shell syntax (``;``, ``&&``, a newline, ``>``, ...) does
|
|
1111
|
+
# not round-trip, so only catalog-rendered argv vectors get past this.
|
|
1112
|
+
try:
|
|
1113
|
+
argv = shlex.split(command)
|
|
1114
|
+
except ValueError:
|
|
1115
|
+
logger.warning("Command could not be parsed as an argv vector")
|
|
1116
|
+
return False
|
|
1117
|
+
|
|
1118
|
+
if not argv:
|
|
1119
|
+
return False
|
|
1120
|
+
|
|
1121
|
+
if shlex.join(argv) != command:
|
|
1122
|
+
logger.warning("Command is not a canonical argv vector: %s", command)
|
|
1123
|
+
return False
|
|
1124
|
+
|
|
1125
|
+
for token in argv:
|
|
1126
|
+
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in token):
|
|
1127
|
+
logger.warning(
|
|
1128
|
+
"Command argument contains a control character: %r", token
|
|
1129
|
+
)
|
|
1130
|
+
return False
|
|
1131
|
+
if any(char in SHELL_METACHARACTERS for char in token):
|
|
1132
|
+
logger.warning(
|
|
1133
|
+
"Command argument contains a shell metacharacter: %r", token
|
|
1134
|
+
)
|
|
1135
|
+
return False
|
|
1136
|
+
|
|
1137
|
+
if self._is_safe_compose(argv):
|
|
1138
|
+
return True
|
|
1139
|
+
|
|
1140
|
+
# A prefix match alone is not enough: ``docker inspect`` and
|
|
1141
|
+
# ``smartctl`` both accept dangerous arguments after a safe prefix.
|
|
1142
|
+
shape = self._argument_shape_safe(argv)
|
|
1143
|
+
if shape is not None:
|
|
1144
|
+
if not shape:
|
|
1145
|
+
logger.warning(
|
|
1146
|
+
"Command arguments do not match an allowed shape: %s", command
|
|
1147
|
+
)
|
|
1148
|
+
return shape
|
|
1149
|
+
|
|
1150
|
+
for prefix in ALLOWED_ARGV_PREFIXES:
|
|
1151
|
+
if tuple(argv[: len(prefix)]) == prefix:
|
|
1152
|
+
return True
|
|
1153
|
+
|
|
1154
|
+
logger.warning("Command is not in the allowed operation catalog: %s", command)
|
|
1155
|
+
return False
|
|
1156
|
+
|
|
1157
|
+
@staticmethod
|
|
1158
|
+
def _positive_integer(value: str, maximum: int) -> bool:
|
|
1159
|
+
return value.isdigit() and 1 <= int(value) <= maximum
|
|
1160
|
+
|
|
1161
|
+
@staticmethod
|
|
1162
|
+
def _safe_docker_format(fmt: str) -> bool:
|
|
1163
|
+
"""Reject inspect/stats format strings that expose secrets or config."""
|
|
1164
|
+
return not any(part in fmt for part in SENSITIVE_DOCKER_FORMAT_PARTS)
|
|
1165
|
+
|
|
1166
|
+
@staticmethod
|
|
1167
|
+
def _is_container_name(value: str) -> bool:
|
|
1168
|
+
return bool(CONTAINER_NAME_PATTERN.match(value))
|
|
1169
|
+
|
|
1170
|
+
@staticmethod
|
|
1171
|
+
def _is_device_path(value: str) -> bool:
|
|
1172
|
+
if not value.startswith("/dev/") or ".." in PurePosixPath(value).parts:
|
|
1173
|
+
return False
|
|
1174
|
+
return re.fullmatch(r"/dev/[A-Za-z0-9._-]+", value) is not None
|
|
1175
|
+
|
|
1176
|
+
def _path_below_services(self, path: str) -> bool:
|
|
1177
|
+
"""Return True when ``path`` is lexically below services_path."""
|
|
1178
|
+
if not path.startswith("/") or "\x00" in path:
|
|
1179
|
+
return False
|
|
1180
|
+
normalised = str(PurePosixPath(path))
|
|
1181
|
+
base = str(PurePosixPath(self.services_base_path))
|
|
1182
|
+
return normalised == base or normalised.startswith(base.rstrip("/") + "/")
|
|
1183
|
+
|
|
1184
|
+
def _safe_container_argv(self, argv: list[str]) -> bool:
|
|
1185
|
+
"""Validate every argument of a container runtime command."""
|
|
1186
|
+
if len(argv) < 2:
|
|
1187
|
+
return False
|
|
1188
|
+
subcommand = argv[1]
|
|
1189
|
+
args = argv[2:]
|
|
1190
|
+
|
|
1191
|
+
if subcommand == "ps":
|
|
1192
|
+
return self._safe_ps_argv(args)
|
|
1193
|
+
if subcommand == "logs":
|
|
1194
|
+
return self._safe_logs_argv(args)
|
|
1195
|
+
if subcommand == "inspect":
|
|
1196
|
+
return self._safe_inspect_argv(args)
|
|
1197
|
+
if subcommand == "stats":
|
|
1198
|
+
return self._safe_stats_argv(args)
|
|
1199
|
+
if subcommand in {"port", "restart", "start", "stop"}:
|
|
1200
|
+
if args and args[0] == "--":
|
|
1201
|
+
args = args[1:]
|
|
1202
|
+
return len(args) == 1 and self._is_container_name(args[0])
|
|
1203
|
+
return False
|
|
1204
|
+
|
|
1205
|
+
def _safe_ps_argv(self, args: list[str]) -> bool:
|
|
1206
|
+
index = 0
|
|
1207
|
+
seen_all = False
|
|
1208
|
+
fmt: str | None = None
|
|
1209
|
+
while index < len(args):
|
|
1210
|
+
token = args[index]
|
|
1211
|
+
if token == "-a" and not seen_all:
|
|
1212
|
+
seen_all = True
|
|
1213
|
+
index += 1
|
|
1214
|
+
elif token == "--format" and fmt is None and index + 1 < len(args):
|
|
1215
|
+
fmt = args[index + 1]
|
|
1216
|
+
index += 2
|
|
1217
|
+
elif token == "--filter" and index + 1 < len(args):
|
|
1218
|
+
if args[index + 1] != "health=unhealthy":
|
|
1219
|
+
return False
|
|
1220
|
+
index += 2
|
|
1221
|
+
else:
|
|
1222
|
+
return False
|
|
1223
|
+
return fmt is None or self._safe_docker_format(fmt)
|
|
1224
|
+
|
|
1225
|
+
def _safe_logs_argv(self, args: list[str]) -> bool:
|
|
1226
|
+
index = 0
|
|
1227
|
+
tail: str | None = None
|
|
1228
|
+
seen_timestamps = False
|
|
1229
|
+
seen_since = False
|
|
1230
|
+
seen_until = False
|
|
1231
|
+
service: str | None = None
|
|
1232
|
+
while index < len(args):
|
|
1233
|
+
token = args[index]
|
|
1234
|
+
if token == "--tail" and tail is None and index + 1 < len(args):
|
|
1235
|
+
tail = args[index + 1]
|
|
1236
|
+
if not self._positive_integer(tail, 100000):
|
|
1237
|
+
return False
|
|
1238
|
+
index += 2
|
|
1239
|
+
elif token == "--timestamps" and not seen_timestamps:
|
|
1240
|
+
seen_timestamps = True
|
|
1241
|
+
index += 1
|
|
1242
|
+
elif token == "--since" and not seen_since and index + 1 < len(args):
|
|
1243
|
+
if not self.validate_log_time(args[index + 1], "since"):
|
|
1244
|
+
return False
|
|
1245
|
+
seen_since = True
|
|
1246
|
+
index += 2
|
|
1247
|
+
elif token == "--until" and not seen_until and index + 1 < len(args):
|
|
1248
|
+
if not self.validate_log_time(args[index + 1], "until"):
|
|
1249
|
+
return False
|
|
1250
|
+
seen_until = True
|
|
1251
|
+
index += 2
|
|
1252
|
+
elif token == "--":
|
|
1253
|
+
rest = args[index + 1 :]
|
|
1254
|
+
return (
|
|
1255
|
+
service is None
|
|
1256
|
+
and len(rest) == 1
|
|
1257
|
+
and self._is_container_name(rest[0])
|
|
1258
|
+
and tail is not None
|
|
1259
|
+
)
|
|
1260
|
+
elif token.startswith("-"):
|
|
1261
|
+
return False
|
|
1262
|
+
elif service is None and self._is_container_name(token):
|
|
1263
|
+
service = token
|
|
1264
|
+
index += 1
|
|
1265
|
+
else:
|
|
1266
|
+
return False
|
|
1267
|
+
return tail is not None and service is not None
|
|
1268
|
+
|
|
1269
|
+
def _safe_inspect_argv(self, args: list[str]) -> bool:
|
|
1270
|
+
index = 0
|
|
1271
|
+
fmt: str | None = None
|
|
1272
|
+
service: str | None = None
|
|
1273
|
+
while index < len(args):
|
|
1274
|
+
token = args[index]
|
|
1275
|
+
if token == "--format" and fmt is None and index + 1 < len(args):
|
|
1276
|
+
fmt = args[index + 1]
|
|
1277
|
+
index += 2
|
|
1278
|
+
elif token == "--":
|
|
1279
|
+
rest = args[index + 1 :]
|
|
1280
|
+
return (
|
|
1281
|
+
service is None
|
|
1282
|
+
and fmt is not None
|
|
1283
|
+
and len(rest) == 1
|
|
1284
|
+
and self._is_container_name(rest[0])
|
|
1285
|
+
and self._safe_docker_format(fmt)
|
|
1286
|
+
)
|
|
1287
|
+
elif token.startswith("-"):
|
|
1288
|
+
return False
|
|
1289
|
+
elif service is None and self._is_container_name(token):
|
|
1290
|
+
service = token
|
|
1291
|
+
index += 1
|
|
1292
|
+
else:
|
|
1293
|
+
return False
|
|
1294
|
+
return fmt is not None and service is not None and self._safe_docker_format(fmt)
|
|
1295
|
+
|
|
1296
|
+
def _safe_stats_argv(self, args: list[str]) -> bool:
|
|
1297
|
+
index = 0
|
|
1298
|
+
fmt: str | None = None
|
|
1299
|
+
seen_no_stream = False
|
|
1300
|
+
service: str | None = None
|
|
1301
|
+
while index < len(args):
|
|
1302
|
+
token = args[index]
|
|
1303
|
+
if token == "--no-stream" and not seen_no_stream:
|
|
1304
|
+
seen_no_stream = True
|
|
1305
|
+
index += 1
|
|
1306
|
+
elif token == "--format" and fmt is None and index + 1 < len(args):
|
|
1307
|
+
fmt = args[index + 1]
|
|
1308
|
+
index += 2
|
|
1309
|
+
elif token == "--":
|
|
1310
|
+
rest = args[index + 1 :]
|
|
1311
|
+
return (
|
|
1312
|
+
service is None
|
|
1313
|
+
and seen_no_stream
|
|
1314
|
+
and len(rest) == 1
|
|
1315
|
+
and self._is_container_name(rest[0])
|
|
1316
|
+
and (fmt is None or self._safe_docker_format(fmt))
|
|
1317
|
+
)
|
|
1318
|
+
elif token.startswith("-"):
|
|
1319
|
+
return False
|
|
1320
|
+
elif service is None and self._is_container_name(token):
|
|
1321
|
+
service = token
|
|
1322
|
+
index += 1
|
|
1323
|
+
else:
|
|
1324
|
+
return False
|
|
1325
|
+
return (
|
|
1326
|
+
seen_no_stream
|
|
1327
|
+
and service is not None
|
|
1328
|
+
and (fmt is None or self._safe_docker_format(fmt))
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
def _safe_smartctl_argv(self, argv: list[str]) -> bool:
|
|
1332
|
+
arguments = argv[1:]
|
|
1333
|
+
if arguments == ["--version"]:
|
|
1334
|
+
return True
|
|
1335
|
+
|
|
1336
|
+
device: str | None = None
|
|
1337
|
+
index = 0
|
|
1338
|
+
while index < len(arguments):
|
|
1339
|
+
token = arguments[index]
|
|
1340
|
+
if token in {"-j", "-a", "-i", "-c"}:
|
|
1341
|
+
index += 1
|
|
1342
|
+
elif token == "-n":
|
|
1343
|
+
if index + 1 >= len(arguments) or arguments[index + 1] != "standby":
|
|
1344
|
+
return False
|
|
1345
|
+
index += 2
|
|
1346
|
+
elif token == "-t":
|
|
1347
|
+
if (
|
|
1348
|
+
index + 1 >= len(arguments)
|
|
1349
|
+
or arguments[index + 1] not in SMART_TEST_TYPES
|
|
1350
|
+
):
|
|
1351
|
+
return False
|
|
1352
|
+
index += 2
|
|
1353
|
+
elif token == "-l":
|
|
1354
|
+
if index + 1 >= len(arguments) or arguments[index + 1] not in {
|
|
1355
|
+
"error",
|
|
1356
|
+
"selftest",
|
|
1357
|
+
}:
|
|
1358
|
+
return False
|
|
1359
|
+
index += 2
|
|
1360
|
+
elif token.startswith("/dev/"):
|
|
1361
|
+
if device is not None or not self.validate_device_name(token[5:]):
|
|
1362
|
+
return False
|
|
1363
|
+
device = token
|
|
1364
|
+
index += 1
|
|
1365
|
+
else:
|
|
1366
|
+
return False
|
|
1367
|
+
return device is not None
|
|
1368
|
+
|
|
1369
|
+
def _argument_shape_safe(self, argv: list[str]) -> bool | None:
|
|
1370
|
+
"""Validate the complete argument vector for each command family.
|
|
1371
|
+
|
|
1372
|
+
Returns ``None`` for an executable this method does not know, letting
|
|
1373
|
+
the fixed prefix allowlist decide. Known families are checked against
|
|
1374
|
+
their exact catalog shape, so adding a dangerous argument to an allowed
|
|
1375
|
+
prefix no longer passes the policy.
|
|
1376
|
+
"""
|
|
1377
|
+
executable = PurePosixPath(argv[0]).name
|
|
1378
|
+
|
|
1379
|
+
if executable == "echo":
|
|
1380
|
+
return len(argv) >= 2
|
|
1381
|
+
if executable == "uptime":
|
|
1382
|
+
return len(argv) == 1
|
|
1383
|
+
if executable == "free":
|
|
1384
|
+
return argv[1:] == ["-h"]
|
|
1385
|
+
if executable == "df":
|
|
1386
|
+
if argv[1:] == [
|
|
1387
|
+
"-h",
|
|
1388
|
+
"-x",
|
|
1389
|
+
"tmpfs",
|
|
1390
|
+
"-x",
|
|
1391
|
+
"devtmpfs",
|
|
1392
|
+
"-x",
|
|
1393
|
+
"overlay",
|
|
1394
|
+
"-x",
|
|
1395
|
+
"squashfs",
|
|
1396
|
+
]:
|
|
1397
|
+
return True
|
|
1398
|
+
return (
|
|
1399
|
+
len(argv) == 3
|
|
1400
|
+
and argv[1] == "-h"
|
|
1401
|
+
and self.validate_disk_path(argv[2]) is not None
|
|
1402
|
+
)
|
|
1403
|
+
if executable == "ls":
|
|
1404
|
+
if argv[1:2] != ["-1"]:
|
|
1405
|
+
return False
|
|
1406
|
+
index = 2
|
|
1407
|
+
if len(argv) > index and argv[index] == "-p":
|
|
1408
|
+
index += 1
|
|
1409
|
+
return len(argv) == index + 1 and self._path_below_services(argv[index])
|
|
1410
|
+
if executable == "test":
|
|
1411
|
+
return (
|
|
1412
|
+
len(argv) == 3
|
|
1413
|
+
and argv[1] == "-d"
|
|
1414
|
+
and self._path_below_services(argv[2])
|
|
1415
|
+
)
|
|
1416
|
+
if executable in CONTAINER_RUNTIMES:
|
|
1417
|
+
return self._safe_container_argv(argv)
|
|
1418
|
+
if executable == "systemctl":
|
|
1419
|
+
return (
|
|
1420
|
+
len(argv) == 5
|
|
1421
|
+
and argv[1:4] == ["status", "--no-pager", "--full"]
|
|
1422
|
+
and self.validate_host_unit(argv[4])
|
|
1423
|
+
)
|
|
1424
|
+
if executable == "journalctl":
|
|
1425
|
+
return (
|
|
1426
|
+
len(argv) == 8
|
|
1427
|
+
and argv[1] == "-u"
|
|
1428
|
+
and self.validate_host_unit(argv[2])
|
|
1429
|
+
and argv[3] == "-n"
|
|
1430
|
+
and self._positive_integer(argv[4], 1000)
|
|
1431
|
+
and argv[5:] == ["--no-pager", "--output", "short-iso"]
|
|
1432
|
+
)
|
|
1433
|
+
if executable == "lsblk":
|
|
1434
|
+
if len(argv) < 2 or argv[1] != "-d":
|
|
1435
|
+
return False
|
|
1436
|
+
index = 2
|
|
1437
|
+
seen_json = False
|
|
1438
|
+
seen_columns = False
|
|
1439
|
+
while index < len(argv):
|
|
1440
|
+
token = argv[index]
|
|
1441
|
+
if token == "--json" and not seen_json:
|
|
1442
|
+
seen_json = True
|
|
1443
|
+
index += 1
|
|
1444
|
+
elif token == "-o" and not seen_columns and index + 1 < len(argv):
|
|
1445
|
+
if re.fullmatch(r"[A-Za-z0-9_,%-]+", argv[index + 1]) is None:
|
|
1446
|
+
return False
|
|
1447
|
+
seen_columns = True
|
|
1448
|
+
index += 2
|
|
1449
|
+
else:
|
|
1450
|
+
return False
|
|
1451
|
+
return True
|
|
1452
|
+
if executable == "iostat":
|
|
1453
|
+
return argv[1:] in (["-x", "1", "1"], ["-d", "1", "1"])
|
|
1454
|
+
if executable == "zpool":
|
|
1455
|
+
if len(argv) < 2:
|
|
1456
|
+
return False
|
|
1457
|
+
subcommand = argv[1]
|
|
1458
|
+
if subcommand == "status":
|
|
1459
|
+
return len(argv) == 2 or (len(argv) == 3 and argv[2] == "-x")
|
|
1460
|
+
if subcommand == "list":
|
|
1461
|
+
return len(argv) == 2 or (
|
|
1462
|
+
len(argv) == 4
|
|
1463
|
+
and argv[2] == "-o"
|
|
1464
|
+
and re.fullmatch(r"[a-z,]+", argv[3]) is not None
|
|
1465
|
+
)
|
|
1466
|
+
if subcommand == "iostat":
|
|
1467
|
+
return len(argv) == 2
|
|
1468
|
+
return False
|
|
1469
|
+
if executable == "zfs":
|
|
1470
|
+
if len(argv) < 2:
|
|
1471
|
+
return False
|
|
1472
|
+
if argv[1] == "list":
|
|
1473
|
+
return len(argv) == 2 or (
|
|
1474
|
+
len(argv) == 4
|
|
1475
|
+
and argv[2] == "-o"
|
|
1476
|
+
and re.fullmatch(r"[a-z,]+", argv[3]) is not None
|
|
1477
|
+
)
|
|
1478
|
+
if argv[1] == "get":
|
|
1479
|
+
return len(argv) == 2 or (
|
|
1480
|
+
len(argv) == 3
|
|
1481
|
+
and re.fullmatch(r"[A-Za-z0-9_.:-]+", argv[2]) is not None
|
|
1482
|
+
)
|
|
1483
|
+
return False
|
|
1484
|
+
if executable == "cat":
|
|
1485
|
+
return argv == ["cat", "/proc/mdstat"]
|
|
1486
|
+
if executable == "mdadm":
|
|
1487
|
+
if argv == ["mdadm", "--detail", "--scan", "--verbose"]:
|
|
1488
|
+
return True
|
|
1489
|
+
if len(argv) == 3 and argv[1] in {"--detail", "--examine"}:
|
|
1490
|
+
return self._is_device_path(argv[2])
|
|
1491
|
+
return False
|
|
1492
|
+
if executable == "snapraid":
|
|
1493
|
+
return argv == ["snapraid", "status"]
|
|
1494
|
+
if executable == "findmnt":
|
|
1495
|
+
return tuple(argv[1:]) in (
|
|
1496
|
+
("--real", "-o", "TARGET,SOURCE,FSTYPE,SIZE,USED,AVAIL,USE%"),
|
|
1497
|
+
("-t", "fuse.mergerfs", "-o", "TARGET,SOURCE,FSTYPE,OPTIONS"),
|
|
1498
|
+
)
|
|
1499
|
+
if executable == "smartctl":
|
|
1500
|
+
return self._safe_smartctl_argv(argv)
|
|
1501
|
+
if executable == "nvme":
|
|
1502
|
+
return (
|
|
1503
|
+
len(argv) == 3
|
|
1504
|
+
and argv[1]
|
|
1505
|
+
in {
|
|
1506
|
+
"smart-log",
|
|
1507
|
+
"smart-log-add",
|
|
1508
|
+
"id-ctrl",
|
|
1509
|
+
"id-ns",
|
|
1510
|
+
"error-log",
|
|
1511
|
+
}
|
|
1512
|
+
and argv[2].startswith("/dev/")
|
|
1513
|
+
and self.validate_device_name(argv[2][5:])
|
|
1514
|
+
)
|
|
1515
|
+
return None
|
|
1516
|
+
|
|
1517
|
+
def _is_safe_compose(self, argv: list[str]) -> bool:
|
|
1518
|
+
"""Validate a ``<runtime> compose --project-directory <dir> <cmd>`` argv.
|
|
1519
|
+
|
|
1520
|
+
Compose operations carry a variable service directory, so they cannot be
|
|
1521
|
+
expressed as a fixed prefix. The directory must stay below
|
|
1522
|
+
``security.services_path`` and only the catalog's compose subcommands
|
|
1523
|
+
and arguments are accepted.
|
|
1524
|
+
"""
|
|
1525
|
+
if len(argv) < 5:
|
|
1526
|
+
return False
|
|
1527
|
+
|
|
1528
|
+
runtime, compose, flag, directory, subcommand = argv[:5]
|
|
1529
|
+
if runtime not in CONTAINER_RUNTIMES or compose != "compose":
|
|
1530
|
+
return False
|
|
1531
|
+
if flag != "--project-directory" or not directory.startswith("/"):
|
|
1532
|
+
return False
|
|
1533
|
+
if ".." in directory.split("/"):
|
|
1534
|
+
return False
|
|
1535
|
+
|
|
1536
|
+
base = self.services_base_path.rstrip("/") or "/"
|
|
1537
|
+
inside_base = directory == base or directory.startswith(
|
|
1538
|
+
base if base == "/" else base + "/"
|
|
1539
|
+
)
|
|
1540
|
+
if not inside_base:
|
|
1541
|
+
logger.warning(
|
|
1542
|
+
"Compose directory %r is outside services_path %r",
|
|
1543
|
+
directory,
|
|
1544
|
+
base,
|
|
1545
|
+
)
|
|
1546
|
+
return False
|
|
1547
|
+
|
|
1548
|
+
allowed = COMPOSE_ARGUMENTS.get(subcommand)
|
|
1549
|
+
if allowed is None:
|
|
1550
|
+
logger.warning("Compose subcommand is not in the catalog: %s", subcommand)
|
|
1551
|
+
return False
|
|
1552
|
+
return tuple(argv[5:]) in allowed
|