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:
host committed 2026-11-06 22:25:55 +00:00
1 parent ee456e6625
commit 25bcd355f8
11 files changed
+2436 -48

No files matched your search

@@ -0,0 +1,360 @@
"""对齐 Muse P5-B 测试:五档严格能力 + 默认策略三档。
只用标准库,绝不联网、不含真实 token(测试值全是假值)。覆盖:
- 五档签发与匹配:one-shot/session/task/timed/permanent;
- 会话/任务绑定(换会话/换任务即不匹配);
- timed 过期、one-shot 用完即废;
- 超范围调用被拒(换主机/换用例/换方法/换路径各一例);
- 撤销后被拒、permanent 默认拒绝签发 + 显式开启后签发成功;
- 签发校验失败(未知档位、空用例、缺绑定 id、timed 缺 ttl);
- 默认策略三档:default 放行只读已允许、cautious 收窄、
permissive 仍挡写操作与敏感路径;
- 规则变化有审计;审计记录只有事件名/能力 id/原因,无值。
"""
import json
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.chv.capabilities import (
TIERS,
CapabilityIssuer,
ScopedCapability,
)
from cloud.chv.policy import (
CAUTION_LEVELS,
READ_METHODS,
WRITE_METHODS,
DefaultPolicy,
)
from cloud.chv.sentinel import EgressRequest
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.capabilities import (
TIERS,
CapabilityIssuer,
ScopedCapability,
)
from cloud.chv.policy import (
CAUTION_LEVELS,
READ_METHODS,
WRITE_METHODS,
DefaultPolicy,
)
from cloud.chv.sentinel import EgressRequest
HOST = "gitea.tose.top"
RULES = (("GET", "prefix", "/api/v1/"),
("POST", "prefix", "/api/v1/"))
NOW = 1000.0
def _req(host=HOST, method="GET", path="/api/v1/version"):
return EgressRequest("gitea", method, "https", host, 443, path)
def _scope(use_case="gitea-sync", session_id=None, task_id=None):
scope = {"use_case": use_case}
if session_id is not None:
scope["session_id"] = session_id
if task_id is not None:
scope["task_id"] = task_id
return scope
def _events(audit, event):
return [r for r in audit if r["event"] == event]
class CapabilitiesTest(unittest.TestCase):
def setUp(self):
self.audit = []
self.issuer = CapabilityIssuer(audit=self.audit)
def _issue(self, tier="one-shot", **kw):
kw.setdefault("connector", "gitea")
kw.setdefault("host", HOST)
kw.setdefault("methods", {"GET"})
kw.setdefault("path_rules", RULES)
kw.setdefault("use_case", "gitea-sync")
kw.setdefault("now", NOW)
return self.issuer.issue(tier, **kw)
def test_tiers_listed(self):
self.assertEqual(
TIERS, ("one-shot", "session", "task", "timed",
"permanent"))
def test_one_shot_defaults(self):
cap = self._issue()
self.assertIsInstance(cap, ScopedCapability)
self.assertEqual(cap.tier, "one-shot")
self.assertEqual(cap.max_uses, 1)
self.assertEqual(cap.uses_left, 1)
self.assertEqual(cap.expires_at, NOW + 300)
self.assertEqual(cap.use_case, "gitea-sync")
self.assertFalse(cap.revoked)
self.assertTrue(cap.matches(_req(), scope=_scope(), now=NOW))
issued = _events(self.audit, "sentinel_capability_issued")
self.assertEqual(len(issued), 1)
self.assertEqual(issued[0]["capability_id"], cap.id)
self.assertEqual(issued[0]["reason"], "issued")
def test_one_shot_use_once_then_exhausted(self):
cap = self._issue()
self.assertTrue(cap.use(_req(), scope=_scope(), now=NOW))
self.assertEqual(cap.uses_left, 0)
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW))
self.assertFalse(cap.use(_req(), scope=_scope(), now=NOW))
denied = _events(self.audit, "sentinel_capability_denied")
reasons = [r["reason"] for r in denied]
self.assertIn("uses_exhausted", reasons)
self.assertEqual(
len(_events(self.audit, "sentinel_capability_used")), 1)
def test_session_bound_and_multi_use(self):
cap = self._issue("session", session_id="s1")
self.assertEqual(cap.max_uses, None)
self.assertEqual(cap.expires_at, NOW + 3600)
good = _scope(session_id="s1")
self.assertTrue(cap.matches(_req(), scope=good, now=NOW))
for _ in range(3):
self.assertTrue(cap.use(_req(), scope=good, now=NOW))
self.assertIsNone(cap.uses_left)
self.assertFalse(cap.matches(
_req(), scope=_scope(session_id="s2"), now=NOW))
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW))
denied = _events(self.audit, "sentinel_capability_denied")
reasons = [r["reason"] for r in denied]
self.assertIn("session_mismatch", reasons)
# scope 也可是带属性的对象,不只 dict。
obj = SimpleNamespace(use_case="gitea-sync",
session_id="s1")
self.assertTrue(cap.matches(_req(), scope=obj, now=NOW))
def test_task_bound(self):
cap = self._issue("task", task_id="t1")
self.assertTrue(cap.matches(
_req(), scope=_scope(task_id="t1"), now=NOW))
self.assertTrue(cap.use(
_req(), scope=_scope(task_id="t1"), now=NOW))
self.assertTrue(cap.use(
_req(), scope=_scope(task_id="t1"), now=NOW))
self.assertFalse(cap.matches(
_req(), scope=_scope(task_id="t2"), now=NOW))
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW))
def test_timed_expiry(self):
cap = self._issue("timed", ttl_seconds=60)
self.assertEqual(cap.expires_at, NOW + 60)
self.assertTrue(cap.matches(_req(), scope=_scope(), now=NOW + 59))
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW + 60))
self.assertFalse(cap.use(_req(), scope=_scope(), now=NOW + 60))
expired = _events(self.audit, "sentinel_capability_expired")
self.assertTrue(expired)
self.assertTrue(all(r["reason"] == "expired"
for r in expired))
def test_out_of_scope_denied(self):
cap = self._issue()
cases = [
(_req(host="other.example"), _scope(),
"host_mismatch"),
(_req(), _scope(use_case="other-use"),
"use_case_mismatch"),
(_req(method="POST"), _scope(), "method_not_allowed"),
(_req(path="/login"), _scope(), "path_not_allowed"),
(_req(), None, "use_case_mismatch"),
]
for req, scope, reason in cases:
self.assertFalse(cap.matches(req, scope=scope, now=NOW), reason)
denied = _events(self.audit, "sentinel_capability_denied")
reasons = [r["reason"] for r in denied]
for _, _, reason in cases:
self.assertIn(reason, reasons)
def test_revoke(self):
cap = self._issue()
self.assertTrue(cap.revoke())
self.assertTrue(cap.revoked)
self.assertFalse(cap.matches(_req(), scope=_scope(), now=NOW))
self.assertFalse(cap.use(_req(), scope=_scope(), now=NOW))
self.assertFalse(cap.revoke())
revoked = _events(self.audit, "sentinel_capability_revoked")
self.assertEqual(len(revoked), 1)
denied = _events(self.audit, "sentinel_capability_denied")
self.assertIn("revoked",
[r["reason"] for r in denied])
def test_permanent_default_refused(self):
with self.assertRaises(ValueError) as ctx:
self._issue("permanent")
self.assertIn("permanent_requires_explicit_opt_in",
str(ctx.exception))
denied = _events(self.audit, "sentinel_capability_denied")
self.assertTrue(any(
r["reason"] == "permanent_requires_explicit_opt_in"
for r in denied))
def test_permanent_explicit_issue(self):
cap = self._issue("permanent", allow_permanent=True)
self.assertEqual(cap.tier, "permanent")
self.assertIsNone(cap.expires_at)
self.assertIsNone(cap.max_uses)
self.assertTrue(cap.matches(_req(), scope=_scope(),
now=NOW + 10 ** 9))
for _ in range(5):
self.assertTrue(cap.use(_req(), scope=_scope(),
now=NOW + 10 ** 9))
def test_issue_validation_errors(self):
with self.assertRaises(ValueError):
self._issue("nope")
with self.assertRaises(ValueError):
self._issue(use_case="")
with self.assertRaises(ValueError):
self._issue("session")
with self.assertRaises(ValueError):
self._issue("task")
with self.assertRaises(ValueError):
self._issue("timed")
def test_audit_records_shape_and_no_values(self):
cap = self._issue()
cap.use(_req(), scope=_scope(), now=NOW)
cap.revoke()
for record in self.audit:
self.assertIn("event", record)
self.assertIn("reason", record)
self.assertEqual(record["capability_id"], cap.id)
self.assertEqual(record["tier"], "one-shot")
self.assertEqual(record["connector"], "gitea")
self.assertEqual(record["host"], HOST)
self.assertEqual(record["use_case"], "gitea-sync")
text = json.dumps(self.audit)
self.assertNotIn("fake-secret", text)
self.assertEqual(
[r["event"] for r in self.audit],
["sentinel_capability_issued",
"sentinel_capability_used",
"sentinel_capability_revoked"])
class PolicyTest(unittest.TestCase):
def setUp(self):
self.audit = []
self.policy = DefaultPolicy(
allow_entries=[("gitea", HOST, "/api/v1/")],
audit=self.audit)
def test_caution_levels_and_method_sets(self):
self.assertEqual(CAUTION_LEVELS,
("default", "cautious", "permissive"))
self.assertEqual(READ_METHODS, {"GET", "HEAD"})
self.assertEqual(WRITE_METHODS,
{"POST", "PUT", "DELETE", "PATCH"})
self.assertEqual(self.policy.caution, "default")
def test_default_allows_read_allowlisted(self):
self.assertTrue(self.policy.auto_allow_eligible(
_req(), tainted=False))
self.assertTrue(self.policy.auto_allow_eligible(
_req(path="/api/v1/repos"), tainted=False))
self.assertTrue(self.policy.auto_allow_eligible(
_req(method="HEAD"), tainted=False))
def test_default_denies_unlisted_host_and_write(self):
self.assertFalse(self.policy.auto_allow_eligible(
_req(host="other.example"), tainted=False))
for method in ("POST", "PUT", "DELETE", "PATCH"):
self.assertFalse(self.policy.auto_allow_eligible(
_req(method=method), tainted=False), method)
def test_default_denies_sensitive_paths(self):
for path in ("/api/v1/admin/users", "/api/v1/settings",
"/api/v1/login", "/api/v1/tokens",
"/api/v1/user/token"):
self.assertFalse(self.policy.auto_allow_eligible(
_req(path=path), tainted=False), path)
def test_tainted_never_auto_allow(self):
self.assertFalse(self.policy.auto_allow_eligible(
_req(), tainted=True))
self.policy.set_caution("permissive")
self.assertFalse(self.policy.auto_allow_eligible(
_req(), tainted=True))
def test_cautious_narrows_to_exact_path(self):
req = _req()
self.assertTrue(self.policy.auto_allow_eligible(
req, tainted=False))
self.assertTrue(self.policy.set_caution("cautious"))
self.assertFalse(self.policy.auto_allow_eligible(
req, tainted=False))
self.assertTrue(self.policy.auto_allow_eligible(
_req(path="/api/v1/"), tainted=False))
self.assertTrue(self.policy.add_rule(
"gitea", HOST, "/api/v1/version"))
self.assertTrue(self.policy.auto_allow_eligible(
req, tainted=False))
def test_permissive_widens_but_blocks_write_and_sensitive(self):
self.assertTrue(self.policy.set_caution("permissive"))
self.assertTrue(self.policy.auto_allow_eligible(
_req(host="other.example"), tainted=False))
self.assertTrue(self.policy.auto_allow_eligible(
_req(host="other.example", path="/x"), tainted=False))
self.assertFalse(self.policy.auto_allow_eligible(
_req(host="other.example", method="POST"),
tainted=False))
for path in ("/admin/x", "/settings", "/login",
"/api/v1/tokens"):
self.assertFalse(self.policy.auto_allow_eligible(
_req(host="other.example", path=path),
tainted=False), path)
def test_set_caution_audits_and_validates(self):
self.assertTrue(self.policy.set_caution("cautious"))
self.assertEqual(self.policy.caution, "cautious")
changed = _events(self.audit, "sentinel_policy_changed")
self.assertEqual(len(changed), 1)
self.assertEqual(changed[0]["change"], "set_caution")
self.assertEqual(changed[0]["old_value"], "default")
self.assertEqual(changed[0]["new_value"], "cautious")
self.assertFalse(self.policy.set_caution("cautious"))
self.assertEqual(
len(_events(self.audit, "sentinel_policy_changed")), 1)
with self.assertRaises(ValueError):
self.policy.set_caution("reckless")
with self.assertRaises(ValueError):
DefaultPolicy(caution="reckless")
def test_add_remove_rule_audits(self):
self.assertTrue(self.policy.add_rule(
"gitea", "other.example", "/api/v1/"))
changed = _events(self.audit, "sentinel_policy_changed")
self.assertEqual(len(changed), 1)
self.assertEqual(changed[0]["change"], "add_rule")
self.assertIsNone(changed[0]["old_value"])
self.assertEqual(changed[0]["new_value"]["host"],
"other.example")
self.assertFalse(self.policy.add_rule(
"gitea", "other.example", "/api/v1/"))
self.assertEqual(
len(_events(self.audit, "sentinel_policy_changed")), 1)
self.assertTrue(self.policy.remove_rule(
"gitea", "other.example", "/api/v1/"))
changed = _events(self.audit, "sentinel_policy_changed")
self.assertEqual(changed[-1]["change"], "remove_rule")
self.assertEqual(changed[-1]["new_value"], None)
self.assertFalse(self.policy.remove_rule(
"gitea", "other.example", "/api/v1/"))
self.assertEqual(len(self.policy.list_rules()), 1)
self.assertEqual(self.policy.list_rules()[0]["host"], HOST)
if __name__ == "__main__":
unittest.main()