ssh-licco 2.1.4__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {ssh_licco-2.1.4/ssh_licco.egg-info → ssh_licco-2.2.0}/PKG-INFO +23 -2
  2. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/README.md +22 -1
  3. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/pyproject.toml +1 -1
  4. {ssh_licco-2.1.4 → ssh_licco-2.2.0/ssh_licco.egg-info}/PKG-INFO +23 -2
  5. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/__init__.py +2 -2
  6. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/execute.py +21 -0
  7. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/security.py +84 -2
  8. ssh_licco-2.2.0/tests/test_security.py +207 -0
  9. ssh_licco-2.1.4/tests/test_security.py +0 -195
  10. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/LICENSE +0 -0
  11. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/setup.cfg +0 -0
  12. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_licco.egg-info/SOURCES.txt +0 -0
  13. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_licco.egg-info/dependency_links.txt +0 -0
  14. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_licco.egg-info/entry_points.txt +0 -0
  15. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_licco.egg-info/requires.txt +0 -0
  16. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_licco.egg-info/top_level.txt +0 -0
  17. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/__main__.py +0 -0
  18. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/approval.py +0 -0
  19. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/audit_logger.py +0 -0
  20. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/batch_executor.py +0 -0
  21. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/cli.py +0 -0
  22. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/clients/__init__.py +0 -0
  23. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/clients/additional_clients.py +0 -0
  24. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/clients/factory.py +0 -0
  25. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/clients/interface.py +0 -0
  26. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/clients/paramiko_client.py +0 -0
  27. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/config_manager.py +0 -0
  28. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/connection_config.py +0 -0
  29. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/connection_pool.py +0 -0
  30. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/exceptions.py +0 -0
  31. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/executor.py +0 -0
  32. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/__init__.py +0 -0
  33. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/approval.py +0 -0
  34. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/connect.py +0 -0
  35. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/context.py +0 -0
  36. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/docker.py +0 -0
  37. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/file_transfer.py +0 -0
  38. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/host.py +0 -0
  39. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/key.py +0 -0
  40. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/process.py +0 -0
  41. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/schemas.py +0 -0
  42. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/session.py +0 -0
  43. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/handlers/utils.py +0 -0
  44. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/key_manager.py +0 -0
  45. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/logging_config.py +0 -0
  46. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/runtime_guard.py +0 -0
  47. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/secret_provider.py +0 -0
  48. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/server.py +0 -0
  49. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/service.py +0 -0
  50. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/session_manager.py +0 -0
  51. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/tunnel.py +0 -0
  52. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/ssh_mcp/watchdog.py +0 -0
  53. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/tests/test_config_manager.py +0 -0
  54. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/tests/test_key_manager.py +0 -0
  55. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/tests/test_server.py +0 -0
  56. {ssh_licco-2.1.4 → ssh_licco-2.2.0}/tests/test_session_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssh-licco
3
- Version: 2.1.4
3
+ Version: 2.2.0
4
4
  Summary: SSH Model Context Protocol Server - Enable SSH functionality for AI models
5
5
  Author-email: SSH LICCO Team <contact@ssh-licco.dev>
6
6
  License: MIT
@@ -235,6 +235,27 @@ export SSH_EXTRA_ALLOWED_COMMANDS="git,pip,npm"
235
235
  - **[SECURITY_CONFIG_GUIDE.md](SECURITY_CONFIG_GUIDE.md)** - 安全配置详解
236
236
  - **[docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)** - 生产加固四项方案(v2.1.0 新增)
237
237
 
238
+ ### 🛡️ 硬拦截灾难性命令(v2.2.0 新增)
239
+
240
+ 为防止任何误操作或越权调用直接打到远程 shell,ssh-licco 在所有安全级别下都**无条件**拦截以下灾难性命令模式,**无法通过 `confirm_dangerous=true`、`confirmation_layer=N`、调整 `SSH_SECURITY_LEVEL` 等任何方式绕过**:
241
+
242
+ - `rm -rf` 作用于绝对路径(含 `/`、`/*`、`/path`、`/path/*`,`-fr` 变体同效)
243
+ - `mkfs.*` 任意文件系统格式化
244
+ - `dd if=/dev/(zero|random|urandom) of=/dev/(sd|nvme)` 覆写裸盘
245
+ - bash fork-bomb(`:(){ :|:& };:` 及空白变体)
246
+ - `chmod -R 777 /` / `chmod -R 000 /` 根目录递归改权限
247
+ - `> /dev/(sd|nvme)` / `>> /dev/(sd|nvme)` 裸设备重定向
248
+
249
+ 如确需执行上述操作,请直接通过 SSH 登录服务器(绕过 MCP 网关)进行。安全且可逆的替代方案:
250
+
251
+ ```bash
252
+ # 旧做法(v2.2.0 之前):rm -rf /path/to/junk ← 现已被硬拦截
253
+ # 推荐做法:mv 到回收站,约定时间后清理
254
+ mv /path/to/junk /tmp/.trash_$(date +%s)/
255
+ ```
256
+
257
+ 命中硬拦截时会输出 `WARNING` 审计日志(含 category 与命令),便于 SOC 监控。
258
+
238
259
  ### 🏛️ 生产加固四项(v2.1.0 新增)
