- 新测试 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 清单
343 lines
15 KiB
Python
343 lines
15 KiB
Python
"""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
|