审计报告 ~/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)。
197 lines
7.8 KiB
Python
197 lines
7.8 KiB
Python
"""CHV 安全加固 helper(feat-chv-secfix,叶子模块)。
|
||
|
||
这里只放不依赖执行器类的纯校验/文件操作:state_dir 安全
|
||
目录、VMM socket 活性、net 条目隔离校验、快照 URL 白名单、
|
||
egress 代理地址校验。宿主防火墙规则、tap 桥接本身是运维
|
||
外部状态,代码建不出来也不校验——那部分只写进部署清单
|
||
(docs/chv-production-readiness.md §11),绝不在代码里
|
||
假装已隔离。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import socket
|
||
|
||
from cloud.chv.errors import ChvFailure
|
||
|
||
# NET_JSON 条目字段白名单(L4):tap/mac/ip/mask 之外的字段
|
||
# 一律拒绝——未知字段原样送进 VMM API 的行为不确定,静默
|
||
# 透传等于把 VMM 的字段语义赌在运维手写上。
|
||
_NET_FIELDS = frozenset({"tap", "mac", "ip", "mask"})
|
||
|
||
|
||
def ensure_secure_state_dir(state_dir):
|
||
"""建 state_dir 并保证 0700(H6):目录不存在即按 0700
|
||
建;已存在但组/其它位可访时 chmod 0700;失败即
|
||
ChvFailure(state_dir_insecure),绝不带着 /tmp 式
|
||
宽松目录启动 VM。umask 影响 mkdir 的默认权限,故建
|
||
后一律显式 chmod 校验一次。"""
|
||
try:
|
||
os.makedirs(state_dir, exist_ok=True)
|
||
os.chmod(state_dir, 0o700)
|
||
except OSError as exc:
|
||
raise ChvFailure(
|
||
"state_dir_insecure",
|
||
"state_dir 无法建成安全目录(0700):%s:%s"
|
||
% (state_dir, exc))
|
||
try:
|
||
mode = os.stat(state_dir).st_mode & 0o777
|
||
except OSError as exc:
|
||
raise ChvFailure(
|
||
"state_dir_insecure",
|
||
"state_dir 无法校验权限:%s:%s" % (state_dir, exc))
|
||
if mode & 0o077:
|
||
raise ChvFailure(
|
||
"state_dir_insecure",
|
||
"state_dir 权限仍宽松(%o),拒绝启动:%s"
|
||
% (mode, state_dir))
|
||
return state_dir
|
||
|
||
|
||
def socket_is_live(socket_path):
|
||
"""VMM api-socket 是否真有监听者(M7):能连通才算活。
|
||
普通文件、空文件、已死 VMM 的残留 socket 一律算死——
|
||
容量计数不再被残留文件占满。连接失败的任何异常都算
|
||
死(不区分 refused/超时,计数是尽力而为的读操作)。
|
||
注意竞态:刚启动、尚未 listen 的 VMM 会被判死,容量
|
||
门在并发唤醒时可能短暂低估——文档明写该窗口,容量门
|
||
是“拒绝超限”的护栏,不是精确的分布式锁。"""
|
||
try:
|
||
if not socket_path or not os.path.exists(socket_path):
|
||
return False
|
||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
try:
|
||
sock.settimeout(0.5)
|
||
return sock.connect_ex(socket_path) == 0
|
||
finally:
|
||
sock.close()
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def parse_net_entries(net_json):
|
||
"""解析 CHV_NET_JSON 为条目 list(非法 JSON / 非
|
||
list/dict 即 net_config_invalid)。"""
|
||
if not net_json:
|
||
return []
|
||
try:
|
||
net = json.loads(net_json)
|
||
except ValueError:
|
||
raise ChvFailure("net_config_invalid",
|
||
"CHV_NET_JSON 不是合法 JSON")
|
||
entries = net if isinstance(net, list) else [net]
|
||
if not all(isinstance(e, dict) for e in entries):
|
||
raise ChvFailure("net_config_invalid",
|
||
"CHV_NET_JSON 条目须为 dict")
|
||
return entries
|
||
|
||
|
||
def validate_net_entries(entries, existing_entries):
|
||
"""net 条目隔离校验(H7 + L4):
|
||
|
||
- 字段白名单:未知字段即 net_config_invalid;
|
||
- 条目内 tap/mac/ip 归一化(mac 小写、空 tap 视为未
|
||
指定,不参与 tap 冲突比较——空 tap 是“VMM 默认网卡”
|
||
的运维写法,代码不编造 tap 名,但空 tap 多 VM 共用
|
||
默认网卡的风险仍在部署清单里明写);
|
||
- 本次条目内 tap/mac/ip 重复即 net_config_invalid;
|
||
- 与已登记 VM(existing_entries,每项为 {tap, mac, ip,
|
||
vm})的 tap/mac/ip 任一重复即 net_tap_conflict,
|
||
拒绝启动并由调用方审计。
|
||
返回归一化后的条目 list。"""
|
||
seen_taps, seen_macs, seen_ips = set(), set(), set()
|
||
normalized = []
|
||
for entry in entries:
|
||
unknown = set(entry) - _NET_FIELDS
|
||
if unknown:
|
||
raise ChvFailure(
|
||
"net_config_invalid",
|
||
"CHV_NET_JSON 条目含未知字段:%s(白名单:%s)"
|
||
% (",".join(sorted(unknown)),
|
||
",".join(sorted(_NET_FIELDS))))
|
||
tap = str(entry.get("tap") or "").strip()
|
||
mac = str(entry.get("mac") or "").strip().lower()
|
||
ip = str(entry.get("ip") or "").strip()
|
||
for kind, value, seen in (("tap", tap, seen_taps),
|
||
("mac", mac, seen_macs),
|
||
("ip", ip, seen_ips)):
|
||
if not value:
|
||
continue
|
||
if kind == "tap" and not tap:
|
||
continue
|
||
if value in seen:
|
||
raise ChvFailure(
|
||
"net_config_invalid",
|
||
"CHV_NET_JSON 内 %s 重复:%s" % (kind, value))
|
||
seen.add(value)
|
||
normalized.append({"tap": tap, "mac": mac, "ip": ip,
|
||
"mask": str(entry.get("mask") or "")})
|
||
for entry in normalized:
|
||
for other in existing_entries:
|
||
for kind in ("tap", "mac", "ip"):
|
||
value = entry.get(kind)
|
||
if value and value == other.get(kind):
|
||
raise ChvFailure(
|
||
"net_tap_conflict",
|
||
"net %s %s 已被 VM %s 使用,拒绝共享"
|
||
" tap/mac/ip:每 VM 必须独立"
|
||
% (kind, value, other.get("vm")))
|
||
return normalized
|
||
|
||
|
||
def validate_egress_proxy(egress_proxy):
|
||
"""egress 代理地址校验(H1 代码门的一部分):须为
|
||
http/https URL 且带主机名;否则 net_config_invalid…
|
||
实际 kind 用 config_invalid(preflight 的配置类失败)。
|
||
空值由调用方另行处理(未设 + 未显式允许直连时
|
||
preflight 已有 egress_not_configured)。"""
|
||
if not egress_proxy:
|
||
return None
|
||
try:
|
||
from urllib.parse import urlparse
|
||
parsed = urlparse(egress_proxy)
|
||
except ValueError:
|
||
parsed = None
|
||
if parsed is None or parsed.scheme not in ("http", "https") \
|
||
or not parsed.hostname:
|
||
raise ChvFailure(
|
||
"config_invalid",
|
||
"CHV_EGRESS_PROXY 必须是 http/https 代理地址:%r"
|
||
% (egress_proxy,))
|
||
return egress_proxy
|
||
|
||
|
||
def _is_within(directory, path):
|
||
directory = os.path.realpath(directory)
|
||
path = os.path.realpath(path)
|
||
return path == directory or path.startswith(directory + os.sep)
|
||
|
||
|
||
def validate_snapshot_url(snapshot_url, state_dir, snapshot_dir=None,
|
||
allow_remote=False):
|
||
"""快照 URL 白名单(L2):
|
||
|
||
- file://:目标须在 state_dir 或 CHV_SNAPSHOT_DIR 内,
|
||
否则 snapshot_path_not_allowed;
|
||
- 其它协议(含无 :// 的裸路径由调用方先补 file://):
|
||
须显式 CHV_ALLOW_REMOTE_SNAPSHOT=1,否则
|
||
snapshot_remote_not_allowed。
|
||
返回 file:// 目标的本地路径(非 file:// 时返回 None)。"""
|
||
if snapshot_url.startswith("file://"):
|
||
path = snapshot_url[len("file://"):]
|
||
allowed = [state_dir]
|
||
if snapshot_dir:
|
||
allowed.append(snapshot_dir)
|
||
if not any(_is_within(d, path) for d in allowed):
|
||
raise ChvFailure(
|
||
"snapshot_path_not_allowed",
|
||
"file:// 快照须在 state_dir 或 CHV_SNAPSHOT_DIR "
|
||
"内:%s" % snapshot_url)
|
||
return path
|
||
if not allow_remote:
|
||
raise ChvFailure(
|
||
"snapshot_remote_not_allowed",
|
||
"非 file:// 快照须显式设 CHV_ALLOW_REMOTE_SNAPSHOT=1:%s"
|
||
% snapshot_url)
|
||
return None
|