ssh-licco 2.3.0__tar.gz → 2.4.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 (53) hide show
  1. {ssh_licco-2.3.0/ssh_licco.egg-info → ssh_licco-2.4.0}/PKG-INFO +27 -18
  2. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/README.md +26 -17
  3. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/pyproject.toml +1 -1
  4. {ssh_licco-2.3.0 → ssh_licco-2.4.0/ssh_licco.egg-info}/PKG-INFO +27 -18
  5. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_licco.egg-info/SOURCES.txt +0 -2
  6. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/__init__.py +2 -2
  7. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/connection_config.py +1 -1
  8. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/connect.py +2 -34
  9. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/execute.py +0 -27
  10. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/schemas.py +0 -5
  11. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/utils.py +0 -47
  12. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/key_manager.py +0 -12
  13. ssh_licco-2.3.0/ssh_mcp/runtime_guard.py +0 -178
  14. ssh_licco-2.3.0/ssh_mcp/secret_provider.py +0 -226
  15. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/LICENSE +0 -0
  16. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/setup.cfg +0 -0
  17. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_licco.egg-info/dependency_links.txt +0 -0
  18. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_licco.egg-info/entry_points.txt +0 -0
  19. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_licco.egg-info/requires.txt +0 -0
  20. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_licco.egg-info/top_level.txt +0 -0
  21. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/__main__.py +0 -0
  22. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/audit_logger.py +0 -0
  23. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/batch_executor.py +0 -0
  24. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/cli.py +0 -0
  25. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/clients/__init__.py +0 -0
  26. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/clients/additional_clients.py +0 -0
  27. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/clients/factory.py +0 -0
  28. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/clients/interface.py +0 -0
  29. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/clients/paramiko_client.py +0 -0
  30. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/config_manager.py +0 -0
  31. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/connection_pool.py +0 -0
  32. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/exceptions.py +0 -0
  33. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/executor.py +0 -0
  34. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/__init__.py +0 -0
  35. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/context.py +0 -0
  36. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/docker.py +0 -0
  37. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/file_transfer.py +0 -0
  38. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/host.py +0 -0
  39. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/key.py +0 -0
  40. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/process.py +0 -0
  41. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/handlers/session.py +0 -0
  42. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/logging_config.py +0 -0
  43. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/security.py +0 -0
  44. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/server.py +0 -0
  45. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/service.py +0 -0
  46. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/session_manager.py +0 -0
  47. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/tunnel.py +0 -0
  48. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/ssh_mcp/watchdog.py +0 -0
  49. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/tests/test_config_manager.py +0 -0
  50. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/tests/test_key_manager.py +0 -0
  51. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/tests/test_security.py +0 -0
  52. {ssh_licco-2.3.0 → ssh_licco-2.4.0}/tests/test_server.py +0 -0
  53. {ssh_licco-2.3.0 → ssh_licco-2.4.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.3.0
3
+ Version: 2.4.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
@@ -233,7 +233,6 @@ export SSH_EXTRA_ALLOWED_COMMANDS="git,pip,npm"
233
233
 
234
234
  - **[MCP_CONFIG_GUIDE.md](MCP_CONFIG_GUIDE.md)** - 完整配置指南,包含 5 种使用场景示例
235
235
  - **[SECURITY_CONFIG_GUIDE.md](SECURITY_CONFIG_GUIDE.md)** - 安全配置详解
236
- - **[docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)** - 生产加固四项方案(v2.1.0 新增)
237
236
 
238
237
  ### 🛡️ 硬拦截灾难性命令(v2.2.0 新增)
239
238
 
@@ -256,21 +255,6 @@ mv /path/to/junk /tmp/.trash_$(date +%s)/
256
255
 
257
256
  命中硬拦截时会输出 `WARNING` 审计日志(含 category 与命令),便于 SOC 监控。
258
257
 
259
- ### 🏛️ 生产加固四项(v2.1.0 + v2.2.0 演进)
260
-
261
- SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
262
-
263
- | 加固点 | 开关 | 说明 |
264
- |--------|------|------|
265
- | 1. 运行账号最小权限 | `SSH_RUNTIME_GUARD=true` | 专用普通账号运行,拒绝 root/sudo 启动 |
266
- | 2. 密钥不落地磁盘 | `SSH_SECRET_PROVIDER_ENABLED=true` | 私钥从 KMS 临时拉取到内存,用完清零,不落盘 |
267
- | 3. 双层命令拦截 | `SSH_REMOTE_GUARD=true` + 远端 ForceCommand | 跳板机侧禁元字符 + 远端二次白名单校验 |
268
- | 4. 灾难性命令硬拦截(v2.2.0 新增) | 默认开启,不可关闭 | rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb、chmod -R 777/000 /、`> /dev/sd*` 等在 MCP 网关层被**无条件**拒绝;任何安全级别、`confirm_dangerous=true` 等均无法绕过 |
269
-
270
- > **关于 v2.1.0 引入的"高危操作审批"项**:v2.1.0 曾提供 `SSH_APPROVAL_GATE=true` + 3 个审批工具作为第 4 项加固。v2.2.0 起该项被**硬拦截取代**——审批流程依赖 AI 自报命令,存在闭环风险;硬拦截更直接,灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。`SSH_APPROVAL_GATE` 环境变量与 `ssh_mcp/approval.py` / `ssh_mcp/handlers/approval.py` 代码已在 v2.2.0 删除。
271
-
272
- 完整方案见 [docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)。
273
-
274
258
  ---
275
259
 
276
260
  ## 🛠️ 可用工具(v2.2.0 维持 9 个;v2.1.0 曾增加的 3 个审批工具因流程闭环风险已下线)
@@ -278,7 +262,7 @@ SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
278
262
  | 工具 | 描述 | 核心能力 |
279
263
  |------|------|---------|
280
264
  | `ssh_connect` | 连接管理 | 自动读取环境变量/配置,支持密码+密钥认证,可保存配置,登录后自动执行命令 |
281
- | `ssh_execute` | 命令执行 | 自动连接、智能后台检测、长任务等待、超时控制,支持 nohup/screen/tmux 三种后台模式;v2.1.0 新增 `remote_guard`(双层拦截)参数;v2.2.0 起对灾难性命令(rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb 等)做**无条件硬拦截** |
265
+ | `ssh_execute` | 命令执行 | 自动连接、智能后台检测、长任务等待、超时控制,支持 nohup/screen/tmux 三种后台模式;v2.2.0 起对灾难性命令(rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb 等)做**无条件硬拦截** |
282
266
  | `ssh_disconnect` | 会话管理 | 断开指定会话 OR 列出所有活跃会话 |
283
267
  | `ssh_file_transfer` | 文件传输 | 上传/下载/列表/写入/追加/删除/创建目录/查看元信息(8 种操作);v2.1.3+ delete 操作新增 Windows/Unix 敏感路径拦截与路径遍历防护 |
284
268
  | `ssh_host` | 主机管理 | `action=list/add/remove` 增删查主机配置 |
@@ -453,6 +437,31 @@ localhost:5432 - accepting connections
453
437
 
454
438
  - **[MCP_CONFIG_GUIDE.md](MCP_CONFIG_GUIDE.md)** - 包含所有配置选项的详细说明
455
439
 
440
+ ### 🌐 完整环境变量速查(v2.3.0)
441
+
442
+ > 下表所有变量均被代码读取。**注意**:
443
+ > - `SSH_RATE_LIMIT` 是 **bool** 总开关(true/false),`SSH_RATE_LIMIT_MAX` 才是次数上限,两者分开配置
444
+ > - 主机密钥检查(strict_host_key_checking)**不通过 env 配置**,请用 `ssh_connect` 工具参数或 `hosts.json`
445
+
446
+ | 分类 | 变量 | 默认 | 说明 |
447
+ |------|------|------|------|
448
+ | **安全** | `SSH_SECURITY_LEVEL` | `balanced` | 安全级别:`strict` / `balanced` / `relaxed` |
449
+ | | `SSH_BASE_DIR` | `/home` | 路径校验基目录 |
450
+ | | `SSH_EXTRA_ALLOWED_COMMANDS` | (空) | 额外允许的命令(逗号分隔) |
451
+ | | `SSH_ALLOWED_COMMANDS_FILE` | (空) | 命令白名单 JSON 文件路径 |
452
+ | | `SSH_AUDIT_LOG_PATH` | (空) | 审计日志文件路径 |
453
+ | **限流** | `SSH_RATE_LIMIT` | `true` | 限流总开关(bool) |
454
+ | | `SSH_RATE_LIMIT_MAX` | `30` | 限流次数上限 |
455
+ | | `SSH_RATE_LIMIT_WINDOW` | `60` | 限流窗口(秒) |
456
+ | **硬拦截** | *(无 env)* | — | 灾难性命令硬拦截,零配置零绕过 |
457
+ | **连接默认** | `SSH_HOST` / `SSH_PORT` / `SSH_USER` / `SSH_PASSWORD` | `127.0.0.1` / `22` / `root` / (空) | 单 host 模式默认连接参数 |
458
+ | | `SSH_TIMEOUT` | `60` | 连接超时(秒) |
459
+ | | `SSH_KEEPALIVE_INTERVAL` | `30` | keepalive 间隔(秒) |
460
+ | | `SSH_SESSION_TIMEOUT` | `7200` | 会话超时(秒) |
461
+ | | `SSH_CLIENT_TYPE` | `paramiko` | SSH 客户端实现:`paramiko` / `asyncssh` |
462
+ | | `SSH_FORCE_ENV_CONFIG` | `false` | 强制 env 配置覆盖 hosts.json |
463
+ | | `SSH_SUDO_PASSWORD` | (空) | sudo 密码,配合 `use_sudo=true` 走 `sudo -S` |
464
+
456
465
  ---
457
466
 
458
467
  ## ⚠️ 依赖版本兼容性
@@ -200,7 +200,6 @@ export SSH_EXTRA_ALLOWED_COMMANDS="git,pip,npm"
200
200
 
201
201
  - **[MCP_CONFIG_GUIDE.md](MCP_CONFIG_GUIDE.md)** - 完整配置指南,包含 5 种使用场景示例
202
202
  - **[SECURITY_CONFIG_GUIDE.md](SECURITY_CONFIG_GUIDE.md)** - 安全配置详解
203
- - **[docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)** - 生产加固四项方案(v2.1.0 新增)
204
203
 
205
204
  ### 🛡️ 硬拦截灾难性命令(v2.2.0 新增)
206
205
 
@@ -223,21 +222,6 @@ mv /path/to/junk /tmp/.trash_$(date +%s)/
223
222
 
224
223
  命中硬拦截时会输出 `WARNING` 审计日志(含 category 与命令),便于 SOC 监控。
225
224
 
226
- ### 🏛️ 生产加固四项(v2.1.0 + v2.2.0 演进)
227
-
228
- SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
229
-
230
- | 加固点 | 开关 | 说明 |
231
- |--------|------|------|
232
- | 1. 运行账号最小权限 | `SSH_RUNTIME_GUARD=true` | 专用普通账号运行,拒绝 root/sudo 启动 |
233
- | 2. 密钥不落地磁盘 | `SSH_SECRET_PROVIDER_ENABLED=true` | 私钥从 KMS 临时拉取到内存,用完清零,不落盘 |
234
- | 3. 双层命令拦截 | `SSH_REMOTE_GUARD=true` + 远端 ForceCommand | 跳板机侧禁元字符 + 远端二次白名单校验 |
235
- | 4. 灾难性命令硬拦截(v2.2.0 新增) | 默认开启,不可关闭 | rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb、chmod -R 777/000 /、`> /dev/sd*` 等在 MCP 网关层被**无条件**拒绝;任何安全级别、`confirm_dangerous=true` 等均无法绕过 |
236
-
237
- > **关于 v2.1.0 引入的"高危操作审批"项**:v2.1.0 曾提供 `SSH_APPROVAL_GATE=true` + 3 个审批工具作为第 4 项加固。v2.2.0 起该项被**硬拦截取代**——审批流程依赖 AI 自报命令,存在闭环风险;硬拦截更直接,灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。`SSH_APPROVAL_GATE` 环境变量与 `ssh_mcp/approval.py` / `ssh_mcp/handlers/approval.py` 代码已在 v2.2.0 删除。
238
-
239
- 完整方案见 [docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)。
240
-
241
225
  ---
242
226
 
243
227
  ## 🛠️ 可用工具(v2.2.0 维持 9 个;v2.1.0 曾增加的 3 个审批工具因流程闭环风险已下线)
@@ -245,7 +229,7 @@ SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
245
229
  | 工具 | 描述 | 核心能力 |
246
230
  |------|------|---------|
247
231
  | `ssh_connect` | 连接管理 | 自动读取环境变量/配置,支持密码+密钥认证,可保存配置,登录后自动执行命令 |
248
- | `ssh_execute` | 命令执行 | 自动连接、智能后台检测、长任务等待、超时控制,支持 nohup/screen/tmux 三种后台模式;v2.1.0 新增 `remote_guard`(双层拦截)参数;v2.2.0 起对灾难性命令(rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb 等)做**无条件硬拦截** |
232
+ | `ssh_execute` | 命令执行 | 自动连接、智能后台检测、长任务等待、超时控制,支持 nohup/screen/tmux 三种后台模式;v2.2.0 起对灾难性命令(rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb 等)做**无条件硬拦截** |
249
233
  | `ssh_disconnect` | 会话管理 | 断开指定会话 OR 列出所有活跃会话 |
250
234
  | `ssh_file_transfer` | 文件传输 | 上传/下载/列表/写入/追加/删除/创建目录/查看元信息(8 种操作);v2.1.3+ delete 操作新增 Windows/Unix 敏感路径拦截与路径遍历防护 |
251
235
  | `ssh_host` | 主机管理 | `action=list/add/remove` 增删查主机配置 |
@@ -420,6 +404,31 @@ localhost:5432 - accepting connections
420
404
 
421
405
  - **[MCP_CONFIG_GUIDE.md](MCP_CONFIG_GUIDE.md)** - 包含所有配置选项的详细说明
422
406
 
407
+ ### 🌐 完整环境变量速查(v2.3.0)
408
+
409
+ > 下表所有变量均被代码读取。**注意**:
410
+ > - `SSH_RATE_LIMIT` 是 **bool** 总开关(true/false),`SSH_RATE_LIMIT_MAX` 才是次数上限,两者分开配置
411
+ > - 主机密钥检查(strict_host_key_checking)**不通过 env 配置**,请用 `ssh_connect` 工具参数或 `hosts.json`
412
+
413
+ | 分类 | 变量 | 默认 | 说明 |
414
+ |------|------|------|------|
415
+ | **安全** | `SSH_SECURITY_LEVEL` | `balanced` | 安全级别:`strict` / `balanced` / `relaxed` |
416
+ | | `SSH_BASE_DIR` | `/home` | 路径校验基目录 |
417
+ | | `SSH_EXTRA_ALLOWED_COMMANDS` | (空) | 额外允许的命令(逗号分隔) |
418
+ | | `SSH_ALLOWED_COMMANDS_FILE` | (空) | 命令白名单 JSON 文件路径 |
419
+ | | `SSH_AUDIT_LOG_PATH` | (空) | 审计日志文件路径 |
420
+ | **限流** | `SSH_RATE_LIMIT` | `true` | 限流总开关(bool) |
421
+ | | `SSH_RATE_LIMIT_MAX` | `30` | 限流次数上限 |
422
+ | | `SSH_RATE_LIMIT_WINDOW` | `60` | 限流窗口(秒) |
423
+ | **硬拦截** | *(无 env)* | — | 灾难性命令硬拦截,零配置零绕过 |
424
+ | **连接默认** | `SSH_HOST` / `SSH_PORT` / `SSH_USER` / `SSH_PASSWORD` | `127.0.0.1` / `22` / `root` / (空) | 单 host 模式默认连接参数 |
425
+ | | `SSH_TIMEOUT` | `60` | 连接超时(秒) |
426
+ | | `SSH_KEEPALIVE_INTERVAL` | `30` | keepalive 间隔(秒) |
427
+ | | `SSH_SESSION_TIMEOUT` | `7200` | 会话超时(秒) |
428
+ | | `SSH_CLIENT_TYPE` | `paramiko` | SSH 客户端实现:`paramiko` / `asyncssh` |
429
+ | | `SSH_FORCE_ENV_CONFIG` | `false` | 强制 env 配置覆盖 hosts.json |
430
+ | | `SSH_SUDO_PASSWORD` | (空) | sudo 密码,配合 `use_sudo=true` 走 `sudo -S` |
431
+
423
432
  ---
424
433
 
425
434
  ## ⚠️ 依赖版本兼容性
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssh-licco"
3
- version = "2.3.0"
3
+ version = "2.4.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.3.0
3
+ Version: 2.4.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
@@ -233,7 +233,6 @@ export SSH_EXTRA_ALLOWED_COMMANDS="git,pip,npm"
233
233
 
234
234
  - **[MCP_CONFIG_GUIDE.md](MCP_CONFIG_GUIDE.md)** - 完整配置指南,包含 5 种使用场景示例
235
235
  - **[SECURITY_CONFIG_GUIDE.md](SECURITY_CONFIG_GUIDE.md)** - 安全配置详解
236
- - **[docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)** - 生产加固四项方案(v2.1.0 新增)
237
236
 
238
237
  ### 🛡️ 硬拦截灾难性命令(v2.2.0 新增)
239
238
 
@@ -256,21 +255,6 @@ mv /path/to/junk /tmp/.trash_$(date +%s)/
256
255
 
257
256
  命中硬拦截时会输出 `WARNING` 审计日志(含 category 与命令),便于 SOC 监控。
258
257
 
259
- ### 🏛️ 生产加固四项(v2.1.0 + v2.2.0 演进)
260
-
261
- SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
262
-
263
- | 加固点 | 开关 | 说明 |
264
- |--------|------|------|
265
- | 1. 运行账号最小权限 | `SSH_RUNTIME_GUARD=true` | 专用普通账号运行,拒绝 root/sudo 启动 |
266
- | 2. 密钥不落地磁盘 | `SSH_SECRET_PROVIDER_ENABLED=true` | 私钥从 KMS 临时拉取到内存,用完清零,不落盘 |
267
- | 3. 双层命令拦截 | `SSH_REMOTE_GUARD=true` + 远端 ForceCommand | 跳板机侧禁元字符 + 远端二次白名单校验 |
268
- | 4. 灾难性命令硬拦截(v2.2.0 新增) | 默认开启,不可关闭 | rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb、chmod -R 777/000 /、`> /dev/sd*` 等在 MCP 网关层被**无条件**拒绝;任何安全级别、`confirm_dangerous=true` 等均无法绕过 |
269
-
270
- > **关于 v2.1.0 引入的"高危操作审批"项**:v2.1.0 曾提供 `SSH_APPROVAL_GATE=true` + 3 个审批工具作为第 4 项加固。v2.2.0 起该项被**硬拦截取代**——审批流程依赖 AI 自报命令,存在闭环风险;硬拦截更直接,灾难性命令在 MCP 网关层就被拒绝,运维侧不需要再走"先申请再审批"流程。`SSH_APPROVAL_GATE` 环境变量与 `ssh_mcp/approval.py` / `ssh_mcp/handlers/approval.py` 代码已在 v2.2.0 删除。
271
-
272
- 完整方案见 [docs/SECURITY_HARDENING.md](docs/SECURITY_HARDENING.md)。
273
-
274
258
  ---
275
259
 
276
260
  ## 🛠️ 可用工具(v2.2.0 维持 9 个;v2.1.0 曾增加的 3 个审批工具因流程闭环风险已下线)
@@ -278,7 +262,7 @@ SSH 跳板模式部署的生产必做加固,补齐跳板机单点风险:
278
262
  | 工具 | 描述 | 核心能力 |
279
263
  |------|------|---------|
280
264
  | `ssh_connect` | 连接管理 | 自动读取环境变量/配置,支持密码+密钥认证,可保存配置,登录后自动执行命令 |
281
- | `ssh_execute` | 命令执行 | 自动连接、智能后台检测、长任务等待、超时控制,支持 nohup/screen/tmux 三种后台模式;v2.1.0 新增 `remote_guard`(双层拦截)参数;v2.2.0 起对灾难性命令(rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb 等)做**无条件硬拦截** |
265
+ | `ssh_execute` | 命令执行 | 自动连接、智能后台检测、长任务等待、超时控制,支持 nohup/screen/tmux 三种后台模式;v2.2.0 起对灾难性命令(rm -rf 绝对路径、mkfs、raw-disk dd、fork-bomb 等)做**无条件硬拦截** |
282
266
  | `ssh_disconnect` | 会话管理 | 断开指定会话 OR 列出所有活跃会话 |
283
267
  | `ssh_file_transfer` | 文件传输 | 上传/下载/列表/写入/追加/删除/创建目录/查看元信息(8 种操作);v2.1.3+ delete 操作新增 Windows/Unix 敏感路径拦截与路径遍历防护 |
284
268
  | `ssh_host` | 主机管理 | `action=list/add/remove` 增删查主机配置 |
@@ -453,6 +437,31 @@ localhost:5432 - accepting connections
453
437
 
454
438
  - **[MCP_CONFIG_GUIDE.md](MCP_CONFIG_GUIDE.md)** - 包含所有配置选项的详细说明
455
439
 
440
+ ### 🌐 完整环境变量速查(v2.3.0)
441
+
442
+ > 下表所有变量均被代码读取。**注意**:
443
+ > - `SSH_RATE_LIMIT` 是 **bool** 总开关(true/false),`SSH_RATE_LIMIT_MAX` 才是次数上限,两者分开配置
444
+ > - 主机密钥检查(strict_host_key_checking)**不通过 env 配置**,请用 `ssh_connect` 工具参数或 `hosts.json`
445
+
446
+ | 分类 | 变量 | 默认 | 说明 |
447
+ |------|------|------|------|
448
+ | **安全** | `SSH_SECURITY_LEVEL` | `balanced` | 安全级别:`strict` / `balanced` / `relaxed` |
449
+ | | `SSH_BASE_DIR` | `/home` | 路径校验基目录 |
450
+ | | `SSH_EXTRA_ALLOWED_COMMANDS` | (空) | 额外允许的命令(逗号分隔) |
451
+ | | `SSH_ALLOWED_COMMANDS_FILE` | (空) | 命令白名单 JSON 文件路径 |
452
+ | | `SSH_AUDIT_LOG_PATH` | (空) | 审计日志文件路径 |
453
+ | **限流** | `SSH_RATE_LIMIT` | `true` | 限流总开关(bool) |
454
+ | | `SSH_RATE_LIMIT_MAX` | `30` | 限流次数上限 |
455
+ | | `SSH_RATE_LIMIT_WINDOW` | `60` | 限流窗口(秒) |
456
+ | **硬拦截** | *(无 env)* | — | 灾难性命令硬拦截,零配置零绕过 |
457
+ | **连接默认** | `SSH_HOST` / `SSH_PORT` / `SSH_USER` / `SSH_PASSWORD` | `127.0.0.1` / `22` / `root` / (空) | 单 host 模式默认连接参数 |
458
+ | | `SSH_TIMEOUT` | `60` | 连接超时(秒) |
459
+ | | `SSH_KEEPALIVE_INTERVAL` | `30` | keepalive 间隔(秒) |
460
+ | | `SSH_SESSION_TIMEOUT` | `7200` | 会话超时(秒) |
461
+ | | `SSH_CLIENT_TYPE` | `paramiko` | SSH 客户端实现:`paramiko` / `asyncssh` |
462
+ | | `SSH_FORCE_ENV_CONFIG` | `false` | 强制 env 配置覆盖 hosts.json |
463
+ | | `SSH_SUDO_PASSWORD` | (空) | sudo 密码,配合 `use_sudo=true` 走 `sudo -S` |
464
+
456
465
  ---
457
466
 
458
467
  ## ⚠️ 依赖版本兼容性
@@ -19,8 +19,6 @@ ssh_mcp/exceptions.py
19
19
  ssh_mcp/executor.py
20
20
  ssh_mcp/key_manager.py
21
21
  ssh_mcp/logging_config.py
22
- ssh_mcp/runtime_guard.py
23
- ssh_mcp/secret_provider.py
24
22
  ssh_mcp/security.py
25
23
  ssh_mcp/server.py
26
24
  ssh_mcp/service.py
@@ -1,8 +1,8 @@
1
1
  # SSH-LICCO 版本信息
2
2
  # 所有代码中的版本信息都从这里读取
3
- # 版本号:2.3.0
3
+ # 版本号:2.4.0
4
4
 
5
- __version__ = "2.3.0"
5
+ __version__ = "2.4.0"
6
6
  __author__ = "Li Qi"
7
7
  __email__ = "1985694657@qq.com"
8
8
  __license__ = "MIT"
@@ -31,7 +31,7 @@ class ConnectionConfig(BaseModel):
31
31
  private_key_path: Path | None = Field(default=None, description="Path to private key file")
32
32
  private_key_material: str | None = Field(
33
33
  default=None,
34
- description="内存中的私钥 PEM(密钥不落地模式:由 SecretProvider 临时拉取,不写入磁盘)",
34
+ description="In-memory private key PEM (used instead of private_key_path when key is provided as a string)",
35
35
  )
36
36
  passphrase: str | None = Field(default=None, description="Passphrase for private key")
37
37
  timeout: int = Field(default=30, description="Connection timeout in seconds")
@@ -9,7 +9,6 @@ from mcp.types import TextContent
9
9
 
10
10
  from ..config_manager import SSHConfig, SSHHost
11
11
  from ..connection_config import ConnectionConfig
12
- from ..secret_provider import SecretManager, SecretProviderError
13
12
  from ..security import SecurityError, command_validator
14
13
  from .context import HandlerContext
15
14
 
@@ -94,35 +93,7 @@ async def handle_connect(ctx: HandlerContext, args: dict) -> list[TextContent]:
94
93
  or None
95
94
  )
