| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- from __future__ import annotations
- from copy import deepcopy
- from datetime import UTC, datetime, timedelta
- import pytest
- from app.core.system.security_governance import SecurityGovernanceService
- OWNER = "01900000-0000-7000-8000-000000010001"
- REVIEWER = "01900000-0000-7000-8000-000000010002"
- USER = "01900000-0000-7000-8000-000000010003"
- DOMAIN = "01900000-0000-7000-8000-000000010101"
- OTHER_DOMAIN = "01900000-0000-7000-8000-000000010102"
- WORKFLOW = "01900000-0000-7000-8000-000000010201"
- class MemorySecurityRepository:
- def __init__(self):
- self.users = {OWNER, REVIEWER, USER}
- self.profiles = {}
- self.scans = {}
- self.findings = {}
- self.policies = {}
- self.decisions = {}
- self.egress = {}
- self.retention = {}
- self.sinks = {}
- self.deliveries = {}
- self.sboms = {}
- self.vulnerabilities = {}
- self.events = []
- def users_available(self, values):
- return set(values) & self.users
- def create_profile(self, record):
- self.profiles[record["uid"]] = deepcopy(record)
- return deepcopy(record)
- def get_profile(self, uid):
- return deepcopy(self.profiles.get(uid))
- def create_scan(self, scan, findings):
- self.scans[scan["uid"]] = deepcopy(scan)
- for finding in findings:
- self.findings[finding["uid"]] = deepcopy(finding)
- return {**deepcopy(scan), "findings": deepcopy(findings)}
- def get_finding(self, uid):
- return deepcopy(self.findings.get(uid))
- def update_finding(self, record, expected_version):
- current = self.findings[record["uid"]]
- if current["current_version"] != expected_version:
- raise RuntimeError("classification finding version conflict")
- saved = deepcopy(record)
- saved["current_version"] += 1
- self.findings[record["uid"]] = saved
- return deepcopy(saved)
- def create_access_policy(self, record):
- self.policies[record["uid"]] = deepcopy(record)
- return deepcopy(record)
- def matching_access_policies(self, **context):
- return [deepcopy(item) for item in self.policies.values() if item["status"] == "active"]
- def create_access_decision(self, record):
- self.decisions[record["uid"]] = deepcopy(record)
- return deepcopy(record)
- def create_egress_request(self, record):
- self.egress[record["uid"]] = deepcopy(record)
- return deepcopy(record)
- def get_egress_request(self, uid):
- return deepcopy(self.egress.get(uid))
- def update_egress_request(self, record, expected_version):
- if self.egress[record["uid"]]["current_version"] != expected_version:
- raise RuntimeError("egress request version conflict")
- saved = deepcopy(record)
- saved["current_version"] += 1
- self.egress[record["uid"]] = saved
- return deepcopy(saved)
- def create_retention_policy(self, record):
- self.retention[record["uid"]] = deepcopy(record)
- return deepcopy(record)
- def retention_candidates(self, as_of, limit):
- return [{"evidence_type": "access_decision", "candidate_count": 2, "automatic_deletion": False}]
- def create_siem_sink(self, record):
- self.sinks[record["uid"]] = deepcopy(record)
- return deepcopy(record)
- def get_siem_sink(self, uid):
- return deepcopy(self.sinks.get(uid))
- def fetch_siem_events(self, **filters):
- return [{"event_uid": "event-1", "category": "authorization", "action": "denied", "safe_detail": {}}]
- def create_siem_delivery(self, record):
- self.deliveries[record["uid"]] = deepcopy(record)
- return deepcopy(record)
- def create_sbom(self, record):
- self.sboms[record["uid"]] = deepcopy(record)
- return deepcopy(record)
- def get_sbom(self, uid):
- return deepcopy(self.sboms.get(uid))
- def upsert_vulnerabilities(self, sbom_uid, records):
- for record in records:
- self.vulnerabilities[record["uid"]] = deepcopy(record)
- return deepcopy(records)
- def get_vulnerability(self, uid):
- return deepcopy(self.vulnerabilities.get(uid))
- def update_vulnerability(self, record, expected_version, action, actor_uid):
- current = self.vulnerabilities[record["uid"]]
- if current["current_version"] != expected_version:
- raise RuntimeError("vulnerability version conflict")
- saved = deepcopy(record)
- saved["current_version"] += 1
- self.vulnerabilities[record["uid"]] = saved
- return deepcopy(saved)
- def add_event(self, resource_type, resource_uid, action, actor_uid, safe_detail):
- self.events.append({
- "resource_type": resource_type, "resource_uid": resource_uid,
- "action": action, "actor_uid": actor_uid, "safe_detail": deepcopy(safe_detail),
- })
- def dashboard(self):
- return {
- "pending_classification_reviews": sum(item["status"] == "pending_review" for item in self.findings.values()),
- "denied_access_count": sum(item["decision"] == "denied" for item in self.decisions.values()),
- "open_vulnerability_count": sum(item["status"] != "closed" for item in self.vulnerabilities.values()),
- }
- class FakeApprovalGateway:
- def __init__(self):
- self.tasks = {}
- def create_egress_task(self, request_record, workflow_uid, actor_uid):
- task = {"uid": f"01900000-0000-7000-8000-{len(self.tasks) + 1:012d}", "status": "pending"}
- self.tasks[task["uid"]] = task
- return deepcopy(task)
- def get_task(self, uid):
- return deepcopy(self.tasks.get(uid))
- class FakeSiemTransport:
- def __init__(self):
- self.sent = []
- def deliver(self, sink, envelope):
- self.sent.append((deepcopy(sink), deepcopy(envelope)))
- return {"status": "delivered", "remote_ref": "siem-accepted-1"}
- @pytest.fixture()
- def security():
- repository = MemorySecurityRepository()
- approvals = FakeApprovalGateway()
- transport = FakeSiemTransport()
- ids = iter(f"01900000-0000-7000-8000-{value:012d}" for value in range(300, 900))
- now = datetime(2026, 8, 2, 8, 0, tzinfo=UTC)
- service = SecurityGovernanceService(
- repository,
- approval_gateway=approvals,
- siem_transport=transport,
- siem_host_allowlist={"siem.example.internal"},
- uid_factory=lambda: next(ids),
- now_factory=lambda: now,
- )
- return service, repository, approvals, transport, now
- def profile_payload():
- return {
- "code": "MATERIAL_SECURITY",
- "name": "备品备件敏感识别规则",
- "business_domain_uid": DOMAIN,
- "default_classification": "internal",
- "rules": [
- {"field_tokens": ["phone", "mobile"], "category": "personal_contact", "classification": "sensitive"},
- {"field_tokens": ["bank", "account"], "category": "financial_account", "classification": "highly_sensitive"},
- ],
- }
- def test_second_domain_sensitive_sample_is_identified_without_persisting_raw_values(security):
- service, repository, _approvals, _transport, _now = security
- profile = service.create_classification_profile(profile_payload(), actor_uid=OWNER)
- scan = service.scan_sensitive_sample({
- "profile_uid": profile["uid"],
- "resource_type": "active_metadata_asset",
- "resource_uid": "01900000-0000-7000-8000-000000010301",
- "business_domain_uid": DOMAIN,
- "fields": [
- {"name": "material_code", "sample_values": ["MAT-0001"]},
- {"name": "supplier_contact_phone", "sample_values": ["13800138000"]},
- {"name": "supplier_bank_account", "sample_values": ["6222021234567890"]},
- ],
- }, actor_uid=OWNER)
- assert {item["proposed_classification"] for item in scan["findings"]} == {"sensitive", "highly_sensitive"}
- serialized = repr({"scan": repository.scans, "findings": repository.findings})
- assert "13800138000" not in serialized
- assert "6222021234567890" not in serialized
- assert "MAT-0001" not in serialized
- assert all(item["status"] == "pending_review" for item in scan["findings"])
- def test_classification_requires_independent_human_review(security):
- service, _repository, _approvals, _transport, _now = security
- profile = service.create_classification_profile(profile_payload(), actor_uid=OWNER)
- scan = service.scan_sensitive_sample({
- "profile_uid": profile["uid"], "resource_type": "file_field",
- "resource_uid": "01900000-0000-7000-8000-000000010302",
- "business_domain_uid": DOMAIN,
- "fields": [{"name": "supplier_contact_phone", "sample_values": ["13800138000"]}],
- }, actor_uid=OWNER)
- finding = scan["findings"][0]
- with pytest.raises(PermissionError, match="independent reviewer"):
- service.review_classification_finding(
- finding["uid"], {"decision": "confirm", "final_classification": "sensitive", "reason": "rule confirmed"},
- expected_version=1, actor_uid=OWNER,
- )
- reviewed = service.review_classification_finding(
- finding["uid"], {"decision": "confirm", "final_classification": "sensitive", "reason": "supplier contact is personal information"},
- expected_version=1, actor_uid=REVIEWER,
- )
- assert reviewed["status"] == "confirmed"
- assert reviewed["reviewed_by"] == REVIEWER
- def test_access_policy_binds_identity_role_domain_purpose_environment_fields_and_expiry(security):
- service, _repository, _approvals, _transport, now = security
- service.create_access_policy({
- "code": "MATERIAL_STEWARD_READ",
- "name": "物料管理员受控读取",
- "business_domain_uid": DOMAIN,
- "subject_user_uids": [USER],
- "subject_roles": ["editor"],
- "purposes": ["material_quality"],
- "environments": ["test"],
- "actions": ["read", "use"],
- "max_classification": "sensitive",
- "allowed_fields": ["material_code", "supplier_contact_phone"],
- "expires_at": (now + timedelta(days=30)).isoformat(),
- "review_due_at": (now + timedelta(days=15)).isoformat(),
- }, actor_uid=OWNER)
- allowed = service.evaluate_access({
- "user_uid": USER, "roles": ["editor"], "business_domain_uid": DOMAIN,
- "purpose": "material_quality", "environment": "test", "action": "read",
- "resource_uid": "01900000-0000-7000-8000-000000010401",
- "classification": "sensitive", "requested_fields": ["material_code"],
- })
- denied = service.evaluate_access({
- "user_uid": USER, "roles": ["editor"], "business_domain_uid": OTHER_DOMAIN,
- "purpose": "material_quality", "environment": "test", "action": "read",
- "resource_uid": "01900000-0000-7000-8000-000000010401",
- "classification": "sensitive", "requested_fields": ["material_code"],
- })
- excessive = service.evaluate_access({
- "user_uid": USER, "roles": ["editor"], "business_domain_uid": DOMAIN,
- "purpose": "material_quality", "environment": "test", "action": "read",
- "resource_uid": "01900000-0000-7000-8000-000000010401",
- "classification": "sensitive", "requested_fields": ["supplier_bank_account"],
- })
- assert allowed["decision"] == "authorized"
- assert denied["reason_code"] == "default_deny"
- assert excessive["reason_code"] == "field_minimization_denied"
- def test_highly_sensitive_egress_is_denied_and_sensitive_egress_requires_approval(security):
- service, _repository, approvals, _transport, now = security
- high = service.submit_egress_request({
- "business_domain_uid": DOMAIN, "resource_uid": "01900000-0000-7000-8000-000000010501",
- "classification": "highly_sensitive", "purpose": "supplier_settlement",
- "environment": "production", "requested_fields": ["supplier_bank_account"],
- "minimized_fields": ["supplier_bank_account"], "masking_applied": True,
- "destination_zone": "partner", "expires_at": (now + timedelta(days=1)).isoformat(),
- }, actor_uid=USER)
- assert high["status"] == "denied"
- assert high["reason_code"] == "highly_sensitive_egress_disabled"
- pending = service.submit_egress_request({
- "business_domain_uid": DOMAIN, "resource_uid": "01900000-0000-7000-8000-000000010502",
- "classification": "sensitive", "purpose": "supplier_contact_validation",
- "environment": "test", "requested_fields": ["supplier_contact_phone", "material_code"],
- "minimized_fields": ["supplier_contact_phone"], "masking_applied": True,
- "destination_zone": "controlled_partner", "expires_at": (now + timedelta(days=2)).isoformat(),
- "workflow_uid": WORKFLOW,
- }, actor_uid=USER)
- assert pending["status"] == "pending_approval"
- approvals.tasks[pending["approval_task_uid"]]["status"] = "approved"
- approved = service.reconcile_egress_request(
- pending["uid"], expected_version=1, actor_uid=REVIEWER
- )
- assert approved["status"] == "authorized_until_expiry"
- assert approved["approved_fields"] == ["supplier_contact_phone"]
- def test_retention_is_policy_driven_and_never_automatically_deletes_evidence(security):
- service, _repository, _approvals, _transport, now = security
- policy = service.create_retention_policy({
- "code": "ACCESS_DECISION_5Y", "name": "访问决策五年保留",
- "evidence_type": "access_decision", "retention_days": 1825,
- "archive_mode": "immutable_external", "disposition_action": "review",
- }, actor_uid=OWNER)
- candidates = service.retention_candidates(as_of=now, limit=100)
- assert policy["automatic_deletion"] is False
- assert candidates[0]["automatic_deletion"] is False
- def test_siem_sink_is_allowlisted_and_delivery_keeps_only_digest(security):
- service, repository, _approvals, transport, now = security
- with pytest.raises(ValueError, match="allowlist"):
- service.create_siem_sink({
- "name": "untrusted", "sink_type": "webhook",
- "endpoint": "https://evil.example/collect", "categories": ["authorization"],
- }, actor_uid=OWNER)
- sink = service.create_siem_sink({
- "name": "enterprise siem", "sink_type": "webhook",
- "endpoint": "https://siem.example.internal/dataops/events",
- "categories": ["authorization", "agent"],
- }, actor_uid=OWNER)
- delivery = service.dispatch_siem_events(sink["uid"], {
- "period_start": (now - timedelta(hours=1)).isoformat(),
- "period_end": now.isoformat(), "limit": 100,
- }, actor_uid=OWNER)
- assert delivery["status"] == "delivered"
- assert len(delivery["payload_digest"]) == 64
- assert "events" not in repository.deliveries[delivery["uid"]]
- assert transport.sent[0][1]["events"][0]["category"] == "authorization"
- def test_sbom_and_vulnerability_lifecycle_requires_assignment_evidence_and_independent_close(security):
- service, _repository, _approvals, _transport, now = security
- sbom = service.register_sbom({
- "artifact_name": "dataops-backend", "artifact_version": "0.3.0",
- "artifact_type": "application", "source_ref": "build:wp10",
- "document": {
- "bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1,
- "components": [{"type": "library", "name": "example-lib", "version": "1.0.0", "purl": "pkg:pypi/example-lib@1.0.0"}],
- },
- }, actor_uid=OWNER)
- finding = service.ingest_vulnerabilities(sbom["uid"], {
- "scanner": "trivy", "scan_ref": "scan:wp10",
- "findings": [{
- "external_id": "CVE-2026-1000", "severity": "high", "component_name": "example-lib",
- "installed_version": "1.0.0", "fixed_version": "1.0.1", "title": "example vulnerability",
- }],
- }, actor_uid=OWNER)[0]
- assigned = service.assign_vulnerability(
- finding["uid"], {"assignee_uid": USER, "due_at": (now + timedelta(days=7)).isoformat()},
- expected_version=1, actor_uid=OWNER,
- )
- resolved = service.resolve_vulnerability(
- finding["uid"], {
- "resolution_type": "patched", "resolved_version": "1.0.1",
- "resolution": "dependency upgraded and rescanned",
- "evidence_refs": [{"type": "scan", "ref": "scan:wp10-fixed", "digest": "a" * 64}],
- }, expected_version=2, actor_uid=USER,
- )
- with pytest.raises(PermissionError, match="independent reviewer"):
- service.close_vulnerability(
- finding["uid"], {"reason": "verified"}, expected_version=3, actor_uid=USER
- )
- closed = service.close_vulnerability(
- finding["uid"], {"reason": "fixed version and rescan evidence verified"},
- expected_version=3, actor_uid=REVIEWER,
- )
- assert assigned["status"] == "in_progress"
- assert resolved["status"] == "resolved"
- assert closed["status"] == "closed"
- assert service.dashboard()["open_vulnerability_count"] == 0
|