bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
404 lines
14 KiB
Python
404 lines
14 KiB
Python
"""
|
|
IOM Lager 8 — Risk Alert System
|
|
Alert-system för risktrösklar med notifieringar.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass, field, asdict
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
from typing import Callable, Dict, List, Optional, Set, Any
|
|
import threading
|
|
|
|
from risk_model import RiskResult, RiskDimension
|
|
|
|
|
|
class AlertSeverity(str, Enum):
|
|
LOW = "low"
|
|
MEDIUM = "medium"
|
|
HIGH = "high"
|
|
CRITICAL = "critical"
|
|
|
|
|
|
class AlertStatus(str, Enum):
|
|
ACTIVE = "active"
|
|
ACKNOWLEDGED = "acknowledged"
|
|
RESOLVED = "resolved"
|
|
SILENCED = "silenced"
|
|
|
|
|
|
# Tröskelvärden för alert-nivåer
|
|
RISK_THRESHOLDS = {
|
|
AlertSeverity.CRITICAL: 9.0,
|
|
AlertSeverity.HIGH: 7.0,
|
|
AlertSeverity.MEDIUM: 5.0,
|
|
AlertSeverity.LOW: 3.0,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class AlertRule:
|
|
"""Regel för när en alert ska triggas."""
|
|
rule_id: str
|
|
name: str
|
|
description: str = ""
|
|
# Trösklar
|
|
min_total_risk: Optional[float] = None
|
|
min_risk_level: Optional[int] = None
|
|
dimension_thresholds: Dict[str, float] = field(default_factory=dict)
|
|
# Filter
|
|
domains: Optional[List[str]] = None
|
|
object_type_codes: Optional[List[str]] = None
|
|
goids: Optional[List[str]] = None
|
|
# Åtgärd
|
|
severity: AlertSeverity = AlertSeverity.MEDIUM
|
|
auto_acknowledge_after_minutes: Optional[int] = None
|
|
cooldown_minutes: int = 60
|
|
enabled: bool = True
|
|
|
|
|
|
@dataclass
|
|
class Alert:
|
|
"""En enskild alert."""
|
|
alert_id: str
|
|
rule_id: str
|
|
goid: str
|
|
severity: AlertSeverity
|
|
status: AlertStatus
|
|
message: str
|
|
risk_result: RiskResult
|
|
created_at: str
|
|
acknowledged_at: Optional[str] = None
|
|
acknowledged_by: Optional[str] = None
|
|
resolved_at: Optional[str] = None
|
|
resolved_by: Optional[str] = None
|
|
resolution_note: Optional[str] = None
|
|
auto_resolve_after: Optional[str] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
d = asdict(self)
|
|
d["risk_result"] = {
|
|
"goid": self.risk_result.goid,
|
|
"total_risk": self.risk_result.total_risk,
|
|
"risk_level": self.risk_result.risk_level,
|
|
"dimension_scores": self.risk_result.dimension_scores,
|
|
"alert_triggered": self.risk_result.alert_triggered,
|
|
"alert_level": self.risk_result.alert_level,
|
|
}
|
|
return d
|
|
|
|
|
|
NotifierCallback = Callable[[Alert], None]
|
|
|
|
|
|
class RiskAlertManager:
|
|
"""Central hanterare för risk-alerts."""
|
|
|
|
def __init__(self):
|
|
self._rules: Dict[str, AlertRule] = {}
|
|
self._alerts: Dict[str, Alert] = {}
|
|
self._goid_alerts: Dict[str, Set[str]] = {} # goid → alert_ids
|
|
self._notifiers: List[NotifierCallback] = []
|
|
self._lock = threading.RLock()
|
|
self._cooldowns: Dict[str, float] = {} # goid+rule_id → timestamp
|
|
|
|
# ─── Regelhantering ──────────────────────────────────────────────────────
|
|
|
|
def add_rule(self, rule: AlertRule) -> None:
|
|
with self._lock:
|
|
self._rules[rule.rule_id] = rule
|
|
|
|
def remove_rule(self, rule_id: str) -> bool:
|
|
with self._lock:
|
|
return self._rules.pop(rule_id, None) is not None
|
|
|
|
def get_rule(self, rule_id: str) -> Optional[AlertRule]:
|
|
with self._lock:
|
|
return self._rules.get(rule_id)
|
|
|
|
def list_rules(self) -> List[AlertRule]:
|
|
with self._lock:
|
|
return list(self._rules.values())
|
|
|
|
def enable_rule(self, rule_id: str) -> bool:
|
|
with self._lock:
|
|
if rule := self._rules.get(rule_id):
|
|
rule.enabled = True
|
|
return True
|
|
return False
|
|
|
|
def disable_rule(self, rule_id: str) -> bool:
|
|
with self._lock:
|
|
if rule := self._rules.get(rule_id):
|
|
rule.enabled = False
|
|
return True
|
|
return False
|
|
|
|
# ─── Notifieringar ───────────────────────────────────────────────────────
|
|
|
|
def register_notifier(self, callback: NotifierCallback) -> None:
|
|
with self._lock:
|
|
self._notifiers.append(callback)
|
|
|
|
def unregister_notifier(self, callback: NotifierCallback) -> None:
|
|
with self._lock:
|
|
if callback in self._notifiers:
|
|
self._notifiers.remove(callback)
|
|
|
|
def _notify(self, alert: Alert) -> None:
|
|
for notifier in list(self._notifiers):
|
|
try:
|
|
notifier(alert)
|
|
except Exception:
|
|
pass # Isolera fel i notifierare
|
|
|
|
# ─── Alert-hantering ─────────────────────────────────────────────────────
|
|
|
|
def evaluate(self, risk_result: RiskResult) -> List[Alert]:
|
|
"""Utvärdera ett riskresultat mot alla regler och skapa alerts."""
|
|
triggered: List[Alert] = []
|
|
with self._lock:
|
|
for rule in self._rules.values():
|
|
if not rule.enabled:
|
|
continue
|
|
if not self._rule_matches(rule, risk_result):
|
|
continue
|
|
cooldown_key = f"{risk_result.goid}:{rule.rule_id}"
|
|
now = time.time()
|
|
last_triggered = self._cooldowns.get(cooldown_key, 0)
|
|
cooldown_seconds = rule.cooldown_minutes * 60
|
|
if now - last_triggered < cooldown_seconds:
|
|
continue
|
|
self._cooldowns[cooldown_key] = now
|
|
|
|
alert = self._create_alert(rule, risk_result)
|
|
self._alerts[alert.alert_id] = alert
|
|
self._goid_alerts.setdefault(risk_result.goid, set()).add(alert.alert_id)
|
|
triggered.append(alert)
|
|
self._notify(alert)
|
|
return triggered
|
|
|
|
def _rule_matches(self, rule: AlertRule, result: RiskResult) -> bool:
|
|
# Total risk-tröskel
|
|
if rule.min_total_risk is not None and result.total_risk < rule.min_total_risk:
|
|
return False
|
|
# Risknivå-tröskel
|
|
if rule.min_risk_level is not None and result.risk_level < rule.min_risk_level:
|
|
return False
|
|
# Dimensions-trösklar
|
|
for dim, threshold in rule.dimension_thresholds.items():
|
|
if result.dimension_scores.get(dim, 0) < threshold:
|
|
return False
|
|
# Domän-filter
|
|
if rule.domains is not None:
|
|
domain = result.goid.split("-")[0] if result.goid else None
|
|
if domain not in rule.domains:
|
|
return False
|
|
# GOID-filter
|
|
if rule.goids is not None and result.goid not in rule.goids:
|
|
return False
|
|
# Objekttyp-filter (prefix-match)
|
|
if rule.object_type_codes is not None:
|
|
matched = False
|
|
for code in rule.object_type_codes:
|
|
# Förenklad: antar att GOID börjar med typkoden
|
|
if result.goid.startswith(code):
|
|
matched = True
|
|
break
|
|
if not matched:
|
|
return False
|
|
return True
|
|
|
|
def _create_alert(self, rule: AlertRule, result: RiskResult) -> Alert:
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
alert_id = f"ALERT-{result.goid}-{rule.rule_id}-{int(time.time())}"
|
|
message = self._build_alert_message(rule, result)
|
|
auto_resolve = None
|
|
if rule.auto_acknowledge_after_minutes:
|
|
auto_resolve_dt = datetime.now(timezone.utc)
|
|
# Sätt auto-resolve-tid
|
|
from datetime import timedelta
|
|
auto_resolve = (auto_resolve_dt + timedelta(minutes=rule.auto_acknowledge_after_minutes)).isoformat()
|
|
|
|
return Alert(
|
|
alert_id=alert_id,
|
|
rule_id=rule.rule_id,
|
|
goid=result.goid,
|
|
severity=rule.severity,
|
|
status=AlertStatus.ACTIVE,
|
|
message=message,
|
|
risk_result=result,
|
|
created_at=now,
|
|
auto_resolve_after=auto_resolve,
|
|
)
|
|
|
|
def _build_alert_message(self, rule: AlertRule, result: RiskResult) -> str:
|
|
parts = [
|
|
f"Risk alert för {result.goid}",
|
|
f"Regel: {rule.name}",
|
|
f"Total risk: {result.total_risk}/10 (nivå {result.risk_level})",
|
|
]
|
|
if result.alert_level:
|
|
parts.append(f"Alert-nivå: {result.alert_level}")
|
|
# Lista höga dimensioner
|
|
high_dims = [
|
|
d for d, v in result.dimension_scores.items() if v >= 7.0
|
|
]
|
|
if high_dims:
|
|
parts.append(f"Högrisk-dimensioner: {', '.join(high_dims)}")
|
|
return " | ".join(parts)
|
|
|
|
def acknowledge_alert(
|
|
self, alert_id: str, user: str, note: Optional[str] = None
|
|
) -> Optional[Alert]:
|
|
with self._lock:
|
|
if alert := self._alerts.get(alert_id):
|
|
if alert.status == AlertStatus.ACTIVE:
|
|
alert.status = AlertStatus.ACKNOWLEDGED
|
|
alert.acknowledged_at = datetime.now(timezone.utc).isoformat()
|
|
alert.acknowledged_by = user
|
|
if note:
|
|
alert.resolution_note = note
|
|
return alert
|
|
return None
|
|
|
|
def resolve_alert(
|
|
self, alert_id: str, user: str, note: Optional[str] = None
|
|
) -> Optional[Alert]:
|
|
with self._lock:
|
|
if alert := self._alerts.get(alert_id):
|
|
alert.status = AlertStatus.RESOLVED
|
|
alert.resolved_at = datetime.now(timezone.utc).isoformat()
|
|
alert.resolved_by = user
|
|
if note:
|
|
alert.resolution_note = note
|
|
return alert
|
|
return None
|
|
|
|
def silence_alert(self, alert_id: str, duration_minutes: int = 1440) -> Optional[Alert]:
|
|
with self._lock:
|
|
if alert := self._alerts.get(alert_id):
|
|
alert.status = AlertStatus.SILENCED
|
|
return alert
|
|
return None
|
|
|
|
def get_alert(self, alert_id: str) -> Optional[Alert]:
|
|
with self._lock:
|
|
return self._alerts.get(alert_id)
|
|
|
|
def list_alerts(
|
|
self,
|
|
goid: Optional[str] = None,
|
|
status: Optional[AlertStatus] = None,
|
|
severity: Optional[AlertSeverity] = None,
|
|
) -> List[Alert]:
|
|
with self._lock:
|
|
alerts = list(self._alerts.values())
|
|
if goid:
|
|
alerts = [a for a in alerts if a.goid == goid]
|
|
if status:
|
|
alerts = [a for a in alerts if a.status == status]
|
|
if severity:
|
|
alerts = [a for a in alerts if a.severity == severity]
|
|
return sorted(alerts, key=lambda a: a.created_at, reverse=True)
|
|
|
|
def get_active_alerts_for_goid(self, goid: str) -> List[Alert]:
|
|
with self._lock:
|
|
alert_ids = self._goid_alerts.get(goid, set())
|
|
alerts = [self._alerts[aid] for aid in alert_ids if aid in self._alerts]
|
|
return [a for a in alerts if a.status == AlertStatus.ACTIVE]
|
|
|
|
def get_alert_summary(self) -> Dict[str, Any]:
|
|
with self._lock:
|
|
total = len(self._alerts)
|
|
by_status: Dict[str, int] = {}
|
|
by_severity: Dict[str, int] = {}
|
|
for alert in self._alerts.values():
|
|
by_status[alert.status.value] = by_status.get(alert.status.value, 0) + 1
|
|
by_severity[alert.severity.value] = by_severity.get(alert.severity.value, 0) + 1
|
|
return {
|
|
"total_alerts": total,
|
|
"by_status": by_status,
|
|
"by_severity": by_severity,
|
|
"active_rules": len([r for r in self._rules.values() if r.enabled]),
|
|
}
|
|
|
|
def clear_old_alerts(self, max_age_hours: int = 168) -> int:
|
|
"""Rensa lösta alerts äldre än max_age_hours."""
|
|
cutoff = time.time() - (max_age_hours * 3600)
|
|
removed = 0
|
|
with self._lock:
|
|
to_remove = [
|
|
aid for aid, alert in self._alerts.items()
|
|
if alert.status in (AlertStatus.RESOLVED, AlertStatus.SILENCED)
|
|
and self._parse_ts(alert.created_at) < cutoff
|
|
]
|
|
for aid in to_remove:
|
|
alert = self._alerts.pop(aid)
|
|
self._goid_alerts.get(alert.goid, set()).discard(aid)
|
|
removed += 1
|
|
return removed
|
|
|
|
@staticmethod
|
|
def _parse_ts(ts: str) -> float:
|
|
try:
|
|
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
return dt.timestamp()
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
# ─── Färdiga notifierare ─────────────────────────────────────────────────────
|
|
|
|
class ConsoleNotifier:
|
|
"""Skriver alerts till stdout."""
|
|
|
|
def __call__(self, alert: Alert) -> None:
|
|
print(f"\n🚨 [{alert.severity.upper()}] {alert.message}")
|
|
print(f" Alert ID: {alert.alert_id}")
|
|
print(f" Skapad: {alert.created_at}\n")
|
|
|
|
|
|
class WebhookNotifier:
|
|
"""Skickar alerts via HTTP POST."""
|
|
|
|
def __init__(self, webhook_url: str, headers: Optional[Dict[str, str]] = None):
|
|
self.webhook_url = webhook_url
|
|
self.headers = headers or {"Content-Type": "application/json"}
|
|
|
|
def __call__(self, alert: Alert) -> None:
|
|
import urllib.request
|
|
payload = json.dumps(alert.to_dict(), default=str).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
self.webhook_url,
|
|
data=payload,
|
|
headers=self.headers,
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
pass
|
|
except Exception as exc:
|
|
print(f"WebhookNotifier failed: {exc}")
|
|
|
|
|
|
# ─── Global instans ──────────────────────────────────────────────────────────
|
|
|
|
_alert_manager: Optional[RiskAlertManager] = None
|
|
|
|
|
|
def get_alert_manager() -> RiskAlertManager:
|
|
global _alert_manager
|
|
if _alert_manager is None:
|
|
_alert_manager = RiskAlertManager()
|
|
return _alert_manager
|
|
|
|
|
|
def reset_alert_manager() -> None:
|
|
global _alert_manager
|
|
_alert_manager = None
|