Files
codex-app-server/cloud/chv/sentinel.py
T
host a90cf78d84 feat(chv): 对齐 Muse P3 收尾——authd 替身凭证 + Sentinel 出口审批
- 新测试 cloud/tests/test_chv_muse_p3.py 30 项:替身过期、真 token
  泄露面、SSRF 挡截、deny/ask 路径、authd-seed 拆分、wake 预审、
  客内 authd 守护进程、git credential helper 端到端(真 git fill)
- 8 个旧测试按新 authd 行为改写(未删测试、未放宽断言):
  GITEA_TOKEN 改走 authd-seed,build_ssh_command 不再 source 进
  codex 环境;wake 测试补 Sentinel 白名单 + 假解析器
- 7 处超 50 行函数拆到 50 行内(不改行为);补 authd/sentinel
  公共 API 注解,mypy 在改动文件上干净
- 真问题修复:git 默认不把 path 发给 helper,chv-sync boot 加
  credential.useHttpPath=true,否则 authd 路径每次都拒绝
- 全量 cloud 套件 1897 项 OK(skipped=8)

真机待验证(未做,等第三棒让出 WSL 测试机):见
docs/chv-muse-p3-gap.md §3.6 清单
2026-11-06 21:21:55 +00:00

343 lines
15 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.
"""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)。其它路径(Web UI、登录页、
# 任意管理接口)一律不在本连接器的能力内。
_CONNECTOR_PATH_RULES = {
"gitea": (
("GET", "prefix", "/api/v1/"),
("POST", "prefix", "/api/v1/"),
("GET", "suffix", ".git/info/refs"),
("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