test_security_governance.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. from __future__ import annotations
  2. from copy import deepcopy
  3. from datetime import UTC, datetime, timedelta
  4. import pytest
  5. from app.core.system.security_governance import SecurityGovernanceService
  6. OWNER = "01900000-0000-7000-8000-000000010001"
  7. REVIEWER = "01900000-0000-7000-8000-000000010002"
  8. USER = "01900000-0000-7000-8000-000000010003"
  9. DOMAIN = "01900000-0000-7000-8000-000000010101"
  10. OTHER_DOMAIN = "01900000-0000-7000-8000-000000010102"
  11. WORKFLOW = "01900000-0000-7000-8000-000000010201"
  12. class MemorySecurityRepository:
  13. def __init__(self):
  14. self.users = {OWNER, REVIEWER, USER}
  15. self.profiles = {}
  16. self.scans = {}
  17. self.findings = {}
  18. self.policies = {}
  19. self.decisions = {}
  20. self.egress = {}
  21. self.retention = {}
  22. self.sinks = {}
  23. self.deliveries = {}
  24. self.sboms = {}
  25. self.vulnerabilities = {}
  26. self.events = []
  27. def users_available(self, values):
  28. return set(values) & self.users
  29. def create_profile(self, record):
  30. self.profiles[record["uid"]] = deepcopy(record)
  31. return deepcopy(record)
  32. def get_profile(self, uid):
  33. return deepcopy(self.profiles.get(uid))
  34. def create_scan(self, scan, findings):
  35. self.scans[scan["uid"]] = deepcopy(scan)
  36. for finding in findings:
  37. self.findings[finding["uid"]] = deepcopy(finding)
  38. return {**deepcopy(scan), "findings": deepcopy(findings)}
  39. def get_finding(self, uid):
  40. return deepcopy(self.findings.get(uid))
  41. def update_finding(self, record, expected_version):
  42. current = self.findings[record["uid"]]
  43. if current["current_version"] != expected_version:
  44. raise RuntimeError("classification finding version conflict")
  45. saved = deepcopy(record)
  46. saved["current_version"] += 1
  47. self.findings[record["uid"]] = saved
  48. return deepcopy(saved)
  49. def create_access_policy(self, record):
  50. self.policies[record["uid"]] = deepcopy(record)
  51. return deepcopy(record)
  52. def matching_access_policies(self, **context):
  53. return [deepcopy(item) for item in self.policies.values() if item["status"] == "active"]
  54. def create_access_decision(self, record):
  55. self.decisions[record["uid"]] = deepcopy(record)
  56. return deepcopy(record)
  57. def create_egress_request(self, record):
  58. self.egress[record["uid"]] = deepcopy(record)
  59. return deepcopy(record)
  60. def get_egress_request(self, uid):
  61. return deepcopy(self.egress.get(uid))
  62. def update_egress_request(self, record, expected_version):
  63. if self.egress[record["uid"]]["current_version"] != expected_version:
  64. raise RuntimeError("egress request version conflict")
  65. saved = deepcopy(record)
  66. saved["current_version"] += 1
  67. self.egress[record["uid"]] = saved
  68. return deepcopy(saved)
  69. def create_retention_policy(self, record):
  70. self.retention[record["uid"]] = deepcopy(record)
  71. return deepcopy(record)
  72. def retention_candidates(self, as_of, limit):
  73. return [{"evidence_type": "access_decision", "candidate_count": 2, "automatic_deletion": False}]
  74. def create_siem_sink(self, record):
  75. self.sinks[record["uid"]] = deepcopy(record)
  76. return deepcopy(record)
  77. def get_siem_sink(self, uid):
  78. return deepcopy(self.sinks.get(uid))
  79. def fetch_siem_events(self, **filters):
  80. return [{"event_uid": "event-1", "category": "authorization", "action": "denied", "safe_detail": {}}]
  81. def create_siem_delivery(self, record):
  82. self.deliveries[record["uid"]] = deepcopy(record)
  83. return deepcopy(record)
  84. def create_sbom(self, record):
  85. self.sboms[record["uid"]] = deepcopy(record)
  86. return deepcopy(record)
  87. def get_sbom(self, uid):
  88. return deepcopy(self.sboms.get(uid))
  89. def upsert_vulnerabilities(self, sbom_uid, records):
  90. for record in records:
  91. self.vulnerabilities[record["uid"]] = deepcopy(record)
  92. return deepcopy(records)
  93. def get_vulnerability(self, uid):
  94. return deepcopy(self.vulnerabilities.get(uid))
  95. def update_vulnerability(self, record, expected_version, action, actor_uid):
  96. current = self.vulnerabilities[record["uid"]]
  97. if current["current_version"] != expected_version:
  98. raise RuntimeError("vulnerability version conflict")
  99. saved = deepcopy(record)
  100. saved["current_version"] += 1
  101. self.vulnerabilities[record["uid"]] = saved
  102. return deepcopy(saved)
  103. def add_event(self, resource_type, resource_uid, action, actor_uid, safe_detail):
  104. self.events.append({
  105. "resource_type": resource_type, "resource_uid": resource_uid,
  106. "action": action, "actor_uid": actor_uid, "safe_detail": deepcopy(safe_detail),
  107. })
  108. def dashboard(self):
  109. return {
  110. "pending_classification_reviews": sum(item["status"] == "pending_review" for item in self.findings.values()),
  111. "denied_access_count": sum(item["decision"] == "denied" for item in self.decisions.values()),
  112. "open_vulnerability_count": sum(item["status"] != "closed" for item in self.vulnerabilities.values()),
  113. }
  114. class FakeApprovalGateway:
  115. def __init__(self):
  116. self.tasks = {}
  117. def create_egress_task(self, request_record, workflow_uid, actor_uid):
  118. task = {"uid": f"01900000-0000-7000-8000-{len(self.tasks) + 1:012d}", "status": "pending"}
  119. self.tasks[task["uid"]] = task
  120. return deepcopy(task)
  121. def get_task(self, uid):
  122. return deepcopy(self.tasks.get(uid))
  123. class FakeSiemTransport:
  124. def __init__(self):
  125. self.sent = []
  126. def deliver(self, sink, envelope):
  127. self.sent.append((deepcopy(sink), deepcopy(envelope)))
  128. return {"status": "delivered", "remote_ref": "siem-accepted-1"}
  129. @pytest.fixture()
  130. def security():
  131. repository = MemorySecurityRepository()
  132. approvals = FakeApprovalGateway()
  133. transport = FakeSiemTransport()
  134. ids = iter(f"01900000-0000-7000-8000-{value:012d}" for value in range(300, 900))
  135. now = datetime(2026, 8, 2, 8, 0, tzinfo=UTC)
  136. service = SecurityGovernanceService(
  137. repository,
  138. approval_gateway=approvals,
  139. siem_transport=transport,
  140. siem_host_allowlist={"siem.example.internal"},
  141. uid_factory=lambda: next(ids),
  142. now_factory=lambda: now,
  143. )
  144. return service, repository, approvals, transport, now
  145. def profile_payload():
  146. return {
  147. "code": "MATERIAL_SECURITY",
  148. "name": "备品备件敏感识别规则",
  149. "business_domain_uid": DOMAIN,
  150. "default_classification": "internal",
  151. "rules": [
  152. {"field_tokens": ["phone", "mobile"], "category": "personal_contact", "classification": "sensitive"},
  153. {"field_tokens": ["bank", "account"], "category": "financial_account", "classification": "highly_sensitive"},
  154. ],
  155. }
  156. def test_second_domain_sensitive_sample_is_identified_without_persisting_raw_values(security):
  157. service, repository, _approvals, _transport, _now = security
  158. profile = service.create_classification_profile(profile_payload(), actor_uid=OWNER)
  159. scan = service.scan_sensitive_sample({
  160. "profile_uid": profile["uid"],
  161. "resource_type": "active_metadata_asset",
  162. "resource_uid": "01900000-0000-7000-8000-000000010301",
  163. "business_domain_uid": DOMAIN,
  164. "fields": [
  165. {"name": "material_code", "sample_values": ["MAT-0001"]},
  166. {"name": "supplier_contact_phone", "sample_values": ["13800138000"]},
  167. {"name": "supplier_bank_account", "sample_values": ["6222021234567890"]},
  168. ],
  169. }, actor_uid=OWNER)
  170. assert {item["proposed_classification"] for item in scan["findings"]} == {"sensitive", "highly_sensitive"}
  171. serialized = repr({"scan": repository.scans, "findings": repository.findings})
  172. assert "13800138000" not in serialized
  173. assert "6222021234567890" not in serialized
  174. assert "MAT-0001" not in serialized
  175. assert all(item["status"] == "pending_review" for item in scan["findings"])
  176. def test_classification_requires_independent_human_review(security):
  177. service, _repository, _approvals, _transport, _now = security
  178. profile = service.create_classification_profile(profile_payload(), actor_uid=OWNER)
  179. scan = service.scan_sensitive_sample({
  180. "profile_uid": profile["uid"], "resource_type": "file_field",
  181. "resource_uid": "01900000-0000-7000-8000-000000010302",
  182. "business_domain_uid": DOMAIN,
  183. "fields": [{"name": "supplier_contact_phone", "sample_values": ["13800138000"]}],
  184. }, actor_uid=OWNER)
  185. finding = scan["findings"][0]
  186. with pytest.raises(PermissionError, match="independent reviewer"):
  187. service.review_classification_finding(
  188. finding["uid"], {"decision": "confirm", "final_classification": "sensitive", "reason": "rule confirmed"},
  189. expected_version=1, actor_uid=OWNER,
  190. )
  191. reviewed = service.review_classification_finding(
  192. finding["uid"], {"decision": "confirm", "final_classification": "sensitive", "reason": "supplier contact is personal information"},
  193. expected_version=1, actor_uid=REVIEWER,
  194. )
  195. assert reviewed["status"] == "confirmed"
  196. assert reviewed["reviewed_by"] == REVIEWER
  197. def test_access_policy_binds_identity_role_domain_purpose_environment_fields_and_expiry(security):
  198. service, _repository, _approvals, _transport, now = security
  199. service.create_access_policy({
  200. "code": "MATERIAL_STEWARD_READ",
  201. "name": "物料管理员受控读取",
  202. "business_domain_uid": DOMAIN,
  203. "subject_user_uids": [USER],
  204. "subject_roles": ["editor"],
  205. "purposes": ["material_quality"],
  206. "environments": ["test"],
  207. "actions": ["read", "use"],
  208. "max_classification": "sensitive",
  209. "allowed_fields": ["material_code", "supplier_contact_phone"],
  210. "expires_at": (now + timedelta(days=30)).isoformat(),
  211. "review_due_at": (now + timedelta(days=15)).isoformat(),
  212. }, actor_uid=OWNER)
  213. allowed = service.evaluate_access({
  214. "user_uid": USER, "roles": ["editor"], "business_domain_uid": DOMAIN,
  215. "purpose": "material_quality", "environment": "test", "action": "read",
  216. "resource_uid": "01900000-0000-7000-8000-000000010401",
  217. "classification": "sensitive", "requested_fields": ["material_code"],
  218. })
  219. denied = service.evaluate_access({
  220. "user_uid": USER, "roles": ["editor"], "business_domain_uid": OTHER_DOMAIN,
  221. "purpose": "material_quality", "environment": "test", "action": "read",
  222. "resource_uid": "01900000-0000-7000-8000-000000010401",
  223. "classification": "sensitive", "requested_fields": ["material_code"],
  224. })
  225. excessive = service.evaluate_access({
  226. "user_uid": USER, "roles": ["editor"], "business_domain_uid": DOMAIN,
  227. "purpose": "material_quality", "environment": "test", "action": "read",
  228. "resource_uid": "01900000-0000-7000-8000-000000010401",
  229. "classification": "sensitive", "requested_fields": ["supplier_bank_account"],
  230. })
  231. assert allowed["decision"] == "authorized"
  232. assert denied["reason_code"] == "default_deny"
  233. assert excessive["reason_code"] == "field_minimization_denied"
  234. def test_highly_sensitive_egress_is_denied_and_sensitive_egress_requires_approval(security):
  235. service, _repository, approvals, _transport, now = security
  236. high = service.submit_egress_request({
  237. "business_domain_uid": DOMAIN, "resource_uid": "01900000-0000-7000-8000-000000010501",
  238. "classification": "highly_sensitive", "purpose": "supplier_settlement",
  239. "environment": "production", "requested_fields": ["supplier_bank_account"],
  240. "minimized_fields": ["supplier_bank_account"], "masking_applied": True,
  241. "destination_zone": "partner", "expires_at": (now + timedelta(days=1)).isoformat(),
  242. }, actor_uid=USER)
  243. assert high["status"] == "denied"
  244. assert high["reason_code"] == "highly_sensitive_egress_disabled"
  245. pending = service.submit_egress_request({
  246. "business_domain_uid": DOMAIN, "resource_uid": "01900000-0000-7000-8000-000000010502",
  247. "classification": "sensitive", "purpose": "supplier_contact_validation",
  248. "environment": "test", "requested_fields": ["supplier_contact_phone", "material_code"],
  249. "minimized_fields": ["supplier_contact_phone"], "masking_applied": True,
  250. "destination_zone": "controlled_partner", "expires_at": (now + timedelta(days=2)).isoformat(),
  251. "workflow_uid": WORKFLOW,
  252. }, actor_uid=USER)
  253. assert pending["status"] == "pending_approval"
  254. approvals.tasks[pending["approval_task_uid"]]["status"] = "approved"
  255. approved = service.reconcile_egress_request(
  256. pending["uid"], expected_version=1, actor_uid=REVIEWER
  257. )
  258. assert approved["status"] == "authorized_until_expiry"
  259. assert approved["approved_fields"] == ["supplier_contact_phone"]
  260. def test_retention_is_policy_driven_and_never_automatically_deletes_evidence(security):
  261. service, _repository, _approvals, _transport, now = security
  262. policy = service.create_retention_policy({
  263. "code": "ACCESS_DECISION_5Y", "name": "访问决策五年保留",
  264. "evidence_type": "access_decision", "retention_days": 1825,
  265. "archive_mode": "immutable_external", "disposition_action": "review",
  266. }, actor_uid=OWNER)
  267. candidates = service.retention_candidates(as_of=now, limit=100)
  268. assert policy["automatic_deletion"] is False
  269. assert candidates[0]["automatic_deletion"] is False
  270. def test_siem_sink_is_allowlisted_and_delivery_keeps_only_digest(security):
  271. service, repository, _approvals, transport, now = security
  272. with pytest.raises(ValueError, match="allowlist"):
  273. service.create_siem_sink({
  274. "name": "untrusted", "sink_type": "webhook",
  275. "endpoint": "https://evil.example/collect", "categories": ["authorization"],
  276. }, actor_uid=OWNER)
  277. sink = service.create_siem_sink({
  278. "name": "enterprise siem", "sink_type": "webhook",
  279. "endpoint": "https://siem.example.internal/dataops/events",
  280. "categories": ["authorization", "agent"],
  281. }, actor_uid=OWNER)
  282. delivery = service.dispatch_siem_events(sink["uid"], {
  283. "period_start": (now - timedelta(hours=1)).isoformat(),
  284. "period_end": now.isoformat(), "limit": 100,
  285. }, actor_uid=OWNER)
  286. assert delivery["status"] == "delivered"
  287. assert len(delivery["payload_digest"]) == 64
  288. assert "events" not in repository.deliveries[delivery["uid"]]
  289. assert transport.sent[0][1]["events"][0]["category"] == "authorization"
  290. def test_sbom_and_vulnerability_lifecycle_requires_assignment_evidence_and_independent_close(security):
  291. service, _repository, _approvals, _transport, now = security
  292. sbom = service.register_sbom({
  293. "artifact_name": "dataops-backend", "artifact_version": "0.3.0",
  294. "artifact_type": "application", "source_ref": "build:wp10",
  295. "document": {
  296. "bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1,
  297. "components": [{"type": "library", "name": "example-lib", "version": "1.0.0", "purl": "pkg:pypi/example-lib@1.0.0"}],
  298. },
  299. }, actor_uid=OWNER)
  300. finding = service.ingest_vulnerabilities(sbom["uid"], {
  301. "scanner": "trivy", "scan_ref": "scan:wp10",
  302. "findings": [{
  303. "external_id": "CVE-2026-1000", "severity": "high", "component_name": "example-lib",
  304. "installed_version": "1.0.0", "fixed_version": "1.0.1", "title": "example vulnerability",
  305. }],
  306. }, actor_uid=OWNER)[0]
  307. assigned = service.assign_vulnerability(
  308. finding["uid"], {"assignee_uid": USER, "due_at": (now + timedelta(days=7)).isoformat()},
  309. expected_version=1, actor_uid=OWNER,
  310. )
  311. resolved = service.resolve_vulnerability(
  312. finding["uid"], {
  313. "resolution_type": "patched", "resolved_version": "1.0.1",
  314. "resolution": "dependency upgraded and rescanned",
  315. "evidence_refs": [{"type": "scan", "ref": "scan:wp10-fixed", "digest": "a" * 64}],
  316. }, expected_version=2, actor_uid=USER,
  317. )
  318. with pytest.raises(PermissionError, match="independent reviewer"):
  319. service.close_vulnerability(
  320. finding["uid"], {"reason": "verified"}, expected_version=3, actor_uid=USER
  321. )
  322. closed = service.close_vulnerability(
  323. finding["uid"], {"reason": "fixed version and rescan evidence verified"},
  324. expected_version=3, actor_uid=REVIEWER,
  325. )
  326. assert assigned["status"] == "in_progress"
  327. assert resolved["status"] == "resolved"
  328. assert closed["status"] == "closed"
  329. assert service.dashboard()["open_vulnerability_count"] == 0