96
95
 
97
- # 加固点 2:密钥不落地磁盘
98
- # 当 SecretManager 启用时,自动从 KMS 临时拉取私钥到内存,不读磁盘路径。
99
- private_key_material: str | None = None
100
96
  private_key_path = Path(args["private_key_path"]) if args.get("private_key_path") else None
101
- sm = SecretManager.instance()
102
- secret_material = None
103
- if sm.enabled and not private_key_path and not host_config.password:
104
- # 仅在「无磁盘私钥 + 无密码」时尝试拉取(避免覆盖显式凭证)
105
- secret_name = args.get("name") or getattr(host_config, "name", None) or host_config.host
106
- try:
107
- secret_material = sm.fetch(secret_name)
108
- private_key_material = secret_material.as_str()
109
- ctx.logger.info(
110
- "[secret] 私钥从 KMS 临时拉取到内存"
111
- f"(source={secret_material.source}, name={secret_name}),不落盘"
112
- )
113
- except SecretProviderError as e:
114
- ctx.logger.error(f"[secret] 拉取私钥失败: {e}")
115
- return [TextContent(type="text", text=f"密钥不落地模式:拉取私钥失败:{e}")]
116
- elif sm.enabled and private_key_path:
117
- return [
118
- TextContent(
119
- type="text",
120
- text=(
121
- "密钥不落地模式已启用(SSH_SECRET_PROVIDER_ENABLED=true),"
122
- "禁止使用 private_key_path 指定磁盘私钥文件。请通过 SecretManager 配置凭证。"
123
- ),
124
- )
125
- ]
126
97
 
