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
|
||||
@@ -0,0 +1,276 @@
|
||||
"""五档严格能力(feat-chv-muse-p5-B,叶子模块,纯标准库)。
|
||||
|
||||
建在 P3 的 ``Capability`` 之上(继承,不重写绑定语义):P3 只给
|
||||
单次 + 300 秒一档;本模块加五档与用例维度,签发/使用/过期/
|
||||
超次/撤销全审计(事件名 + 能力 id + 原因,绝无凭证值与用户
|
||||
数据内容)。
|
||||
|
||||
档位(``TIERS``)与签发默认值(``CapabilityIssuer.issue``):
|
||||
- one-shot:单次、300 秒(P3 默认);
|
||||
- session:会话内多次、无固定次数,会话 id 绑定,默认 3600 秒;
|
||||
- task:任务内多次、无固定次数,任务 id 绑定,默认 3600 秒;
|
||||
- timed:时限内多次、无固定次数,须显式 ttl;
|
||||
- permanent:永久、无过期无次数上限——默认不签发,须显式
|
||||
``allow_permanent=True`` + 审计,否则 ``ValueError``。
|
||||
|
||||
能力绑定 = {连接器,主机,用例} 三元组 + 方法/路径规则 +
|
||||
次数/过期。``matches(request, now, scope)`` 精确匹配(``scope``
|
||||
须按关键字传,``now`` 仍占第二位置参数,与 P3 旧口径一致):
|
||||
用例、方法、路径、会话/任务绑定、有效期、剩余次数任一项越界
|
||||
即 False(并记审计)。``scope`` 是 dict 或带属性的对象,含
|
||||
``use_case`` / ``session_id`` / ``task_id``;没给 scope(None)
|
||||
按空 scope 计,即用例/会话/任务绑定一律不匹配(fail-closed)。
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from cloud.chv.sentinel import (
|
||||
DEFAULT_CAPABILITY_TTL_SECONDS,
|
||||
Capability,
|
||||
EgressRequest,
|
||||
_path_allowed,
|
||||
)
|
||||
|
||||
TIERS = ("one-shot", "session", "task", "timed", "permanent")
|
||||
DEFAULT_SESSION_TTL_SECONDS = 3600
|
||||
DEFAULT_TASK_TTL_SECONDS = 3600
|
||||
|
||||
_EVENT_ISSUED = "sentinel_capability_issued"
|
||||
_EVENT_USED = "sentinel_capability_used"
|
||||
_EVENT_DENIED = "sentinel_capability_denied"
|
||||
_EVENT_EXPIRED = "sentinel_capability_expired"
|
||||
_EVENT_REVOKED = "sentinel_capability_revoked"
|
||||
|
||||
|
||||
def _scope_get(scope: object | None, key: str) -> object | None:
|
||||
"""从 scope(dict 或带属性的对象)取一键,缺失即 None。"""
|
||||
if scope is None:
|
||||
return None
|
||||
if isinstance(scope, dict):
|
||||
return scope.get(key)
|
||||
return getattr(scope, key, None)
|
||||
|
||||
|
||||
def _audit_record(cap: "ScopedCapability", event: str,
|
||||
reason: str) -> dict:
|
||||
"""审计记录:事件名 + 能力 id + 原因,无值。"""
|
||||
return {"event": event, "capability_id": cap.id,
|
||||
"tier": cap.tier, "reason": reason,
|
||||
"connector": cap.connector, "host": cap.host,
|
||||
"use_case": cap.use_case}
|
||||
|
||||
|
||||
class ScopedCapability(Capability):
|
||||
"""五档严格能力:P3 绑定 + 用例三元组 + 会话/任务绑定。
|
||||
|
||||
``max_uses=None`` 表示无固定次数(session/task/timed/
|
||||
permanent 的默认);``expires_at=None`` 表示永不过期
|
||||
(仅 permanent)。``revoked`` 后 ``matches`` 恒 False。
|
||||
``audit_log`` 与签发方共享:签发/使用/过期/超次/撤销
|
||||
全记进同一份审计(事件名 + 能力 id + 原因,无值)。
|
||||
"""
|
||||
|
||||
def __init__(self, connector: str, host: str,
|
||||
methods: Iterable[str],
|
||||
path_rules: Iterable[tuple[str, str, str]],
|
||||
tier: str, use_case: str,
|
||||
session_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
ttl_seconds: int | None = None,
|
||||
max_uses: int | None = None,
|
||||
now: float | None = None,
|
||||
audit: list | None = None) -> None:
|
||||
super().__init__(
|
||||
connector, host, methods, path_rules,
|
||||
ttl_seconds=(ttl_seconds if ttl_seconds is not None
|
||||
else 0),
|
||||
max_uses=(max_uses if max_uses is not None else 1),
|
||||
now=now)
|
||||
self.tier = tier
|
||||
self.use_case = use_case
|
||||
self.session_id = session_id
|
||||
self.task_id = task_id
|
||||
self.revoked = False
|
||||
self.max_uses = max_uses
|
||||
self.uses_left = max_uses
|
||||
if ttl_seconds is None:
|
||||
self.expires_at = None
|
||||
self.audit_log = audit if audit is not None else []
|
||||
|
||||
def _mismatch_reason(self, request: EgressRequest,
|
||||
scope: object | None,
|
||||
now: float | None) -> str | None:
|
||||
"""越界原因(None 表示在绑定内)。检查顺序固定:
|
||||
撤销 → 用例 → 会话/任务 → 连接器/主机/方法/路径 →
|
||||
过期 → 次数。"""
|
||||
if self.revoked:
|
||||
return "revoked"
|
||||
if _scope_get(scope, "use_case") != self.use_case:
|
||||
return "use_case_mismatch"
|
||||
if self.tier == "session" and (
|
||||
_scope_get(scope, "session_id") != self.session_id):
|
||||
return "session_mismatch"
|
||||
if self.tier == "task" and (
|
||||
_scope_get(scope, "task_id") != self.task_id):
|
||||
return "task_mismatch"
|
||||
if request.connector != self.connector:
|
||||
return "connector_mismatch"
|
||||
if request.host != self.host:
|
||||
return "host_mismatch"
|
||||
if request.method not in self.methods:
|
||||
return "method_not_allowed"
|
||||
if not _path_allowed(request, self.path_rules):
|
||||
return "path_not_allowed"
|
||||
at = now if now is not None else time.time()
|
||||
if self.expires_at is not None and at >= self.expires_at:
|
||||
return "expired"
|
||||
if self.uses_left is not None and self.uses_left <= 0:
|
||||
return "uses_exhausted"
|
||||
return None
|
||||
|
||||
def _audit(self, event: str, reason: str) -> None:
|
||||
"""记一条审计(事件名 + 能力 id + 原因,无值)。"""
|
||||
self.audit_log.append(_audit_record(self, event, reason))
|
||||
|
||||
def matches(self, request: EgressRequest,
|
||||
now: float | None = None,
|
||||
scope: object | None = None) -> bool:
|
||||
"""该请求是否在能力绑定内(不消耗次数,只判定)。
|
||||
|
||||
``scope`` 须按关键字传(``now`` 仍占第二位置参数,
|
||||
与 P3 旧口径一致)。越界即 False 并记审计:过期记
|
||||
expired,其余记 denied。
|
||||
"""
|
||||
reason = self._mismatch_reason(request, scope, now)
|
||||
if reason is None:
|
||||
return True
|
||||
event = (_EVENT_EXPIRED if reason == "expired"
|
||||
else _EVENT_DENIED)
|
||||
self._audit(event, reason)
|
||||
return False
|
||||
|
||||
def use(self, request: EgressRequest,
|
||||
now: float | None = None,
|
||||
scope: object | None = None) -> bool:
|
||||
"""消耗一次能力:绑定内才扣次数并返回 True,否则 False
|
||||
(失败原因已由 ``matches`` 记审计,不再重复记)。"""
|
||||
if not self.matches(request, now=now, scope=scope):
|
||||
return False
|
||||
if self.uses_left is not None:
|
||||
self.uses_left -= 1
|
||||
self._audit(_EVENT_USED, "used")
|
||||
return True
|
||||
|
||||
def revoke(self) -> bool:
|
||||
"""撤销:首次返回 True 并记审计;已撤销再调返回 False。
|
||||
撤销后 ``matches`` 恒 False(原因 revoked)。"""
|
||||
if self.revoked:
|
||||
self._audit(_EVENT_DENIED, "already_revoked")
|
||||
return False
|
||||
self.revoked = True
|
||||
self._audit(_EVENT_REVOKED, "revoked")
|
||||
return True
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""审计/展示用绑定描述(只含绑定,不含值)。"""
|
||||
return {"capability_id": self.id, "tier": self.tier,
|
||||
"connector": self.connector, "host": self.host,
|
||||
"use_case": self.use_case,
|
||||
"session_id": self.session_id,
|
||||
"task_id": self.task_id,
|
||||
"methods": sorted(self.methods),
|
||||
"max_uses": self.max_uses,
|
||||
"uses_left": self.uses_left,
|
||||
"expires_at": self.expires_at,
|
||||
"revoked": self.revoked}
|
||||
|
||||
|
||||
class CapabilityIssuer:
|
||||
"""五档能力签发入口:按档位设默认值,审计与签出的能力共享。
|
||||
|
||||
permanent 默认拒绝:须调用时显式 ``allow_permanent=True``,
|
||||
拒绝本身也记审计(denied,原因
|
||||
``permanent_requires_explicit_opt_in``)。
|
||||
"""
|
||||
|
||||
def __init__(self, audit: list | None = None,
|
||||
clock: Callable[[], float] | None = None) -> None:
|
||||
self.audit_log = audit if audit is not None else []
|
||||
self._clock = clock or time.time
|
||||
|
||||
def issue(self, tier: str, connector: str, host: str,
|
||||
methods: Iterable[str],
|
||||
path_rules: Iterable[tuple[str, str, str]],
|
||||
use_case: str, session_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
ttl_seconds: int | None = None,
|
||||
max_uses: int | None = None,
|
||||
now: float | None = None,
|
||||
allow_permanent: bool = False) -> ScopedCapability:
|
||||
"""签发一档能力(按档位设默认值,见模块 docstring)。
|
||||
|
||||
未知档位、空用例、session 缺会话 id、task 缺任务 id、
|
||||
timed 缺 ttl、permanent 未显式开启一律 ValueError;
|
||||
校验失败先记审计(无能力 id)再抛错。
|
||||
"""
|
||||
at = now if now is not None else self._clock()
|
||||
if tier not in TIERS:
|
||||
raise ValueError("unknown_tier:%s" % tier)
|
||||
if not use_case:
|
||||
raise ValueError("use_case_required")
|
||||
try:
|
||||
ttl, uses = self._defaults(
|
||||
tier, session_id, task_id, ttl_seconds, max_uses,
|
||||
allow_permanent)
|
||||
except ValueError as exc:
|
||||
self.audit_log.append(
|
||||
{"event": _EVENT_DENIED, "capability_id": None,
|
||||
"tier": tier, "reason": str(exc),
|
||||
"connector": connector, "host": host,
|
||||
"use_case": use_case})
|
||||
raise
|
||||
cap = ScopedCapability(
|
||||
connector, host, methods, path_rules, tier, use_case,
|
||||
session_id=session_id, task_id=task_id,
|
||||
ttl_seconds=ttl, max_uses=uses, now=at,
|
||||
audit=self.audit_log)
|
||||
cap._audit(_EVENT_ISSUED, "issued")
|
||||
return cap
|
||||
|
||||
def _defaults(self, tier: str, session_id: str | None,
|
||||
task_id: str | None, ttl_seconds: int | None,
|
||||
max_uses: int | None,
|
||||
allow_permanent: bool
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""各档默认值(非法组合抛 ValueError,原因即消息)。
|
||||
|
||||
one-shot:1 次/300 秒;session/task:无固定次数,默认
|
||||
3600 秒;timed:须显式 ttl;permanent:无过期无次数,
|
||||
忽略传入的 ttl/max_uses。"""
|
||||
if tier == "one-shot":
|
||||
return (ttl_seconds if ttl_seconds is not None
|
||||
else DEFAULT_CAPABILITY_TTL_SECONDS,
|
||||
max_uses if max_uses is not None else 1)
|
||||
if tier == "session":
|
||||
if not session_id:
|
||||
raise ValueError("session_id_required")
|
||||
return (ttl_seconds if ttl_seconds is not None
|
||||
else DEFAULT_SESSION_TTL_SECONDS, max_uses)
|
||||
if tier == "task":
|
||||
if not task_id:
|
||||
raise ValueError("task_id_required")
|
||||
return (ttl_seconds if ttl_seconds is not None
|
||||
else DEFAULT_TASK_TTL_SECONDS, max_uses)
|
||||
if tier == "timed":
|
||||
if ttl_seconds is None:
|
||||
raise ValueError("ttl_required")
|
||||
return (ttl_seconds, max_uses)
|
||||
if not allow_permanent:
|
||||
raise ValueError("permanent_requires_explicit_opt_in")
|
||||
return (None, None)
|
||||
|
||||
|
||||
__all__ = ["TIERS", "DEFAULT_SESSION_TTL_SECONDS",
|
||||
"DEFAULT_TASK_TTL_SECONDS", "ScopedCapability",
|
||||
"CapabilityIssuer"]
|
||||
@@ -0,0 +1,325 @@
|
||||
"""eBPF 污点传播加载层(对齐 Muse P5-A,纯标准库 + 可选工具链)。
|
||||
|
||||
Muse 口径(见 docs/chv-muse-p5-gap.md §1 污点出口行):用 eBPF
|
||||
cgroup 程序做网络拦截与进程归因、及挂在 LSM hook 上的 eBPF
|
||||
程序做污点传播。
|
||||
|
||||
本模块只含两样东西,绝不冒充已加载:
|
||||
1. eBPF 程序源码(字符串常量,见 `EBPF_CGROUP_EGRESS_C` 与
|
||||
`EBPF_LSM_TAINT_C`):cgroup egress 拦截 + 进程归因 map +
|
||||
LSM hook 污点传播骨架。源码本身不是“已在拦截流量”。
|
||||
2. 加载器 `EbpfTaintLoader`:能力探测(内核版本、bpffs、
|
||||
CAP_BPF/CAP_SYS_ADMIN、bpftool/clang 是否在位)→ 状态
|
||||
loaded/degraded/blocked + 原因;加载失败一律明确降级为
|
||||
“策略引擎 + 代理层钩子”模式(`FALLBACK_MODE`),绝不把
|
||||
degraded 写成 loaded。
|
||||
|
||||
加载成功的硬条件(缺一即 blocked/degraded,不重试蒙混):
|
||||
- 内核 >= 5.8(cgroup_skb egress 挂载点;LSM hook 另需
|
||||
内核开 CONFIG_BPF_LSM,见 LSM 源码头注释);
|
||||
- /sys/fs/bpf 存在且可写(pin 落点);
|
||||
- 进程有效能力集含 CAP_BPF(bit 39)或 CAP_SYS_ADMIN
|
||||
(bit 21)——且 bpf() 系统调用本身不被 seccomp 挡;
|
||||
- bpftool + clang 在位(编译与加载工具链)。
|
||||
|
||||
本机实测结论(2026-11-07,Muse VM):不行。内核
|
||||
7.0.0-43-generic、CapEff 含 CAP_BPF 与 CAP_SYS_ADMIN,但
|
||||
bpf() 与 BPF_MAP_CREATE 均报 EPERM(Seccomp filter 在位,
|
||||
4 条过滤器),且无 /sys/fs/bpf、无 bpftool/clang。能力位
|
||||
在位不等于系统调用放行——探测只看 CapEff 会误判,所以
|
||||
`probe()` 把“缺 bpffs/缺工具链”也记为 blocker,不以
|
||||
CapEff 单项定生死。
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
|
||||
# 加载失败后的降级模式:策略引擎(taint.py + sentinel.py 决策)
|
||||
# + 代理层钩子(Tinyproxy 白名单等现有强制),不走 eBPF。
|
||||
FALLBACK_MODE = "policy_engine_proxy_hooks"
|
||||
|
||||
# cgroup egress 拦截的最低内核版本。
|
||||
MIN_KERNEL = (5, 8)
|
||||
|
||||
# CAP 位号(/proc/self/status 的 CapEff,按 Linux capability.h)。
|
||||
_CAP_BPF_BIT = 39
|
||||
_CAP_SYS_ADMIN_BIT = 21
|
||||
|
||||
# eBPF cgroup egress 拦截程序源码。
|
||||
#
|
||||
# 作用(按 section):
|
||||
# - `SEC("cgroup_skb/egress")`:挂在 cgroup 的 egress 路径上,
|
||||
# 每个出站 skb 都过这里;返回 0 放行、返回非 0 丢弃。污点
|
||||
# 进程的直连在这里被拦下,而不是等代理层才发现。
|
||||
# - `taint_map`(BPF_MAP_TYPE_HASH,key=pid u32,value=污点
|
||||
# 标志 u8):进程归因 map。用户态追踪器(taint.py)与 LSM
|
||||
# 程序共用这张表:LSM 侧写污点,cgroup 侧读污点。
|
||||
# - `events`(BPF_MAP_TYPE_RINGBUF):拦截/放行事件上报给
|
||||
# 用户态审计,不含包内容,只含 pid 与 verdict。
|
||||
# - 归因键:`bpf_get_current_pid_tgid() >> 32` 取当前进程 pid;
|
||||
# cgroup 归属由挂载时的 cgroup 决定,不靠包里的地址猜进程。
|
||||
# 内核要求:>= 5.8,CAP_BPF(或 CAP_SYS_ADMIN),cgroup v2,
|
||||
# bpffs 可写(pin 落点),clang -target bpf + bpftool 加载。
|
||||
EBPF_CGROUP_EGRESS_C = r"""
|
||||
#include <linux/bpf.h>
|
||||
#include <bpf/bpf_helpers.h>
|
||||
|
||||
/* 进程归因 map:pid -> 污点标志(1=污点,0=干净)。LSM 程序写,
|
||||
本程序读;用户态也可经 map fd 直接查。 */
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_HASH);
|
||||
__uint(max_entries, 65536);
|
||||
__type(key, __u32);
|
||||
__type(value, __u8);
|
||||
} taint_map SEC(".maps");
|
||||
|
||||
/* 事件上报:只含 pid 与 verdict,不含包内容。 */
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_RINGBUF);
|
||||
__uint(max_entries, 1 << 16);
|
||||
} events SEC(".maps");
|
||||
|
||||
struct event {
|
||||
__u32 pid;
|
||||
__u32 verdict; /* 0=放行,1=丢弃 */
|
||||
};
|
||||
|
||||
/* cgroup egress 拦截:污点进程的出站包一律丢弃并上报,干净
|
||||
进程放行(放行不等于“自动放行通过”——窄范围自动放行仍由
|
||||
Sentinel 策略引擎在 L7 判定,这里只做强制层拦截)。 */
|
||||
SEC("cgroup_skb/egress")
|
||||
int egress_filter(struct __sk_buff *skb)
|
||||
{
|
||||
__u32 pid = (__u32)(bpf_get_current_pid_tgid() >> 32);
|
||||
__u8 *tainted = bpf_map_lookup_elem(&taint_map, &pid);
|
||||
struct event *e;
|
||||
if (tainted && *tainted) {
|
||||
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
|
||||
if (e) {
|
||||
e->pid = pid;
|
||||
e->verdict = 1;
|
||||
bpf_ringbuf_submit(e, 0);
|
||||
}
|
||||
return 0; /* 丢弃:skb 不出 cgroup */
|
||||
}
|
||||
return 1; /* 放行 */
|
||||
}
|
||||
|
||||
char _license[] SEC("license") = "GPL";
|
||||
"""
|
||||
|
||||
# LSM hook 污点传播程序源码(骨架)。
|
||||
#
|
||||
# 作用(按 section):
|
||||
# - `SEC("lsm/file_open")`:进程打开文件时触发。hook 内只判
|
||||
# “打开的是不是用户数据路径”(路径表由用户态经 map 下发,
|
||||
# 这里不硬编码路径),是则把当前 pid 写进 `taint_map`
|
||||
# (与 cgroup 程序共用同一张 pin 住的 map)。
|
||||
# - 污点传播方向:读用户数据的进程变污点;子进程的污点由
|
||||
# 用户态 `fork_child` 继承(本骨架不替 fork 背书——fork
|
||||
# 时子进程尚未读文件,LSM 看不到它,必须由追踪器继承)。
|
||||
# 内核要求:>= 5.7 且内核开 CONFIG_BPF_LSM(否则 lsm/* 的
|
||||
# section 挂不上);CAP_BPF;LSM BPF 程序挂载还受系统 LSM
|
||||
# 策略约束(与 SELinux/AppArmor 共存时以后者为准)。本骨架
|
||||
# 只做“读事件 → 写 map”,不做文件内容读取、不记路径内容。
|
||||
EBPF_LSM_TAINT_C = r"""
|
||||
#include <linux/bpf.h>
|
||||
#include <bpf/bpf_helpers.h>
|
||||
|
||||
/* 与 cgroup 程序共用同一张 pin 住的 map(pin 路径
|
||||
/sys/fs/bpf/chv_taint_map,用户态建表后两程序同引)。 */
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_HASH);
|
||||
__uint(max_entries, 65536);
|
||||
__type(key, __u32);
|
||||
__type(value, __u8);
|
||||
} taint_map SEC(".maps");
|
||||
|
||||
/* LSM hook 骨架:文件打开时触发。是否“用户数据路径”由用户
|
||||
态下发的路径表判定(本骨架只留判断位,不硬编码路径、不读
|
||||
文件内容);命中则把当前 pid 记为污点。 */
|
||||
SEC("lsm/file_open")
|
||||
int taint_on_file_open(struct file *file)
|
||||
{
|
||||
__u32 pid = (__u32)(bpf_get_current_pid_tgid() >> 32);
|
||||
__u8 flag = 1;
|
||||
/* TODO(真机接线时补):查用户态下发的路径表,命中才写;
|
||||
未命中直接 return 0。骨架阶段不写 map,避免误标。 */
|
||||
(void)pid;
|
||||
(void)flag;
|
||||
return 0;
|
||||
}
|
||||
|
||||
char _license[] SEC("license") = "GPL";
|
||||
"""
|
||||
|
||||
|
||||
def _parse_kernel(release: str) -> tuple[int, int, int]:
|
||||
"""`7.0.0-43-generic` 这类 release 串解为三元组。"""
|
||||
parts = release.split("-")[0].split(".")
|
||||
nums = []
|
||||
for part in parts[:3]:
|
||||
nums.append(int(part) if part.isdigit() else 0)
|
||||
while len(nums) < 3:
|
||||
nums.append(0)
|
||||
return (nums[0], nums[1], nums[2])
|
||||
|
||||
|
||||
def _cap_bit(cap_eff_hex: str, bit: int) -> bool:
|
||||
"""CapEff 十六进制串里某一位是否在位。"""
|
||||
try:
|
||||
value = int(cap_eff_hex.strip(), 16)
|
||||
except ValueError:
|
||||
return False
|
||||
return bool((value >> bit) & 1)
|
||||
|
||||
|
||||
class EbpfTaintLoader:
|
||||
"""eBPF 加载器:探测 → 加载(或明确降级)→ 状态上报。
|
||||
|
||||
状态机(`load_status()` 读到的 `status`):
|
||||
- `blocked`:探测到硬 blocker(内核太旧 / 无 bpffs /
|
||||
无能力位 / 缺工具链),加载不会尝试;
|
||||
- `degraded`:未加载成功,工作在 `FALLBACK_MODE`(策略
|
||||
引擎 + 代理层钩子),这是失败时的唯一出口;
|
||||
- `loaded`:bpftool 加载 + pin 成功,且 pin 文件在位。
|
||||
只有 `loaded` 才表示 eBPF 在拦截;其余一律不冒充。
|
||||
"""
|
||||
|
||||
def __init__(self, bpffs_path: str = "/sys/fs/bpf",
|
||||
status_path: str = "/proc/self/status",
|
||||
which: Callable[[str], str | None] | None = None
|
||||
) -> None:
|
||||
self._bpffs_path = bpffs_path
|
||||
self._status_path = status_path
|
||||
self._which = which or shutil.which
|
||||
self._status: dict = {
|
||||
"status": "degraded",
|
||||
"mode": FALLBACK_MODE,
|
||||
"reason": "not_probed_yet",
|
||||
"blockers": [],
|
||||
}
|
||||
|
||||
def _read_caps(self) -> tuple[bool, bool]:
|
||||
"""读 CapEff:返回 (CAP_BPF 在位, CAP_SYS_ADMIN 在位)。"""
|
||||
try:
|
||||
with open(self._status_path) as fh:
|
||||
for line in fh:
|
||||
if line.startswith("CapEff:"):
|
||||
hex_value = line.split(":", 1)[1]
|
||||
return (_cap_bit(hex_value, _CAP_BPF_BIT),
|
||||
_cap_bit(hex_value, _CAP_SYS_ADMIN_BIT))
|
||||
except OSError:
|
||||
return (False, False)
|
||||
return (False, False)
|
||||
|
||||
def _check_tools(self) -> dict:
|
||||
"""bpftool / clang / llvm-objdump 是否在位。"""
|
||||
found = {}
|
||||
for tool in ("bpftool", "clang", "llvm-objdump"):
|
||||
found[tool] = self._which(tool) is not None
|
||||
return found
|
||||
|
||||
def _blockers(self, kernel: tuple[int, int, int],
|
||||
cap_bpf: bool, cap_sys_admin: bool,
|
||||
tools: dict) -> list:
|
||||
"""按硬条件列 blocker(空表 = 探测关全过,可试加载)。"""
|
||||
blockers = []
|
||||
if kernel < MIN_KERNEL:
|
||||
blockers.append("kernel_too_old:%d.%d.%d" % kernel)
|
||||
if not os.path.isdir(self._bpffs_path):
|
||||
blockers.append("bpffs_missing:" + self._bpffs_path)
|
||||
elif not os.access(self._bpffs_path, os.W_OK):
|
||||
blockers.append("bpffs_not_writable:" + self._bpffs_path)
|
||||
if not cap_bpf and not cap_sys_admin:
|
||||
blockers.append("cap_bpf_and_cap_sys_admin_missing")
|
||||
for tool, present in sorted(tools.items()):
|
||||
if not present:
|
||||
blockers.append("tool_missing:" + tool)
|
||||
return blockers
|
||||
|
||||
def probe(self) -> dict:
|
||||
"""能力探测:只读检查,不加载、不改系统状态。
|
||||
|
||||
返回探测结果副本;`status` 为 blocked(有 blocker)
|
||||
或 degraded(探测关全过、但尚未加载——仍在降级模式)。
|
||||
注意:CapEff 在位不等于 bpf() 放行(本机实测:能力位
|
||||
全在,bpf() 仍 EPERM,见模块头注释),所以探测结果
|
||||
从不写“eBPF 可用”,只写“探测关是否全过”。"""
|
||||
kernel = _parse_kernel(platform.uname().release)
|
||||
cap_bpf, cap_sys_admin = self._read_caps()
|
||||
tools = self._check_tools()
|
||||
bpffs_exists = os.path.isdir(self._bpffs_path)
|
||||
blockers = self._blockers(kernel, cap_bpf, cap_sys_admin,
|
||||
tools)
|
||||
status = "blocked" if blockers else "degraded"
|
||||
reason = ("; ".join(blockers) if blockers
|
||||
else "probe_passed_not_loaded")
|
||||
self._status = {
|
||||
"status": status, "mode": FALLBACK_MODE,
|
||||
"reason": reason, "blockers": list(blockers),
|
||||
"kernel": "%d.%d.%d" % kernel,
|
||||
"kernel_ok": kernel >= MIN_KERNEL,
|
||||
"bpffs_exists": bpffs_exists,
|
||||
"bpffs_writable": (bpffs_exists and os.access(
|
||||
self._bpffs_path, os.W_OK)),
|
||||
"cap_bpf": cap_bpf, "cap_sys_admin": cap_sys_admin,
|
||||
"tools": dict(tools),
|
||||
}
|
||||
return dict(self._status)
|
||||
|
||||
def load_status(self) -> dict:
|
||||
"""当前加载状态上报(未探测过时先跑一次探测)。"""
|
||||
if self._status.get("reason") == "not_probed_yet":
|
||||
return self.probe()
|
||||
return dict(self._status)
|
||||
|
||||
def try_load(self) -> dict:
|
||||
"""尝试加载:探测关不过即降级,不尝试;过了才编译加载。
|
||||
|
||||
编译/加载任一步失败也降级,`status` stays degraded,
|
||||
`reason` 写清失败步。成功须 bpftool 加载 + pin 文件
|
||||
在位双重确认,缺一律不报 loaded。"""
|
||||
result = self.probe()
|
||||
if result["blockers"]:
|
||||
return dict(self._status)
|
||||
loaded = self._compile_and_load()
|
||||
if loaded:
|
||||
self._status = dict(result)
|
||||
self._status["status"] = "loaded"
|
||||
self._status["mode"] = "ebpf"
|
||||
self._status["reason"] = "loaded_and_pinned"
|
||||
self._status["blockers"] = []
|
||||
else:
|
||||
self._status["reason"] = "load_failed_fallback"
|
||||
return dict(self._status)
|
||||
|
||||
def _compile_and_load(self) -> bool:
|
||||
"""clang 编译 + bpftool 加载 + pin(只在探测关全过时跑)。
|
||||
|
||||
任一步失败返回 False(调用方降级)。本机永远走不到
|
||||
这里(探测关不过);真机上失败原因由 bpftool 的
|
||||
stderr 决定,这里只记成败、不猜原因。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = os.path.join(tmp, "egress.c")
|
||||
obj = os.path.join(tmp, "egress.o")
|
||||
pin = os.path.join(self._bpffs_path,
|
||||
"chv_egress_filter")
|
||||
with open(src, "w") as fh:
|
||||
fh.write(EBPF_CGROUP_EGRESS_C)
|
||||
compiled = subprocess.run(
|
||||
["clang", "-target", "bpf", "-O2", "-c", src,
|
||||
"-o", obj],
|
||||
capture_output=True, timeout=120)
|
||||
if compiled.returncode != 0:
|
||||
return False
|
||||
loaded = subprocess.run(
|
||||
["bpftool", "prog", "load", obj, pin],
|
||||
capture_output=True, timeout=120)
|
||||
if loaded.returncode != 0:
|
||||
return False
|
||||
return os.path.exists(pin)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""默认策略(feat-chv-muse-p5-B,叶子模块,纯标准库)。
|
||||
|
||||
默认策略规则表 + 谨慎度三档(``DefaultPolicy``)。只读方法
|
||||
(GET/HEAD)+ 已允许主机/路径 + 低风险判定(无 body 写操作、
|
||||
无敏感路径段)→ 自动放行候选;其余走正常审批流。
|
||||
|
||||
谨慎度:
|
||||
- default(默认):已允许主机 + 路径前缀匹配即候选;
|
||||
- cautious:自动放行收窄——只放行已明确允许过的精确主机 +
|
||||
精确路径(前缀规则不再放行),ask 增多;
|
||||
- permissive:放宽自动放行范围——只读 + 非敏感路径即候选,
|
||||
不必先前已允许;但写操作(POST/PUT/DELETE/PATCH)与敏感
|
||||
路径(/admin、/settings、/login、token 相关)永远不自动放行。
|
||||
|
||||
``auto_allow_eligible(request, tainted)`` 只是“候选”判定,不
|
||||
是放行 verdict:污点进程一律 False(与 P5-A 污点追踪器口径
|
||||
一致,tainted 由调用方传);未知连接器仍由 SentinelPolicy
|
||||
的 evaluate() 挡。档位切换与规则增删(``set_caution`` /
|
||||
``add_rule`` / ``remove_rule``)每次变化记审计事件
|
||||
``sentinel_policy_changed``(旧值 → 新值,无值)。
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from cloud.chv.sentinel import EgressRequest
|
||||
|
||||
READ_METHODS = frozenset({"GET", "HEAD"})
|
||||
WRITE_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
|
||||
CAUTION_LEVELS = ("default", "cautious", "permissive")
|
||||
_SENSITIVE_MARKERS = ("/admin", "/settings", "/login", "token")
|
||||
_EVENT_POLICY_CHANGED = "sentinel_policy_changed"
|
||||
|
||||
|
||||
def _is_sensitive_path(path: str) -> bool:
|
||||
"""敏感路径:/admin、/settings、/login 或 token 相关。"""
|
||||
lowered = (path or "").lower()
|
||||
return any(m in lowered for m in _SENSITIVE_MARKERS)
|
||||
|
||||
|
||||
def _rule_key(connector: str, host: str, path: str,
|
||||
methods: frozenset) -> tuple:
|
||||
"""规则内部形态:(连接器,主机小写,路径,方法集)。"""
|
||||
return (connector, (host or "").lower(), path, methods)
|
||||
|
||||
|
||||
def _rule_dict(rule: tuple) -> dict:
|
||||
"""规则的审计/展示形态(只含连接器/主机/路径/方法)。"""
|
||||
return {"connector": rule[0], "host": rule[1],
|
||||
"path": rule[2], "methods": sorted(rule[3])}
|
||||
|
||||
|
||||
def _rule_matches(rule: tuple, connector: str, host: str,
|
||||
path: str,
|
||||
methods: Iterable[str] | None) -> bool:
|
||||
"""该规则是否命中(连接器,主机,路径)[+ 方法]。"""
|
||||
if rule[0] != connector or rule[1] != (host or "").lower():
|
||||
return False
|
||||
if rule[2] != path:
|
||||
return False
|
||||
if methods is None:
|
||||
return True
|
||||
return rule[3] == frozenset(m.upper() for m in methods)
|
||||
|
||||
|
||||
class DefaultPolicy:
|
||||
"""默认策略:规则表(已允许主机 + 路径)+ 谨慎度三档。
|
||||
|
||||
规则表由调用方播种(``allow_entries``,每条是
|
||||
(连接器,主机,路径前缀)三元组,方法默认只读)或
|
||||
``add_rule`` 增补;``audit_log`` 收全部策略变化审计。
|
||||
"""
|
||||
|
||||
def __init__(self, caution: str = "default",
|
||||
allow_entries: Iterable[tuple[str, str, str]] = (),
|
||||
audit: list | None = None) -> None:
|
||||
if caution not in CAUTION_LEVELS:
|
||||
raise ValueError("unknown_caution:%s" % caution)
|
||||
self.caution = caution
|
||||
self._rules: list = []
|
||||
for connector, host, path in allow_entries:
|
||||
self._rules.append(_rule_key(
|
||||
connector, host, path, READ_METHODS))
|
||||
self.audit_log = audit if audit is not None else []
|
||||
|
||||
def _audit_change(self, change: str, old_value: object,
|
||||
new_value: object) -> None:
|
||||
"""记一条策略变化审计(旧值 → 新值,无值)。"""
|
||||
self.audit_log.append(
|
||||
{"event": _EVENT_POLICY_CHANGED, "change": change,
|
||||
"old_value": old_value, "new_value": new_value})
|
||||
|
||||
def set_caution(self, level: str) -> bool:
|
||||
"""切换谨慎度:变化记审计并返回 True;非法档位抛
|
||||
ValueError;与现值相同则不记审计、返回 False。"""
|
||||
if level not in CAUTION_LEVELS:
|
||||
raise ValueError("unknown_caution:%s" % level)
|
||||
if level == self.caution:
|
||||
return False
|
||||
old = self.caution
|
||||
self.caution = level
|
||||
self._audit_change("set_caution", old, level)
|
||||
return True
|
||||
|
||||
def add_rule(self, connector: str, host: str, path: str,
|
||||
methods: Iterable[str] = READ_METHODS) -> bool:
|
||||
"""增一条已允许规则(连接器 + 主机 + 路径前缀 + 方法)。
|
||||
已存在相同规则返回 False(不记审计);新增记审计。"""
|
||||
rule = _rule_key(connector, host, path,
|
||||
frozenset(m.upper() for m in methods))
|
||||
if rule in self._rules:
|
||||
return False
|
||||
self._rules.append(rule)
|
||||
self._audit_change("add_rule", None, _rule_dict(rule))
|
||||
return True
|
||||
|
||||
def remove_rule(self, connector: str, host: str, path: str,
|
||||
methods: Iterable[str] | None = None) -> bool:
|
||||
"""删规则:methods 为 None 时按(连接器,主机,路径)
|
||||
删全部方法变体;否则只删精确匹配。删到记审计。"""
|
||||
removed = False
|
||||
kept = []
|
||||
for rule in self._rules:
|
||||
if _rule_matches(rule, connector, host, path, methods):
|
||||
removed = True
|
||||
self._audit_change("remove_rule",
|
||||
_rule_dict(rule), None)
|
||||
else:
|
||||
kept.append(rule)
|
||||
self._rules = kept
|
||||
return removed
|
||||
|
||||
def list_rules(self) -> list:
|
||||
"""全部已允许规则(副本)。"""
|
||||
return [_rule_dict(rule) for rule in self._rules]
|
||||
|
||||
def _allowlisted(self, request: EgressRequest,
|
||||
exact: bool) -> bool:
|
||||
"""主机 + 路径是否在规则表内(exact 为真时路径须全等,
|
||||
否则前缀匹配即算;方法也须在规则方法集内)。"""
|
||||
for rule in self._rules:
|
||||
if rule[0] != request.connector:
|
||||
continue
|
||||
if rule[1] != request.host:
|
||||
continue
|
||||
if request.method not in rule[3]:
|
||||
continue
|
||||
if exact:
|
||||
if request.path == rule[2]:
|
||||
return True
|
||||
elif request.path.startswith(rule[2]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def auto_allow_eligible(self, request: EgressRequest,
|
||||
tainted: bool) -> bool:
|
||||
"""是否自动放行候选(只是候选判定,不是 verdict)。
|
||||
|
||||
污点进程一律 False;写操作与敏感路径在任何档位都不
|
||||
自动放行;default 档须已允许主机 + 路径前缀;cautious
|
||||
档须精确主机 + 精确路径;permissive 档只读 + 非敏感
|
||||
即候选(不必先前已允许)。
|
||||
"""
|
||||
if tainted:
|
||||
return False
|
||||
if request.method not in READ_METHODS:
|
||||
return False
|
||||
if _is_sensitive_path(request.path):
|
||||
return False
|
||||
if self.caution == "permissive":
|
||||
return True
|
||||
if self.caution == "cautious":
|
||||
return self._allowlisted(request, exact=True)
|
||||
return self._allowlisted(request, exact=False)
|
||||
|
||||
|
||||
__all__ = ["READ_METHODS", "WRITE_METHODS", "CAUTION_LEVELS",
|
||||
"DefaultPolicy"]
|
||||
+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 出现即记入,执行停止。
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""污点追踪器(对齐 Muse P5-A,叶子模块,纯标准库)。
|
||||
|
||||
Muse 口径(见 docs/chv-muse-p5-gap.md §1 污点出口行):每个工具
|
||||
执行进程初始干净,读取用户数据后变“污点”;干净且符合窄范围
|
||||
自动放行策略并通过出站 URL 其他检查的请求可免打扰放行,污点
|
||||
或不可验证进程失去自动放行、回到正常审批流。
|
||||
|
||||
本模块只是用户态追踪器,不是强制层:
|
||||
- “读用户数据”事件靠调用方上报(工具执行层在读用户数据前
|
||||
调 `mark_tainted`,或 eBPF 通了之后由 LSM hook 回调上报)。
|
||||
没接 LSM hook 之前漏报是可能的,本模块不宣称“所有读用户
|
||||
数据的行为都被捕获”。
|
||||
- 污点是单向的:没有清除入口,只能随进程退出消失。
|
||||
- `source` 只记“哪类用户数据”(见 `_SOURCE_CATEGORIES`),
|
||||
绝不记内容;不在分类表里的上报值一律归一化为 `unknown`,
|
||||
原始字符串不会落进任何记录。审计记录里只有事件名、pid、
|
||||
原因与分类,绝无用户数据内容、无凭证值。
|
||||
- 未知 pid(未注册)一律不可自动放行:`is_tainted` 对它报
|
||||
False(没证据说它污),`auto_allow_eligible` 对它也报
|
||||
False(没证据说它干净可信)。
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
# 允许的 source 分类:只记“哪类用户数据”,不记内容。
|
||||
_SOURCE_CATEGORIES = frozenset({
|
||||
"gitea_repo",
|
||||
"user_file",
|
||||
"user_memory",
|
||||
"chat_history",
|
||||
"credential_vault",
|
||||
"tool_output",
|
||||
"unknown",
|
||||
})
|
||||
|
||||
# 审计事件名:沿用 P3 的 `sentinel_*` 契约(见 gap §1 审计行)。
|
||||
_EVENT_MARKED = "sentinel_taint_marked"
|
||||
_EVENT_INHERITED = "sentinel_taint_inherited"
|
||||
_EVENT_REGISTERED = "sentinel_taint_registered"
|
||||
_EVENT_RELEASED = "sentinel_taint_released"
|
||||
|
||||
|
||||
def _normalize_source(source: str) -> str:
|
||||
"""source 归一化为分类表里的值,不在表里的记 `unknown`。
|
||||
|
||||
调用方误把内容当 source 传进来时,原始字符串在这里就被
|
||||
丢掉,绝不会进入进程记录或审计记录。"""
|
||||
value = (source or "").strip().lower()
|
||||
if value in _SOURCE_CATEGORIES:
|
||||
return value
|
||||
return "unknown"
|
||||
|
||||
|
||||
class TaintTracker:
|
||||
"""进程污点两态追踪器(内存态,进程退出即清)。
|
||||
|
||||
每个进程一条记录:pid、cgroup、连接器、用例、是否污点。
|
||||
污点只能由 `mark_tainted` 置位、由 `fork_child` 继承、由
|
||||
`process_exit` 随进程消失;没有反向清除入口。
|
||||
"""
|
||||
|
||||
def __init__(self, clock: Callable[[], float] | None = None
|
||||
) -> None:
|
||||
self._records: dict[int, dict] = {}
|
||||
self._audit: list[dict] = []
|
||||
self._clock = clock or time.time
|
||||
|
||||
def _emit(self, event: str, pid: int, reason: str,
|
||||
source: str = "") -> None:
|
||||
"""记一条审计(事件名 + pid + 原因 + 分类,无内容)"""
|
||||
record = {"event": event, "pid": pid, "reason": reason,
|
||||
"ts": self._clock()}
|
||||
if source:
|
||||
record["source"] = source
|
||||
self._audit.append(record)
|
||||
|
||||
def register(self, pid: int, cgroup: str = "",
|
||||
connector: str = "", use_case: str = "") -> bool:
|
||||
"""登记一个干净进程。pid 非法或已登记返回 False。"""
|
||||
if pid <= 0 or pid in self._records:
|
||||
return False
|
||||
self._records[pid] = {
|
||||
"pid": pid, "cgroup": cgroup,
|
||||
"connector": connector, "use_case": use_case,
|
||||
"tainted": False,
|
||||
}
|
||||
self._emit(_EVENT_REGISTERED, pid, "process_registered")
|
||||
return True
|
||||
|
||||
def mark_tainted(self, pid: int, source: str) -> bool:
|
||||
"""上报“读用户数据”事件:该进程变污点,不可逆。
|
||||
|
||||
未登记的 pid 也接受上报(fail-closed:读了用户数据的
|
||||
进程先记为污点,不等它补登记);source 只存分类。"""
|
||||
category = _normalize_source(source)
|
||||
record = self._records.get(pid)
|
||||
if record is None:
|
||||
self._records[pid] = {
|
||||
"pid": pid, "cgroup": "", "connector": "",
|
||||
"use_case": "", "tainted": True,
|
||||
}
|
||||
else:
|
||||
record["tainted"] = True
|
||||
reason = "read_user_data:" + category
|
||||
self._emit(_EVENT_MARKED, pid, reason, source=category)
|
||||
return True
|
||||
|
||||
def fork_child(self, parent_pid: int, child_pid: int) -> bool:
|
||||
"""子进程继承污点:父污则子污,父干净则子干净。
|
||||
|
||||
父未登记或子 pid 非法返回 False,不建记录;子已登记
|
||||
时只改它的污点位,不覆盖它自己的 cgroup/连接器/用例。"""
|
||||
if child_pid <= 0 or child_pid == parent_pid:
|
||||
return False
|
||||
parent = self._records.get(parent_pid)
|
||||
if parent is None:
|
||||
return False
|
||||
child = self._records.get(child_pid)
|
||||
if child is None:
|
||||
self._records[child_pid] = {
|
||||
"pid": child_pid, "cgroup": parent["cgroup"],
|
||||
"connector": parent["connector"],
|
||||
"use_case": parent["use_case"],
|
||||
"tainted": parent["tainted"],
|
||||
}
|
||||
else:
|
||||
child["tainted"] = parent["tainted"]
|
||||
if parent["tainted"]:
|
||||
self._emit(_EVENT_INHERITED, child_pid,
|
||||
"inherited_from_parent:%d" % parent_pid)
|
||||
return True
|
||||
|
||||
def process_exit(self, pid: int) -> bool:
|
||||
"""进程退出:记录连同污点一起消失。未知 pid 返回 False。"""
|
||||
if pid not in self._records:
|
||||
return False
|
||||
del self._records[pid]
|
||||
self._emit(_EVENT_RELEASED, pid, "process_exit")
|
||||
return True
|
||||
|
||||
def is_tainted(self, pid: int) -> bool:
|
||||
"""该进程是否污点。未知 pid 报 False(无证据说它污)。"""
|
||||
record = self._records.get(pid)
|
||||
return bool(record and record["tainted"])
|
||||
|
||||
def is_registered(self, pid: int) -> bool:
|
||||
"""该 pid 是否已登记。"""
|
||||
return pid in self._records
|
||||
|
||||
def state(self, pid: int) -> dict | None:
|
||||
"""该进程记录的副本(无记录返回 None)。"""
|
||||
record = self._records.get(pid)
|
||||
if record is None:
|
||||
return None
|
||||
return dict(record)
|
||||
|
||||
def auto_allow_eligible(self, pid: int) -> bool:
|
||||
"""出口自动放行资格:已登记且干净才 True。
|
||||
|
||||
污点进程一律 False;未知 pid(不可验证)也一律 False。
|
||||
这只是决策输入,不放行任何请求——放行仍须走 Sentinel
|
||||
的出站 URL 其他检查(见 gap §1)。"""
|
||||
record = self._records.get(pid)
|
||||
if record is None:
|
||||
return False
|
||||
return not record["tainted"]
|
||||
|
||||
def audit(self) -> list:
|
||||
"""全部审计记录的副本(事件名 + pid + 原因 + 分类)。"""
|
||||
return [dict(record) for record in self._audit]
|
||||
Reference in new issue
Block a user