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:
1 parent
ee456e6625
commit
25bcd355f8
11 files changed
+2436
-48
No files matched your search
+179
-48
@@ -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 出现即记入,执行停止。
|
||||
|
||||
Reference in new issue
Block a user