127
98
  try:
128
99
  config = ConnectionConfig(
@@ -139,7 +110,7 @@ async def handle_connect(ctx: HandlerContext, args: dict) -> list[TextContent]:
139
110
  known_hosts_path=args.get("known_hosts_path"),
140
111
  accept_new_host_key=args.get("accept_new_host_key", True),
141
112
  private_key_path=private_key_path,
142
- private_key_material=private_key_material,
113
+ private_key_material=None,
143
114
  passphrase=args.get("passphrase"),
144
115
  sudo_password=sudo_password,
145
116
  )
@@ -221,10 +192,7 @@ async def handle_connect(ctx: HandlerContext, args: dict) -> list[TextContent]:
221
192
  )
222
193
  ]
223
194
  finally:
224
- # 加固点 2:连接建立/失败后立即清零内存中的私钥
225
- # (paramiko 已在内部把私钥加载为 PKey 对象,原始 PEM 字符串不再需要)
226
- if secret_material is not None:
227
- sm.release(secret_material)
195
+ pass
228
196
 
229
197
 
230
198
  async def handle_disconnect(ctx: HandlerContext, args: dict) -> list[TextContent]:
@@ -16,7 +16,6 @@ from .utils import (
16
16
  diagnose_exit_code,
17
17
  diagnose_startup_failure,
18
18
  ensure_session,
19
- normalize_command_for_remote_guard,
20
19
  sanitize_remote_path,
21
20
  should_run_background,
22
21
  )
