审计报告 ~/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)。
212 lines
10 KiB
Python
212 lines
10 KiB
Python
"""Cloud Hypervisor 执行器配置解析(叶子模块,不依赖
|
||
cloud.sandbox,避免循环导入)。
|
||
|
||
职责只有两件:内存字符串解析,以及把构造参数 + 环境变量
|
||
解析成一份显式配置。参数优先于环境变量、环境变量优先于
|
||
默认值——与 #216 原 __init__ 的 `or` 链完全一致,只收紧了
|
||
两处原来会静默 fallback 的 nonsense 输入(见下文),收紧点
|
||
在 PR 描述里明写、单测钉住:
|
||
|
||
- CHV_SSH_USER 显式传空字符串:原来 `or` 链会静默 fallback
|
||
成 root(调用方以为没给用户、实际跑了 root),现在直接
|
||
ValueError。
|
||
- CHV_SSH_PORT 显式传 0、或解析后超出 1-65535:原来 0 会
|
||
静默 fallback 成 2222、其它越界值会原样进 ssh 命令,现在
|
||
直接 ValueError。
|
||
|
||
与 #215 的关系(只列清单,不复制模块):#215 未合并,
|
||
本轨不假装复用它。#215 合并后应再复用的点:超时类
|
||
(api_timeout/ready_timeout/ready_interval/task_timeout →
|
||
timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘
|
||
配额门、egress fail-closed → gates 模块)、配额类
|
||
(max_vms、disk_gb、任务成本走 worker quota → quota 模块)、
|
||
隔离类(单任务单 VM、磁盘 copy 模式、egress 代理、SSH 钉死
|
||
→ sandbox_policy 模块)。见 PR 描述的复用清单。
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
|
||
|
||
def _parse_memory_bytes(value):
|
||
"""内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。
|
||
非法值 ValueError,绝不静默按默认算。"""
|
||
m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$",
|
||
str(value))
|
||
if not m:
|
||
raise ValueError("CHV_MEM 格式非法:%r" % (value,))
|
||
mult = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3}
|
||
return int(m.group(1)) * mult[m.group(2).upper()]
|
||
|
||
|
||
class ChvConfig:
|
||
"""解析后的 CHV 配置:纯数据对象,字段名与
|
||
CloudHypervisorExecutor 原 __init__ 的属性名一一对应,
|
||
执行器 __init__ 直接把字段抄到 self 上,旧测试读属性
|
||
的方式全部不变。"""
|
||
|
||
def __init__(self, **fields):
|
||
for key, value in fields.items():
|
||
setattr(self, key, value)
|
||
|
||
|
||
def build_chv_config(params, env):
|
||
"""把显式构造参数(params,None=未给)与环境变量(env,
|
||
默认为 os.environ 的调用方传入 dict)解析成 ChvConfig。
|
||
|
||
显式参数只有 __init__ 本来就有的那些;max_vms、磁盘模式、
|
||
磁盘配额、egress、快照、预热池目标数本来就没有显式参数,
|
||
仍只走环境变量(与 #216 一致,不悄悄加新参数面)。"""
|
||
env = env if env is not None else {}
|
||
binary = params.get("binary") or env.get("CHV_BINARY") \
|
||
or "cloud-hypervisor"
|
||
kernel = params.get("kernel") or env.get("CHV_KERNEL")
|
||
rootfs = params.get("rootfs") or params.get("image") \
|
||
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE")
|
||
cpus = str(params.get("cpus") or env.get("CHV_CPU") or "2")
|
||
memory = params.get("memory") or env.get("CHV_MEM") or "2G"
|
||
state_dir = params.get("state_dir") or env.get("CHV_STATE_DIR") \
|
||
or os.path.join(os.path.expanduser("~"),
|
||
".local", "share", "codex-app-server", "chv-vms")
|
||
ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY")
|
||
if params.get("ssh_user") is not None \
|
||
and params.get("ssh_user") == "":
|
||
raise ValueError("CHV_SSH_USER 不能为空字符串")
|
||
ssh_user = params.get("ssh_user") or env.get("CHV_SSH_USER") \
|
||
or "root"
|
||
if params.get("ssh_port") is not None:
|
||
raw_port = params.get("ssh_port")
|
||
else:
|
||
raw_port = env.get("CHV_SSH_PORT") or 2222
|
||
try:
|
||
ssh_port = int(raw_port)
|
||
except (TypeError, ValueError):
|
||
raise ValueError("CHV_SSH_PORT 必须是整数")
|
||
if not 1 <= ssh_port <= 65535:
|
||
raise ValueError("CHV_SSH_PORT 必须在 1-65535 之间")
|
||
guest_host_py = params.get("guest_host_py") or "/app/host.py"
|
||
cmdline = params.get("cmdline") or env.get("CHV_CMDLINE") \
|
||
or "console=hvc0 root=/dev/vda1 rw"
|
||
net = params.get("net") or env.get("CHV_NET")
|
||
net_json = params.get("net_json") or env.get("CHV_NET_JSON")
|
||
kvm_path = params.get("kvm_path") \
|
||
if params.get("kvm_path") is not None else "/dev/kvm"
|
||
cpuinfo_path = params.get("cpuinfo_path") \
|
||
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo"
|
||
ch_remote = params.get("ch_remote") or env.get("CHV_REMOTE") \
|
||
or "ch-remote"
|
||
# P0-4 SSH 目标解析(feat-chv-fix):显式 CHV_SSH_HOST 优先;
|
||
# 其次取 VmConfig net 首个带 ip 的条目(静态 tap 客机 IP,
|
||
# 即选定的客机 IP 发现方案);两者皆无时只有显式
|
||
# CHV_SSH_FORWARD=1 才允许回环 127.0.0.1(运维宣称宿主
|
||
# NAT 已把该端口转发到客机 sshd),否则执行器在 wake/
|
||
# wait 时报 ssh_target_undeclared,绝不无条件假设回环能通。
|
||
# vsock 未选:需客内 agent + 宿主代理两套新代码(#216 已
|
||
# 否决);纯 NAT 方案未选:DNAT 是宿主防火墙外部状态,
|
||
# 执行器代码既不能创建也不能校验它,写进代码仍是假保证。
|
||
ssh_host = params.get("ssh_host") or env.get("CHV_SSH_HOST")
|
||
ssh_forward = env.get("CHV_SSH_FORWARD") == "1"
|
||
# P0-3:reuse 实验二次确认(名字即带实验含义)。
|
||
reuse_experiment = env.get(
|
||
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT") == "1"
|
||
model = params.get("model")
|
||
try:
|
||
max_vms = int(env.get("CHV_MAX_VMS") or 4)
|
||
except ValueError:
|
||
raise ValueError("CHV_MAX_VMS 必须是整数")
|
||
disk_mode = env.get("CHV_DISK_MODE") or "copy"
|
||
if disk_mode not in ("copy", "reuse"):
|
||
raise ValueError(
|
||
"CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode)
|
||
disk_gb_raw = env.get("CHV_DISK_GB")
|
||
# feat-chv-secfix(M3):配额默认开启。CHV_DISK_GB 未设时
|
||
# 不再是 None(配额门直接返回),而是 50 GB 总量预算;
|
||
# 显式设 0 或负数同样非法——配额门是 fail-closed,不是
|
||
# “设个小值绕过”。check_disk_quota 仍接受 None(直接
|
||
# 调用者的第二道门),但执行器路径永远带默认值。
|
||
try:
|
||
disk_gb = float(disk_gb_raw) if disk_gb_raw else 50.0
|
||
except ValueError:
|
||
raise ValueError("CHV_DISK_GB 必须是数字")
|
||
if disk_gb <= 0:
|
||
raise ValueError("CHV_DISK_GB 必须大于 0")
|
||
egress_proxy = env.get("CHV_EGRESS_PROXY")
|
||
allow_direct_egress = env.get("CHV_ALLOW_DIRECT_EGRESS") == "1"
|
||
host_key_mode = params.get("host_key_mode") \
|
||
or env.get("CHV_HOST_KEY_MODE") or "provisioned"
|
||
if host_key_mode not in ("provisioned", "keyscan"):
|
||
raise ValueError(
|
||
"CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan:%s"
|
||
% host_key_mode)
|
||
# feat-chv-secfix(M6):reuse 实验放行须同时声明单租户
|
||
# 项目(部署声明 + 审计,代码无法从环境变量验证数据库
|
||
# 里是否真的只有一个租户——这一点在文档与 preflight 的
|
||
# kind 里明写,不假装已校验)。
|
||
single_tenant_project = env.get("CHV_SINGLE_TENANT_PROJECT")
|
||
# feat-chv-secfix(M3 快照上限 / L2 快照目录白名单):
|
||
# 快照数、总量、保留期均有默认值;file:// 快照只允许在
|
||
# state_dir 或 CHV_SNAPSHOT_DIR 内,远端协议须显式放行
|
||
# (见 hardening.validate_snapshot_url)。
|
||
try:
|
||
snapshot_max_count = int(env.get("CHV_SNAPSHOT_MAX_COUNT")
|
||
or 10)
|
||
snapshot_max_total_gb = float(
|
||
env.get("CHV_SNAPSHOT_MAX_TOTAL_GB") or 20.0)
|
||
snapshot_retention_days = float(
|
||
env.get("CHV_SNAPSHOT_RETENTION_DAYS") or 30.0)
|
||
except ValueError:
|
||
raise ValueError("CHV_SNAPSHOT_* 必须是数字")
|
||
if snapshot_max_count < 0 or snapshot_max_total_gb < 0 \
|
||
or snapshot_retention_days < 0:
|
||
raise ValueError("CHV_SNAPSHOT_* 不能为负数")
|
||
snapshot_dir = env.get("CHV_SNAPSHOT_DIR")
|
||
allow_remote_snapshot = env.get("CHV_ALLOW_REMOTE_SNAPSHOT") \
|
||
== "1"
|
||
task_timeout_raw = env.get("CHV_TASK_TIMEOUT")
|
||
task_timeout = float(task_timeout_raw) \
|
||
if task_timeout_raw else None
|
||
snapshot_url = env.get("CHV_SNAPSHOT_URL")
|
||
try:
|
||
warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0)
|
||
except ValueError:
|
||
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
|
||
try:
|
||
api_timeout = float(
|
||
params.get("api_timeout")
|
||
if params.get("api_timeout") is not None
|
||
else env.get("CHV_API_TIMEOUT") or 30)
|
||
ready_timeout = float(
|
||
params.get("ready_timeout")
|
||
if params.get("ready_timeout") is not None
|
||
else env.get("CHV_READY_TIMEOUT") or 120)
|
||
ready_interval = float(
|
||
params.get("ready_interval")
|
||
if params.get("ready_interval") is not None
|
||
else env.get("CHV_READY_INTERVAL") or 5)
|
||
except ValueError:
|
||
raise ValueError("CHV_*_TIMEOUT/INTERVAL 必须是数字")
|
||
if ready_interval <= 0:
|
||
ready_interval = 5
|
||
return ChvConfig(
|
||
binary=binary, kernel=kernel, rootfs=rootfs, cpus=cpus,
|
||
memory=memory, state_dir=state_dir, ssh_key=ssh_key,
|
||
ssh_user=ssh_user, ssh_port=ssh_port,
|
||
guest_host_py=guest_host_py, cmdline=cmdline, net=net,
|
||
net_json=net_json, kvm_path=kvm_path,
|
||
cpuinfo_path=cpuinfo_path, ch_remote=ch_remote, model=model,
|
||
max_vms=max_vms, disk_mode=disk_mode, disk_gb=disk_gb,
|
||
ssh_host=ssh_host, ssh_forward=ssh_forward,
|
||
reuse_experiment=reuse_experiment,
|
||
egress_proxy=egress_proxy,
|
||
allow_direct_egress=allow_direct_egress,
|
||
host_key_mode=host_key_mode,
|
||
task_timeout=task_timeout, snapshot_url=snapshot_url,
|
||
warm_pool_size=warm_pool_size, api_timeout=api_timeout,
|
||
ready_timeout=ready_timeout, ready_interval=ready_interval,
|
||
single_tenant_project=single_tenant_project,
|
||
snapshot_max_count=snapshot_max_count,
|
||
snapshot_max_total_gb=snapshot_max_total_gb,
|
||
snapshot_retention_days=snapshot_retention_days,
|
||
snapshot_dir=snapshot_dir,
|
||
allow_remote_snapshot=allow_remote_snapshot)
|