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

+150
View File
@@ -85,6 +85,14 @@ def provision_task_disk(state_dir, vm_name, rootfs, disk_mode):
task_dir = os.path.join(state_dir, vm_name)
os.makedirs(task_dir, exist_ok=True)
task_disk = os.path.join(task_dir, "disk.raw")
# feat-chv-secfix(L1):目标是符号链接时先删链接本身,
# 绝不跟随链接写入目标文件(copyfile 跟随链接会截断并
# 写进链接目标)。删链接与重建之间的 TOCTOU 窗口已在
# 文档里明写:state_dir 须 0700 且仅 worker 用户可写,
# 宿主本地攻击者本就写不进该目录,L1 修的是“链接已
# 预置”情形下的最后一道门。
if os.path.islink(task_disk):
os.remove(task_disk)
if os.path.exists(task_disk):
try:
same = os.path.getsize(task_disk) == os.path.getsize(rootfs)
@@ -102,3 +110,145 @@ def provision_task_disk(state_dir, vm_name, rootfs, disk_mode):
"disk_quota_unverified",
"任务磁盘拷贝失败:%s" % exc)
return task_disk
def task_disk_path(state_dir, vm_name):
"""该 VM 的任务磁盘路径:state_dir/<vm>/disk.raw。"""
return os.path.join(state_dir, vm_name, "disk.raw")
def delete_task_disk(state_dir, vm_name):
"""删任务磁盘(feat-chv-secfix,H3):stop/recycle/
清理路径都调它。返回 True 表示确实删掉了一个文件,
False 表示本来就没有;删失败抛 OSError(调用方记审计,
绝不静默——残留盘由 gc_stale_disks 下次再清)。
符号链接只删链接本身,不跟随。"""
path = task_disk_path(state_dir, vm_name)
if os.path.islink(path) or os.path.exists(path):
os.remove(path)
return True
return False
# -- 快照登记与上限(feat-chv-secfix,M3)-------------------------
# 登记文件:state_dir/<vm>/snapshots.json,内容为
# [{url, path(file:// 才有), size, created_at}]。登记是
# 执行器在宿主侧的账本,不是 VMM 的快照元数据;VMM 不认
# 它,删登记不等于删快照文件(反之亦然),两者在
# prune/record 里成对处理并审计。
def _snapshots_path(state_dir, vm_name):
return os.path.join(state_dir, vm_name, "snapshots.json")
def _load_snapshots(state_dir, vm_name):
import json
path = _snapshots_path(state_dir, vm_name)
try:
with open(path, "r") as fh:
data = json.load(fh)
except (OSError, ValueError):
return []
return data if isinstance(data, list) else []
def _save_snapshots(state_dir, vm_name, entries):
import json
path = _snapshots_path(state_dir, vm_name)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(entries, fh)
os.replace(tmp_path, path)
def prune_snapshots(state_dir, vm_name, retention_days,
now=None, delete_files=True):
"""按保留期剪枝:删过期条目;delete_files 时同时删
对应的 file:// 快照文件(只删登记里记的 path,不猜
文件名)。返回(保留条目,剪掉条目)。"""
import time
now = time.time() if now is None else now
kept, pruned = [], []
for entry in _load_snapshots(state_dir, vm_name):
created = entry.get("created_at") or 0
if retention_days is not None \
and now - created > retention_days * 86400:
pruned.append(entry)
if delete_files and entry.get("path"):
try:
if os.path.exists(entry["path"]):
os.remove(entry["path"])
except OSError:
pass
else:
kept.append(entry)
if pruned:
_save_snapshots(state_dir, vm_name, kept)
return kept, pruned
def check_snapshot_limits(state_dir, vm_name, max_count,
max_total_gb, retention_days):
"""快照上限预检(snapshot_vm 调 ch-remote 之前调):
先按保留期剪枝,再查数量(已有数 + 本次 > 上限即
snapshot_quota_exceeded)与总量(已有总量已达上限即
拒绝;本次大小事前未知,总量在 record 时二次收紧,
见 record_snapshot)。返回剪枝后条目。"""
kept, _ = prune_snapshots(state_dir, vm_name, retention_days)
if len(kept) + 1 > max_count:
raise ChvFailure(
"snapshot_quota_exceeded",
"快照数量已达上限 %d(VM %s)" % (max_count, vm_name))
total = 0
for entry in kept:
size = entry.get("size")
if isinstance(size, (int, float)):
total += size
elif entry.get("path") and os.path.exists(entry["path"]):
try:
total += os.path.getsize(entry["path"])
except OSError:
pass
if total >= max_total_gb * 1024 ** 3:
raise ChvFailure(
"snapshot_quota_exceeded",
"快照总量 %d 字节已达上限 %.1f GB(VM %s)"
% (total, max_total_gb, vm_name))
return kept
def record_snapshot(state_dir, vm_name, snapshot_url, max_total_gb):
"""快照成功后登记;file:// 目标记实际大小。登记后若
总量超上限,按最旧优先删登记外的旧快照文件(总量硬
帽,删动作由调用方审计——本函数返回被删的旧条目)。
返回(全部条目,被总量帽删掉的旧条目)。"""
import time
path = None
size = None
if snapshot_url.startswith("file://"):
path = snapshot_url[len("file://"):]
try:
size = os.path.getsize(path)
except OSError:
size = None
entries = _load_snapshots(state_dir, vm_name)
entries.append({"url": snapshot_url, "path": path,
"size": size, "created_at": time.time()})
_save_snapshots(state_dir, vm_name, entries)
removed = []
if max_total_gb is not None:
cap = max_total_gb * 1024 ** 3
total = sum(e.get("size") or 0 for e in entries)
while len(entries) > 1 and total > cap:
oldest = entries.pop(0)
if oldest.get("path") and os.path.exists(oldest["path"]):
try:
os.remove(oldest["path"])
except OSError:
pass
total -= oldest.get("size") or 0
removed.append(oldest)
if removed:
_save_snapshots(state_dir, vm_name, entries)
return entries, removed