@@ -28,32 +27,6 @@ async def handle_execute(ctx: HandlerContext, args: dict) -> list[TextContent]:
28
27
  timeout = args.get("timeout", 120) # 默认 120s,避免 docker pull/pg_basebackup 等长任务超时
29
28
  background = args.get("background", None)
30
29
 
31
- # 加固点 3:双层命令拦截 — 第一层(跳板机侧)
32
- # 当 remote_guard=True(或全局 SSH_REMOTE_GUARD=true)时,强制把命令规范为
33
- # 单一 argv 形式,禁止 shell 元字符,防止远端 ForceCommand 的 bash -c 解析时
34
- # 通过元字符注入绕过白名单。
35
- remote_guard = (
36
- args.get("remote_guard", False) or os.getenv("SSH_REMOTE_GUARD", "false").lower() == "true"
37
- )
38
- if remote_guard:
39
- normalized, guard_err = normalize_command_for_remote_guard(command)
40
- if guard_err:
41
- ctx.logger.warning(f"Command blocked by remote_guard normalization: {command}")
42
- return [
43
- TextContent(
44
- type="text",
45
- text=(
46
- f"❌ 命令被远端 guard 模式拦截(第一层)\n\n"
47
- f"Command: {command}\n"
48
- f"Reason: {guard_err}\n\n"
49
- f"远端已启用 ForceCommand 二次校验,命令必须为单一 argv 形式,\n"
50
- f"禁止 shell 元字符(| ; & $() ` > < && ||)、子 shell、管道。\n"
51
- f"如需复杂命令,请在跳板机本地拆分为多条简单命令分别执行。"
52
- ),
53
- )
54
- ]
55
- command = normalized
56
-
57
30
  # Resolve session via session_id / name / host / env fallback
