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:
1 parent
003ea357f9
commit
baf26beb78
16 files changed
+2106
-132
No files matched your search
+65
-13
@@ -150,14 +150,23 @@ class ChvFixCase(unittest.TestCase):
|
||||
class SeedDiskTest(ChvFixCase):
|
||||
def test_vm_config_carries_seed_disk_readonly(self):
|
||||
ex = self.make_exec()
|
||||
cfg = ex.build_vm_config("/tmp/x/disk.raw",
|
||||
seed_disk="/tmp/x/seed.iso")
|
||||
# feat-chv-secfix(L2):磁盘路径须在 state_dir 内,
|
||||
# 旧 /tmp/x 路径不再合法,改用 state_dir 下路径。
|
||||
disk = os.path.join(self.state_dir, "x", "disk.raw")
|
||||
seed = os.path.join(self.state_dir, "x", "seed.iso")
|
||||
cfg = ex.build_vm_config(disk, seed_disk=seed)
|
||||
self.assertEqual(len(cfg["disks"]), 2)
|
||||
self.assertEqual(cfg["disks"][1]["path"], "/tmp/x/seed.iso")
|
||||
self.assertEqual(cfg["disks"][1]["path"], seed)
|
||||
self.assertTrue(cfg["disks"][1]["readonly"])
|
||||
cfg2 = ex.build_vm_config("/tmp/x/disk.raw")
|
||||
cfg2 = ex.build_vm_config(disk)
|
||||
self.assertEqual(len(cfg2["disks"]), 1)
|
||||
|
||||
def test_vm_config_rejects_disk_outside_state_dir(self):
|
||||
ex = self.make_exec()
|
||||
with self.assertRaises(ChvFailure) as ctx:
|
||||
ex.build_vm_config("/tmp/x/disk.raw")
|
||||
self.assertEqual(ctx.exception.kind, "disk_path_not_allowed")
|
||||
|
||||
def test_provisioned_wake_mounts_seed_disk(self):
|
||||
ex = self.make_exec(
|
||||
env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
|
||||
@@ -208,8 +217,11 @@ class RestoreNoFallbackTest(ChvFixCase):
|
||||
env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
|
||||
launches = []
|
||||
ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True)
|
||||
# feat-chv-secfix(L2):恢复源须在白名单目录内,
|
||||
# 本测试的意图是“起 VMM 前失败”,用 state_dir 下路径。
|
||||
snap_url = "file://" + os.path.join(self.state_dir, "snap", "x")
|
||||
with self.assertRaises(ChvFailure) as ctx:
|
||||
ex.restore_vm({"id": "r1"}, "file:///snap/x")
|
||||
ex.restore_vm({"id": "r1"}, snap_url)
|
||||
self.assertEqual(ctx.exception.kind, "host_key_not_pinned")
|
||||
self.assertEqual(launches, [])
|
||||
|
||||
@@ -226,7 +238,9 @@ class ReuseBlockTest(ChvFixCase):
|
||||
def test_reuse_passes_with_experiment_flag_and_is_audited(self):
|
||||
ex = self.make_exec(env_extra={
|
||||
"CHV_DISK_MODE": "reuse",
|
||||
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1"})
|
||||
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1",
|
||||
# feat-chv-secfix(M6):实验放行还须声明单租户项目。
|
||||
"CHV_SINGLE_TENANT_PROJECT": "p1"})
|
||||
self.assertTrue(ex.preflight())
|
||||
out = ex.wake_vm({"id": "reuse1"})
|
||||
self.assertEqual(out["state"], "running")
|
||||
@@ -264,12 +278,19 @@ class SshTargetTest(ChvFixCase):
|
||||
# -- P1-1 网关 extra_env ------------------------------------------------
|
||||
class GatewayExtraEnvTest(ChvFixCase):
|
||||
def test_chv_ssh_command_carries_extra_env(self):
|
||||
# feat-chv-secfix(H2):密钥值绝不再进 argv。
|
||||
# 非密钥 extra_env 仍走 env 前缀;密钥子集须 seed
|
||||
# 投递后才 source 客内文件(见 worker 网关测试)。
|
||||
ex = self.make_exec()
|
||||
cmd = ex.build_ssh_command({"id": "t"}, ["true"],
|
||||
extra_env={"SUB2API_API_KEY": "k1"})
|
||||
extra_env={"SUB2API_BASE_URL":
|
||||
"http://gw/"})
|
||||
joined = " ".join(cmd)
|
||||
self.assertIn("SUB2API_API_KEY=k1", joined)
|
||||
self.assertIn("SUB2API_BASE_URL=http://gw/", joined)
|
||||
self.assertIn("HTTPS_PROXY=http://10.0.0.1:8888", joined)
|
||||
with self.assertRaises(ChvFailure):
|
||||
ex.build_ssh_command({"id": "t"}, ["true"],
|
||||
extra_env={"SUB2API_API_KEY": "k1"})
|
||||
|
||||
def test_worker_gateway_guard_allows_chv(self):
|
||||
from cloud.server import init_db
|
||||
@@ -287,8 +308,20 @@ class GatewayExtraEnvTest(ChvFixCase):
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(result["status"], "completed")
|
||||
ssh_calls = [c for c in self.calls if c and c[0] == "ssh"]
|
||||
self.assertTrue(any("SUB2API_API_KEY=gwkey" in " ".join(c)
|
||||
# feat-chv-secfix(H2):密钥值不得出现在任何 ssh
|
||||
# argv 里;远程命令改为 source 客内 seed 密钥文件。
|
||||
self.assertFalse(any("SUB2API_API_KEY=gwkey" in " ".join(c)
|
||||
for c in ssh_calls))
|
||||
self.assertTrue(any("/etc/chv/guest-env" in " ".join(c)
|
||||
for c in ssh_calls))
|
||||
# 任务结束 stop 后 seed 目录与宿主私钥均已清(H3/H5;
|
||||
# seed 内容本身的断言见 test_chv_secfix 的直接
|
||||
# provision_host_key 测试,stop 前 seed 仍在)。
|
||||
self.assertFalse(os.path.exists(
|
||||
os.path.join(self.state_dir, "chv-gw1", "seed")))
|
||||
self.assertFalse(os.path.exists(
|
||||
os.path.join(self.state_dir, "chv-gw1",
|
||||
"ssh_host_ed25519_key")))
|
||||
|
||||
|
||||
# -- P1-2 取消/清理/看门狗 ----------------------------------------------
|
||||
@@ -420,14 +453,30 @@ class PoolAcquireResumeTest(unittest.TestCase):
|
||||
# -- P1-4 容量持久化计数 -------------------------------------------------
|
||||
class CapacityPersistentTest(ChvFixCase):
|
||||
def test_socket_counts_after_restart(self):
|
||||
# feat-chv-secfix(M7):只有真能连通的 socket 才
|
||||
# 计数——用真实监听 socket 代表活 VMM。
|
||||
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
|
||||
os.makedirs(self.state_dir, exist_ok=True)
|
||||
sock_path = os.path.join(self.state_dir, "chv-other.sock")
|
||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
srv.bind(sock_path)
|
||||
srv.listen(1)
|
||||
try:
|
||||
with self.assertRaises(ChvFailure) as ctx:
|
||||
ex.wake_vm({"id": "new1"})
|
||||
self.assertEqual(ctx.exception.kind, "host_capacity_exceeded")
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
def test_dead_socket_file_does_not_block(self):
|
||||
# M7 的另一面:空文件/死 socket 不再占满容量。
|
||||
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
|
||||
os.makedirs(self.state_dir, exist_ok=True)
|
||||
with open(os.path.join(self.state_dir, "chv-other.sock"),
|
||||
"w") as fh:
|
||||
fh.write("")
|
||||
with self.assertRaises(ChvFailure) as ctx:
|
||||
ex.wake_vm({"id": "new1"})
|
||||
self.assertEqual(ctx.exception.kind, "host_capacity_exceeded")
|
||||
out = ex.wake_vm({"id": "new1"})
|
||||
self.assertEqual(out["state"], "running")
|
||||
|
||||
def test_own_socket_does_not_block_rewake(self):
|
||||
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
|
||||
@@ -523,8 +572,11 @@ class ApiTruncationTest(unittest.TestCase):
|
||||
class SnapshotPersistTest(ChvFixCase):
|
||||
def test_snapshot_missing_file_does_not_shutdown(self):
|
||||
ex = self.make_exec()
|
||||
# feat-chv-secfix(L2):快照须在白名单目录内,
|
||||
# 用 state_dir 下的不存在路径,仍验 snapshot_not_persisted。
|
||||
missing = os.path.join(self.state_dir, "missing.snap")
|
||||
with self.assertRaises(ChvFailure) as ctx:
|
||||
ex.hibernate_vm({"id": "h1"}, "file:///nonexistent/snap")
|
||||
ex.hibernate_vm({"id": "h1"}, "file://" + missing)
|
||||
self.assertEqual(ctx.exception.kind, "snapshot_not_persisted")
|
||||
kinds = [c[-1] for c in self.calls if c[0] == self.ch_remote]
|
||||
self.assertIn("pause", kinds)
|
||||
|
||||
Reference in new issue
Block a user