239
260
 
240
261
  SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
@@ -591,7 +612,7 @@ ssh-licco 的核心依赖是:
591
612
 
592
613
  | 指标 | 状态 |
593
614
  |------|------|
594
- | **测试用例** | 348 passed, 0 skipped |
615
+ | **测试用例** | 400 passed, 0 skipped |
595
616
  | **覆盖率** | 16 个源模块全覆盖 |
596
617
  | **测试框架** | pytest + pytest-asyncio |
597
618
 
@@ -202,6 +202,27 @@ export SSH_EXTRA_ALLOWED_COMMANDS="git,pip,npm"
202
202
  - **[SECURITY_CONFIG_GUIDE.md](SECURITY_CONFIG_GUIDE.md)** - 安全配置详解
203
203
  - **[docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)** - 生产加固四项方案(v2.1.0 新增)
204
204
 
205
+ ### 🛡️ 硬拦截灾难性命令(v2.2.0 新增)
206
+
207
+ 为防止任何误操作或越权调用直接打到远程 shell,ssh-licco 在所有安全级别下都**无条件**拦截以下灾难性命令模式,**无法通过 `confirm_dangerous=true`、`confirmation_layer=N`、调整 `SSH_SECURITY_LEVEL` 等任何方式绕过**:
208
+
209
+ - `rm -rf` 作用于绝对路径(含 `/`、`/*`、`/path`、`/path/*`,`-fr` 变体同效)
210
+ - `mkfs.*` 任意文件系统格式化
211
+ - `dd if=/dev/(zero|random|urandom) of=/dev/(sd|nvme)` 覆写裸盘
212
+ - bash fork-bomb(`:(){ :|:& };:` 及空白变体)
213
+ - `chmod -R 777 /` / `chmod -R 000 /` 根目录递归改权限
214
+ - `> /dev/(sd|nvme)` / `>> /dev/(sd|nvme)` 裸设备重定向
215
+
216
+ 如确需执行上述操作,请直接通过 SSH 登录服务器(绕过 MCP 网关)进行。安全且可逆的替代方案:
217
+
218
+ ```bash
219
+ # 旧做法(v2.2.0 之前):rm -rf /path/to/junk ← 现已被硬拦截
220
+ # 推荐做法:mv 到回收站,约定时间后清理
221
+ mv /path/to/junk /tmp/.trash_$(date +%s)/
222
+ ```
223
+
224
+ 命中硬拦截时会输出 `WARNING` 审计日志(含 category 与命令),便于 SOC 监控。
225
+
205
226
  ### 🏛️ 生产加固四项(v2.1.0 新增)
206
227
 
207
228
  SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
@@ -558,7 +579,7 @@ ssh-licco 的核心依赖是:
558
579
 
559
580
  | 指标 | 状态 |
560
581
  |------|------|
561
- | **测试用例** | 348 passed, 0 skipped |
582
+ | **测试用例** | 400 passed, 0 skipped |
562
583
  | **覆盖率** | 16 个源模块全覆盖 |
563
584
  | **测试框架** | pytest + pytest-asyncio |
564
585
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssh-licco"
3
- version = "2.1.4"
3
+ version = "2.2.0"
4
4
  description = "SSH Model Context Protocol Server - Enable SSH functionality for AI models"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssh-licco
3
- Version: 2.1.4
3
+ Version: 2.2.0
4
4
  Summary: SSH Model Context Protocol Server - Enable SSH functionality for AI models
5
5
  Author-email: SSH LICCO Team <contact@ssh-licco.dev>
6
6
  License: MIT
@@ -235,6 +235,27 @@ export SSH_EXTRA_ALLOWED_COMMANDS="git,pip,npm"
235
235
  - **[SECURITY_CONFIG_GUIDE.md](SECURITY_CONFIG_GUIDE.md)** - 安全配置详解
236
236
  - **[docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)** - 生产加固四项方案(v2.1.0 新增)
237
237
 