58
31
  session_id = await ensure_session(ctx, args, handle_connect)
59
32
  if not session_id:
@@ -137,11 +137,6 @@ TOOLS: dict[str, Tool] = {
137
137
  "default": False,
138
138
  "description": "Bypass security validation for known-dangerous commands (e.g. rm -rf /path). Use with caution — only for operations you explicitly intend to perform.",
139
139
  },
140
- "remote_guard": {
141
- "type": "boolean",
142
- "default": False,
143
- "description": "加固点 3:标记远端已启用 ForceCommand 二次校验。启用后 ssh-licco 侧会强制把命令规范为单一 argv 形式下发,禁止 shell 元字符,确保远端 bash -c 解析时无法绕过白名单。",
144
- },
145
140
  },
146
141
  "required": ["command"],
147
142
  },
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
- import shlex
7
6
  from collections.abc import Awaitable, Callable
8
7
 
9
8
  from mcp.types import TextContent
@@ -69,52 +68,6 @@ async def ensure_session(
69
68
  return None
70
69
 
71
70
 
72
- def normalize_command_for_remote_guard(command: str) -> tuple[str, str | None]:
73
- """加固点 3:把命令规范为远端 ForceCommand 可安全解析的形式。
74
-
75
- remote_guard 模式下,远端 sshd 的 ForceCommand 脚本会用 `bash -c "$SSH_ORIGINAL_COMMAND"`
76
- 执行。如果原始命令包含 shell 元字符(| ; & $() ` 等),bash -c 解析时会拆分出
77
- 多条命令或子 shell,绕过 ForceCommand 脚本的白名单校验(白名单只看第一个 token)。
78
-
79
- 因此本方法在跳板机侧(第一层)就把命令强制规范为「单一命令 + 参数」形式:
80
- - 禁止管道 |、命令分隔 ;、后台 &、逻辑 && ||、命令替换 $() ``、重定向 > <、子 shell ()
81
- - 允许普通参数(含路径、引号包裹的字符串参数)
82
- - 返回 (规范化后的命令, 错误信息);错误信息非 None 表示被拦截
83
-
84
- 这样远端 ForceCommand 解析出的「基础命令」就是真实要执行的那个,无法通过元字符
85
- 注入隐藏的第二条命令。
86
- """
87
- if not command or not command.strip():
88
- return command, "命令为空"
89
-
90
- # 远端 guard 模式下禁止的元字符/构造
91
- forbidden = ["|", ";", "&", "&&", "||", "$(", ")", "`", ">", "<", "\n", "\r"]
92
- for token in forbidden:
93
- if token in command:
94
- return command, (
95
- f"命令包含禁止的 shell 元字符 '{token}'。"
96
- f"远端 guard 模式要求单一 argv,禁止管道/重定向/命令替换/子 shell。"
97
- )
98
-
99
- # 子 shell 圆括号(前面已拦 ')',这里再拦开括号 '(' 作为保险)
100
- if "(" in command:
101
- return command, "命令包含禁止的子 shell 构造 '('"
102
- # 命令长度校验
103
- if len(command) > 4096:
104
- return command, "命令过长(最大 4096 字符)"
105
-
106
- # 通过 shlex 解析验证命令格式合法(不实际执行)
107
- try:
108
- parts = shlex.split(command)
109
- except ValueError as e:
110
- return command, f"命令格式无法解析:{e}"
111
- if not parts:
112
- return command, "命令解析后为空"
113
-
114
- # 返回原命令(已通过元字符检查),远端 bash -c 会安全解析
115
- return command, None
116
-
117
-
118
71
  def diagnose_exit_code(exit_code: int, log_tail: str, stderr: str) -> str:
119
72
  """根据退出码和日志输出诊断命令失败原因"""
120
73
  combined = (log_tail + " " + stderr).lower()
@@ -116,9 +116,6 @@ class KeyManager:
116
116
  def load_key_from_str(self, private_key_pem: str, passphrase: str | None = None) -> SSHKeyPair:
117
117
  """从内存字符串加载私钥,不接触磁盘。
118
118
 
119
- 用于「密钥不落地」加固场景:私钥由 SecretProvider 从 KMS 临时拉取到内存,
120
- 通过本方法解析为 SSHKeyPair,用完即由调用方清零。
121
-
122
119
  Args:
123
120
  private_key_pem: PEM 格式私钥字符串
124
121
  passphrase: 私钥口令(可选)
@@ -161,15 +158,6 @@ class KeyManager:
161
158
  )
162
159
 
163
160
  def save_key(self, key_pair: SSHKeyPair, private_key_path: Path) -> None:
164
- # 加固点 2:密钥不落地磁盘
165
- # 当 SSH_SECRET_PROVIDER_ENABLED=true 时,禁止把私钥写入磁盘。
166
- from .secret_provider import is_secret_provider_enabled
167
-
168
- if is_secret_provider_enabled():
169
- raise PermissionError(
170
- "密钥不落地模式已启用(SSH_SECRET_PROVIDER_ENABLED=true),"
171
- "禁止将私钥写入磁盘。私钥应通过 SecretProvider 临时拉取到内存使用。"
172
- )
173
161
  private_key_path.parent.mkdir(parents=True, exist_ok=True)
174
162
  with open(private_key_path, "w") as f:
175
163
  f.write(key_pair.private_key)
@@ -1,178 +0,0 @@
1
- """SSH-LICCO 运行时守护 — 加固点 1:运行账号最小权限
2
-
3
- 生产跳板机部署 ssh-licco 时,必须以专用普通运维账号启动,禁止 root / 禁止 sudo。
4
- 本模块在进程入口(run_server)处强制校验运行身份,发现违规立即拒绝启动。
5
-
6
- 启用方式(环境变量):
7
- SSH_RUNTIME_GUARD=true # 总开关,默认 false(开发/测试不强制)
8
- SSH_RUNTIME_ALLOW_ROOT=false # 是否允许 root 启动,默认 false
9
- SSH_RUNTIME_ALLOWED_USERS= # 逗号分隔的允许账号白名单,留空表示不限制具体用户名
10
- 例如:sshlicco,ops
11
- SSH_RUNTIME_BLOCK_SUDO=true # 检测 SUDO_* / PKEXEC_* 等提权环境变量,默认 true
12
-
13
- 为什么这样设计:
14
- - 跳板机是 SSH 代理的唯一执行点,进程权限 = 可下发的最大远端命令权限上限。
15
- - root / sudo 启动会让命令白名单形同虚设:任何白名单内的命令都能以 root 身份
16
- 在跳板机本地造成破坏(例如读取本地私钥、改 hosts.json、注入 sudo_password)。
17
- - 因此"运行账号最小权限"是其它三项加固(密钥不落地、双层拦截、审批)的前置条件。
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import os
23
- import sys
24
- from dataclasses import dataclass
25
-
26
-
27
- class RuntimeGuardError(RuntimeError):
28
- """运行身份校验失败"""
29
-
30
-
31
- @dataclass
32
- class RuntimeCheckResult:
33
- ok: bool
34
- reason: str
35
- current_user: str
36
- is_root: bool
37
- is_sudo_context: bool
38
-
39
-
40
- def _posix_user_name(uid: int) -> str:
41
- try:
42
- import pwd # 仅 POSIX 可用
43
-
44
- return pwd.getpwuid(uid).pw_name
45
- except Exception:
46
- return str(uid)
47
-
48
-
49
- def _current_user_name() -> str:
50
- """获取当前运行账号名。Windows 下返回 USERNAME 环境变量(仅用于提示,不强制)。"""
51
- if os.name == "posix":
52
- try:
53
- return _posix_user_name(os.getuid())
54
- except Exception:
55
- return os.getenv("USER", "unknown")
56
- # Windows 不强制(生产跳板机本身是 Linux),仅用于日志提示
57
- return os.getenv("USERNAME") or os.getenv("USER") or "unknown-windows-user"
58
-
59
-
60
- def _is_root() -> bool:
61
- if os.name != "posix":
62
- return False
63
- return os.geteuid() == 0
64
-
65
-
66
- def _is_sudo_context() -> bool:
67
- """检测 sudo / doas / pkexec 提权环境。"""
68
- sudo_markers = (
69
- "SUDO_USER",
70
- "SUDO_UID",
71
- "SUDO_GID",
72
- "SUDO_COMMAND",
73
- "PKEXEC_UID",
74
- "DOAS_USER",
75
- )
76
- return any(os.getenv(m) for m in sudo_markers)
77
-
78
-
79
- def check_runtime_identity() -> RuntimeCheckResult:
80
- """执行运行账号最小权限校验。返回检查结果,不抛异常。"""
81
- current_user = _current_user_name()
82
- is_root = _is_root()
83
- is_sudo = _is_sudo_context()
84
-
85
- # POSIX 之外(Windows 开发环境)直接放行,生产跳板机本身是 Linux
86
- if os.name != "posix":
87
- return RuntimeCheckResult(
88
- ok=True,
89
- reason=f"non-POSIX platform ({os.name}), runtime guard skipped (dev only)",
90
- current_user=current_user,
91
- is_root=is_root,
92
- is_sudo_context=is_sudo,
93
- )
94
-
95
- allow_root = os.getenv("SSH_RUNTIME_ALLOW_ROOT", "false").lower() == "true"
96
- block_sudo = os.getenv("SSH_RUNTIME_BLOCK_SUDO", "true").lower() == "true"
97
- allowed_users_raw = os.getenv("SSH_RUNTIME_ALLOWED_USERS", "").strip()
98
- allowed_users = {u.strip() for u in allowed_users_raw.split(",") if u.strip()}
99
-
100
- # 1. root 拦截
101
- if is_root and not allow_root:
102
- return RuntimeCheckResult(
103
- ok=False,
104
- reason=(
105
- "ssh-licco 进程以 root 身份启动被拒绝。\n"
106
- " 生产跳板机必须使用专用普通运维账号运行,禁止 root 启动。\n"
107
- " 排查:检查 systemd unit 的 User= / 启动脚本的 su/sudo。\n"
108
- " 如确实需要在 root 下临时调试,设置 SSH_RUNTIME_ALLOW_ROOT=true(不推荐)。"
109
- ),
110
- current_user=current_user,
111
- is_root=is_root,
112
- is_sudo_context=is_sudo,
113
- )
114
-
115
- # 2. sudo 上下文拦截
116
- if is_sudo and block_sudo:
117
- return RuntimeCheckResult(
118
- ok=False,
119
- reason=(
120
- "ssh-licco 进程在 sudo/doas/pkexec 提权上下文中启动被拒绝。\n"
121
- " 生产跳板机禁止通过 sudo 启动 ssh-licco,请直接以专用账号登录后启动。\n"
122
- " 如需临时调试,设置 SSH_RUNTIME_BLOCK_SUDO=false(不推荐)。"
123
- ),
124
- current_user=current_user,
125
- is_root=is_root,
126
- is_sudo_context=is_sudo,
127
- )
128
-
129
- # 3. 用户白名单
130
- if allowed_users and current_user not in allowed_users:
131
- return RuntimeCheckResult(
132
- ok=False,
133
- reason=(
134
- f"ssh-licco 进程以账号 '{current_user}' 启动,不在白名单 {sorted(allowed_users)} 中。\n"
135
- " 请使用 SSH_RUNTIME_ALLOWED_USERS 中声明的专用运维账号启动。"
136
- ),
137
- current_user=current_user,
138
- is_root=is_root,
139
- is_sudo_context=is_sudo,
140
- )
141
-
142
- return RuntimeCheckResult(
143
- ok=True,
144
- reason="runtime identity OK",
145
- current_user=current_user,
146
- is_root=is_root,
147
- is_sudo_context=is_sudo,
148
- )
149
-
150
-
151
- def enforce_runtime_guard() -> None:
152
- """入口处调用:开启守护时若校验失败,打印原因并 sys.exit(2)。
153
-
154
- 未开启守护(SSH_RUNTIME_GUARD 未设为 true)时仅打印提示,不强制。
155
- """
156
- enabled = os.getenv("SSH_RUNTIME_GUARD", "false").lower() == "true"
157
- result = check_runtime_identity()
158
-
159
- if result.ok:
160
- # 即使通过,也把运行账号落到日志,便于审计
161
- print(
162
- f"[runtime_guard] identity check passed: user={result.current_user} "
163
- f"root={result.is_root} sudo_ctx={result.is_sudo_context}",
164
- file=sys.stderr,
165
- )
166
- return
167
-
168
- # 校验失败
169
- if enabled:
170
- print(f"[runtime_guard] FATAL: {result.reason}", file=sys.stderr)
171
- sys.exit(2)
172
- else:
173
- # 未开启守护,仅警告(开发/测试环境)
174
- print(
175
- f"[runtime_guard] WARNING (not enforced, set SSH_RUNTIME_GUARD=true to enforce): "
176
- f"{result.reason}",
177
- file=sys.stderr,
178
- )
@@ -1,226 +0,0 @@
1
- """SSH-LICCO 凭证管理 — 加固点 2:密钥不落地磁盘
2
-
3
- 生产跳板机托管的服务器私钥,不能以明文文件形式持久保存在跳板机磁盘上。
4
- 本模块提供 SecretProvider 抽象 + 多种后端实现,从外部密钥管理服务(KMS)临时
5
- 拉取私钥到内存中使用,进程退出(或连接关闭)时立即清理:
6
-
7
- ┌──────────────┐ fetch-on-demand ┌────────────────┐
8
- │ ssh-licco │ ───────────────────────▶ │ KMS / Vault / │
9
- │ (in memory) │ ◀─────────────────────── │ env-injected │
10
- └──────────────┘ 私钥仅存于进程内存 └────────────────┘
11
-
12
- │ atexit / session close
13
-
14
- 立即清零内存中的私钥字节
15
-
16
- 支持的后端(通过环境变量 SSH_SECRET_PROVIDER 选择):
17
-
18
- - env : 私钥直接从环境变量读取(开发/CI 用,仍不落盘)
19
- SSH_SECRET_ENV_KEY_<NAME> 指定每个连接的私钥环境变量名
20
- - command : 执行外部命令(如 vault kv get / aws secretsmanager get)拉取私钥
21
- SSH_SECRET_COMMAND_<NAME> 指定命令,stdout 即私钥内容
22
- - http : 从 HTTP(S) 接口拉取,请求头携带 SSH_SECRET_HTTP_TOKEN
23
- SSH_SECRET_HTTP_URL_<NAME> / SSH_SECRET_HTTP_TOKEN 配置
24
-
25
- 设计原则:
26
- - 私钥内容绝不写入磁盘文件;KeyManager.save_key 在「密钥不落地」模式启用时
27
- 会抛错,强制走内存路径。
28
- - 私钥字节在不再使用时显式清零(best-effort,Python 字符串不可变,但 bytes
29
- 可通过 ctypes.memset 清零;这里用 bytearray + del 最大化清理效果)。
30
- - 临时凭证缓存仅存活于进程生命周期,atexit 钩子统一清理。
31
- """
32
-
33
- from __future__ import annotations
34
-
35
- import atexit
36
- import os
37
- import subprocess
38
- import threading
39
- import urllib.request
40
- from dataclasses import dataclass
41
-
42
-
43
- class SecretProviderError(RuntimeError):
44
- """凭证拉取失败"""
45
-
46
-
47
- @dataclass
48
- class SecretMaterial:
49
- """拉取到的凭证材料。使用完应调用 wipe() 清零。"""
50
-
51
- name: str # 连接名 / 凭证标识
52
- data: bytearray # 私钥内容(bytearray 便于清零)
53
- source: str # 来源描述(env/command/http),便于审计
54
-
55
- def as_str(self) -> str:
56
- return self.data.decode("utf-8", errors="strict")
57
-
58
- def wipe(self) -> None:
59
- """清零内存中的私钥字节(best-effort)。"""
60
- for i in range(len(self.data)):
61
- self.data[i] = 0
62
- self.data.clear()
63
-
64
-
65
- class SecretProvider:
66
- """凭证提供者抽象基类。子类实现 fetch()。"""
67
-
68
- def fetch(self, name: str) -> SecretMaterial: # pragma: no cover - abstract
69
- raise NotImplementedError
70
-
71
- def close(self) -> None:
72
- """清理资源(如 HTTP keep-alive 连接)。默认无操作。"""
73
-
74
-
75
- class EnvSecretProvider(SecretProvider):
76
- """从环境变量读取私钥。"""
77
-
78
- def fetch(self, name: str) -> SecretMaterial:
79
- var = os.getenv(f"SSH_SECRET_ENV_KEY_{name.upper()}") or os.getenv(
80
- f"SSH_SECRET_ENV_KEY_{name}"
81
- )
82
- if not var:
83
- # 兼容:直接以 name 作为变量名
84
- var = os.getenv(name)
85
- if not var:
86
- raise SecretProviderError(
87
- f"env provider: 私钥环境变量未配置(尝试过 SSH_SECRET_ENV_KEY_{name.upper()} / {name})"
88
- )
89
- return SecretMaterial(name=name, data=bytearray(var.encode("utf-8")), source="env")
90
-
91
-
92
- class CommandSecretProvider(SecretProvider):
93
- """执行外部命令拉取私钥。stdout 即私钥 PEM 内容。"""
94
-
95
- def fetch(self, name: str) -> SecretMaterial:
96
- cmd = os.getenv(f"SSH_SECRET_COMMAND_{name.upper()}") or os.getenv(
97
- f"SSH_SECRET_COMMAND_{name}"
98
- )
99
- if not cmd:
100
- raise SecretProviderError(f"command provider: 未配置 SSH_SECRET_COMMAND_{name.upper()}")
101
- try:
102
- proc = subprocess.run(
103
- cmd,
104
- shell=True,
105
- capture_output=True,
106
- timeout=30,
107
- check=True,
108
- text=False, # 拿 bytes 自行管理清零
109
- )
110
- except subprocess.CalledProcessError as e:
111
- raise SecretProviderError(
112
- f"command provider: 命令退出码 {e.returncode}: {e.stderr.decode('utf-8', 'replace')}"
113
- )
114
- except subprocess.TimeoutExpired:
115
- raise SecretProviderError("command provider: 拉取私钥命令超时(>30s)")
116
- return SecretMaterial(name=name, data=bytearray(proc.stdout), source="command")
117
-
118
-
119
- class HttpSecretProvider(SecretProvider):
120
- """从 HTTP(S) 接口拉取私钥。"""
121
-
122
- def __init__(self):
123
- self._token = os.getenv("SSH_SECRET_HTTP_TOKEN", "")
124
- self._timeout = float(os.getenv("SSH_SECRET_HTTP_TIMEOUT", "15"))
125
-
126
- def fetch(self, name: str) -> SecretMaterial:
127
- url = os.getenv(f"SSH_SECRET_HTTP_URL_{name.upper()}") or os.getenv(
128
- f"SSH_SECRET_HTTP_URL_{name}"
129
- )
130
- if not url:
131
- raise SecretProviderError(f"http provider: 未配置 SSH_SECRET_HTTP_URL_{name.upper()}")
132
- req = urllib.request.Request(url)
133
- if self._token:
134
- req.add_header("Authorization", f"Bearer {self._token}")
135
- try:
136
- with urllib.request.urlopen(req, timeout=self._timeout) as resp:
137
- body = resp.read()
138
- except Exception as e:
139
- raise SecretProviderError(f"http provider: 拉取 {name} 失败: {e}")
140
- return SecretMaterial(name=name, data=bytearray(body), source="http")
141
-
142
-
143
- _PROVIDERS: dict[str, type[SecretProvider]] = {
144
- "env": EnvSecretProvider,
145
- "command": CommandSecretProvider,
146
- "http": HttpSecretProvider,
147
- }
148
-
149
-
150
- class SecretManager:
151
- """凭证管理器:单例,负责 provider 选择、内存缓存、atexit 清理。
152
-
153
- 使用方式(在 ssh_connect 处理器中):
154
- sm = SecretManager.instance()
155
- if sm.enabled:
156
- material = sm.fetch(host_name) # 拉到内存
157
- try:
158
- pair = KeyManager().load_key_from_str(material.as_str())
159
- ...用 pair 建立 SSH 连接...
160
- finally:
161
- sm.release(material) # 用完立即清零
162
- """
163
-
164
- _instance: SecretManager | None = None
165
- _lock = threading.Lock()
166
-
167
- def __init__(self):
168
- self.enabled = os.getenv("SSH_SECRET_PROVIDER_ENABLED", "false").lower() == "true"
169
- provider_kind = os.getenv("SSH_SECRET_PROVIDER", "env").lower()
170
- self._provider: SecretProvider | None = None
171
- if self.enabled:
172
- cls = _PROVIDERS.get(provider_kind)
173
- if cls is None:
174
- raise SecretProviderError(
175
- f"未知的 SSH_SECRET_PROVIDER={provider_kind!r},可选: {list(_PROVIDERS)}"
176
- )
177
- self._provider = cls()
178
- # 注册进程退出清理
179
- atexit.register(self.shutdown)
180
- # 当前进程持有的所有凭证材料(用于退出时统一清零)
181
- self._live: list[SecretMaterial] = []
182
- self._live_lock = threading.Lock()
183
-
184
- @classmethod
185
- def instance(cls) -> SecretManager:
186
- with cls._lock:
187
- if cls._instance is None:
188
- cls._instance = cls()
189
- return cls._instance
190
-
191
- def fetch(self, name: str) -> SecretMaterial:
192
- if not self.enabled or self._provider is None:
193
- raise SecretProviderError("secret manager 未启用(SSH_SECRET_PROVIDER_ENABLED=true)")
194
- material = self._provider.fetch(name)
195
- with self._live_lock:
196
- self._live.append(material)
197
- return material
198
-
199
- def release(self, material: SecretMaterial) -> None:
200
- """归还凭证:从 live 列表移除并清零。"""
201
- with self._live_lock:
202
- try:
203
- self._live.remove(material)
204
- except ValueError:
205
- pass
206
- material.wipe()
207
-
208
- def shutdown(self) -> None:
209
- """进程退出时统一清零所有未释放的凭证。"""
210
- with self._live_lock:
211
- for m in self._live:
212
- try:
213
- m.wipe()
214
- except Exception:
215
- pass
216
- self._live.clear()
217
- if self._provider is not None:
218
- try:
219
- self._provider.close()
220
- except Exception:
221
- pass
222
-
223
-
224
- def is_secret_provider_enabled() -> bool:
225
- """便捷查询:是否启用了密钥不落地模式。"""
226
- return SecretManager.instance().enabled
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