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

+179 -48
View File
@@ -26,6 +26,17 @@ import 它(见 chv-guest/authd.py)。解析器可注入(`resolve`
参数),默认 `socket.getaddrinfo`;测试用假解析器,绝不联网。
审计记录里绝不含凭证值、也绝不含解析出的 IP 以外的内容——
reason 只含网段名与策略名。
P5 纵深(feat-chv-muse-p5):evaluate() 可接三件套——污点追踪器
(taint.py:污点进程失去自动放行、回到正常审批流)、默认策略
(policy.py:干净进程 + 只读 + 已允许 + 低风险可自动放行,
三档谨慎度)、五档严格能力(capabilities.py:一次/会话/任务/
时限/永久,默认仍单次);审批答复只走 approval_channel.py 的
独立通道(nonce + 过期 + HMAC),对话内容伪造无效;eBPF 程序
源码与加载器在 ebpf_taint.py(本机实测加载被 seccomp 挡,见
docs/chv-muse-p5-gap.md §3)。顶层仍只用标准库(客内 authd
直接 import 不受影响);五档能力签发是函数内懒 import,只在
tier != "one-shot" 时触发。
"""
import ipaddress
@@ -33,6 +44,22 @@ import socket
import time
import uuid
from collections.abc import Callable, Iterable
from typing import Protocol
class _TaintTracker(Protocol):
"""污点追踪器口径(P5-A 的 TaintTracker 须满足):只读
一个方法,不持有追踪器实例、不 import taint 模块——
客内 authd 直接 import 本模块时不受影响。"""
def is_tainted(self, pid: int) -> bool: ...
class _DefaultPolicy(Protocol):
"""默认策略口径(P5-B 的 DefaultPolicy 须满足)。"""
def auto_allow_eligible(self, request: "EgressRequest",
tainted: bool) -> bool: ...
# SSRF 挡截:解析到这些网段的 IP 一律 deny。公开主机名解析到
# 私有地址即挡(Muse 安全文的 SSRF 防护口径);解析失败也
@@ -150,10 +177,13 @@ class Capability:
self.host = (host or "").lower()
self.methods = frozenset(methods)
self.path_rules = tuple(path_rules)
self.expires_at = (now if now is not None else time.time()) \
+ ttl_seconds
self.max_uses = max_uses
self.uses_left = max_uses
# None = 不限(五档能力的 session/task/timed/permanent
# 用;单次档仍是 int,行为不变)。
self.expires_at: float | None = (
(now if now is not None else time.time())
+ ttl_seconds)
self.max_uses: int | None = max_uses
self.uses_left: int | None = max_uses
def matches(self, request: EgressRequest,
now: float | None = None) -> bool:
@@ -167,9 +197,9 @@ class Capability:
if not _path_allowed(request, self.path_rules):
return False
at = now if now is not None else time.time()
if at >= self.expires_at:
if self.expires_at is not None and at >= self.expires_at:
return False
if self.uses_left <= 0:
if self.uses_left is not None and self.uses_left <= 0:
return False
return True
@@ -179,7 +209,8 @@ class Capability:
(不抛错,调用方按 deny 处理)。"""
if not self.matches(request, now=now):
return False
self.uses_left -= 1
if self.uses_left is not None:
self.uses_left -= 1
return True
@@ -188,11 +219,13 @@ class Decision:
时有)+ 审计记录(无值)。"""
def __init__(self, verdict: str, reasons: Iterable[str],
capability: Capability | None = None) -> None:
capability: Capability | None = None,
tainted: bool = False) -> None:
assert verdict in ("allow", "deny", "ask")
self.verdict = verdict
self.reasons = list(reasons)
self.capability = capability
self.tainted = tainted
def audit(self, request: EgressRequest) -> dict:
"""审计记录:事件名按 verdict,detail 只含目标描述与原因。"""
@@ -200,6 +233,8 @@ class Decision:
record["event"] = "sentinel_egress_" + self.verdict
record["verdict"] = self.verdict
record["reasons"] = list(self.reasons)
if self.tainted:
record["tainted"] = True
if self.capability is not None:
record["capability_id"] = self.capability.id
return record
@@ -217,6 +252,107 @@ def _path_allowed(request: EgressRequest, rules) -> bool:
return False
def _scope_str(scope: object | None, key: str) -> str | None:
"""从 scope(dict 或带属性的对象)取一键,只认 str,
别的当缺失(fail-closed,不猜)。"""
if scope is None:
return None
value = scope.get(key) if isinstance(scope, dict) \
else getattr(scope, key, None)
return value if isinstance(value, str) else None
def _one_shot_capability(request: EgressRequest, rules: tuple,
now: float | None) -> Capability:
"""P3 默认能力:单次、300 秒。"""
return Capability(
request.connector, request.host, {request.method}, rules,
ttl_seconds=DEFAULT_CAPABILITY_TTL_SECONDS,
max_uses=1, now=now)
def _issue_capability(request: EgressRequest, rules: tuple,
tier: str, scope: object | None,
now: float | None) -> tuple[Capability, bool]:
"""按档位签发严格能力;非法组合(缺用例/会话 id、
timed 缺 ttl、permanent 未显式开启)回落单次(更严格)
并报告 fallback=True,不抛错。"""
if tier == "one-shot":
return _one_shot_capability(request, rules, now), False
from cloud.chv.capabilities import CapabilityIssuer
issuer = CapabilityIssuer()
try:
cap = issuer.issue(
tier, request.connector, request.host,
{request.method}, rules,
use_case=_scope_str(scope, "use_case") or "",
session_id=_scope_str(scope, "session_id"),
task_id=_scope_str(scope, "task_id"), now=now)
except ValueError:
return _one_shot_capability(request, rules, now), True
return cap, False
def _is_tainted(tracker: _TaintTracker | None,
pid: int | None) -> bool:
"""该 pid 是否已污点:未接追踪器或未给 pid 即 False
(不阻断评估,污点只关自动放行)。"""
if tracker is None or pid is None:
return False
return bool(tracker.is_tainted(pid))
def _safety_verdict(policy: "SentinelPolicy",
request: EgressRequest, rules: tuple,
resolve: Callable[[str, int], list] | None
) -> Decision | None:
"""步骤 2–6:scheme/端口/拒绝名单/解析失败/SSRF 私网
IP → deny;全部通过返回 None(继续往下走)。"""
if request.scheme not in _CONNECTOR_SCHEMES.get(
request.connector, frozenset()):
return Decision("deny", ["scheme_not_allowed"])
if request.port not in _CONNECTOR_PORTS.get(
request.connector, frozenset()):
return Decision("deny", ["port_not_allowed"])
if request.host in policy.deny_hosts:
return Decision("deny", ["host_denied"])
resolver = resolve or _default_resolve
try:
ips = resolver(request.host, request.port)
except Exception:
return Decision("deny", ["dns_resolution_failed"])
if not ips:
return Decision("deny", ["dns_resolution_failed"])
for ip in ips:
net = _blocked_network_for(ip)
if net is not None:
return Decision("deny", ["ssrf_private_ip:%s" % net])
return None
def _auto_allow_decision(request: EgressRequest, rules: tuple,
default_policy: _DefaultPolicy | None,
tainted: bool, tier: str,
scope: object | None,
now: float | None) -> Decision | None:
"""默认策略自动放行:未接策略、污点进程、不合候选条件
时返回 None(走正常审批流);命中即 allow + 按档位签发
能力(仍须过连接器 L7 路径规则)。"""
if default_policy is None:
return None
if not default_policy.auto_allow_eligible(request, tainted):
return None
if not _path_allowed(request, rules):
return None
capability, fallback = _issue_capability(
request, rules, tier, scope, now)
reasons = ["policy_auto_allow"]
if fallback:
reasons.append("capability_tier_fallback")
return Decision("allow", reasons, capability=capability,
tainted=tainted)
class SentinelPolicy:
"""Sentinel 策略:白名单主机 + 拒绝主机 + 连接器 L7 规则。
@@ -253,54 +389,49 @@ class SentinelPolicy:
def evaluate(self, request: EgressRequest,
resolve: Callable[[str, int], list] | None = None,
now: float | None = None) -> Decision:
now: float | None = None,
taint_tracker: _TaintTracker | None = None,
pid: int | None = None,
default_policy: _DefaultPolicy | None = None,
tier: str = "one-shot",
scope: object | None = None) -> Decision:
"""评估一次出口请求(按顺序,首个命中即定 verdict):
1. 未知连接器 → deny(没有策略的连接器不放行);
2. scheme 不在该连接器允许集 → deny;
3. 端口不在该连接器允许集 → deny;
4. 主机在拒绝名单 → deny;
5. 解析失败 → deny(fail-closed);
6. 任一解析 IP 落在挡截网段 → deny(SSRF);
7. 主机不在白名单 → ask(记入待审批队列,调用方停止执行);
8. 方法/路径不在连接器 L7 规则内 → deny;
9. 全部通过 → allow + 严格能力(单次、300 秒)。
1–6. 安全检查(未知连接器/scheme/端口/拒绝名单/
解析失败/SSRF 私网 IP)→ deny;
7. 默认策略自动放行(须接 default_policy;干净进程
+ 只读 + 已允许 + 低风险才放行;污点进程一律不放,
回到正常审批流);
8. 主机不在白名单 → ask;9. 方法/路径越界 → deny;
10. 全部通过 → allow + 按档位签发严格能力(默认
单次、300 秒;tier/scope 由调用方传)。
不传接线参数时行为与 P3 完全一致。
"""
rules = self.connector_paths.get(request.connector)
if rules is None:
return Decision("deny", ["unknown_connector"])
if request.scheme not in _CONNECTOR_SCHEMES.get(
request.connector, frozenset()):
return Decision("deny", ["scheme_not_allowed"])
if request.port not in _CONNECTOR_PORTS.get(
request.connector, frozenset()):
return Decision("deny", ["port_not_allowed"])
if request.host in self.deny_hosts:
return Decision("deny", ["host_denied"])
resolver = resolve or _default_resolve
try:
ips = resolver(request.host, request.port)
except Exception:
return Decision("deny", ["dns_resolution_failed"])
if not ips:
return Decision("deny", ["dns_resolution_failed"])
for ip in ips:
net = _blocked_network_for(ip)
if net is not None:
return Decision(
"deny", ["ssrf_private_ip:%s" % net])
denied = _safety_verdict(self, request, rules, resolve)
if denied is not None:
return denied
tainted = _is_tainted(taint_tracker, pid)
auto = _auto_allow_decision(
request, rules, default_policy, tainted,
tier, scope, now)
if auto is not None:
return auto
if request.host not in self.allow_hosts:
return Decision("ask", ["host_not_allowlisted"])
return Decision("ask", ["host_not_allowlisted"],
tainted=tainted)
if not _path_allowed(request, rules):
return Decision("deny", ["path_not_allowed"])
capability = Capability(
request.connector, request.host,
{request.method}, rules,
ttl_seconds=DEFAULT_CAPABILITY_TTL_SECONDS,
max_uses=1, now=now)
return Decision("allow", ["policy_allow"],
capability=capability)
return Decision("deny", ["path_not_allowed"],
tainted=tainted)
capability, fallback = _issue_capability(
request, rules, tier, scope, now)
reasons = ["policy_allow"]
if fallback:
reasons.append("capability_tier_fallback")
return Decision("allow", reasons,
capability=capability, tainted=tainted)
class ApprovalQueue:
"""ask 待审批队列(内存态):ask 出现即记入,执行停止。