238
+ ### 🛡️ 硬拦截灾难性命令(v2.2.0 新增)
239
+
240
+ 为防止任何误操作或越权调用直接打到远程 shell,ssh-licco 在所有安全级别下都**无条件**拦截以下灾难性命令模式,**无法通过 `confirm_dangerous=true`、`confirmation_layer=N`、调整 `SSH_SECURITY_LEVEL` 等任何方式绕过**:
241
+
242
+ - `rm -rf` 作用于绝对路径(含 `/`、`/*`、`/path`、`/path/*`,`-fr` 变体同效)
243
+ - `mkfs.*` 任意文件系统格式化
244
+ - `dd if=/dev/(zero|random|urandom) of=/dev/(sd|nvme)` 覆写裸盘
245
+ - bash fork-bomb(`:(){ :|:& };:` 及空白变体)
246
+ - `chmod -R 777 /` / `chmod -R 000 /` 根目录递归改权限
247
+ - `> /dev/(sd|nvme)` / `>> /dev/(sd|nvme)` 裸设备重定向
248
+
249
+ 如确需执行上述操作,请直接通过 SSH 登录服务器(绕过 MCP 网关)进行。安全且可逆的替代方案:
250
+
251
+ ```bash
252
+ # 旧做法(v2.2.0 之前):rm -rf /path/to/junk ← 现已被硬拦截
253
+ # 推荐做法:mv 到回收站,约定时间后清理
254
+ mv /path/to/junk /tmp/.trash_$(date +%s)/
255
+ ```
256
+
257
+ 命中硬拦截时会输出 `WARNING` 审计日志(含 category 与命令),便于 SOC 监控。
258
+
238
259
  ### 🏛️ 生产加固四项(v2.1.0 新增)
239
260
 
240
261
  SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
@@ -591,7 +612,7 @@ ssh-licco 的核心依赖是:
591
612
 
592
613
  | 指标 | 状态 |
593
614
  |------|------|
594
- | **测试用例** | 348 passed, 0 skipped |
615
+ | **测试用例** | 400 passed, 0 skipped |
595
616
  | **覆盖率** | 16 个源模块全覆盖 |
596
617
  | **测试框架** | pytest + pytest-asyncio |
597
618
 
@@ -1,8 +1,8 @@
1
1
  # SSH-LICCO 版本信息
2
2
  # 所有代码中的版本信息都从这里读取
3
- # 版本号:2.1.4
3
+ # 版本号:2.2.0
4
4
 
5
- __version__ = "2.1.4"
5
+ __version__ = "2.2.0"
6
6
  __author__ = "Li Qi"
7
7
  __email__ = "1985694657@qq.com"
8
8
  __license__ = "MIT"
@@ -107,6 +107,27 @@ async def handle_execute(ctx: HandlerContext, args: dict) -> list[TextContent]:
107
107
  if confirmation_message:
108
108
  ctx.logger.info(f"Multi-layer confirmation passed: {confirmation_message}")
109
109
 
110
+ # 硬拦截:与安全级别、confirm_dangerous、confirmation_layer 都无关,任何情况下都生效
111
+ try:
112
+ command_validator.check_hard_block(command)
113
+ except SecurityError as e:
114
+ ctx.logger.error(f"Command hard-blocked: {e}")
115
+ return [
116
+ TextContent(
117
+ type="text",
118
+ text=f"""❌ 命令被硬拦截,无法绕过
119
+
120
+ Command: {command}
121
+ Reason: {str(e)}
122
+
123
+ 此操作属于灾难性命令(如 rm -rf 绝对路径、mkfs、dd 覆盘、fork-bomb 等),
124
+ 任何安全级别、任何参数(包括 confirm_dangerous=true)都无法使其通过 MCP 执行。
125
+ 如确需执行,请直接通过 SSH 登录服务器操作。
126
+
127
+ Current security level: {os.getenv("SSH_SECURITY_LEVEL", "balanced")}""",
128
+ )
129
+ ]
130
+
110
131
  # 原有的安全检查逻辑(保持兼容性)
111
132
  if not confirm_dangerous:
112
133
  try:
@@ -3,12 +3,15 @@ SSH-LICCO Security Module
3
3
  安全验证和防护模块 - 支持多级安全策略
