feat(chv): 安全审计修复 High/Medium/Low(堆叠于 #239)

审计报告 ~/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)。
This commit is contained in:
host committed 2026-10-31 03:02:10 +00:00
1 parent 003ea357f9
commit baf26beb78
16 files changed
+2106 -132

No files matched your search

+91 -22
View File
@@ -21,11 +21,86 @@ StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null):
工作,见 docs/chv-production-readiness.md 的待办),本轨
在 mock 测试里只能验证“捕获→写文件→后续命令带钉死
选项”的管线,绝不声称 mock 验证了首次密钥的真实性。
- 密钥红线不变:argv 里只有 key 文件路径,绝无 key 值;
known_hosts 文件只含 host key 公钥行,不含任何私钥。
- 密钥红线不变且收紧(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):
@@ -35,38 +110,32 @@ def known_hosts_path(state_dir, vm_name):
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):
extra_env=None, guest_env_file=None):
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
+ user@<ssh_host> + 客内命令。
+ user@<ssh_host> + 客内命令(远程 token 均经 shlex.quote,
见 remote_command)。
feat-chv-fix(P0-4):ssh_host 不再在传输层写死回环,
由执行器解析传入(显式 CHV_SSH_HOST / net 客机 IP /
显式转发宣称下的回环);本函数缺省回环仅为旧调用点
兼容,执行器路径绝不依赖该缺省。
extra_env(feat-chv-fix,P1-1 网关注入):经同一 env
前缀进客机环境(与 egress 代理前缀同款机制),不进
镜像/代码/prompt;值本身只出现在宿主侧 ssh argv(与
worker 同一信任边界),执行器绝不把完整命令记入日志。
feat-chv-secfix(H2):extra_env 只接受非密钥变量;
密钥类变量须先经 split_secret_env 拆出、走 seed 盘客内
文件投递,调用方把 guest_env_file 传进来(客内 seed
写出的 /etc/chv/guest-env),本函数在远程命令前 source
它;密钥值绝不出现在本函数返回的任何 argv 元素里。
egress_proxy 已设时客内命令前加 env 前缀(代理地址本身
不是密钥;客机出口只走该白名单代理)。"""
inner = list(inner_cmd)
env_assignments = []
if extra_env:
for key in sorted(extra_env):
env_assignments.append("%s=%s" % (key, extra_env[key]))
if egress_proxy:
env_assignments += [
"HTTPS_PROXY=%s" % egress_proxy,
"HTTP_PROXY=%s" % egress_proxy,
"NO_PROXY=localhost,127.0.0.1"]
if env_assignments:
inner = ["env"] + env_assignments + inner
不是密钥;客机出口只走该白名单代理——但这只是 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)] + inner
"%s@%s" % (ssh_user, ssh_host)] + remote
def parse_keyscan_output(text, host="127.0.0.1"):