Files
codex-app-server/cloud/chv/ebpf_taint.py
T
host 25bcd355f8 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
2026-11-06 22:25:55 +00:00

326 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)