4
4
  """
5
5
 
6
+ import logging
6
7
  import os
7
8
  import re
8
9
  import shlex
9
10
  from enum import Enum
10
11
  from pathlib import Path, PurePosixPath
11
12
 
13
+ logger = logging.getLogger(__name__)
14
+
12
15
 
13
16
  class SecurityLevel(Enum):
14
17
  """安全级别枚举"""
@@ -29,9 +32,16 @@ class RiskLevel(Enum):
29
32
 
30
33
 
31
34
  class SecurityError(Exception):
32
- """安全异常类"""
35
+ """安全异常类
36
+
37
+ Attributes:
38
+ hard_block: 若为 True,表示该错误来自硬拦截(无法通过参数绕过),
39
+ 调用方应在错误响应中不要提示任何 bypass 方案。
40
+ """
33
41
 
34
- pass
42
+ def __init__(self, message: str, hard_block: bool = False):
43
+ super().__init__(message)
44
+ self.hard_block = hard_block
35
45
 
36
46
 
37
47
  class CommandValidator:
@@ -376,6 +386,34 @@ class CommandValidator:
376
386
  r"pkill\s+-9\s+", # pkill -9 (批量强杀)
377
387
  ]
378
388
 
389
+ # 硬拦截模式集:任何安全级别、confirm_dangerous、confirmation_layer 都不能绕过
390
+ # 每条 (regex, category) 配对,category 用于审计日志识别命中类别
391
+ # 对应 spec ssh-security "Hard Block on Catastrophic Commands"
392
+ HARD_BLOCKED_PATTERNS: list[tuple[str, str]] = [
393
+ # rm -rf / 任意绝对路径(含 -rf / -fr 两种顺序)
394
+ (r"rm\s+-rf\s+/(?:\s|$|\*)", "absolute_path_rm_rf"),
395
+ (r"rm\s+-fr\s+/(?:\s|$|\*)", "absolute_path_rm_rf"),
396
+ (r"rm\s+-rf\s+/[^\s]+/\*", "absolute_path_rm_rf"),
397
+ (r"rm\s+-fr\s+/[^\s]+/\*", "absolute_path_rm_rf"),
398
+ (r"rm\s+-rf\s+/[^\s]+", "absolute_path_rm_rf"),
399
+ (r"rm\s+-fr\s+/[^\s]+", "absolute_path_rm_rf"),
400
+ # mkfs 任意文件系统格式化
401
+ (r"mkfs\.\w+", "disk_format"),
402
+ # dd 覆写磁盘
403
+ (r"dd\s+if=/dev/(?:zero|random|urandom)\s+of=/dev/sd", "raw_disk_dd"),
404
+ (r"dd\s+if=/dev/(?:zero|random|urandom)\s+of=/dev/nvm", "raw_disk_dd"),
405
+ # bash fork-bomb(含各种空白变体)
406
+ (r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;", "fork_bomb"),
407
+ # chmod -R 777/000 /
408
+ (r"chmod\s+-R\s+777\s+/(?:\s|$)", "root_chmod"),
409
+ (r"chmod\s+-R\s+000\s+/(?:\s|$)", "root_chmod"),
410
+ # 写裸磁盘设备(含 >> 重定向)
411
+ (r">\s*/dev/sd[a-z]", "raw_disk_redirect"),
412
+ (r">\s*/dev/nvme", "raw_disk_redirect"),
413
+ (r">>\s*/dev/sd[a-z]", "raw_disk_redirect"),
414
+ (r">>\s*/dev/nvme", "raw_disk_redirect"),
415
+ ]
416
+
379
417
  # 危险关键字
380
418
  DANGEROUS_KEYWORDS = ["passwd", "shadow", "/etc/shadow", "/root/.ssh"]
381
419
 
@@ -477,6 +515,13 @@ class CommandValidator:
477
515
  re.compile(pattern) for pattern in self.RELAXED_BLOCKED_PATTERNS
478
516
  ]
479
517
 
518
+ # 硬拦截正则:所有安全级别都生效,confirm_dangerous 也无法绕过
519
+ # 存储为 (compiled_regex, category) 元组列表,category 用于审计日志
520
+ self._hard_block_regex: list[tuple[re.Pattern, str]] = [
521
+ (re.compile(pattern), category)
522
+ for pattern, category in self.HARD_BLOCKED_PATTERNS
523
+ ]
524
+
480
525
  @classmethod
481
526
  def from_config_file(
482
527
  cls,
@@ -539,6 +584,39 @@ class CommandValidator:
539
584
  """编译危险模式正则"""
540
585
  self.dangerous_regex = [re.compile(pattern) for pattern in self.dangerous_patterns]
541
586
 
587
+ def check_hard_block(self, command: str) -> None:
588
+ """硬拦截检查:与安全级别、confirm_dangerous、confirmation_layer 无关。
589
+
590
+ 任何匹配 HARD_BLOCKED_PATTERNS 的命令都会被拒绝并抛出 SecurityError(hard_block=True)。
591
+ 调用方应在错误响应中不要提示任何 bypass 方案。
592
+
593
+ Args:
594
+ command: 要检查的命令
595
+
596
+ Raises:
597
+ SecurityError: hard_block=True
598
+ """
599
+ if not command:
600
+ return
601
+ for regex, category in self._hard_block_regex:
602
+ if regex.search(command):
603
+ # 审计日志:仅记录命令和 category,不回显正则(避免泄露黑名单形状)
604
+ logger.warning(
605
+ "hard-block command rejected: category=%s command=%s",
606
+ category,
607
+ command,
608
+ )
609
+ raise SecurityError(
610
+ f"命令被硬拦截({category}),无法通过任何参数绕过。\n"
611
+ f"被阻止的命令:{command}\n"
612
+ f"如确需执行,请直接登录服务器操作。",
613
+ hard_block=True,
614
+ )
615
+
616
+ def _check_hard_block(self, command: str) -> None:
617
+ """validate_command 内部使用的硬拦截短路检查。"""
618
+ self.check_hard_block(command)
619
+
542
620
  def validate_command(self, command: str) -> bool:
543
621
  """
