- taint.py:污点追踪器(登记/污点不可逆/子进程继承/审计) - ebpf_taint.py:eBPF 程序源码 + 加载器(能力探测 + 降级); 本机实测加载被 seccomp 挡(bpf() EPERM),报 blocked 不冒充 - capabilities.py:五档严格能力(一次/会话/任务/时限/永久, 三元组绑定 + 精确匹配 + 撤销;permanent 默认不签发) - policy.py:默认策略规则表 + 谨慎度三档(写操作与敏感路径 永远不自动放行,规则变化审计) - approval_channel.py:对话外审批通道(nonce + 15 分钟过期 + HMAC,答复一次有效,对话伪造无效) - sentinel.py 接线:evaluate() 可选接 taint/policy/tier/scope, 不传时行为与 P3 一致;自动放行步骤 + 档位签发回落单次; Capability 基类属性放宽为 Optional(None = 不限) - 新测试 61 项;全量 cloud 套件 1958 项 OK(skipped=8) 真机待验证(未做,排队在 round-11 之后):见 docs/chv-muse-p5-gap.md §4
268 lines
12 KiB
Python
268 lines
12 KiB
Python
"""对齐 Muse P5-A 测试:污点追踪 + eBPF 加载层。
|
|
|
|
只用标准库与 unittest,绝不启动真实 VM、不联网、不加载真实
|
|
eBPF 程序、不含真实凭证与用户数据内容(测试值全是假值)。
|
|
覆盖:
|
|
- 污点追踪器:登记干净、读用户数据后变污点、子进程继承、
|
|
污点不可清除(无清除入口、只能随进程退出消失)、污点进程
|
|
出口不自动放行、未知 pid 不可验证也不自动放行;
|
|
- 审计:事件名 + pid + 原因,无用户数据内容、无凭证值,
|
|
调用方误把内容当 source 传也不落盘;
|
|
- eBPF 加载器:在无 bpftool/无 bpffs/无 CAP 时报 blocked +
|
|
原因、降级模式仍可用策略引擎(mode 恒为降级模式、绝不
|
|
报 loaded);源码常量含 cgroup egress 拦截、进程归因 map、
|
|
LSM hook 骨架。
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from cloud.chv.ebpf_taint import (
|
|
EBPF_CGROUP_EGRESS_C,
|
|
EBPF_LSM_TAINT_C,
|
|
FALLBACK_MODE,
|
|
EbpfTaintLoader,
|
|
)
|
|
from cloud.chv.taint import TaintTracker
|
|
except ImportError:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
from cloud.chv.ebpf_taint import (
|
|
EBPF_CGROUP_EGRESS_C,
|
|
EBPF_LSM_TAINT_C,
|
|
FALLBACK_MODE,
|
|
EbpfTaintLoader,
|
|
)
|
|
from cloud.chv.taint import TaintTracker
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SECRET_LIKE = "sk-fake-secret-content-12345"
|
|
|
|
|
|
def _fake_status(cap_eff_hex: str) -> str:
|
|
"""写一份假 /proc/self/status(只含 CapEff 一行)。"""
|
|
tmp = tempfile.NamedTemporaryFile(
|
|
"w", delete=False, suffix="-status")
|
|
tmp.write("Name:\tfake\nCapEff:\t%s\n" % cap_eff_hex)
|
|
tmp.close()
|
|
return tmp.name
|
|
|
|
|
|
# -- (1) 污点追踪器 --------------------------------------------------------
|
|
class TaintTrackerTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tracker = TaintTracker(clock=lambda: 1000.0)
|
|
|
|
def test_clean_process_is_auto_allow_eligible(self):
|
|
self.assertTrue(self.tracker.register(
|
|
100, cgroup="/sys/fs/cgroup/chv-100",
|
|
connector="gitea", use_case="clone"))
|
|
self.assertFalse(self.tracker.is_tainted(100))
|
|
self.assertTrue(self.tracker.auto_allow_eligible(100))
|
|
state = self.tracker.state(100)
|
|
self.assertEqual(state["connector"], "gitea")
|
|
self.assertEqual(state["use_case"], "clone")
|
|
|
|
def test_register_rejects_bad_or_duplicate_pid(self):
|
|
self.assertFalse(self.tracker.register(0))
|
|
self.assertFalse(self.tracker.register(-5))
|
|
self.assertTrue(self.tracker.register(101))
|
|
self.assertFalse(self.tracker.register(101))
|
|
self.assertTrue(self.tracker.is_registered(101))
|
|
self.assertFalse(self.tracker.is_registered(999))
|
|
|
|
def test_read_user_data_marks_tainted(self):
|
|
self.tracker.register(110, connector="gitea",
|
|
use_case="sync")
|
|
self.assertTrue(
|
|
self.tracker.mark_tainted(110, "gitea_repo"))
|
|
self.assertTrue(self.tracker.is_tainted(110))
|
|
self.assertFalse(self.tracker.auto_allow_eligible(110))
|
|
events = [r for r in self.tracker.audit()
|
|
if r["event"] == "sentinel_taint_marked"]
|
|
self.assertEqual(len(events), 1)
|
|
self.assertEqual(events[0]["pid"], 110)
|
|
self.assertEqual(events[0]["source"], "gitea_repo")
|
|
self.assertIn("read_user_data", events[0]["reason"])
|
|
|
|
def test_mark_tainted_unknown_pid_registers_as_tainted(self):
|
|
# 未登记进程上报读用户数据:先记为污点,不等补登记。
|
|
self.assertTrue(self.tracker.mark_tainted(111, "user_file"))
|
|
self.assertTrue(self.tracker.is_tainted(111))
|
|
self.assertFalse(self.tracker.auto_allow_eligible(111))
|
|
|
|
def test_child_inherits_taint_and_clean_parent_stays_clean(self):
|
|
self.tracker.register(120, connector="gitea",
|
|
use_case="clone")
|
|
self.tracker.register(121)
|
|
self.tracker.mark_tainted(120, "user_memory")
|
|
self.assertTrue(self.tracker.fork_child(120, 130))
|
|
self.assertTrue(self.tracker.is_tainted(130))
|
|
self.assertFalse(self.tracker.auto_allow_eligible(130))
|
|
# 干净父进程的孩子也干净。
|
|
self.assertTrue(self.tracker.fork_child(121, 131))
|
|
self.assertFalse(self.tracker.is_tainted(131))
|
|
self.assertTrue(self.tracker.auto_allow_eligible(131))
|
|
# 继承带上父的连接器/用例。
|
|
self.assertEqual(self.tracker.state(130)["connector"],
|
|
"gitea")
|
|
|
|
def test_fork_child_rejects_unknown_parent_and_bad_pid(self):
|
|
self.assertFalse(self.tracker.fork_child(999, 140))
|
|
self.assertFalse(self.tracker.is_registered(140))
|
|
self.tracker.register(141)
|
|
self.assertFalse(self.tracker.fork_child(141, 0))
|
|
self.assertFalse(self.tracker.fork_child(141, 141))
|
|
|
|
def test_taint_cannot_be_cleared_only_exits_with_process(self):
|
|
# 否定测试:没有任何清除入口;污点只能随进程退出消失。
|
|
self.tracker.register(150)
|
|
self.tracker.mark_tainted(150, "chat_history")
|
|
for name in ("clear_taint", "untaint", "remove_taint",
|
|
"set_clean", "reset"):
|
|
self.assertFalse(hasattr(self.tracker, name), name)
|
|
# 再上报一次也不会变干净;重复登记也不会洗掉污点。
|
|
self.tracker.mark_tainted(150, "user_file")
|
|
self.assertTrue(self.tracker.is_tainted(150))
|
|
self.assertFalse(self.tracker.register(150))
|
|
self.assertTrue(self.tracker.is_tainted(150))
|
|
self.assertTrue(self.tracker.process_exit(150))
|
|
self.assertFalse(self.tracker.is_registered(150))
|
|
self.assertFalse(self.tracker.is_tainted(150))
|
|
# 退出后是未知 pid:仍不可自动放行。
|
|
self.assertFalse(self.tracker.auto_allow_eligible(150))
|
|
self.assertFalse(self.tracker.process_exit(150))
|
|
|
|
def test_unknown_pid_is_not_auto_allow_eligible(self):
|
|
# 否定测试:不可验证进程失去自动放行(不是默认放行)。
|
|
self.assertFalse(self.tracker.is_tainted(999))
|
|
self.assertFalse(self.tracker.auto_allow_eligible(999))
|
|
self.assertIsNone(self.tracker.state(999))
|
|
|
|
def test_audit_never_contains_user_data_content(self):
|
|
# 否定测试:调用方误把内容当 source 传,原始串也不落盘;
|
|
# 审计里只有事件名 + pid + 原因 + 分类。
|
|
self.tracker.register(160)
|
|
self.tracker.mark_tainted(160, SECRET_LIKE)
|
|
blob = json.dumps(self.tracker.audit())
|
|
self.assertNotIn(SECRET_LIKE, blob)
|
|
self.assertNotIn("sk-fake", blob)
|
|
marked = [r for r in self.tracker.audit()
|
|
if r["event"] == "sentinel_taint_marked"][0]
|
|
self.assertEqual(marked["source"], "unknown")
|
|
for record in self.tracker.audit():
|
|
self.assertTrue(set(record) <= {
|
|
"event", "pid", "reason", "ts", "source"}, record)
|
|
|
|
|
|
# -- (2) eBPF 加载器 --------------------------------------------------------
|
|
class EbpfLoaderTest(unittest.TestCase):
|
|
def _loader(self, cap_hex="0000000000000000",
|
|
bpffs="/nonexistent-bpffs",
|
|
tools_missing=True):
|
|
status_path = _fake_status(cap_hex)
|
|
self.addCleanup(os.unlink, status_path)
|
|
which = (lambda name: None) if tools_missing else None
|
|
return EbpfTaintLoader(
|
|
bpffs_path=bpffs, status_path=status_path, which=which)
|
|
|
|
def test_blocked_without_bpftool_bpffs_and_caps(self):
|
|
# 本机同款处境:无工具链、无 bpffs、无能力位。
|
|
loader = self._loader()
|
|
result = loader.probe()
|
|
self.assertEqual(result["status"], "blocked")
|
|
self.assertEqual(result["mode"], FALLBACK_MODE)
|
|
self.assertIn("tool_missing:bpftool", result["blockers"])
|
|
self.assertIn("tool_missing:clang", result["blockers"])
|
|
self.assertIn("bpffs_missing:/nonexistent-bpffs",
|
|
result["blockers"])
|
|
self.assertIn("cap_bpf_and_cap_sys_admin_missing",
|
|
result["blockers"])
|
|
self.assertFalse(result["cap_bpf"])
|
|
self.assertFalse(result["cap_sys_admin"])
|
|
self.assertNotEqual(result["status"], "loaded")
|
|
|
|
def test_blocked_without_caps_even_when_tools_present(self):
|
|
# 否定测试:工具有了、能力位没有,仍 blocked,不试加载。
|
|
loader = self._loader(tools_missing=False)
|
|
loader._which = lambda name: "/usr/bin/" + name
|
|
result = loader.probe()
|
|
self.assertEqual(result["status"], "blocked")
|
|
self.assertIn("cap_bpf_and_cap_sys_admin_missing",
|
|
result["blockers"])
|
|
|
|
def test_cap_bits_decoded_from_status(self):
|
|
# CAP_BPF bit 39、CAP_SYS_ADMIN bit 21 的解码对照。
|
|
only_bpf = self._loader(cap_hex="%016x" % (1 << 39))
|
|
self.assertTrue(only_bpf.probe()["cap_bpf"])
|
|
self.assertFalse(only_bpf.probe()["cap_sys_admin"])
|
|
only_admin = self._loader(cap_hex="%016x" % (1 << 21))
|
|
self.assertFalse(only_admin.probe()["cap_bpf"])
|
|
self.assertTrue(only_admin.probe()["cap_sys_admin"])
|
|
both = self._loader(
|
|
cap_hex="%016x" % ((1 << 39) | (1 << 21)))
|
|
result = both.probe()
|
|
self.assertTrue(result["cap_bpf"])
|
|
self.assertTrue(result["cap_sys_admin"])
|
|
|
|
def test_try_load_degrades_never_claims_loaded(self):
|
|
# 否定测试:加载失败一律降级,绝不冒充已加载。
|
|
loader = self._loader()
|
|
result = loader.try_load()
|
|
self.assertEqual(result["status"], "blocked")
|
|
self.assertEqual(result["mode"], FALLBACK_MODE)
|
|
self.assertNotEqual(result["status"], "loaded")
|
|
status = loader.load_status()
|
|
self.assertEqual(status["status"], "blocked")
|
|
self.assertEqual(status["mode"], FALLBACK_MODE)
|
|
|
|
def test_degraded_mode_still_usable_via_policy_engine(self):
|
|
# 降级模式仍可用:策略引擎(污点追踪器)照常工作,
|
|
# 加载器状态明确写着 degraded/blocked 而不是 loaded。
|
|
loader = self._loader()
|
|
loader.probe()
|
|
self.assertEqual(loader.load_status()["mode"],
|
|
FALLBACK_MODE)
|
|
tracker = TaintTracker(clock=lambda: 1000.0)
|
|
tracker.register(200)
|
|
self.assertTrue(tracker.auto_allow_eligible(200))
|
|
tracker.mark_tainted(200, "gitea_repo")
|
|
self.assertFalse(tracker.auto_allow_eligible(200))
|
|
|
|
def test_load_status_probes_lazily(self):
|
|
loader = self._loader()
|
|
status = loader.load_status()
|
|
self.assertIn(status["status"], ("blocked", "degraded"))
|
|
self.assertEqual(status["mode"], FALLBACK_MODE)
|
|
|
|
|
|
# -- (3) eBPF 程序源码常量 --------------------------------------------------
|
|
class EbpfSourceTest(unittest.TestCase):
|
|
def test_cgroup_program_has_egress_hook_and_taint_map(self):
|
|
self.assertIn("cgroup_skb/egress", EBPF_CGROUP_EGRESS_C)
|
|
self.assertIn("taint_map", EBPF_CGROUP_EGRESS_C)
|
|
self.assertIn("BPF_MAP_TYPE_HASH", EBPF_CGROUP_EGRESS_C)
|
|
self.assertIn("BPF_MAP_TYPE_RINGBUF", EBPF_CGROUP_EGRESS_C)
|
|
self.assertIn("bpf_get_current_pid_tgid",
|
|
EBPF_CGROUP_EGRESS_C)
|
|
self.assertIn("GPL", EBPF_CGROUP_EGRESS_C)
|
|
|
|
def test_lsm_program_has_hook_and_shared_map(self):
|
|
self.assertIn("lsm/file_open", EBPF_LSM_TAINT_C)
|
|
self.assertIn("taint_map", EBPF_LSM_TAINT_C)
|
|
self.assertIn("GPL", EBPF_LSM_TAINT_C)
|
|
|
|
def test_sources_do_not_claim_to_be_loaded(self):
|
|
# 否定测试:源码注释写的是作用与内核要求,不是“已拦截”。
|
|
for src in (EBPF_CGROUP_EGRESS_C, EBPF_LSM_TAINT_C):
|
|
self.assertNotIn("loaded", src.lower())
|
|
self.assertNotIn("已拦截", src)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|