"""Sentinel 式出口审批决策引擎(feat-chv-muse-p3,叶子模块)。 对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见 docs/chv-muse-p3-gap.md):Sentinel 是连接器动作与全部网络出口的 唯一权限机构;L4 与 L7 评估(主机名、解析后与最终目的 IP、端口、 协议、HTTP 方法、路径、解码后的实际请求);SSRF 防护(防止公开 主机名在 DNS 解析后指向私有基础设施);审批是严格能力 (strict capabilities),绑定到特定连接器/目的地与用例,支持 一次性/会话级/任务级/时限级授权;审批 UI 不在对话内。 本模块只是决策层(纯函数 + 内存态队列),不是网络层强制: - 它不拦截客内流量。客内直连的强制仍靠 Tinyproxy 白名单 + 宿主防火墙(第三棒落地中);eBPF cgroup 拦截 + LSM hook 污点 传播是后续工作,见差距表 §3.4,不得把本模块写成“出口已全部 经 Sentinel”。 - 本期两个接线点:① 执行器 wake 时的 Gitea 出口预审(起 VMM 前 fail-closed);② 客内 authd 放凭证前评估。两者都调本模块的 `evaluate()`,决策记录进审计(无值)。 - `ask` 的语义:主机不在白名单、也不在拒绝名单时返回 ask,并记入 `ApprovalQueue` 待审批;调用方遇到 ask 必须停止执行、不得自动 放行。审批 UI 不在对话内——本期只留队列,不实现任何对话内审批 入口(见差距表 §1)。 本模块只用标准库,不依赖 cloud.*,客内 authd 守护进程可直接 import 它(见 chv-guest/authd.py)。解析器可注入(`resolve` 参数),默认 `socket.getaddrinfo`;测试用假解析器,绝不联网。 审计记录里绝不含凭证值、也绝不含解析出的 IP 以外的内容—— reason 只含网段名与策略名。 """ import ipaddress import socket import time import uuid from collections.abc import Callable, Iterable # SSRF 挡截:解析到这些网段的 IP 一律 deny。公开主机名解析到 # 私有地址即挡(Muse 安全文的 SSRF 防护口径);解析失败也 # deny(fail-closed,不把“解析不出”当成“可以放行”)。 _BLOCKED_NETWORKS = tuple( ipaddress.ip_network(net) for net in ( "0.0.0.0/8", # 本网络 / 未指定 "10.0.0.0/8", # RFC1918 "100.64.0.0/10", # CGNAT 共享地址空间 "127.0.0.0/8", # 回环 "169.254.0.0/16", # 链路本地 "172.16.0.0/12", # RFC1918 "192.0.0.0/24", # IETF 协议分配 "192.0.2.0/24", # 文档 TEST-NET-1 "192.88.99.0/24", # 6to4 中继(已废弃) "192.168.0.0/16", # RFC1918 # 注意:198.18.0.0/15(基准测试)故意不在表里——本部署的 # egress/DNS 基础设施自用该网段,gitea.tose.top 在此环境 # 真实解析到 198.18.139.152;挡掉它会把真 Gitea 主机也拒了。 "198.51.100.0/24", # 文档 TEST-NET-2 "203.0.113.0/24", # 文档 TEST-NET-3 "224.0.0.0/4", # 组播 "240.0.0.0/4", # 保留 "255.255.255.255/32", "::1/128", # 回环 "::/128", # 未指定 "fc00::/7", # 唯一本地 "fe80::/10", # 链路本地 "ff00::/8", # 组播 )) # 每连接器的 L7 路径策略:(方法, 匹配方式, 模式)。 # gitea:API 只放 /api/v1/;git 智能 HTTP 只放三条(clone/pull/push # 的实际路径,见 git http-protocol)加裸 .git(round-11 真机修复 # 层 4:git 智能 HTTP 的发现请求只发裸 .git 路径,裸路径只放 GET、 # 一律当 pull;POST 裸 .git 仍拒绝)。其它路径(Web UI、登录页、 # 任意管理接口)一律不在本连接器的能力内。 _CONNECTOR_PATH_RULES = { "gitea": ( ("GET", "prefix", "/api/v1/"), ("POST", "prefix", "/api/v1/"), ("GET", "suffix", ".git/info/refs"), ("GET", "suffix", ".git"), ("POST", "suffix", ".git/git-upload-pack"), ("POST", "suffix", ".git/git-receive-pack"), ), } # 每连接器允许的 scheme/端口(默认:只 https/443;http 只在 # 显式放行时才出现,本期没有任何连接器放 http)。 _CONNECTOR_SCHEMES = {"gitea": frozenset({"https"})} _CONNECTOR_PORTS = {"gitea": frozenset({443})} # 默认放凭证有效期(秒):与 authd 按次释放同款 300 秒。 DEFAULT_CAPABILITY_TTL_SECONDS = 300 def _blocked_network_for(ip_text: str): """该 IP 落在哪个挡截网段里(无即 None)。非法 IP 文本 按“挡不住也说不清”处理:返回 0.0.0.0/8(deny),绝不 把解析异常当成放行信号。""" try: addr = ipaddress.ip_address(ip_text) except ValueError: return _BLOCKED_NETWORKS[0] for net in _BLOCKED_NETWORKS: if addr.version == net.version and addr in net: return net return None def _default_resolve(host: str, port: int) -> list: """默认解析器:socket.getaddrinfo 去重后的 IP 列表。""" infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) out = [] for info in infos: ip = info[4][0] if ip not in out: out.append(ip) return out class EgressRequest: """一次出口请求(决策输入):连接器 + 方法 + 目标 + 解析后 IP(解析在 evaluate 里做,这里只记目标描述,不记任何凭证值)。""" def __init__(self, connector: str, method: str, scheme: str, host: str, port: int, path: str) -> None: self.connector = connector self.method = (method or "").upper() self.scheme = (scheme or "").lower() self.host = (host or "").lower() self.port = int(port) self.path = path or "/" def describe(self) -> dict: """审计用描述(只含连接器/目标/方法,不含值)。""" return {"connector": self.connector, "method": self.method, "scheme": self.scheme, "host": self.host, "port": self.port, "path": self.path} class Capability: """严格能力(Muse 口径:绑定到特定连接器/目的地与用例)。 一次 evaluate().capability 只对:一个连接器、一个主机、声明 的方法与路径模式、在有效期内、剩余次数内有效。越界使用 (换主机、换方法、换路径、过期、用完)一律 `matches()` 为 False,调用方必须重新走 evaluate,不得自行续期。""" def __init__(self, connector: str, host: str, methods: Iterable[str], path_rules: Iterable[tuple[str, str, str]], ttl_seconds: int = DEFAULT_CAPABILITY_TTL_SECONDS, max_uses: int = 1, now: float | None = None) -> None: self.id = uuid.uuid4().hex[:16] self.connector = connector 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 def matches(self, request: EgressRequest, now: float | None = None) -> bool: """该请求是否在能力绑定内(不消耗次数,只判定)。""" if request.connector != self.connector: return False if request.host != self.host: return False if request.method not in self.methods: return False 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: return False if self.uses_left <= 0: return False return True def use(self, request: EgressRequest, now: float | None = None) -> bool: """消耗一次能力:绑定内才扣次数并返回 True,否则 False (不抛错,调用方按 deny 处理)。""" if not self.matches(request, now=now): return False self.uses_left -= 1 return True class Decision: """一次审批决策:allow / deny / ask + 原因 + 能力(仅 allow 时有)+ 审计记录(无值)。""" def __init__(self, verdict: str, reasons: Iterable[str], capability: Capability | None = None) -> None: assert verdict in ("allow", "deny", "ask") self.verdict = verdict self.reasons = list(reasons) self.capability = capability def audit(self, request: EgressRequest) -> dict: """审计记录:事件名按 verdict,detail 只含目标描述与原因。""" record = request.describe() record["event"] = "sentinel_egress_" + self.verdict record["verdict"] = self.verdict record["reasons"] = list(self.reasons) if self.capability is not None: record["capability_id"] = self.capability.id return record def _path_allowed(request: EgressRequest, rules) -> bool: """路径是否在该连接器的 L7 规则内。""" for method, kind, pattern in rules: if request.method != method: continue if kind == "prefix" and request.path.startswith(pattern): return True if kind == "suffix" and request.path.endswith(pattern): return True return False class SentinelPolicy: """Sentinel 策略:白名单主机 + 拒绝主机 + 连接器 L7 规则。 默认拒绝(default-deny):主机不在白名单、也不在拒绝名单时 返回 ask(不是 allow);拒绝名单命中、SSRF 命中、scheme/ 端口/路径越界时返回 deny。白名单只放行“已知且解析干净”的 目标——白名单不是“解析后是什么都行”,SSRF 检查对白名单 主机同样生效(DNS 可被投毒,白名单主机解析到私网 IP 仍 deny)。""" def __init__(self, allow_hosts: Iterable[str] = (), deny_hosts: Iterable[str] = (), connector_paths: dict[str, tuple] | None = None ) -> None: self.allow_hosts = frozenset( h.lower() for h in (allow_hosts or ())) self.deny_hosts = frozenset( h.lower() for h in (deny_hosts or ())) self.connector_paths = dict( connector_paths if connector_paths is not None else _CONNECTOR_PATH_RULES) @classmethod def from_env(cls: type["SentinelPolicy"], env: dict | None, extra_allow_hosts: Iterable[str] = () ) -> "SentinelPolicy": """从宿主环境变量建策略:CHV_SENTINEL_ALLOW_HOSTS(逗号 分隔主机名)+ 调用方追加的白名单(执行器 wake 预审时把 配置的 Gitea 主机追加进来——只放行配置的 Gitea 主机, 不是放行任意主机)。""" raw = (env or {}).get("CHV_SENTINEL_ALLOW_HOSTS") or "" hosts = [h.strip().lower() for h in raw.split(",") if h.strip()] hosts += [h.lower() for h in (extra_allow_hosts or ())] return cls(allow_hosts=hosts) def evaluate(self, request: EgressRequest, resolve: Callable[[str, int], list] | None = None, now: float | 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 秒)。 """ 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]) if request.host not in self.allow_hosts: return Decision("ask", ["host_not_allowlisted"]) 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) class ApprovalQueue: """ask 待审批队列(内存态):ask 出现即记入,执行停止。 设计约束(Muse 口径):审批 UI 不在对话内。本期只留队列与 查询/标记接口,不实现任何对话内审批入口,也不自动放行。 队列是内存态,进程重启即清——ask 本来就意味着“这次执行 已停止”,重启后须重新申请。""" def __init__(self, clock: Callable[[], float] | None = None) -> None: self._pending: dict[str, dict] = {} self._clock = clock or time.time def record(self, request: EgressRequest, reasons: Iterable[str]) -> str: """记入一条待审批,返回 pending id(只含目标描述与原因)。""" pending_id = uuid.uuid4().hex[:16] record = request.describe() record["pending_id"] = pending_id record["reasons"] = list(reasons) record["ts"] = self._clock() self._pending[pending_id] = record return pending_id def list_pending(self) -> list: """全部待审批(副本,按记录时间排序)。""" return [dict(v) for _, v in sorted( self._pending.items(), key=lambda kv: kv[1]["ts"])] def resolve(self, pending_id: str, verdict: str) -> bool: """标记一条待审批已处理(verdict 只记 allow/deny,不在 这里执行放行——放行须调用方重新走 evaluate)。未知 id 返回 False,不抛错。""" if pending_id not in self._pending: return False if verdict not in ("allow", "deny"): return False self._pending.pop(pending_id) return True