544
622
  验证命令是否安全
@@ -555,6 +633,10 @@ class CommandValidator:
555
633
  if not command or not command.strip():
556
634
  raise SecurityError("命令不能为空")
557
635
 
636
+ # 0. 硬拦截:所有安全级别都生效,confirm_dangerous / confirmation_layer 也无法绕过
637
+ # 先于所有其他检查,包括 RELAXED 模式的短路径,避免出现不同模式下行为不一致
638
+ self._check_hard_block(command)
639
+
558
640
  # 分割命令获取基础命令
559
641
  try:
560
642
  cmd_parts = shlex.split(command)
@@ -0,0 +1,207 @@
1
+ """Tests for ssh_mcp.security — Hard Block on Catastrophic Commands.
2
+
3
+ Spec: openspec/changes/hardblock-dangerous-commands/specs/ssh-security/spec.md
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+
11
+ import pytest
12
+
13
+ from ssh_mcp.security import CommandValidator, SecurityError, SecurityLevel
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Parametrize fixtures
17
+ # ---------------------------------------------------------------------------
18
+
19
+ ABSOLUTE_PATH_RM_RF = [
20
+ "rm -rf /",
21
+ "rm -rf /*",
22
+ "rm -rf /etc",
23
+ "rm -rf /var/lib/postgresql/data",
24
+ "rm -rf /home/user/junk/*",
25
+ "rm -fr /etc", # -fr variant
26
+ ]
27
+
28
+ DISK_DESTRUCTIVE = [
29
+ "mkfs.ext4 /dev/sda1",
30
+ "mkfs.xfs /dev/nvme0n1",
31
+ "dd if=/dev/zero of=/dev/sda bs=1M",
32
+ "dd if=/dev/urandom of=/dev/nvme0n1",
33
+ "chmod -R 777 /",
34
+ "chmod -R 000 /",
35
+ ":(){:|:&};:",
36
+ ": ( ) { : | : & } ;", # whitespace variant
37
+ "echo junk > /dev/sda",
38
+ "echo junk > /dev/nvme0n1",
39
+ "echo junk >> /dev/sdb",
40
+ ]
41
+
42
+ SAFE_DELETE = [
43
+ "rm /tmp/test.log",
44
+ "rm -f /var/log/app.log", # -f without -r is still safe
45
+ "rmdir /tmp/empty_dir",
46
+ ]
47
+
48
+ ALL_LEVELS = [
49
+ SecurityLevel.STRICT,
50
+ SecurityLevel.BALANCED,
51
+ SecurityLevel.RELAXED,
52
+ ]
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # 4.2: rm -rf on absolute paths is rejected in all security levels
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ @pytest.mark.parametrize("cmd", ABSOLUTE_PATH_RM_RF)
61
+ @pytest.mark.parametrize("level", ALL_LEVELS)
62
+ def test_hard_block_rejects_absolute_path_rm_rf(cmd: str, level: SecurityLevel) -> None:
63
+ """rm -rf against any absolute path is hard-blocked regardless of security level."""
64
+ v = CommandValidator(security_level=level)
65
+ with pytest.raises(SecurityError) as exc_info:
66
+ v.check_hard_block(cmd)
67
+ assert exc_info.value.hard_block is True
68
+
69
+
70
+ @pytest.mark.parametrize("cmd", ABSOLUTE_PATH_RM_RF)
71
+ @pytest.mark.parametrize("level", ALL_LEVELS)
72
+ def test_hard_block_rejects_even_with_validate_command(cmd: str, level: SecurityLevel) -> None:
73
+ """validate_command also runs the hard block first (so it works via the unified path)."""
74
+ v = CommandValidator(security_level=level)
75
+ with pytest.raises(SecurityError) as exc_info:
76
+ v.validate_command(cmd)
77
+ assert exc_info.value.hard_block is True
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # 4.3: disk-destructive commands are rejected in all security levels
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ @pytest.mark.parametrize("cmd", DISK_DESTRUCTIVE)
86
+ @pytest.mark.parametrize("level", ALL_LEVELS)
87
+ def test_hard_block_rejects_disk_destructive(cmd: str, level: SecurityLevel) -> None:
88
+ """mkfs / raw-disk dd / root chmod 777 / fork-bomb / raw redirect — all rejected."""
89
+ v = CommandValidator(security_level=level)
90
+ with pytest.raises(SecurityError) as exc_info:
91
+ v.check_hard_block(cmd)
92
+ assert exc_info.value.hard_block is True
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # 4.4: safe delete operations still work
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ @pytest.mark.parametrize("cmd", SAFE_DELETE)
101
+ @pytest.mark.parametrize("level", ALL_LEVELS)
102
+ def test_safe_delete_still_allowed(cmd: str, level: SecurityLevel) -> None:
103
+ """rm /tmp/file, rmdir empty, and rm -f single file must NOT be hard-blocked."""
104
+ v = CommandValidator(security_level=level)
105
+ # check_hard_block is the only thing we're testing here; should not raise.
106
+ v.check_hard_block(cmd)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # 4.5: relative-path rm -rf is NOT hard-blocked (regression guard)
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ @pytest.mark.parametrize("cmd", [
115
+ "rm -rf ./build/",
116
+ "rm -rf relative/dir",
117
+ "rm -rf ../sibling",
118
+ "rm -rf .",
119
+ ])
120
+ def test_soft_risk_relative_path_not_hard_blocked(cmd: str) -> None:
121
+ """Relative-path rm -rf must not be hard-blocked (only absolute paths are)."""
122
+ v = CommandValidator(security_level=SecurityLevel.BALANCED)
123
+ # check_hard_block should pass (no exception)
124
+ v.check_hard_block(cmd)
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # 4.6: WARNING log is emitted on hard block, regex is not echoed
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ @pytest.mark.parametrize("cmd", ABSOLUTE_PATH_RM_RF[:2])
133
+ def test_hard_block_logs_warning_with_category(
134
+ cmd: str, caplog: pytest.LogCaptureFixture
135
+ ) -> None:
136
+ """A WARNING log line is emitted; it contains the category but not the regex."""
137
+ v = CommandValidator(security_level=SecurityLevel.BALANCED)
138
+ with caplog.at_level(logging.WARNING, logger="ssh_mcp.security"):
139
+ with pytest.raises(SecurityError):
140
+ v.check_hard_block(cmd)
141
+
142
+ # At least one WARNING record exists
143
+ warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
144
+ assert warnings, f"no WARNING log emitted for {cmd}"
145
+
146
+ # The category is in the log, the command is in the log
147
+ joined = " ".join(r.getMessage() for r in warnings)
148
+ assert cmd in joined, f"command not in log: {joined}"
149
+ assert "category=" in joined, f"category label not in log: {joined}"
150
+
151
+ # The log message must NOT contain raw regex syntax markers from the blacklist
152
+ # (i.e. \s, \., \[, etc.) — only the human-readable category is allowed.
153
+ for record in warnings:
154
+ msg = record.getMessage()
155
+ assert not re.search(r"\\[sdw+\[\]]", msg), (
156
+ f"regex metacharacters leaked into log message: {msg}"
157
+ )
158
+
159
+
160
+ def test_hard_block_message_does_not_mention_override() -> None:
161
+ """The error message must not suggest any parameter can bypass the hard block."""
162
+ v = CommandValidator(security_level=SecurityLevel.RELAXED)
163
+ with pytest.raises(SecurityError) as exc_info:
164
+ v.check_hard_block("rm -rf /etc")
165
+ msg = str(exc_info.value)
166
+ assert "confirm_dangerous" not in msg
167
+ assert "confirmation_layer" not in msg
168
+ assert "无法通过任何参数绕过" in msg or "无法绕过" in msg or "无法" in msg
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # SecurityError attribute contract
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def test_security_error_has_hard_block_attribute() -> None:
177
+ """SecurityError exposes `hard_block: bool`; default is False, hard block sets True."""
178
+ # Default (other errors)
179
+ err = SecurityError("plain")
180
+ assert err.hard_block is False
181
+
182
+ # Hard block path
183
+ v = CommandValidator(security_level=SecurityLevel.BALANCED)
184
+ with pytest.raises(SecurityError) as exc_info:
185
+ v.check_hard_block("rm -rf /")
186
+ assert exc_info.value.hard_block is True
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Pattern coverage: every documented catastrophic pattern has a regex entry
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ def test_hard_block_patterns_cover_documented_categories() -> None:
195
+ """The HARD_BLOCKED_PATTERNS set must include all categories in the spec."""
196
+ expected_categories = {
197
+ "absolute_path_rm_rf",
198
+ "disk_format",
199
+ "raw_disk_dd",
200
+ "fork_bomb",
201
+ "root_chmod",
202
+ "raw_disk_redirect",
203
+ }
204
+ actual = {category for _, category in CommandValidator.HARD_BLOCKED_PATTERNS}
205
+ assert expected_categories.issubset(actual), (
206
+ f"missing categories: {expected_categories - actual}"
207
+ )
@@ -1,195 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- from pathlib import PurePosixPath
5
- from unittest.mock import patch
6
-
7
- import pytest
8
-
9
- from ssh_mcp.security import (
10
- CommandValidator,
11
- PathValidator,
12
- SecurityError,
13
- SecurityLevel,
14
- create_validators_from_env,
15
- )
16
-
17
-
18
- class TestSecurityLevel:
19
- def test_values(self):
20
- assert SecurityLevel.STRICT.value == "strict"
21
- assert SecurityLevel.BALANCED.value == "balanced"
22
- assert SecurityLevel.RELAXED.value == "relaxed"
23
-
24
-
25
- class TestCommandValidator:
26
- def test_balanced_allows_simple_command(self):
27
- v = CommandValidator(SecurityLevel.BALANCED)
28
- assert v.validate_command("ls") is True
29
-
30
- def test_balanced_allows_common_commands(self):
31
- v = CommandValidator(SecurityLevel.BALANCED)
32
- for cmd in ["ls", "pwd", "cat file.txt", "grep pattern", "docker ps"]:
33
- assert v.validate_command(cmd) is True
34
-
35
- def test_balanced_blocks_pipe(self):
36
- v = CommandValidator(SecurityLevel.BALANCED)
37
- with pytest.raises(SecurityError, match="危险字符"):
38
- v.validate_command("ls | grep foo")
39
-
40
- def test_balanced_blocks_semicolon(self):
41
- v = CommandValidator(SecurityLevel.BALANCED)
42
- with pytest.raises(SecurityError, match="危险字符"):
43
- v.validate_command("ls ; rm -rf /")
44
-
45
- def test_balanced_blocks_command_substitution(self):
46
- v = CommandValidator(SecurityLevel.BALANCED)
47
- with pytest.raises(SecurityError, match="危险字符"):
48
- v.validate_command("echo $(whoami)")
49
-
50
- def test_balanced_blocks_backtick(self):
51
- v = CommandValidator(SecurityLevel.BALANCED)
52
- with pytest.raises(SecurityError, match="危险字符"):
53
- v.validate_command("echo `whoami`")
54
-
55
- def test_strict_blocks_redirect(self):
56
- v = CommandValidator(SecurityLevel.STRICT)
57
- with pytest.raises(SecurityError, match="危险字符"):
58
- v.validate_command("echo hello > file.txt")
59
-
60
- def test_strict_blocks_ampersand(self):
61
- v = CommandValidator(SecurityLevel.STRICT)
62
- with pytest.raises(SecurityError, match="危险字符"):
63
- v.validate_command("ls 10 &")
64
-
65
- def test_relaxed_allows_pipe(self):
66
- v = CommandValidator(SecurityLevel.RELAXED)
67
- assert v.validate_command("ls | grep foo") is True
68
-
69
- def test_relaxed_allows_redirect(self):
70
- v = CommandValidator(SecurityLevel.RELAXED)
71
- assert v.validate_command("echo hello > file.txt") is True
72
-
73
- def test_empty_command(self):
74
- v = CommandValidator(SecurityLevel.BALANCED)
75
- with pytest.raises(SecurityError, match="不能为空"):
76
- v.validate_command("")
77
-
78
- def test_whitespace_command(self):
79
- v = CommandValidator(SecurityLevel.BALANCED)
80
- with pytest.raises(SecurityError, match="不能为空"):
81
- v.validate_command(" ")
82
-
83
- def test_unknown_command(self):
84
- v = CommandValidator(SecurityLevel.BALANCED)
85
- with pytest.raises(SecurityError, match="不在允许列表中"):
86
- v.validate_command("evil_command arg1")
87
-
88
- def test_extra_allowed_commands(self):
89
- v = CommandValidator(SecurityLevel.BALANCED, extra_allowed_commands={"myapp"})
90
- assert v.validate_command("myapp --flag") is True
91
-
92
- def test_dangerous_keywords_passwd(self):
93
- v = CommandValidator(SecurityLevel.BALANCED)
94
- with pytest.raises(SecurityError, match="受限关键字"):
95
- v.validate_command("cat /etc/passwd")
96
-
97
- def test_dangerous_keywords_shadow(self):
98
- v = CommandValidator(SecurityLevel.BALANCED)
99
- with pytest.raises(SecurityError, match="受限关键字"):
100
- v.validate_command("cat /etc/shadow")
101
-
102
- def test_command_too_long(self):
103
- v = CommandValidator(SecurityLevel.BALANCED)
104
- long_cmd = "ls " + "a" * 5000
105
- with pytest.raises(SecurityError, match="过长"):
106
- v.validate_command(long_cmd)
107
-
108
- def test_malformed_command(self):
109
- v = CommandValidator(SecurityLevel.BALANCED)
110
- with pytest.raises(SecurityError, match="格式错误"):
111
- v.validate_command("echo 'unclosed")
112
-
113
- def test_docker_command_allowed(self):
114
- v = CommandValidator(SecurityLevel.BALANCED)
115
- assert v.validate_command("docker build -t myapp .") is True
116
-
117
- def test_git_command_allowed(self):
118
- v = CommandValidator(SecurityLevel.BALANCED)
119
- assert v.validate_command("git status") is True
120
-
121
-
122
- class TestPathValidator:
123
- def test_valid_path(self):
124
- v = PathValidator(SecurityLevel.BALANCED, base_dir="/home")
125
- result = v.validate_path("user/file.txt")
126
- assert str(result).startswith(str(v.base_dir))
127
-
128
- def test_path_traversal_attack(self):
129
- v = PathValidator(SecurityLevel.BALANCED, base_dir="/home")
130
- with pytest.raises(SecurityError, match="遍历"):
131
- v.validate_path("../../../etc/passwd")
132
-
133
- def test_empty_path(self):
134
- v = PathValidator(SecurityLevel.BALANCED)
135
- with pytest.raises(SecurityError, match="不能为空"):
136
- v.validate_path("")
137
-
138
- def test_forbidden_path_etc(self):
139
- """测试禁止访问 /etc/shadow(PurePosixPath 确保跨平台兼容)"""
140
- v = PathValidator(SecurityLevel.BALANCED, base_dir="/")
141
- with pytest.raises(SecurityError, match="敏感路径"):
142
- v.validate_path("etc/shadow")
143
-
144
- def test_forbidden_path_root(self):
145
- """测试禁止访问 /root/.ssh(PurePosixPath 确保跨平台兼容)"""
146
- v = PathValidator(SecurityLevel.BALANCED, base_dir="/")
147
- with pytest.raises(SecurityError, match="敏感路径"):
148
- v.validate_path("root/.ssh")
149
-
150
- def test_relaxed_no_forbidden_paths(self):
151
- v = PathValidator(SecurityLevel.RELAXED, base_dir="/")
152
- result = v.validate_path("etc/config")
153
- assert result is not None
154
-
155
- def test_strict_blocks_traversal(self):
156
- v = PathValidator(SecurityLevel.STRICT, base_dir="/home")
157
- with pytest.raises(SecurityError, match="遍历"):
158
- v.validate_path("../../tmp/evil")
159
-
160
- def test_extra_allowed_paths(self):
161
- v = PathValidator(SecurityLevel.BALANCED, base_dir="/home", extra_allowed_paths=["/data"])
162
- assert v.extra_allowed_paths == ["/data"]
163
-
164
-
165
- class TestCreateValidatorsFromEnv:
166
- def test_default_balanced(self):
167
- with patch.dict(os.environ, {}, clear=True):
168
- cv, pv = create_validators_from_env()
169
- assert cv.security_level == SecurityLevel.BALANCED
170
-
171
- def test_strict_level(self):
172
- with patch.dict(os.environ, {"SSH_SECURITY_LEVEL": "strict"}):
173
- cv, pv = create_validators_from_env()
174
- assert cv.security_level == SecurityLevel.STRICT
175
-
176
- def test_relaxed_level(self):
177
- with patch.dict(os.environ, {"SSH_SECURITY_LEVEL": "relaxed"}):
178
- cv, pv = create_validators_from_env()
179
- assert cv.security_level == SecurityLevel.RELAXED
180
-
181
- def test_unknown_level_defaults_balanced(self):
182
- with patch.dict(os.environ, {"SSH_SECURITY_LEVEL": "unknown"}):
183
- cv, pv = create_validators_from_env()
184
- assert cv.security_level == SecurityLevel.BALANCED
185
-
186
- def test_extra_commands(self):
187
- with patch.dict(os.environ, {"SSH_EXTRA_ALLOWED_COMMANDS": "myapp,mytool"}):
188
- cv, pv = create_validators_from_env()
189
- assert "myapp" in cv.allowed_commands
190
- assert "mytool" in cv.allowed_commands
191
-
192
- def test_custom_base_dir(self):
193
- with patch.dict(os.environ, {"SSH_BASE_DIR": "/data"}):
194
- cv, pv = create_validators_from_env()
195
- assert "data" in str(pv.base_dir)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes