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
@@ -0,0 +1,196 @@
|
||||
"""对话外审批通道(feat-chv-muse-p5-C,叶子模块,纯标准库)。
|
||||
|
||||
接在 P3 的 ``ApprovalQueue`` 之上:队列只记 pending 待审批,
|
||||
答复只走本通道,绝不走 agent 对话内容。对话里出现的“批准/
|
||||
同意/allow”字样没有任何效力——没有合法 nonce 与 HMAC 签名,
|
||||
校验一律失败。
|
||||
|
||||
通道契约(客户端 UI 呈现是另一侧的事,本模块只给协议):
|
||||
- 请求载荷(``ApprovalRequest.payload()``,可外发给客户端)::
|
||||
{"pending_id": str, "nonce": str, "target": {...},
|
||||
"reasons": [str, ...], "expires_at": float}
|
||||
``target`` 只有目标描述(连接器/方法/scheme/主机/端口/路径),
|
||||
``reasons`` 只有原因名;载荷里绝无凭证值与用户数据内容。
|
||||
``nonce`` 是随机 16 字节 hex(32 字符),每次请求唯一。
|
||||
``expires_at`` 默认请求后 15 分钟(900 秒),可注入时钟。
|
||||
- 答复(``Answer``,客户端答完直接回 Sentinel)::
|
||||
{"pending_id": str, "nonce": str, "verdict": "allow"|"deny",
|
||||
"expires_at": float, "signature": str}
|
||||
``signature`` 是 HMAC-SHA256,key 为通道密钥,签名覆盖
|
||||
``pending_id + nonce + verdict + expires_at``(见 ``_sign``)。
|
||||
- 校验(``verify_answer``):pending 存在(本通道签发过且未
|
||||
消费)、nonce 对上、未过期、签名对上、verdict 在 allow/deny
|
||||
内——五项缺一即 False。答复只能用一次:``resolve_via_channel``
|
||||
成功后该 pending 的通道记录即失效,重放同一答复被拒。
|
||||
- 密钥由调用方保管:通道本身不保存密钥、不把密钥写进载荷、
|
||||
绝不进日志;签发请求时传 ``secret`` 只是调用方证明其持有
|
||||
通道密钥(本模块不存它),签名由持有密钥的答复方计算。
|
||||
- 队列接线:校验通过才调 ``ApprovalQueue.resolve``;校验失败
|
||||
返回 False 且队列原样不动(pending 仍在,须重新答复)。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
|
||||
from cloud.chv.sentinel import ApprovalQueue
|
||||
|
||||
# 默认审批有效期(秒):15 分钟。
|
||||
DEFAULT_TTL_SECONDS = 900
|
||||
|
||||
_VERDICTS = ("allow", "deny")
|
||||
|
||||
# 本通道签发过、尚未消费的请求:pending_id -> {"nonce", "expires_at"}。
|
||||
# 只记 nonce 与过期,不记任何凭证值与用户数据内容。
|
||||
_REQUESTS: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _sign(pending_id: str, nonce: str, verdict: str,
|
||||
expires_at: float, secret: str) -> str:
|
||||
"""HMAC-SHA256 签名:覆盖 pending_id + nonce + verdict +
|
||||
expires_at。expires_at 统一按 float 格式化,签发与校验
|
||||
两侧口径一致。"""
|
||||
message = "\n".join((pending_id, nonce, verdict,
|
||||
repr(float(expires_at)))).encode("utf-8")
|
||||
return hmac.new(secret.encode("utf-8"), message,
|
||||
hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _target_from_record(queue_record: dict) -> dict:
|
||||
"""队列记录里只取目标描述字段,别的什么都不带出通道。"""
|
||||
keys = ("connector", "method", "scheme", "host", "port", "path")
|
||||
return {k: queue_record[k] for k in keys if k in queue_record}
|
||||
|
||||
|
||||
class ApprovalRequest:
|
||||
"""一次通道审批请求:pending_id + 目标描述 + 原因 + nonce
|
||||
+ 过期时间。只含描述性字段,无凭证值、无用户数据内容。"""
|
||||
|
||||
def __init__(self, pending_id: str, target: dict,
|
||||
reasons: Iterable[str] | None,
|
||||
nonce: str | None = None,
|
||||
now: float | None = None,
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS) -> None:
|
||||
self.pending_id = pending_id
|
||||
self.target = dict(target)
|
||||
self.reasons = list(reasons or [])
|
||||
self.nonce = nonce or secrets.token_hex(16)
|
||||
at = now if now is not None else time.time()
|
||||
self.expires_at = float(at) + ttl_seconds
|
||||
|
||||
def payload(self) -> dict:
|
||||
"""可外发给客户端的请求载荷(只含描述字段 + nonce +
|
||||
过期,无密钥、无凭证值、无用户数据内容)。"""
|
||||
return {"pending_id": self.pending_id, "nonce": self.nonce,
|
||||
"target": dict(self.target),
|
||||
"reasons": list(self.reasons),
|
||||
"expires_at": self.expires_at}
|
||||
|
||||
|
||||
def request_approval(queue_record: dict, secret: str,
|
||||
now: float | None = None,
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS
|
||||
) -> ApprovalRequest:
|
||||
"""为一条队列记录生成通道请求并登记(nonce + 过期)。
|
||||
|
||||
``secret`` 是调用方持有的通道密钥:本模块不保存它、不进
|
||||
载荷、不进日志;答复方用同一密钥签名,校验时对上才算数。
|
||||
返回的 ``ApprovalRequest.payload()`` 即发给客户端的东西。
|
||||
"""
|
||||
pending_id = queue_record["pending_id"]
|
||||
req = ApprovalRequest(
|
||||
pending_id, _target_from_record(queue_record),
|
||||
queue_record.get("reasons", []),
|
||||
now=now, ttl_seconds=ttl_seconds)
|
||||
_REQUESTS[pending_id] = {"nonce": req.nonce,
|
||||
"expires_at": req.expires_at}
|
||||
return req
|
||||
|
||||
|
||||
class Answer:
|
||||
"""一次通道答复:pending_id + nonce + verdict + 过期 +
|
||||
HMAC 签名。verdict 只认 allow/deny,其它值校验即拒。"""
|
||||
|
||||
def __init__(self, pending_id: str, nonce: str, verdict: str,
|
||||
expires_at: float, signature: str) -> None:
|
||||
self.pending_id = pending_id
|
||||
self.nonce = nonce
|
||||
self.verdict = verdict
|
||||
self.expires_at = float(expires_at)
|
||||
self.signature = signature
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
"""答复的线上传输格式(与请求载荷同款纯数据 dict)。"""
|
||||
return {"pending_id": self.pending_id, "nonce": self.nonce,
|
||||
"verdict": self.verdict,
|
||||
"expires_at": self.expires_at,
|
||||
"signature": self.signature}
|
||||
|
||||
|
||||
def make_answer(request: ApprovalRequest, verdict: str,
|
||||
secret: str) -> Answer:
|
||||
"""答复方(持有通道密钥的客户端)构造已签名的答复。
|
||||
verdict 只认 allow/deny,其它值直接抛错、不签发。"""
|
||||
if verdict not in _VERDICTS:
|
||||
raise ValueError("verdict must be allow or deny")
|
||||
signature = _sign(request.pending_id, request.nonce, verdict,
|
||||
request.expires_at, secret)
|
||||
return Answer(request.pending_id, request.nonce, verdict,
|
||||
request.expires_at, signature)
|
||||
|
||||
|
||||
def _as_answer(answer) -> Answer | None:
|
||||
"""Answer 对象或同款 dict 统一成 Answer;字段不全即 None
|
||||
(缺字段的答复直接判无效,不抛错)。"""
|
||||
if isinstance(answer, Answer):
|
||||
return answer
|
||||
if isinstance(answer, dict):
|
||||
try:
|
||||
return Answer(answer["pending_id"], answer["nonce"],
|
||||
answer["verdict"], answer["expires_at"],
|
||||
answer["signature"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def verify_answer(answer: Answer | dict, secret: str,
|
||||
now: float | None = None) -> bool:
|
||||
"""校验一次答复:pending 存在(本通道签发过且未消费)、
|
||||
nonce 对上、未过期、签名对上、verdict 合法——五项缺一
|
||||
即 False。本函数是纯校验,不消费答复(单次性由
|
||||
``resolve_via_channel`` 在成功时消费登记来保证)。"""
|
||||
ans = _as_answer(answer)
|
||||
if ans is None or ans.verdict not in _VERDICTS:
|
||||
return False
|
||||
issued = _REQUESTS.get(ans.pending_id)
|
||||
if issued is None:
|
||||
return False
|
||||
if ans.nonce != issued["nonce"]:
|
||||
return False
|
||||
at = now if now is not None else time.time()
|
||||
if at >= issued["expires_at"] or at >= ans.expires_at:
|
||||
return False
|
||||
expected = _sign(ans.pending_id, ans.nonce, ans.verdict,
|
||||
ans.expires_at, secret)
|
||||
return hmac.compare_digest(ans.signature, expected)
|
||||
|
||||
|
||||
def resolve_via_channel(queue: ApprovalQueue,
|
||||
answer: Answer | dict, secret: str,
|
||||
now: float | None = None) -> bool:
|
||||
"""校验通过才调队列的 resolve;校验失败返回 False 且
|
||||
队列不动。成功时消费该 pending 的通道登记(答复只能用
|
||||
一次),队列 resolve 失败(pending 已不在队列)则不消费、
|
||||
返回 False。"""
|
||||
if not verify_answer(answer, secret, now=now):
|
||||
return False
|
||||
ans = _as_answer(answer)
|
||||
if ans is None:
|
||||
return False
|
||||
if not queue.resolve(ans.pending_id, ans.verdict):
|
||||
return False
|
||||
_REQUESTS.pop(ans.pending_id, None)
|
||||
return True
|
||||
Reference in new issue
Block a user