审计报告 ~/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)。
702 lines
29 KiB
Python
702 lines
29 KiB
Python
"""CHV 安全审计修复轨测试(feat-chv-secfix,堆叠于 #239)。
|
||
|
||
只用标准库与 mock runner/launcher/api_client,绝不启动真实
|
||
VM:当前环境没有可跑真 VM 的宿主。本文件只证明代码层面
|
||
的修复(密钥移出 argv、磁盘删除、默认值、校验、审计等)
|
||
成立;真机否定测试(tap 防火墙实效、unset 代理后直连、
|
||
IP spoof 后 SSH 拒绝等)一律列在就绪清单 §11,不在本
|
||
文件声称已验证。测试里只用 FAKE 占位密钥,绝无真实密钥。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import shlex
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
try:
|
||
from cloud.chv.config import build_chv_config
|
||
from cloud.chv.disk import (
|
||
check_snapshot_limits,
|
||
delete_task_disk,
|
||
provision_task_disk,
|
||
record_snapshot,
|
||
)
|
||
from cloud.chv.hardening import (
|
||
ensure_secure_state_dir,
|
||
socket_is_live,
|
||
validate_net_entries,
|
||
validate_snapshot_url,
|
||
)
|
||
from cloud.chv.hostkeys import (
|
||
build_seed_disk,
|
||
dirty_marker_path,
|
||
write_seed,
|
||
)
|
||
from cloud.chv.pool import WarmPool
|
||
from cloud.chv.ssh_transport import (
|
||
build_ssh_command,
|
||
split_secret_env,
|
||
)
|
||
from cloud.sandbox import (
|
||
ChvFailure,
|
||
CloudHypervisorExecutor,
|
||
)
|
||
except ImportError:
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
from cloud.chv.config import build_chv_config
|
||
from cloud.chv.disk import (
|
||
check_snapshot_limits,
|
||
delete_task_disk,
|
||
provision_task_disk,
|
||
record_snapshot,
|
||
)
|
||
from cloud.chv.hardening import (
|
||
ensure_secure_state_dir,
|
||
socket_is_live,
|
||
validate_net_entries,
|
||
validate_snapshot_url,
|
||
)
|
||
from cloud.chv.hostkeys import (
|
||
build_seed_disk,
|
||
dirty_marker_path,
|
||
write_seed,
|
||
)
|
||
from cloud.chv.pool import WarmPool
|
||
from cloud.chv.ssh_transport import (
|
||
build_ssh_command,
|
||
split_secret_env,
|
||
)
|
||
from cloud.sandbox import (
|
||
ChvFailure,
|
||
CloudHypervisorExecutor,
|
||
)
|
||
|
||
|
||
class FakeResult:
|
||
def __init__(self, returncode=0, stdout="", stderr=""):
|
||
self.returncode = returncode
|
||
self.stdout = stdout
|
||
self.stderr = stderr
|
||
|
||
|
||
class FakeProc:
|
||
def __init__(self, alive=True):
|
||
self._alive = alive
|
||
|
||
def poll(self):
|
||
return None if self._alive else 1
|
||
|
||
def terminate(self):
|
||
self._alive = False
|
||
|
||
def wait(self, timeout=None):
|
||
return 0
|
||
|
||
def kill(self):
|
||
self._alive = False
|
||
|
||
|
||
def _write(path, content=b"x"):
|
||
with open(path, "wb") as fh:
|
||
fh.write(content)
|
||
os.chmod(path, 0o755)
|
||
return path
|
||
|
||
|
||
class SecfixCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
self.dir = self.tmp.name
|
||
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
|
||
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
|
||
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
|
||
self.rootfs = _write(os.path.join(self.dir, "disk.raw"),
|
||
b"R" * 100)
|
||
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
|
||
self.kvm = _write(os.path.join(self.dir, "kvm"))
|
||
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
|
||
with open(self.cpuinfo, "w") as fh:
|
||
fh.write("flags : fpu vmx sse\n")
|
||
self.state_dir = os.path.join(self.dir, "state")
|
||
self.calls = []
|
||
self.audit = []
|
||
|
||
def tearDown(self):
|
||
self.tmp.cleanup()
|
||
|
||
def _default_runner(self, cmd, timeout=None):
|
||
self.calls.append(list(cmd))
|
||
if cmd and cmd[0] == "ssh":
|
||
if cmd[-1] == "true":
|
||
return FakeResult(0, "", "")
|
||
return FakeResult(0, "", "")
|
||
if cmd and cmd[0] == "ssh-keygen":
|
||
priv = cmd[cmd.index("-f") + 1]
|
||
with open(priv, "w") as fh:
|
||
fh.write("-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE\n")
|
||
os.chmod(priv, 0o600)
|
||
with open(priv + ".pub", "w") as fh:
|
||
fh.write("ssh-ed25519 AAAAfake1 vm-key\n")
|
||
return FakeResult(0, "", "")
|
||
if cmd and cmd[0] == "genisoimage":
|
||
out = cmd[cmd.index("-output") + 1]
|
||
with open(out, "wb") as fh:
|
||
fh.write(b"CIDATA" * 16)
|
||
return FakeResult(0, "", "")
|
||
return FakeResult(0, "", "")
|
||
|
||
def _default_api(self, socket_path, method, path, payload=None):
|
||
return (200, {})
|
||
|
||
def make_exec(self, runner=None, env_extra=None, **overrides):
|
||
# 生产默认:provisioned(不显式设 keyscan),回环仍
|
||
# 是实验室宣称(mock runner 假装转发已通)。
|
||
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
|
||
"CHV_SSH_FORWARD": "1"}
|
||
if env_extra:
|
||
env.update(env_extra)
|
||
kwargs = dict(
|
||
binary=self.binary, ch_remote=self.ch_remote,
|
||
kernel=self.kernel, rootfs=self.rootfs,
|
||
ssh_key=self.ssh_key, kvm_path=self.kvm,
|
||
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
|
||
runner=runner or self._default_runner,
|
||
launcher=lambda cmd: FakeProc(alive=True),
|
||
api_client=self._default_api,
|
||
sleeper=lambda s: None, env=env)
|
||
kwargs.update(overrides)
|
||
ex = CloudHypervisorExecutor(**kwargs)
|
||
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
|
||
(e, t.get("id") if isinstance(t, dict) else t, d, k))
|
||
return ex
|
||
|
||
|
||
# -- H1 egress 代码门 + H7 tap 隔离 ------------------------------------
|
||
class EgressAndNetTest(SecfixCase):
|
||
def test_egress_proxy_non_http_rejected(self):
|
||
ex = self.make_exec(
|
||
env_extra={"CHV_EGRESS_PROXY": "ftp://10.0.0.1:21"})
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.preflight()
|
||
self.assertEqual(ctx.exception.kind, "config_invalid")
|
||
|
||
def test_egress_proxy_without_host_rejected(self):
|
||
ex = self.make_exec(
|
||
env_extra={"CHV_EGRESS_PROXY": "http:///path"})
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.preflight()
|
||
self.assertEqual(ctx.exception.kind, "config_invalid")
|
||
|
||
def test_net_unknown_field_rejected(self):
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
validate_net_entries(
|
||
[{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:01",
|
||
"ip": "10.0.0.2", "gateway": "10.0.0.1"}], [])
|
||
self.assertEqual(ctx.exception.kind, "net_config_invalid")
|
||
|
||
def test_net_intra_config_dup_rejected(self):
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
validate_net_entries(
|
||
[{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:01",
|
||
"ip": "10.0.0.2"},
|
||
{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:02",
|
||
"ip": "10.0.0.3"}], [])
|
||
self.assertEqual(ctx.exception.kind, "net_config_invalid")
|
||
|
||
def test_net_cross_vm_conflict_refuses_second_wake(self):
|
||
net = '[{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:01",' \
|
||
'"ip": "10.0.0.2", "mask": "255.255.255.0"}]'
|
||
ex = self.make_exec(env_extra={"CHV_NET_JSON": net})
|
||
out = ex.wake_vm({"id": "vm1"})
|
||
self.assertEqual(out["state"], "running")
|
||
# 第一台的 net.json 已登记;第二台同 tap 即拒绝,
|
||
# 且未起 VMM(launcher 只被调过一次)。
|
||
launches = []
|
||
ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True)
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.wake_vm({"id": "vm2"})
|
||
self.assertEqual(ctx.exception.kind, "net_tap_conflict")
|
||
self.assertEqual(launches, [])
|
||
self.assertTrue(any(e == "vm_failed"
|
||
for e, _, _, _ in self.audit))
|
||
|
||
|
||
# -- H2 密钥移出 argv ---------------------------------------------------
|
||
class SecretDeliveryTest(SecfixCase):
|
||
def test_secret_env_split_and_argv_refusal(self):
|
||
secrets, plain = split_secret_env(
|
||
{"SUB2API_API_KEY": "FAKE-KEY",
|
||
"SUB2API_BASE_URL": "http://gw/"})
|
||
self.assertEqual(secrets, {"SUB2API_API_KEY": "FAKE-KEY"})
|
||
self.assertEqual(plain, {"SUB2API_BASE_URL": "http://gw/"})
|
||
with self.assertRaises(ValueError):
|
||
build_ssh_command(2222, "/k", "root", None, "/kh",
|
||
["true"],
|
||
extra_env={"SUB2API_API_KEY": "FAKE-KEY"})
|
||
|
||
def test_seed_writes_single_write_files_with_guest_env(self):
|
||
seed_dir = os.path.join(self.dir, "seed")
|
||
write_seed(seed_dir, "chv-t1", "FAKE-PRIV", "ssh-ed25519 AAAAx",
|
||
secret_env={"SUB2API_API_KEY": "FAKE-KEY-123"})
|
||
text = ""
|
||
with open(os.path.join(seed_dir, "user-data")) as fh:
|
||
text = fh.read()
|
||
# 只能有一个 write_files 键(重复键会覆盖 host key 条目)。
|
||
self.assertEqual(text.count("write_files:"), 1)
|
||
self.assertIn("/etc/chv/guest-env", text)
|
||
self.assertIn("SUB2API_API_KEY=", text)
|
||
self.assertEqual(oct(os.stat(
|
||
os.path.join(seed_dir, "user-data")).st_mode & 0o777),
|
||
"0o600")
|
||
|
||
def test_keyscan_with_secret_fails_closed_before_launch(self):
|
||
ex = self.make_exec(
|
||
env_extra={"CHV_HOST_KEY_MODE": "keyscan"})
|
||
launches = []
|
||
ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True)
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.wake_vm({"id": "k1"},
|
||
extra_env={"SUB2API_API_KEY": "FAKE-KEY"})
|
||
self.assertEqual(ctx.exception.kind,
|
||
"secret_env_requires_provisioned")
|
||
self.assertEqual(launches, [])
|
||
|
||
def test_provisioned_wake_deletes_host_private_key(self):
|
||
ex = self.make_exec()
|
||
out = ex.wake_vm({"id": "s1"},
|
||
extra_env={"SUB2API_API_KEY": "FAKE-KEY"})
|
||
self.assertEqual(out["state"], "running")
|
||
vm_dir = os.path.join(self.state_dir, "chv-s1")
|
||
# 宿主私钥已删(seed 盘建好后即删);seed.iso 在且 0600;
|
||
# 密钥投递有审计(只含名,不含值)。
|
||
self.assertFalse(os.path.exists(
|
||
os.path.join(vm_dir, "ssh_host_ed25519_key")))
|
||
seed_iso = os.path.join(vm_dir, "seed.iso")
|
||
self.assertTrue(os.path.exists(seed_iso))
|
||
self.assertEqual(oct(os.stat(seed_iso).st_mode & 0o777),
|
||
"0o600")
|
||
self.assertTrue(any(e == "host_key_generated"
|
||
for e, _, _, _ in self.audit))
|
||
self.assertFalse(any("FAKE-KEY" in (d or "")
|
||
for _, _, d, _ in self.audit))
|
||
|
||
def test_seed_iso_build_sets_0600(self):
|
||
seed_dir = os.path.join(self.dir, "seed2")
|
||
os.makedirs(seed_dir, exist_ok=True)
|
||
out = os.path.join(self.dir, "seed2.iso")
|
||
build_seed_disk(seed_dir, out, runner=self._default_runner)
|
||
self.assertEqual(oct(os.stat(out).st_mode & 0o777), "0o600")
|
||
|
||
|
||
# -- H3 任务盘删除 + GC --------------------------------------------------
|
||
class DiskDeletionTest(SecfixCase):
|
||
def test_stop_deletes_task_disk_and_audits(self):
|
||
ex = self.make_exec()
|
||
disk = provision_task_disk(self.state_dir, "chv-d1",
|
||
self.rootfs, "copy")
|
||
self.assertTrue(os.path.exists(disk))
|
||
ex.stop_vm({"id": "d1"})
|
||
self.assertFalse(os.path.exists(disk))
|
||
self.assertTrue(any(e == "vm_disk_deleted"
|
||
for e, _, _, _ in self.audit))
|
||
|
||
def test_gc_deletes_orphan_disk_only(self):
|
||
ex = self.make_exec()
|
||
provision_task_disk(self.state_dir, "chv-orphan",
|
||
self.rootfs, "copy")
|
||
live_disk = provision_task_disk(self.state_dir, "chv-live",
|
||
self.rootfs, "copy")
|
||
# live VM 有 socket 文件(mock 占位,不代表活 VMM,
|
||
# 但 GC 的 live 判定含 socket 存在——见 gc_stale_disks
|
||
# 的 socket 判定,此处只验 orphan 被删、live 不动)。
|
||
os.makedirs(self.state_dir, exist_ok=True)
|
||
with open(os.path.join(self.state_dir, "chv-live.sock"),
|
||
"w") as fh:
|
||
fh.write("")
|
||
result = ex.gc_stale_disks()
|
||
self.assertIn("chv-orphan", result["deleted"])
|
||
self.assertTrue(os.path.exists(live_disk))
|
||
|
||
def test_disk_delete_failure_audited_and_marks_dirty(self):
|
||
ex = self.make_exec()
|
||
provision_task_disk(self.state_dir, "chv-f1",
|
||
self.rootfs, "copy")
|
||
from unittest import mock
|
||
with mock.patch("cloud.chv.executor.delete_task_disk",
|
||
side_effect=OSError("disk busy")):
|
||
ex.stop_vm({"id": "f1"})
|
||
self.assertTrue(any(e == "vm_disk_delete_failed"
|
||
for e, _, _, _ in self.audit))
|
||
self.assertTrue(os.path.exists(
|
||
dirty_marker_path(self.state_dir, "chv-f1")))
|
||
|
||
|
||
# -- H4 默认值 ------------------------------------------------------------
|
||
class DefaultsTest(unittest.TestCase):
|
||
def test_production_defaults(self):
|
||
cfg = build_chv_config({}, {})
|
||
self.assertEqual(cfg.host_key_mode, "provisioned")
|
||
self.assertNotEqual(cfg.state_dir, "/tmp/chv-vms")
|
||
self.assertTrue(cfg.state_dir.startswith(os.path.expanduser("~")))
|
||
self.assertEqual(cfg.disk_gb, 50.0)
|
||
self.assertEqual(cfg.snapshot_max_count, 10)
|
||
self.assertEqual(cfg.snapshot_max_total_gb, 20.0)
|
||
self.assertEqual(cfg.snapshot_retention_days, 30.0)
|
||
|
||
def test_keyscan_still_explicitly_selectable(self):
|
||
cfg = build_chv_config(
|
||
{}, {"CHV_HOST_KEY_MODE": "keyscan"})
|
||
self.assertEqual(cfg.host_key_mode, "keyscan")
|
||
|
||
def test_disk_gb_zero_or_negative_rejected(self):
|
||
with self.assertRaises(ValueError):
|
||
build_chv_config({}, {"CHV_DISK_GB": "0"})
|
||
with self.assertRaises(ValueError):
|
||
build_chv_config({}, {"CHV_DISK_GB": "-5"})
|
||
|
||
|
||
# -- H5 崩溃残留扫描 -------------------------------------------------------
|
||
class ResidueScanTest(SecfixCase):
|
||
def test_cleanup_stale_secrets_removes_residue(self):
|
||
ex = self.make_exec()
|
||
vm_dir = os.path.join(self.state_dir, "chv-old")
|
||
os.makedirs(os.path.join(vm_dir, "seed"), exist_ok=True)
|
||
with open(os.path.join(vm_dir, "ssh_host_ed25519_key"),
|
||
"w") as fh:
|
||
fh.write("FAKE-PRIV")
|
||
with open(os.path.join(vm_dir, "seed.iso"), "wb") as fh:
|
||
fh.write(b"CIDATA")
|
||
cleaned = ex.cleanup_stale_secrets()
|
||
self.assertIn("chv-old", cleaned)
|
||
self.assertFalse(os.path.exists(
|
||
os.path.join(vm_dir, "ssh_host_ed25519_key")))
|
||
self.assertFalse(os.path.exists(
|
||
os.path.join(vm_dir, "seed.iso")))
|
||
self.assertTrue(any(e == "host_secret_residue_cleaned"
|
||
for e, _, _, _ in self.audit))
|
||
|
||
|
||
# -- H6 state_dir 与 socket 权限 -------------------------------------------
|
||
class StateDirTest(SecfixCase):
|
||
def test_preflight_creates_state_dir_0700(self):
|
||
ex = self.make_exec()
|
||
self.assertTrue(ex.preflight())
|
||
mode = os.stat(self.state_dir).st_mode & 0o777
|
||
self.assertEqual(oct(mode), "0o700")
|
||
|
||
def test_ensure_secure_state_dir_fixes_loose_dir(self):
|
||
os.makedirs(self.state_dir, exist_ok=True)
|
||
os.chmod(self.state_dir, 0o755)
|
||
ensure_secure_state_dir(self.state_dir)
|
||
self.assertEqual(
|
||
oct(os.stat(self.state_dir).st_mode & 0o777), "0o700")
|
||
|
||
def test_group_writable_socket_fails_fast(self):
|
||
ex = self.make_exec()
|
||
os.makedirs(self.state_dir, exist_ok=True)
|
||
sock_path = os.path.join(self.state_dir, "chv-sock.sock")
|
||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
srv.bind(sock_path)
|
||
srv.listen(1)
|
||
os.chmod(sock_path, 0o666)
|
||
try:
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.wake_vm({"id": "sock"})
|
||
self.assertEqual(ctx.exception.kind,
|
||
"socket_permissions_insecure")
|
||
finally:
|
||
srv.close()
|
||
|
||
def test_dead_socket_not_counted_live_socket_counted(self):
|
||
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-dead.sock"),
|
||
"w") as fh:
|
||
fh.write("")
|
||
self.assertEqual(ex._persistent_vm_count(), 0)
|
||
self.assertFalse(socket_is_live(
|
||
os.path.join(self.state_dir, "chv-dead.sock")))
|
||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
srv.bind(os.path.join(self.state_dir, "chv-live.sock"))
|
||
srv.listen(1)
|
||
try:
|
||
self.assertTrue(socket_is_live(
|
||
os.path.join(self.state_dir, "chv-live.sock")))
|
||
self.assertEqual(ex._persistent_vm_count(), 1)
|
||
finally:
|
||
srv.close()
|
||
|
||
|
||
# -- M1 远程转义 ------------------------------------------------------------
|
||
class RemoteQuotingTest(unittest.TestCase):
|
||
def test_metachars_round_trip_through_remote_shell(self):
|
||
inner = ["python3", "/app/host.py", "dispatch",
|
||
"--task", "a; rm -rf / $(whoami) 'q' \"d\""]
|
||
cmd = build_ssh_command(2222, "/k", "root", None, "/kh",
|
||
inner, ssh_host="10.0.0.5")
|
||
user_idx = cmd.index("root@10.0.0.5")
|
||
remote = cmd[user_idx + 1:]
|
||
# ssh 拼接后远程 shell 解析回来的恰好是本地 list。
|
||
self.assertEqual(shlex.split(" ".join(remote)), inner)
|
||
# 含元字符的 token 在 argv 里是被引用的单个元素,
|
||
# 不是裸的 shell 元字符。
|
||
task_token = [t for t in cmd if "whoami" in t]
|
||
self.assertEqual(len(task_token), 1)
|
||
self.assertTrue(task_token[0].startswith("'"))
|
||
|
||
|
||
# -- M2 脏标记拒起 ------------------------------------------------------------
|
||
class DirtyMarkerTest(SecfixCase):
|
||
def test_dirty_marker_refuses_provision_and_wake(self):
|
||
ex = self.make_exec()
|
||
vm_dir = os.path.join(self.state_dir, "chv-dirty")
|
||
os.makedirs(vm_dir, exist_ok=True)
|
||
with open(dirty_marker_path(self.state_dir, "chv-dirty"),
|
||
"w") as fh:
|
||
fh.write("cleanup failed\n")
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.provision_host_key({"id": "dirty"})
|
||
self.assertEqual(ctx.exception.kind, "vm_dirty_refused")
|
||
with self.assertRaises(ChvFailure) as ctx2:
|
||
ex.wake_vm({"id": "dirty"})
|
||
self.assertEqual(ctx2.exception.kind, "vm_dirty_refused")
|
||
|
||
|
||
# -- M3 快照上限 ---------------------------------------------------------------
|
||
class SnapshotLimitTest(SecfixCase):
|
||
def _snap_file(self, name, size=64):
|
||
path = os.path.join(self.state_dir, name)
|
||
os.makedirs(self.state_dir, exist_ok=True)
|
||
with open(path, "wb") as fh:
|
||
fh.write(b"S" * size)
|
||
return "file://" + path
|
||
|
||
def test_snapshot_count_limit_refused_before_chremote(self):
|
||
ex = self.make_exec(env_extra={"CHV_SNAPSHOT_MAX_COUNT": "1"})
|
||
url1 = self._snap_file("s1.snap")
|
||
record_snapshot(self.state_dir, "chv-q", url1, 20.0)
|
||
calls_before = len(self.calls)
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.snapshot_vm({"id": "q"}, self._snap_file("s2.snap"))
|
||
self.assertEqual(ctx.exception.kind, "snapshot_quota_exceeded")
|
||
# 上限在 ch-remote 之前拒绝,runner 未被调新命令。
|
||
self.assertEqual(len(self.calls), calls_before)
|
||
|
||
def test_snapshot_retention_prunes_expired(self):
|
||
import time
|
||
ex = self.make_exec()
|
||
old_url = self._snap_file("old.snap")
|
||
entries = [{"url": old_url,
|
||
"path": old_url[len("file://"):],
|
||
"size": 64,
|
||
"created_at": time.time() - 40 * 86400}]
|
||
import json as _json
|
||
vm_dir = os.path.join(self.state_dir, "chv-r")
|
||
os.makedirs(vm_dir, exist_ok=True)
|
||
with open(os.path.join(vm_dir, "snapshots.json"), "w") as fh:
|
||
_json.dump(entries, fh)
|
||
new_url = self._snap_file("new.snap")
|
||
ex.snapshot_vm({"id": "r"}, new_url)
|
||
self.assertFalse(os.path.exists(old_url[len("file://"):]))
|
||
self.assertTrue(os.path.exists(new_url[len("file://"):]))
|
||
|
||
def test_snapshot_total_cap_prunes_oldest(self):
|
||
ex = self.make_exec(
|
||
env_extra={"CHV_SNAPSHOT_MAX_TOTAL_GB": "0.0000001"})
|
||
# 总量帽约 107 字节:两块 64 字节快照登记后,旧的被剪。
|
||
url1 = self._snap_file("t1.snap")
|
||
record_snapshot(self.state_dir, "chv-t", url1, 20.0)
|
||
url2 = self._snap_file("t2.snap")
|
||
_entries, removed = record_snapshot(
|
||
self.state_dir, "chv-t", url2, 0.0000001)
|
||
self.assertEqual(len(removed), 1)
|
||
self.assertFalse(os.path.exists(url1[len("file://"):]))
|
||
|
||
|
||
# -- M4/M8 构建脚本 --------------------------------------------------------------
|
||
class BuildScriptTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
self.dir = self.tmp.name
|
||
self.bin_dir = os.path.join(self.dir, "bin")
|
||
os.makedirs(os.path.join(self.bin_dir, "codex-resources"))
|
||
for name in ("codex", "codex-code-mode-host"):
|
||
p = os.path.join(self.bin_dir, name)
|
||
with open(p, "w") as fh:
|
||
fh.write("#!/bin/sh\n")
|
||
os.chmod(p, 0o755)
|
||
self.repo = os.path.join(self.dir, "repo")
|
||
os.makedirs(os.path.join(self.repo, "cloud"))
|
||
with open(os.path.join(self.repo, "host.py"), "w") as fh:
|
||
fh.write("# host\n")
|
||
self.script = str(Path(__file__).resolve().parents[2]
|
||
/ "chv-guest" / "build.sh")
|
||
|
||
def tearDown(self):
|
||
self.tmp.cleanup()
|
||
|
||
def _run(self, args, env_extra=None):
|
||
env = dict(os.environ)
|
||
env.update({"CODEX_BIN_DIR": self.bin_dir,
|
||
"UBUNTU_VERSION": "24.04",
|
||
"CODEX_VERSION": "0.164.0-alpha.9",
|
||
"REPO_COMMIT": "test123",
|
||
"REPO_DIR": self.repo})
|
||
if env_extra:
|
||
env.update(env_extra)
|
||
return subprocess.run(["bash", self.script] + args,
|
||
capture_output=True, text=True, env=env)
|
||
|
||
def test_dry_run_requires_sha_and_prints_sshd_hardening(self):
|
||
out_dir = os.path.join(self.dir, "out")
|
||
r = self._run(["--dry-run", out_dir])
|
||
self.assertNotEqual(r.returncode, 0)
|
||
self.assertIn("UBUNTU_IMAGE_SHA256", r.stderr)
|
||
r2 = self._run(["--dry-run", out_dir],
|
||
env_extra={"UBUNTU_IMAGE_SHA256": "0" * 64})
|
||
self.assertEqual(r2.returncode, 0, r2.stderr)
|
||
self.assertIn("99-chv.conf", r2.stdout)
|
||
self.assertIn("PasswordAuthentication no", r2.stdout)
|
||
self.assertIn("sshd -t", r2.stdout)
|
||
self.assertIn("virt-cat", r2.stdout)
|
||
|
||
|
||
# -- M5 审计补齐 ------------------------------------------------------------------
|
||
class AuditCoverageTest(SecfixCase):
|
||
def test_host_key_mismatch_audited_and_fail_fast(self):
|
||
ex = self.make_exec(
|
||
env_extra={"CHV_HOST_KEY_MODE": "keyscan"})
|
||
kh = ex.known_hosts_path({"id": "m1"})
|
||
os.makedirs(os.path.dirname(kh), exist_ok=True)
|
||
with open(kh, "w") as fh:
|
||
fh.write("[127.0.0.1]:2222 ssh-ed25519 AAAAfake\n")
|
||
|
||
def bad_ssh(cmd, timeout=None):
|
||
self.calls.append(list(cmd))
|
||
if cmd and cmd[0] == "ssh":
|
||
return FakeResult(
|
||
255, "",
|
||
"Host key verification failed.\n"
|
||
"REMOTE HOST IDENTIFICATION HAS CHANGED\n")
|
||
return FakeResult(0, "", "")
|
||
|
||
ex.runner = bad_ssh
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.wait_guest_ready({"id": "m1"})
|
||
self.assertEqual(ctx.exception.kind, "host_key_mismatch")
|
||
self.assertTrue(any(e == "host_key_mismatch"
|
||
for e, _, _, _ in self.audit))
|
||
|
||
def test_preflight_failure_is_audited(self):
|
||
ex = self.make_exec(binary="/tmp/no-such-chv-binary")
|
||
with self.assertRaises(ChvFailure):
|
||
ex.preflight()
|
||
self.assertTrue(any(e == "preflight_failed"
|
||
for e, _, _, _ in self.audit))
|
||
|
||
def test_audit_callback_failure_does_not_break_task(self):
|
||
ex = self.make_exec()
|
||
ex.audit_callback = lambda *args: (_ for _ in ()).throw(
|
||
RuntimeError("audit store down"))
|
||
# _emit 只记宿主 error 日志,不抛异常。
|
||
ex._emit("vm_create", {"id": "t"}, "vm=chv-t")
|
||
|
||
def test_capture_success_is_audited(self):
|
||
ex = self.make_exec(
|
||
env_extra={"CHV_HOST_KEY_MODE": "keyscan"})
|
||
|
||
def keyscan_runner(cmd, timeout=None):
|
||
self.calls.append(list(cmd))
|
||
if cmd and cmd[0] == "ssh-keyscan":
|
||
return FakeResult(
|
||
0, "[127.0.0.1]:2222 ssh-ed25519 AAAAfake1\n", "")
|
||
return FakeResult(0, "", "")
|
||
|
||
ex.runner = keyscan_runner
|
||
self.assertTrue(ex.capture_host_key({"id": "cap1"}))
|
||
self.assertTrue(any(e == "host_key_captured"
|
||
for e, _, _, _ in self.audit))
|
||
|
||
|
||
# -- M6 reuse 租户声明 ---------------------------------------------------------------
|
||
class ReuseTenantTest(SecfixCase):
|
||
def test_reuse_without_tenant_declaration_refused(self):
|
||
ex = self.make_exec(env_extra={
|
||
"CHV_DISK_MODE": "reuse",
|
||
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1"})
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.preflight()
|
||
self.assertEqual(ctx.exception.kind, "reuse_tenant_undeclared")
|
||
|
||
def test_reuse_with_declaration_passes_preflight(self):
|
||
ex = self.make_exec(env_extra={
|
||
"CHV_DISK_MODE": "reuse",
|
||
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1",
|
||
"CHV_SINGLE_TENANT_PROJECT": "p1"})
|
||
self.assertTrue(ex.preflight())
|
||
|
||
|
||
# -- L1/L2/L3/L4 -----------------------------------------------------------------------
|
||
class LowItemsTest(SecfixCase):
|
||
def test_task_disk_symlink_not_followed(self):
|
||
target = os.path.join(self.dir, "victim.bin")
|
||
with open(target, "wb") as fh:
|
||
fh.write(b"VICTIM")
|
||
vm_dir = os.path.join(self.state_dir, "chv-l1")
|
||
os.makedirs(vm_dir, exist_ok=True)
|
||
os.symlink(target, os.path.join(vm_dir, "disk.raw"))
|
||
disk = provision_task_disk(self.state_dir, "chv-l1",
|
||
self.rootfs, "copy")
|
||
self.assertFalse(os.path.islink(disk))
|
||
with open(disk, "rb") as fh:
|
||
self.assertEqual(fh.read(), b"R" * 100)
|
||
with open(target, "rb") as fh:
|
||
self.assertEqual(fh.read(), b"VICTIM")
|
||
|
||
def test_snapshot_url_outside_allowed_dir_refused(self):
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
validate_snapshot_url("file:///etc/passwd",
|
||
self.state_dir)
|
||
self.assertEqual(ctx.exception.kind,
|
||
"snapshot_path_not_allowed")
|
||
|
||
def test_remote_snapshot_requires_explicit_flag(self):
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
validate_snapshot_url("s3://bucket/snap",
|
||
self.state_dir)
|
||
self.assertEqual(ctx.exception.kind,
|
||
"snapshot_remote_not_allowed")
|
||
self.assertIsNone(validate_snapshot_url(
|
||
"s3://bucket/snap", self.state_dir, allow_remote=True))
|
||
|
||
def test_capacity_failure_is_audited(self):
|
||
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "0"})
|
||
with self.assertRaises(ChvFailure) as ctx:
|
||
ex.wake_vm({"id": "cap"})
|
||
self.assertEqual(ctx.exception.kind, "host_capacity_exceeded")
|
||
self.assertTrue(any(e == "vm_failed" and k == "host_capacity_exceeded"
|
||
for e, _, _, k in self.audit))
|
||
|
||
def test_dual_net_vars_warn_but_pass(self):
|
||
ex = self.make_exec(env_extra={
|
||
"CHV_NET": "tap=tap0",
|
||
"CHV_NET_JSON": '[{"tap": "tap0"}]'})
|
||
self.assertTrue(ex.preflight())
|
||
self.assertTrue(any(e == "net_config_dual"
|
||
for e, _, _, _ in self.audit))
|
||
|
||
def test_warm_pool_acquire_doc_defines_task_disk_policy(self):
|
||
# L5 是文档项(接线仍未做):acquire 文档必须写清
|
||
# 预热盘与任务盘的对应关系,不假装已接。
|
||
self.assertIn("provision_task_disk", WarmPool.acquire.__doc__)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|