""" 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