mcp-ssh-vps 0.4.1__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.
- mcp_ssh_vps-0.4.1.dist-info/METADATA +482 -0
- mcp_ssh_vps-0.4.1.dist-info/RECORD +47 -0
- mcp_ssh_vps-0.4.1.dist-info/WHEEL +5 -0
- mcp_ssh_vps-0.4.1.dist-info/entry_points.txt +4 -0
- mcp_ssh_vps-0.4.1.dist-info/licenses/LICENSE +21 -0
- mcp_ssh_vps-0.4.1.dist-info/top_level.txt +1 -0
- sshmcp/__init__.py +3 -0
- sshmcp/cli.py +473 -0
- sshmcp/config.py +155 -0
- sshmcp/core/__init__.py +5 -0
- sshmcp/core/container.py +291 -0
- sshmcp/models/__init__.py +15 -0
- sshmcp/models/command.py +69 -0
- sshmcp/models/file.py +102 -0
- sshmcp/models/machine.py +139 -0
- sshmcp/monitoring/__init__.py +0 -0
- sshmcp/monitoring/alerts.py +464 -0
- sshmcp/prompts/__init__.py +7 -0
- sshmcp/prompts/backup.py +151 -0
- sshmcp/prompts/deploy.py +115 -0
- sshmcp/prompts/monitor.py +146 -0
- sshmcp/resources/__init__.py +7 -0
- sshmcp/resources/logs.py +99 -0
- sshmcp/resources/metrics.py +204 -0
- sshmcp/resources/status.py +160 -0
- sshmcp/security/__init__.py +7 -0
- sshmcp/security/audit.py +314 -0
- sshmcp/security/rate_limiter.py +221 -0
- sshmcp/security/totp.py +392 -0
- sshmcp/security/validator.py +234 -0
- sshmcp/security/whitelist.py +169 -0
- sshmcp/server.py +632 -0
- sshmcp/ssh/__init__.py +6 -0
- sshmcp/ssh/async_client.py +247 -0
- sshmcp/ssh/client.py +464 -0
- sshmcp/ssh/executor.py +79 -0
- sshmcp/ssh/forwarding.py +368 -0
- sshmcp/ssh/pool.py +343 -0
- sshmcp/ssh/shell.py +518 -0
- sshmcp/ssh/transfer.py +461 -0
- sshmcp/tools/__init__.py +13 -0
- sshmcp/tools/commands.py +226 -0
- sshmcp/tools/files.py +220 -0
- sshmcp/tools/helpers.py +321 -0
- sshmcp/tools/history.py +372 -0
- sshmcp/tools/processes.py +214 -0
- sshmcp/tools/servers.py +484 -0
sshmcp/security/totp.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""TOTP-based two-factor authentication for critical operations."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger()
|
|
14
|
+
|
|
15
|
+
# Optional imports for TOTP
|
|
16
|
+
try:
|
|
17
|
+
import pyotp
|
|
18
|
+
|
|
19
|
+
PYOTP_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
PYOTP_AVAILABLE = False
|
|
22
|
+
pyotp = None # type: ignore
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import qrcode
|
|
26
|
+
|
|
27
|
+
QRCODE_AVAILABLE = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
QRCODE_AVAILABLE = False
|
|
30
|
+
qrcode = None # type: ignore
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TOTPError(Exception):
|
|
34
|
+
"""Error in TOTP operations."""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TOTPNotConfigured(TOTPError):
|
|
40
|
+
"""TOTP is not configured for this host."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TOTPVerificationFailed(TOTPError):
|
|
46
|
+
"""TOTP verification failed."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TOTPManager:
|
|
52
|
+
"""
|
|
53
|
+
TOTP-based two-factor authentication manager.
|
|
54
|
+
|
|
55
|
+
Provides TOTP setup, verification, and management for critical SSH operations.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
secrets_file: str | None = None,
|
|
61
|
+
issuer: str = "SSH-MCP",
|
|
62
|
+
digits: int = 6,
|
|
63
|
+
interval: int = 30,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Initialize TOTP manager.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
secrets_file: Path to encrypted secrets file.
|
|
70
|
+
issuer: Issuer name for TOTP (shown in authenticator apps).
|
|
71
|
+
digits: Number of digits in TOTP code.
|
|
72
|
+
interval: Time interval in seconds.
|
|
73
|
+
"""
|
|
74
|
+
if not PYOTP_AVAILABLE:
|
|
75
|
+
raise TOTPError(
|
|
76
|
+
"pyotp package is required for TOTP. Install with: pip install pyotp"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
self.secrets_file = secrets_file or str(
|
|
80
|
+
Path.home() / ".sshmcp" / "totp_secrets.json"
|
|
81
|
+
)
|
|
82
|
+
self.issuer = issuer
|
|
83
|
+
self.digits = digits
|
|
84
|
+
self.interval = interval
|
|
85
|
+
|
|
86
|
+
self._secrets: dict[str, dict] = {}
|
|
87
|
+
self._lock = threading.Lock()
|
|
88
|
+
self._critical_commands: list[str] = [
|
|
89
|
+
r".*rm\s+-rf.*",
|
|
90
|
+
r".*shutdown.*",
|
|
91
|
+
r".*reboot.*",
|
|
92
|
+
r".*systemctl\s+(stop|disable).*",
|
|
93
|
+
r".*docker\s+(rm|stop|kill).*",
|
|
94
|
+
r".*DROP\s+DATABASE.*",
|
|
95
|
+
r".*DROP\s+TABLE.*",
|
|
96
|
+
]
|
|
97
|
+
self._verification_callbacks: list[Callable[[str, bool], None]] = []
|
|
98
|
+
|
|
99
|
+
self._load_secrets()
|
|
100
|
+
|
|
101
|
+
def setup_totp(self, host: str, account_name: str | None = None) -> dict:
|
|
102
|
+
"""
|
|
103
|
+
Set up TOTP for a host.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
host: Host name to set up TOTP for.
|
|
107
|
+
account_name: Account name (default: host name).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Dictionary with secret, provisioning URI, and optional QR code.
|
|
111
|
+
"""
|
|
112
|
+
account = account_name or host
|
|
113
|
+
secret = pyotp.random_base32()
|
|
114
|
+
|
|
115
|
+
totp = pyotp.TOTP(secret, digits=self.digits, interval=self.interval)
|
|
116
|
+
uri = totp.provisioning_uri(name=account, issuer_name=self.issuer)
|
|
117
|
+
|
|
118
|
+
result = {
|
|
119
|
+
"host": host,
|
|
120
|
+
"secret": secret,
|
|
121
|
+
"uri": uri,
|
|
122
|
+
"account": account,
|
|
123
|
+
"issuer": self.issuer,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Generate QR code if available
|
|
127
|
+
if QRCODE_AVAILABLE:
|
|
128
|
+
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
|
129
|
+
qr.add_data(uri)
|
|
130
|
+
qr.make(fit=True)
|
|
131
|
+
|
|
132
|
+
# Generate ASCII QR code
|
|
133
|
+
buffer = io.StringIO()
|
|
134
|
+
qr.print_ascii(out=buffer)
|
|
135
|
+
result["qr_ascii"] = buffer.getvalue()
|
|
136
|
+
|
|
137
|
+
# Save secret
|
|
138
|
+
with self._lock:
|
|
139
|
+
self._secrets[host] = {
|
|
140
|
+
"secret": secret,
|
|
141
|
+
"account": account,
|
|
142
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
143
|
+
"enabled": True,
|
|
144
|
+
}
|
|
145
|
+
self._save_secrets()
|
|
146
|
+
|
|
147
|
+
logger.info("totp_setup", host=host)
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
def verify_totp(self, host: str, code: str) -> bool:
|
|
151
|
+
"""
|
|
152
|
+
Verify a TOTP code for a host.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
host: Host name.
|
|
156
|
+
code: TOTP code to verify.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
True if verification successful.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
TOTPNotConfigured: If TOTP not set up for host.
|
|
163
|
+
TOTPVerificationFailed: If code is invalid.
|
|
164
|
+
"""
|
|
165
|
+
with self._lock:
|
|
166
|
+
if host not in self._secrets:
|
|
167
|
+
raise TOTPNotConfigured(f"TOTP not configured for host: {host}")
|
|
168
|
+
|
|
169
|
+
host_config = self._secrets[host]
|
|
170
|
+
if not host_config.get("enabled", True):
|
|
171
|
+
return True # TOTP disabled for this host
|
|
172
|
+
|
|
173
|
+
secret = host_config["secret"]
|
|
174
|
+
|
|
175
|
+
totp = pyotp.TOTP(secret, digits=self.digits, interval=self.interval)
|
|
176
|
+
|
|
177
|
+
# Verify with 1-step window for clock drift
|
|
178
|
+
is_valid = totp.verify(code, valid_window=1)
|
|
179
|
+
|
|
180
|
+
# Notify callbacks
|
|
181
|
+
for callback in self._verification_callbacks:
|
|
182
|
+
try:
|
|
183
|
+
callback(host, is_valid)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
if not is_valid:
|
|
188
|
+
logger.warning("totp_verification_failed", host=host)
|
|
189
|
+
raise TOTPVerificationFailed("Invalid TOTP code")
|
|
190
|
+
|
|
191
|
+
logger.info("totp_verified", host=host)
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def is_totp_required(self, host: str) -> bool:
|
|
195
|
+
"""
|
|
196
|
+
Check if TOTP is required for a host.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
host: Host name.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
True if TOTP is configured and enabled.
|
|
203
|
+
"""
|
|
204
|
+
with self._lock:
|
|
205
|
+
if host not in self._secrets:
|
|
206
|
+
return False
|
|
207
|
+
return self._secrets[host].get("enabled", True)
|
|
208
|
+
|
|
209
|
+
def is_critical_command(self, command: str) -> bool:
|
|
210
|
+
"""
|
|
211
|
+
Check if a command is considered critical (requires TOTP).
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
command: Command to check.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
True if command is critical.
|
|
218
|
+
"""
|
|
219
|
+
import re
|
|
220
|
+
|
|
221
|
+
command_lower = command.lower()
|
|
222
|
+
for pattern in self._critical_commands:
|
|
223
|
+
if re.match(pattern, command_lower, re.IGNORECASE):
|
|
224
|
+
return True
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
def add_critical_pattern(self, pattern: str) -> None:
|
|
228
|
+
"""
|
|
229
|
+
Add a pattern for critical commands.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
pattern: Regex pattern to add.
|
|
233
|
+
"""
|
|
234
|
+
self._critical_commands.append(pattern)
|
|
235
|
+
|
|
236
|
+
def remove_totp(self, host: str) -> bool:
|
|
237
|
+
"""
|
|
238
|
+
Remove TOTP configuration for a host.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
host: Host name.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
True if removed, False if not configured.
|
|
245
|
+
"""
|
|
246
|
+
with self._lock:
|
|
247
|
+
if host not in self._secrets:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
del self._secrets[host]
|
|
251
|
+
self._save_secrets()
|
|
252
|
+
|
|
253
|
+
logger.info("totp_removed", host=host)
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
def disable_totp(self, host: str) -> bool:
|
|
257
|
+
"""
|
|
258
|
+
Temporarily disable TOTP for a host.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
host: Host name.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
True if disabled.
|
|
265
|
+
"""
|
|
266
|
+
with self._lock:
|
|
267
|
+
if host not in self._secrets:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
self._secrets[host]["enabled"] = False
|
|
271
|
+
self._save_secrets()
|
|
272
|
+
|
|
273
|
+
logger.info("totp_disabled", host=host)
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
def enable_totp(self, host: str) -> bool:
|
|
277
|
+
"""
|
|
278
|
+
Enable TOTP for a host.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
host: Host name.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
True if enabled.
|
|
285
|
+
"""
|
|
286
|
+
with self._lock:
|
|
287
|
+
if host not in self._secrets:
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
self._secrets[host]["enabled"] = True
|
|
291
|
+
self._save_secrets()
|
|
292
|
+
|
|
293
|
+
logger.info("totp_enabled", host=host)
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
def get_configured_hosts(self) -> list[str]:
|
|
297
|
+
"""Get list of hosts with TOTP configured."""
|
|
298
|
+
with self._lock:
|
|
299
|
+
return list(self._secrets.keys())
|
|
300
|
+
|
|
301
|
+
def register_verification_callback(
|
|
302
|
+
self, callback: Callable[[str, bool], None]
|
|
303
|
+
) -> None:
|
|
304
|
+
"""
|
|
305
|
+
Register a callback for verification events.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
callback: Function called with (host, success).
|
|
309
|
+
"""
|
|
310
|
+
self._verification_callbacks.append(callback)
|
|
311
|
+
|
|
312
|
+
def _load_secrets(self) -> None:
|
|
313
|
+
"""Load secrets from file."""
|
|
314
|
+
path = Path(self.secrets_file)
|
|
315
|
+
if not path.exists():
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
with open(path) as f:
|
|
320
|
+
self._secrets = json.load(f)
|
|
321
|
+
logger.info("totp_secrets_loaded", count=len(self._secrets))
|
|
322
|
+
except Exception as e:
|
|
323
|
+
logger.error("totp_secrets_load_error", error=str(e))
|
|
324
|
+
|
|
325
|
+
def _save_secrets(self) -> None:
|
|
326
|
+
"""Save secrets to file."""
|
|
327
|
+
path = Path(self.secrets_file)
|
|
328
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
with open(path, "w") as f:
|
|
332
|
+
json.dump(self._secrets, f, indent=2)
|
|
333
|
+
# Set restrictive permissions
|
|
334
|
+
os.chmod(path, 0o600)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
logger.error("totp_secrets_save_error", error=str(e))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# Global TOTP manager instance
|
|
340
|
+
_totp_manager: TOTPManager | None = None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def get_totp_manager() -> TOTPManager:
|
|
344
|
+
"""Get or create the global TOTP manager."""
|
|
345
|
+
global _totp_manager
|
|
346
|
+
if _totp_manager is None:
|
|
347
|
+
_totp_manager = TOTPManager()
|
|
348
|
+
return _totp_manager
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def init_totp_manager(
|
|
352
|
+
secrets_file: str | None = None,
|
|
353
|
+
issuer: str = "SSH-MCP",
|
|
354
|
+
) -> TOTPManager:
|
|
355
|
+
"""
|
|
356
|
+
Initialize the global TOTP manager.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
secrets_file: Path to secrets file.
|
|
360
|
+
issuer: Issuer name for TOTP.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Initialized TOTPManager.
|
|
364
|
+
"""
|
|
365
|
+
global _totp_manager
|
|
366
|
+
_totp_manager = TOTPManager(secrets_file=secrets_file, issuer=issuer)
|
|
367
|
+
return _totp_manager
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def require_totp(host: str, code: str | None = None) -> bool:
|
|
371
|
+
"""
|
|
372
|
+
Check if TOTP is required and verify if code provided.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
host: Host name.
|
|
376
|
+
code: Optional TOTP code.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
True if verified or not required.
|
|
380
|
+
|
|
381
|
+
Raises:
|
|
382
|
+
TOTPVerificationFailed: If code required but invalid/missing.
|
|
383
|
+
"""
|
|
384
|
+
manager = get_totp_manager()
|
|
385
|
+
|
|
386
|
+
if not manager.is_totp_required(host):
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
if code is None:
|
|
390
|
+
raise TOTPVerificationFailed(f"TOTP code required for host: {host}")
|
|
391
|
+
|
|
392
|
+
return manager.verify_totp(host, code)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Security validation for commands and paths."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
from sshmcp.models.machine import SecurityConfig
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationError(Exception):
|
|
15
|
+
"""Command or path validation failed."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_command(
|
|
21
|
+
command: str,
|
|
22
|
+
security: SecurityConfig,
|
|
23
|
+
) -> Tuple[bool, str | None]:
|
|
24
|
+
"""
|
|
25
|
+
Validate command against security rules.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
command: Command to validate.
|
|
29
|
+
security: Security configuration with allowed/forbidden patterns.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Tuple of (is_valid, error_message).
|
|
33
|
+
"""
|
|
34
|
+
command = command.strip()
|
|
35
|
+
|
|
36
|
+
if not command:
|
|
37
|
+
return False, "Empty command"
|
|
38
|
+
|
|
39
|
+
# Check forbidden commands first (blacklist)
|
|
40
|
+
for pattern in security.forbidden_commands:
|
|
41
|
+
try:
|
|
42
|
+
if re.match(pattern, command, re.IGNORECASE):
|
|
43
|
+
logger.warning(
|
|
44
|
+
"command_forbidden",
|
|
45
|
+
command=command,
|
|
46
|
+
pattern=pattern,
|
|
47
|
+
)
|
|
48
|
+
return False, f"Command matches forbidden pattern: {pattern}"
|
|
49
|
+
except re.error as e:
|
|
50
|
+
logger.error("invalid_regex_pattern", pattern=pattern, error=str(e))
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
# If no allowed commands specified, allow all (except forbidden)
|
|
54
|
+
if not security.allowed_commands:
|
|
55
|
+
return True, None
|
|
56
|
+
|
|
57
|
+
# Check allowed commands (whitelist)
|
|
58
|
+
for pattern in security.allowed_commands:
|
|
59
|
+
try:
|
|
60
|
+
if re.match(pattern, command, re.IGNORECASE):
|
|
61
|
+
logger.debug(
|
|
62
|
+
"command_allowed",
|
|
63
|
+
command=command,
|
|
64
|
+
pattern=pattern,
|
|
65
|
+
)
|
|
66
|
+
return True, None
|
|
67
|
+
except re.error as e:
|
|
68
|
+
logger.error("invalid_regex_pattern", pattern=pattern, error=str(e))
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
logger.warning(
|
|
72
|
+
"command_not_allowed",
|
|
73
|
+
command=command,
|
|
74
|
+
)
|
|
75
|
+
return False, "Command not in allowed list"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_path(
|
|
79
|
+
path: str,
|
|
80
|
+
security: SecurityConfig,
|
|
81
|
+
check_type: str = "read",
|
|
82
|
+
) -> Tuple[bool, str | None]:
|
|
83
|
+
"""
|
|
84
|
+
Validate file path against security rules.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
path: Path to validate.
|
|
88
|
+
security: Security configuration with allowed/forbidden paths.
|
|
89
|
+
check_type: Type of operation ('read' or 'write').
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Tuple of (is_valid, error_message).
|
|
93
|
+
"""
|
|
94
|
+
if not path:
|
|
95
|
+
return False, "Empty path"
|
|
96
|
+
|
|
97
|
+
# Normalize path
|
|
98
|
+
normalized = normalize_path(path)
|
|
99
|
+
|
|
100
|
+
# Check for path traversal attacks
|
|
101
|
+
if ".." in normalized or normalized.startswith("../"):
|
|
102
|
+
logger.warning(
|
|
103
|
+
"path_traversal_attempt",
|
|
104
|
+
path=path,
|
|
105
|
+
normalized=normalized,
|
|
106
|
+
)
|
|
107
|
+
return False, "Path traversal not allowed"
|
|
108
|
+
|
|
109
|
+
# Check forbidden paths
|
|
110
|
+
for forbidden in security.forbidden_paths:
|
|
111
|
+
forbidden_normalized = normalize_path(forbidden)
|
|
112
|
+
if (
|
|
113
|
+
normalized.startswith(forbidden_normalized)
|
|
114
|
+
or normalized == forbidden_normalized
|
|
115
|
+
):
|
|
116
|
+
logger.warning(
|
|
117
|
+
"path_forbidden",
|
|
118
|
+
path=path,
|
|
119
|
+
forbidden=forbidden,
|
|
120
|
+
)
|
|
121
|
+
return False, f"Path is forbidden: {forbidden}"
|
|
122
|
+
|
|
123
|
+
# If no allowed paths specified, allow all (except forbidden)
|
|
124
|
+
if not security.allowed_paths:
|
|
125
|
+
return True, None
|
|
126
|
+
|
|
127
|
+
# Check allowed paths
|
|
128
|
+
for allowed in security.allowed_paths:
|
|
129
|
+
allowed_normalized = normalize_path(allowed)
|
|
130
|
+
if (
|
|
131
|
+
normalized.startswith(allowed_normalized)
|
|
132
|
+
or normalized == allowed_normalized
|
|
133
|
+
):
|
|
134
|
+
logger.debug(
|
|
135
|
+
"path_allowed",
|
|
136
|
+
path=path,
|
|
137
|
+
allowed=allowed,
|
|
138
|
+
)
|
|
139
|
+
return True, None
|
|
140
|
+
|
|
141
|
+
logger.warning(
|
|
142
|
+
"path_not_allowed",
|
|
143
|
+
path=path,
|
|
144
|
+
)
|
|
145
|
+
return False, "Path not in allowed list"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def normalize_path(path: str) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Normalize a file path.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
path: Path to normalize.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Normalized path.
|
|
157
|
+
"""
|
|
158
|
+
# Expand user home directory
|
|
159
|
+
if path.startswith("~"):
|
|
160
|
+
path = os.path.expanduser(path)
|
|
161
|
+
|
|
162
|
+
# Remove trailing slashes
|
|
163
|
+
path = path.rstrip("/")
|
|
164
|
+
|
|
165
|
+
# Collapse multiple slashes
|
|
166
|
+
while "//" in path:
|
|
167
|
+
path = path.replace("//", "/")
|
|
168
|
+
|
|
169
|
+
return path
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def check_command_safety(command: str) -> List[str]:
|
|
173
|
+
"""
|
|
174
|
+
Check command for potential safety issues.
|
|
175
|
+
|
|
176
|
+
Returns list of warnings (empty if no issues found).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
command: Command to check.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
List of warning messages.
|
|
183
|
+
"""
|
|
184
|
+
warnings = []
|
|
185
|
+
|
|
186
|
+
# Dangerous patterns to warn about
|
|
187
|
+
dangerous_patterns = [
|
|
188
|
+
(r"rm\s+-rf", "Recursive force delete detected"),
|
|
189
|
+
(r"rm\s+.*\*", "Wildcard delete detected"),
|
|
190
|
+
(r">\s*/dev/", "Writing to /dev/ detected"),
|
|
191
|
+
(r"chmod\s+777", "World-writable permissions detected"),
|
|
192
|
+
(r"\|\s*sh", "Piping to shell detected"),
|
|
193
|
+
(r"\|\s*bash", "Piping to bash detected"),
|
|
194
|
+
(r"curl.*\|\s*", "Curl piped to command detected"),
|
|
195
|
+
(r"wget.*\|\s*", "Wget piped to command detected"),
|
|
196
|
+
(r"eval\s+", "Eval command detected"),
|
|
197
|
+
(r";\s*rm\s+", "Command chaining with rm detected"),
|
|
198
|
+
(r"&&\s*rm\s+", "Command chaining with rm detected"),
|
|
199
|
+
(r"\$\(.*\)", "Command substitution detected"),
|
|
200
|
+
(r"`.*`", "Backtick command substitution detected"),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
for pattern, warning in dangerous_patterns:
|
|
204
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
205
|
+
warnings.append(warning)
|
|
206
|
+
|
|
207
|
+
return warnings
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def sanitize_command_for_log(command: str) -> str:
|
|
211
|
+
"""
|
|
212
|
+
Sanitize command for logging (hide potential secrets).
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
command: Command to sanitize.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Sanitized command safe for logging.
|
|
219
|
+
"""
|
|
220
|
+
# Patterns that might contain secrets
|
|
221
|
+
secret_patterns = [
|
|
222
|
+
(r"password[=:\s]+\S+", "password=***"),
|
|
223
|
+
(r"passwd[=:\s]+\S+", "passwd=***"),
|
|
224
|
+
(r"secret[=:\s]+\S+", "secret=***"),
|
|
225
|
+
(r"token[=:\s]+\S+", "token=***"),
|
|
226
|
+
(r"api[_-]?key[=:\s]+\S+", "api_key=***"),
|
|
227
|
+
(r"AWS_SECRET[=:\s]+\S+", "AWS_SECRET=***"),
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
sanitized = command
|
|
231
|
+
for pattern, replacement in secret_patterns:
|
|
232
|
+
sanitized = re.sub(pattern, replacement, sanitized, flags=re.IGNORECASE)
|
|
233
|
+
|
|
234
|
+
return sanitized
|