feat(chv): 对齐 Muse P5 纵深——eBPF 污点传播 + 五档严格能力 + 对话外审批 + 默认策略

- 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
This commit is contained in:
host committed 2026-11-06 22:25:55 +00:00
1 parent ee456e6625
commit 25bcd355f8
11 files changed
+2436 -48

No files matched your search

+325
View File
@@ -0,0 +1,325 @@
"""eBPF 污点传播加载层(对齐 Muse P5-A,纯标准库 + 可选工具链)。
Muse 口径(见 docs/chv-muse-p5-gap.md §1 污点出口行):用 eBPF
cgroup 程序做网络拦截与进程归因、及挂在 LSM hook 上的 eBPF
程序做污点传播。
本模块只含两样东西,绝不冒充已加载:
1. eBPF 程序源码(字符串常量,见 `EBPF_CGROUP_EGRESS_C` 与
`EBPF_LSM_TAINT_C`):cgroup egress 拦截 + 进程归因 map +
LSM hook 污点传播骨架。源码本身不是“已在拦截流量”。
2. 加载器 `EbpfTaintLoader`:能力探测(内核版本、bpffs、
CAP_BPF/CAP_SYS_ADMIN、bpftool/clang 是否在位)→ 状态
loaded/degraded/blocked + 原因;加载失败一律明确降级为
“策略引擎 + 代理层钩子”模式(`FALLBACK_MODE`),绝不把
degraded 写成 loaded。
加载成功的硬条件(缺一即 blocked/degraded,不重试蒙混):
- 内核 >= 5.8(cgroup_skb egress 挂载点;LSM hook 另需
内核开 CONFIG_BPF_LSM,见 LSM 源码头注释);
- /sys/fs/bpf 存在且可写(pin 落点);
- 进程有效能力集含 CAP_BPF(bit 39)或 CAP_SYS_ADMIN
(bit 21)——且 bpf() 系统调用本身不被 seccomp 挡;
- bpftool + clang 在位(编译与加载工具链)。
本机实测结论(2026-11-07,Muse VM):不行。内核
7.0.0-43-generic、CapEff 含 CAP_BPF 与 CAP_SYS_ADMIN,但
bpf() 与 BPF_MAP_CREATE 均报 EPERM(Seccomp filter 在位,
4 条过滤器),且无 /sys/fs/bpf、无 bpftool/clang。能力位
在位不等于系统调用放行——探测只看 CapEff 会误判,所以
`probe()` 把“缺 bpffs/缺工具链”也记为 blocker,不以
CapEff 单项定生死。
"""
import os
import platform
import shutil
import subprocess
import tempfile
from collections.abc import Callable
# 加载失败后的降级模式:策略引擎(taint.py + sentinel.py 决策)
# + 代理层钩子(Tinyproxy 白名单等现有强制),不走 eBPF。
FALLBACK_MODE = "policy_engine_proxy_hooks"
# cgroup egress 拦截的最低内核版本。
MIN_KERNEL = (5, 8)
# CAP 位号(/proc/self/status 的 CapEff,按 Linux capability.h)。
_CAP_BPF_BIT = 39
_CAP_SYS_ADMIN_BIT = 21
# eBPF cgroup egress 拦截程序源码。
#
# 作用(按 section):
# - `SEC("cgroup_skb/egress")`:挂在 cgroup 的 egress 路径上,
# 每个出站 skb 都过这里;返回 0 放行、返回非 0 丢弃。污点
# 进程的直连在这里被拦下,而不是等代理层才发现。
# - `taint_map`(BPF_MAP_TYPE_HASH,key=pid u32,value=污点
# 标志 u8):进程归因 map。用户态追踪器(taint.py)与 LSM
# 程序共用这张表:LSM 侧写污点,cgroup 侧读污点。
# - `events`(BPF_MAP_TYPE_RINGBUF):拦截/放行事件上报给
# 用户态审计,不含包内容,只含 pid 与 verdict。
# - 归因键:`bpf_get_current_pid_tgid() >> 32` 取当前进程 pid;
# cgroup 归属由挂载时的 cgroup 决定,不靠包里的地址猜进程。
# 内核要求:>= 5.8,CAP_BPF(或 CAP_SYS_ADMIN),cgroup v2,
# bpffs 可写(pin 落点),clang -target bpf + bpftool 加载。
EBPF_CGROUP_EGRESS_C = r"""
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
/* 进程归因 map:pid -> 污点标志(1=污点,0=干净)。LSM 程序写,
本程序读;用户态也可经 map fd 直接查。 */
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 65536);
__type(key, __u32);
__type(value, __u8);
} taint_map SEC(".maps");
/* 事件上报:只含 pid 与 verdict,不含包内容。 */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 16);
} events SEC(".maps");
struct event {
__u32 pid;
__u32 verdict; /* 0=放行,1=丢弃 */
};
/* cgroup egress 拦截:污点进程的出站包一律丢弃并上报,干净
进程放行(放行不等于“自动放行通过”——窄范围自动放行仍由
Sentinel 策略引擎在 L7 判定,这里只做强制层拦截)。 */
SEC("cgroup_skb/egress")
int egress_filter(struct __sk_buff *skb)
{
__u32 pid = (__u32)(bpf_get_current_pid_tgid() >> 32);
__u8 *tainted = bpf_map_lookup_elem(&taint_map, &pid);
struct event *e;
if (tainted && *tainted) {
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (e) {
e->pid = pid;
e->verdict = 1;
bpf_ringbuf_submit(e, 0);
}
return 0; /* 丢弃:skb 不出 cgroup */
}
return 1; /* 放行 */
}
char _license[] SEC("license") = "GPL";
"""
# LSM hook 污点传播程序源码(骨架)。
#
# 作用(按 section):
# - `SEC("lsm/file_open")`:进程打开文件时触发。hook 内只判
# “打开的是不是用户数据路径”(路径表由用户态经 map 下发,
# 这里不硬编码路径),是则把当前 pid 写进 `taint_map`
# (与 cgroup 程序共用同一张 pin 住的 map)。
# - 污点传播方向:读用户数据的进程变污点;子进程的污点由
# 用户态 `fork_child` 继承(本骨架不替 fork 背书——fork
# 时子进程尚未读文件,LSM 看不到它,必须由追踪器继承)。
# 内核要求:>= 5.7 且内核开 CONFIG_BPF_LSM(否则 lsm/* 的
# section 挂不上);CAP_BPF;LSM BPF 程序挂载还受系统 LSM
# 策略约束(与 SELinux/AppArmor 共存时以后者为准)。本骨架
# 只做“读事件 → 写 map”,不做文件内容读取、不记路径内容。
EBPF_LSM_TAINT_C = r"""
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
/* 与 cgroup 程序共用同一张 pin 住的 map(pin 路径
/sys/fs/bpf/chv_taint_map,用户态建表后两程序同引)。 */
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 65536);
__type(key, __u32);
__type(value, __u8);
} taint_map SEC(".maps");
/* LSM hook 骨架:文件打开时触发。是否“用户数据路径”由用户
态下发的路径表判定(本骨架只留判断位,不硬编码路径、不读
文件内容);命中则把当前 pid 记为污点。 */
SEC("lsm/file_open")
int taint_on_file_open(struct file *file)
{
__u32 pid = (__u32)(bpf_get_current_pid_tgid() >> 32);
__u8 flag = 1;
/* TODO(真机接线时补):查用户态下发的路径表,命中才写;
未命中直接 return 0。骨架阶段不写 map,避免误标。 */
(void)pid;
(void)flag;
return 0;
}
char _license[] SEC("license") = "GPL";
"""
def _parse_kernel(release: str) -> tuple[int, int, int]:
"""`7.0.0-43-generic` 这类 release 串解为三元组。"""
parts = release.split("-")[0].split(".")
nums = []
for part in parts[:3]:
nums.append(int(part) if part.isdigit() else 0)
while len(nums) < 3:
nums.append(0)
return (nums[0], nums[1], nums[2])
def _cap_bit(cap_eff_hex: str, bit: int) -> bool:
"""CapEff 十六进制串里某一位是否在位。"""
try:
value = int(cap_eff_hex.strip(), 16)
except ValueError:
return False
return bool((value >> bit) & 1)
class EbpfTaintLoader:
"""eBPF 加载器:探测 → 加载(或明确降级)→ 状态上报。
状态机(`load_status()` 读到的 `status`):
- `blocked`:探测到硬 blocker(内核太旧 / 无 bpffs /
无能力位 / 缺工具链),加载不会尝试;
- `degraded`:未加载成功,工作在 `FALLBACK_MODE`(策略
引擎 + 代理层钩子),这是失败时的唯一出口;
- `loaded`:bpftool 加载 + pin 成功,且 pin 文件在位。
只有 `loaded` 才表示 eBPF 在拦截;其余一律不冒充。
"""
def __init__(self, bpffs_path: str = "/sys/fs/bpf",
status_path: str = "/proc/self/status",
which: Callable[[str], str | None] | None = None
) -> None:
self._bpffs_path = bpffs_path
self._status_path = status_path
self._which = which or shutil.which
self._status: dict = {
"status": "degraded",
"mode": FALLBACK_MODE,
"reason": "not_probed_yet",
"blockers": [],
}
def _read_caps(self) -> tuple[bool, bool]:
"""读 CapEff:返回 (CAP_BPF 在位, CAP_SYS_ADMIN 在位)。"""
try:
with open(self._status_path) as fh:
for line in fh:
if line.startswith("CapEff:"):
hex_value = line.split(":", 1)[1]
return (_cap_bit(hex_value, _CAP_BPF_BIT),
_cap_bit(hex_value, _CAP_SYS_ADMIN_BIT))
except OSError:
return (False, False)
return (False, False)
def _check_tools(self) -> dict:
"""bpftool / clang / llvm-objdump 是否在位。"""
found = {}
for tool in ("bpftool", "clang", "llvm-objdump"):
found[tool] = self._which(tool) is not None
return found
def _blockers(self, kernel: tuple[int, int, int],
cap_bpf: bool, cap_sys_admin: bool,
tools: dict) -> list:
"""按硬条件列 blocker(空表 = 探测关全过,可试加载)。"""
blockers = []
if kernel < MIN_KERNEL:
blockers.append("kernel_too_old:%d.%d.%d" % kernel)
if not os.path.isdir(self._bpffs_path):
blockers.append("bpffs_missing:" + self._bpffs_path)
elif not os.access(self._bpffs_path, os.W_OK):
blockers.append("bpffs_not_writable:" + self._bpffs_path)
if not cap_bpf and not cap_sys_admin:
blockers.append("cap_bpf_and_cap_sys_admin_missing")
for tool, present in sorted(tools.items()):
if not present:
blockers.append("tool_missing:" + tool)
return blockers
def probe(self) -> dict:
"""能力探测:只读检查,不加载、不改系统状态。
返回探测结果副本;`status` 为 blocked(有 blocker)
或 degraded(探测关全过、但尚未加载——仍在降级模式)。
注意:CapEff 在位不等于 bpf() 放行(本机实测:能力位
全在,bpf() 仍 EPERM,见模块头注释),所以探测结果
从不写“eBPF 可用”,只写“探测关是否全过”。"""
kernel = _parse_kernel(platform.uname().release)
cap_bpf, cap_sys_admin = self._read_caps()
tools = self._check_tools()
bpffs_exists = os.path.isdir(self._bpffs_path)
blockers = self._blockers(kernel, cap_bpf, cap_sys_admin,
tools)
status = "blocked" if blockers else "degraded"
reason = ("; ".join(blockers) if blockers
else "probe_passed_not_loaded")
self._status = {
"status": status, "mode": FALLBACK_MODE,
"reason": reason, "blockers": list(blockers),
"kernel": "%d.%d.%d" % kernel,
"kernel_ok": kernel >= MIN_KERNEL,
"bpffs_exists": bpffs_exists,
"bpffs_writable": (bpffs_exists and os.access(
self._bpffs_path, os.W_OK)),
"cap_bpf": cap_bpf, "cap_sys_admin": cap_sys_admin,
"tools": dict(tools),
}
return dict(self._status)
def load_status(self) -> dict:
"""当前加载状态上报(未探测过时先跑一次探测)。"""
if self._status.get("reason") == "not_probed_yet":
return self.probe()
return dict(self._status)
def try_load(self) -> dict:
"""尝试加载:探测关不过即降级,不尝试;过了才编译加载。
编译/加载任一步失败也降级,`status` stays degraded,
`reason` 写清失败步。成功须 bpftool 加载 + pin 文件
在位双重确认,缺一律不报 loaded。"""
result = self.probe()
if result["blockers"]:
return dict(self._status)
loaded = self._compile_and_load()
if loaded:
self._status = dict(result)
self._status["status"] = "loaded"
self._status["mode"] = "ebpf"
self._status["reason"] = "loaded_and_pinned"
self._status["blockers"] = []
else:
self._status["reason"] = "load_failed_fallback"
return dict(self._status)
def _compile_and_load(self) -> bool:
"""clang 编译 + bpftool 加载 + pin(只在探测关全过时跑)。
任一步失败返回 False(调用方降级)。本机永远走不到
这里(探测关不过);真机上失败原因由 bpftool 的
stderr 决定,这里只记成败、不猜原因。"""
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "egress.c")
obj = os.path.join(tmp, "egress.o")
pin = os.path.join(self._bpffs_path,
"chv_egress_filter")
with open(src, "w") as fh:
fh.write(EBPF_CGROUP_EGRESS_C)
compiled = subprocess.run(
["clang", "-target", "bpf", "-O2", "-c", src,
"-o", obj],
capture_output=True, timeout=120)
if compiled.returncode != 0:
return False
loaded = subprocess.run(
["bpftool", "prog", "load", obj, pin],
capture_output=True, timeout=120)
if loaded.returncode != 0:
return False
return os.path.exists(pin)