- 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
361 lines
15 KiB
Python
361 lines
15 KiB
Python
"""对齐 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()
|