审计报告 ~/workspace/chv-security-audit/REPORT.md(No-Go, Critical 0 / High 7 / Medium 8 / Low 5)代码层修复,mock 测试覆盖,真机动态验证一律未做、只列清单(见 docs/chv-production-readiness.md §11)。 High: - H1 egress 代码门:代理地址校验、直连显式允许+审计、 每 VM 独立 tap/mac/ip 校验、net 字段白名单;宿主防火墙 列为部署清单,代码门+防火墙缺一不可。 - H2 网关 key 移出 argv:provisioned seed 盘客内文件 /etc/chv/guest-env(0600)投递,传输层密钥进 argv 直接 ValueError;keyscan 模式密钥投递 fail-closed。 - H3 任务盘删除:stop/recycle/cleanup 删 disk.raw(保留期 0)+ 残留盘 GC,删失败审计+脏标记。 - H4 默认值改 provisioned:keyscan 只作显式 opt-in。 - H5 seed.iso 生成即 0600;宿主私钥 seed 盘建好后即删; 崩溃残留扫描 cleanup_stale_secrets。 - H6 state_dir 默认出 /tmp(~/.local/share/.../chv-vms, preflight 保 0700);api-socket 组/其它可写即 fail-fast。 - H7 tap 隔离校验:net.json 登记+stop 释放,共享即拒绝审计。 Medium:M1 shlex 远程转义;M2 清理失败即拒起(脏标记); M3 配额默认 50GB + 快照数/总量/保留期上限;M4 SHA256 必填 + BUILDINFO versions 实测记录(事前 lockfile 列清单); M5 host key/ preflight/失败审计补齐;M6 reuse 须声明单租户 项目;M7 容量计数只认连通 socket;M8 sshd 加固进 build.sh 断言。Low:L1 链接不跟随;L2 路径白名单;L3 失败审计; L4 双变量警告+字段白名单;L5 预热盘/任务盘对应关系定义。 旧测试:实验室默认值与生产默认值区分标注;行为已变的旧 测试按新语义更新(容量死 socket、快照白名单、磁盘路径 白名单、网关 argv、reuse 租户声明)。 测试:新 test_chv_secfix 39 项;旧 117 项 + 2 项新增断言 = 158 项;worker 相关 129 项;前端 vitest 9 项;cloud 全量 1588 项(skipped 4)。
177 lines
8.3 KiB
Python
177 lines
8.3 KiB
Python
"""Cloud Hypervisor 客机 SSH 传输(叶子模块,不依赖
|
||
cloud.sandbox,避免循环导入)。
|
||
|
||
安全模型(本轨的核心变化,替代 #216 的
|
||
StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null):
|
||
|
||
- 每 VM 一个独立 known_hosts 文件:
|
||
state_dir/<vm 名>/known_hosts(与每任务磁盘同目录,
|
||
任务销毁时不自动删——known_hosts 留在宿主 state_dir 里,
|
||
供事后审计该 VM 当时钉死的 key;VM 名本身已含任务 id,
|
||
不会串到别的 VM 上)。
|
||
- 首次信任(TOFU,边界必须写清,不假装是强验证):
|
||
首次启动时客机 host key 未知,执行器用 ssh-keyscan 经
|
||
宿主转发端口(127.0.0.1:<port>,只走本机回环)抓一次
|
||
当时呈现的 key,写入该 VM 的 known_hosts 文件;此后
|
||
同一 VM 的每次 SSH 都用 StrictHostKeyChecking=yes +
|
||
该文件校验,VM 被替换 / 中间人换 key 时 SSH 直接拒绝,
|
||
不再静默接受。首次捕获本身不验证“这台客机就是我们要
|
||
的那台”——它只防首次之后的替换;更强的方案是镜像构建
|
||
/cloud-init 时把客机 host key 预置进 known_hosts(未来
|
||
工作,见 docs/chv-production-readiness.md 的待办),本轨
|
||
在 mock 测试里只能验证“捕获→写文件→后续命令带钉死
|
||
选项”的管线,绝不声称 mock 验证了首次密钥的真实性。
|
||
- 密钥红线不变且收紧(feat-chv-secfix,H2/M1):argv 里只有
|
||
key 文件路径,绝无 key 值;网关密钥类变量(见
|
||
_CHV_SECRET_ENV_NAMES,与 Docker 的 _DOCKER_SECRET_ENV_NAMES
|
||
同款“只传名”口径,但 CHV 更进一步:值连宿主子进程环境
|
||
都不进,改走 seed 盘里的客内文件,见 hostkeys.write_seed)
|
||
出现在 extra_env 里时本函数直接 ValueError——传输层
|
||
fail-closed,任何调用方都不能再把密钥值拼回 argv。
|
||
非密钥 extra_env 仍可走 env 前缀(值在 argv 里可见,
|
||
但按定义不是密钥)。
|
||
- 远程 shell 转义(feat-chv-secfix,M1):SSH 协议的 exec
|
||
请求本身是一条字符串,ssh 客户端把多个参数用空格拼成
|
||
远程 shell 命令——此前 inner 参数原样进 argv,prompt 里
|
||
的 ;/$()/引号/换行会在远程 shell 里被二次解析。现在每个
|
||
远程 token 都经 shlex.quote 引用后仍以列表元素形式返回
|
||
(ssh 拼接后远程解析结果与本地 list 语义一致);客内
|
||
agent 本来就是 root,本项修的是“任务执行语义不被 prompt
|
||
改写 + 审计不失真”,不是“防客内提权”(客内 root 无
|
||
需注入即可为所欲为,边界见执行器文档)。
|
||
"""
|
||
|
||
import os
|
||
import shlex
|
||
|
||
# CHV 密钥类环境变量名:值绝不进 ssh argv。Docker 线是
|
||
# “-e 只传名、值由 CLI 子进程环境提供”(值仍在宿主进程
|
||
# 环境里,/proc/<pid>/environ 对同用户可见);CHV 线是
|
||
# “值只进 seed 盘客内文件”(宿主进程表与宿主进程环境里
|
||
# 都没有值),比 Docker 线更严——差异见执行器文档。
|
||
_CHV_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"})
|
||
|
||
|
||
def split_secret_env(extra_env):
|
||
"""把 extra_env 拆成(密钥子集,非密钥子集)。"""
|
||
secrets, plain = {}, {}
|
||
for key, value in dict(extra_env or {}).items():
|
||
if key in _CHV_SECRET_ENV_NAMES:
|
||
secrets[key] = value
|
||
else:
|
||
plain[key] = value
|
||
return secrets, plain
|
||
|
||
|
||
def remote_command(inner_cmd, egress_proxy=None, extra_env=None,
|
||
guest_env_file=None):
|
||
"""构造远程 shell 命令的 token 列表(不含 ssh 前缀)。
|
||
|
||
每个 token 都经 shlex.quote:ssh 客户端把 argv 用空格拼
|
||
成一条远程命令时,远程 shell 解析回来的恰好是本地 list
|
||
语义。guest_env_file 给定时,远程命令先 `set -a; . <文件>;
|
||
set +a`(把 seed 投递的密钥文件 source 进环境并 export),
|
||
再接 env 前缀与客内命令;文件路径本身不是密钥。
|
||
extra_env 含密钥名时 ValueError(调用方须先 split 出
|
||
密钥子集走 seed 投递,见 split_secret_env)。"""
|
||
if extra_env:
|
||
secrets, _ = split_secret_env(extra_env)
|
||
if secrets:
|
||
raise ValueError(
|
||
"密钥类环境变量 %s 不得进 ssh argv,须走 seed "
|
||
"文件投递" % ",".join(sorted(secrets)))
|
||
tokens = []
|
||
if guest_env_file:
|
||
tokens += ["set", "-a;", ".",
|
||
shlex.quote(guest_env_file) + ";", "set", "+a;"]
|
||
env_assignments = []
|
||
if extra_env:
|
||
for key in sorted(extra_env):
|
||
env_assignments.append("%s=%s"
|
||
% (key, shlex.quote(
|
||
str(extra_env[key]))))
|
||
if egress_proxy:
|
||
env_assignments += [
|
||
"HTTPS_PROXY=%s" % shlex.quote(egress_proxy),
|
||
"HTTP_PROXY=%s" % shlex.quote(egress_proxy),
|
||
"NO_PROXY=localhost,127.0.0.1"]
|
||
inner = [shlex.quote(str(arg)) for arg in inner_cmd]
|
||
if env_assignments:
|
||
tokens += ["env"] + env_assignments + inner
|
||
else:
|
||
tokens += inner
|
||
return tokens
|
||
|
||
|
||
def known_hosts_path(state_dir, vm_name):
|
||
"""该 VM 的 known_hosts 文件路径:state_dir/<vm>/known_hosts。"""
|
||
return os.path.join(state_dir, vm_name, "known_hosts")
|
||
|
||
|
||
def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy,
|
||
known_hosts_file, inner_cmd, ssh_host="127.0.0.1",
|
||
extra_env=None, guest_env_file=None):
|
||
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
|
||
+ user@<ssh_host> + 客内命令(远程 token 均经 shlex.quote,
|
||
见 remote_command)。
|
||
|
||
feat-chv-fix(P0-4):ssh_host 不再在传输层写死回环,
|
||
由执行器解析传入(显式 CHV_SSH_HOST / net 客机 IP /
|
||
显式转发宣称下的回环);本函数缺省回环仅为旧调用点
|
||
兼容,执行器路径绝不依赖该缺省。
|
||
feat-chv-secfix(H2):extra_env 只接受非密钥变量;
|
||
密钥类变量须先经 split_secret_env 拆出、走 seed 盘客内
|
||
文件投递,调用方把 guest_env_file 传进来(客内 seed
|
||
写出的 /etc/chv/guest-env),本函数在远程命令前 source
|
||
它;密钥值绝不出现在本函数返回的任何 argv 元素里。
|
||
egress_proxy 已设时客内命令前加 env 前缀(代理地址本身
|
||
不是密钥;客机出口只走该白名单代理——但这只是 env 约
|
||
定,网络层强制见宿主防火墙部署清单,代码不声称已隔离)。"""
|
||
remote = remote_command(inner_cmd, egress_proxy=egress_proxy,
|
||
extra_env=extra_env,
|
||
guest_env_file=guest_env_file)
|
||
return ["ssh", "-p", str(ssh_port),
|
||
"-i", ssh_key,
|
||
"-o", "StrictHostKeyChecking=yes",
|
||
"-o", "UserKnownHostsFile=%s" % known_hosts_file,
|
||
"-o", "BatchMode=yes",
|
||
"%s@%s" % (ssh_user, ssh_host)] + remote
|
||
|
||
|
||
def parse_keyscan_output(text, host="127.0.0.1"):
|
||
"""从 ssh-keyscan 的 stdout 里挑出目标主机的 host key
|
||
行:形如 "[<host>]:<port> ssh-ed25519 AAAA..."(-p 时
|
||
的标准写法)或 "<host> ssh-rsa AAAA..."。其它主机行、
|
||
注释行、空行一律丢掉,绝不把陌生主机的 key 写进文件。
|
||
host 缺省回环仅为旧调用点兼容;客机 IP 方案下应传
|
||
解析出的客机 IP。"""
|
||
kept = []
|
||
for line in (text or "").splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith("#"):
|
||
continue
|
||
parts = line.split()
|
||
if len(parts) < 3:
|
||
continue
|
||
line_host, keytype = parts[0], parts[1]
|
||
if line_host != host \
|
||
and not line_host.startswith("[%s]:" % host):
|
||
continue
|
||
if not keytype.startswith("ssh-"):
|
||
continue
|
||
kept.append(line)
|
||
return kept
|
||
|
||
|
||
def write_known_hosts(path, key_lines):
|
||
"""原子写 known_hosts:先写同目录临时文件再 rename,
|
||
文件权限 0600(只含公钥行,仍按敏感文件对待)。"""
|
||
directory = os.path.dirname(path)
|
||
if directory:
|
||
os.makedirs(directory, exist_ok=True)
|
||
tmp_path = path + ".tmp"
|
||
with open(tmp_path, "w") as fh:
|
||
fh.write("\n".join(key_lines) + "\n")
|
||
os.chmod(tmp_path, 0o600)
|
||
os.replace(tmp_path, path)
|