security: 机构独立 SSH key + host key 校验(accept-new 防 MITM)

- ensure_org_key: 每机构独立 ed25519 key pair(~/.ssh/org_keys/<org_id>/),防单点泄露
- org 级远程模式自动用机构 key,不允许指定任意 key
- StrictHostKeyChecking=no → accept-new,首次记录后严格校验 host key 防 MITM
This commit is contained in:
ymq 2026-08-14 11:58:48 +08:00
parent d35800a100
commit 939fd252ec
2 changed files with 56 additions and 2 deletions

View File

@ -50,6 +50,8 @@ from .work_env import (
run_remote_sandbox,
run_in_work_env,
ensure_remote_bwrap,
ensure_org_key,
get_org_pubkey,
)
__version__ = "3.5.0"

View File

@ -19,6 +19,7 @@ import shlex
import shutil
import asyncio
import logging
import subprocess
logger = logging.getLogger("pipeline.work_env")
@ -70,8 +71,12 @@ def _ssh_target(env: dict) -> str:
def _ssh_common_args(env: dict) -> list:
"""ssh/scp/rsync 共用的 SSH 参数。"""
args = ["-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10"]
"""ssh/scp/rsync 共用的 SSH 参数。
StrictHostKeyChecking=accept-new首次连接记录 host key之后严格校验
MITMhost key 变化会拒绝替代原来的 =no完全不校验
"""
args = ["-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10"]
key = env.get("remote_key_path", "")
if key:
args += ["-i", key]
@ -80,6 +85,48 @@ def _ssh_common_args(env: dict) -> list:
return args
# ── 机构独立 SSH key每机构一把防单点泄露 ────────────────
SSH_KEYS_DIR = os.path.expanduser("~/.ssh/org_keys")
def _org_key_dir(org_id: str) -> str:
"""机构 key 目录org_id sanitize 防路径穿越)。"""
safe = re.sub(r"[^a-zA-Z0-9_-]", "_", org_id or "")
if not safe:
safe = "_"
return os.path.join(SSH_KEYS_DIR, safe)
def ensure_org_key(org_id: str) -> dict:
"""确保机构有独立 SSH key paired25519没有则生成。
Returns: {"key_path", "pubkey_path", "pubkey"}
"""
d = _org_key_dir(org_id)
os.makedirs(d, mode=0o700, exist_ok=True)
priv = os.path.join(d, "id_ed25519")
pub = priv + ".pub"
if not (os.path.exists(priv) and os.path.exists(pub)):
subprocess.run(
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", priv,
"-C", "pipeline-org-" + (re.sub(r"[^a-zA-Z0-9_-]", "_", org_id) or "_")],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
os.chmod(priv, 0o600)
pubkey = ""
if os.path.exists(pub):
with open(pub, "r", encoding="utf-8") as f:
pubkey = f.read().strip()
return {"key_path": priv, "pubkey_path": pub, "pubkey": pubkey}
def get_org_pubkey(org_id: str) -> dict:
"""获取机构 public key供 admin 复制到远程主机 authorized_keys"""
info = ensure_org_key(org_id)
return {"ok": True, "org_id": org_id, "pubkey": info["pubkey"],
"key_path": info["key_path"]}
# ── 远程配置安全校验 ───────────────────────────────────────
_HOST_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$")
@ -232,6 +279,11 @@ async def set_work_env(sor, owner_type: str, owner_id: str, mode: str,
return {"ok": False, "error": "mode 必须是 local 或 remote"}
remote_config = remote_config or {}
if owner_type == "org" and mode == "remote":
# 机构级远程:自动使用机构独立 key每机构一把防单点泄露
# 不允许 admin 指定任意 key
_key_info = ensure_org_key(owner_id)
remote_config["remote_key_path"] = _key_info["key_path"]
if mode == "remote":
err = _validate_remote(remote_config)
if err: