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

+69 -5
View File
@@ -31,6 +31,13 @@
"""
import os
import shlex
# seed 投递的客内密钥环境文件路径(feat-chv-secfix,H2):
# 只在 provisioned 模式的 seed user-data 里写出,0600 root,
# 客内 host.py 命令执行前由远程命令 source 进环境;宿主侧
# 除 seed 目录/user-data(随 VM 销毁而删)外不另存该文件。
GUEST_ENV_PATH = "/etc/chv/guest-env"
def host_key_paths(state_dir, vm_name):
@@ -44,6 +51,25 @@ def host_key_paths(state_dir, vm_name):
}
def dirty_marker_path(state_dir, vm_name):
"""清理失败脏标记路径(feat-chv-secfix,M2):stop_vm
清理 seed/私钥/磁盘任一步失败时写下该标记;标记存在
时同名 VM 拒绝启动(清理失败即拒起),人工清理并删
标记后才能再起——“已审计即可”不再当作根治。"""
return os.path.join(state_dir, vm_name, ".cleanup-dirty")
def delete_host_private_key(state_dir, vm_name):
"""删宿主侧 host 私钥文件(feat-chv-secfix,H5):seed
盘建好后宿主私钥已无存在必要(客机已有私钥、宿主只需
公钥 + known_hosts 做校验)。删失败即 OSError,由调用
方 fail-fast(VM 尚未启动,此时拒绝是最安全的)。"""
path = host_key_paths(state_dir, vm_name)["private"]
if os.path.exists(path):
os.remove(path)
return path
def known_hosts_line(port, public_key_text, host="127.0.0.1"):
"""公钥文件内容转 known_hosts 行:只取前两列(类型+数据)。
@@ -86,6 +112,12 @@ def build_seed_disk(seed_dir, seed_disk, runner=None):
raise RuntimeError("genisoimage 失败:%s" % result.stderr)
if not os.path.isfile(seed_disk) or os.path.getsize(seed_disk) == 0:
raise RuntimeError("seed 磁盘未生成:%s" % seed_disk)
# feat-chv-secfix(H5):seed.iso 内含 host 私钥明文
# user-data,生成即 0600,不再取 umask 默认 0644。
try:
os.chmod(seed_disk, 0o600)
except OSError as exc:
raise RuntimeError("seed 磁盘 chmod 0600 失败:%s" % exc)
return seed_disk
@@ -124,11 +156,19 @@ def build_seed_user_data(private_key_text, public_key_text):
return "\n".join(lines) + "\n"
def write_seed(seed_dir, vm_name, private_key_text, public_key_text):
def write_seed(seed_dir, vm_name, private_key_text, public_key_text,
secret_env=None):
"""写 seed 目录:meta-data + user-data(均为 0600)。
返回 seed 目录路径。seed 目录本身不含已知主机之外的
共享数据,调用方(执行器关闭/回收)负责删除它。"""
共享数据,调用方(执行器关闭/回收)负责删除它。
feat-chv-secfix(H2):secret_env 给定时,user-data 再
写一个 write_files 条目,把密钥环境变量写进客内
GUEST_ENV_PATH(0600 root),值经 shlex.quote 引用后
才进文件内容——远程命令 source 该文件即可得到密钥
环境,密钥值从不出现在宿主 ssh argv、宿主进程环境与
审计明文里。secret_env 为空时不写该条目(客内文件不
存在时远程命令也不 source 它,见执行器)。"""
os.makedirs(seed_dir, exist_ok=True)
meta_path = os.path.join(seed_dir, "meta-data")
user_path = os.path.join(seed_dir, "user-data")
@@ -136,8 +176,30 @@ def write_seed(seed_dir, vm_name, private_key_text, public_key_text):
fh.write("instance-id: %s\nlocal-hostname: %s\n"
% (vm_name, vm_name))
os.chmod(meta_path, 0o600)
user_data = build_seed_user_data(private_key_text, public_key_text)
if secret_env:
# 注意:只能把条目并进已有的 write_files 列表,绝不能
# 再写一个 write_files: 键(YAML 重复键会覆盖掉 host
# key 的两条写文件条目,预置即失效)。条目插在 runcmd
# 段之前、仍属同一列表。
lines = [
" - path: %s" % GUEST_ENV_PATH,
" permissions: '0600'",
" owner: root:root",
" content: |",
]
for key in sorted(secret_env):
lines.append(" %s=%s"
% (key, shlex.quote(str(secret_env[key]))))
# 条目插在 runcmd 段之前、仍属同一 write_files 列表。
marker = "runcmd:\n"
if marker in user_data:
head, tail = user_data.split(marker, 1)
user_data = head + "\n".join(lines) + "\n" + marker + tail
else:
user_data = user_data + "\n".join(lines) + "\n"
with open(user_path, "w") as fh:
fh.write(build_seed_user_data(private_key_text, public_key_text))
fh.write(user_data)
os.chmod(user_path, 0o600)
return seed_dir
@@ -175,6 +237,8 @@ def generate_host_keypair(state_dir, vm_name, runner=None,
raise RuntimeError("ssh-keygen 未生成 host key 对")
try:
os.chmod(paths["private"], 0o600)
except OSError:
pass
except OSError as exc:
# feat-chv-secfix(H5):私钥 chmod 失败不再静默
# pass——权限不对的私钥文件绝不能留着继续用。
raise RuntimeError("host 私钥 chmod 0600 失败:%s" % exc)
return paths