"""Problem rule evaluators (v1): brute-force, privilege spike, host silence.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta, timezone from sqlalchemy import func, select from sqlalchemy.orm import Session from app.config import get_settings from app.models import Event, Host from app.services.host_health import HEARTBEAT_TYPE, agent_status BRUTE_FAILED_TYPES = frozenset({"ssh.login.failed", "rdp.login.failed"}) PRIVILEGE_SUDO_TYPE = "privilege.sudo.command" RULE_BRUTE_FORCE = "rule:brute_force_burst" RULE_PRIVILEGE_SPIKE = "rule:privilege_spike" RULE_HOST_SILENCE = "rule:host_silence" @dataclass(frozen=True) class RuleMatch: rule_id: str correlation_type: str title: str summary: str severity: str fingerprint_suffix: str = "" def _now() -> datetime: return datetime.now(timezone.utc) def _event_source_ip(event: Event) -> str: details = event.details if isinstance(event.details, dict) else {} ip = details.get("source_ip") or details.get("ip") return str(ip) if ip else "unknown" def count_events_in_window( db: Session, *, host_id: int, event_type: str, since: datetime, source_ip: str | None = None, ) -> int: rows = db.scalars( select(Event).where( Event.host_id == host_id, Event.type == event_type, Event.occurred_at >= since, ) ).all() if source_ip is None: return len(rows) return sum(1 for e in rows if _event_source_ip(e) == source_ip) def last_heartbeat_at(db: Session, host_id: int) -> datetime | None: return db.scalar( select(func.max(Event.received_at)).where( Event.host_id == host_id, Event.type == HEARTBEAT_TYPE, ) ) def evaluate_brute_force_burst(db: Session, event: Event) -> RuleMatch | None: if event.type not in BRUTE_FAILED_TYPES: return None settings = get_settings() source_ip = _event_source_ip(event) since = _now() - timedelta(minutes=settings.sac_brute_force_window_minutes) count = count_events_in_window( db, host_id=event.host_id, event_type=event.type, since=since, source_ip=source_ip, ) if count < settings.sac_brute_force_threshold: return None return RuleMatch( rule_id=RULE_BRUTE_FORCE, correlation_type=event.type, fingerprint_suffix=f"ip{source_ip}", title=f"Brute-force burst: {count} failed logins", summary=( f"{count} событий {event.type} за {settings.sac_brute_force_window_minutes} мин " f"(IP {source_ip}, порог {settings.sac_brute_force_threshold})" ), severity="high", ) def evaluate_privilege_spike(db: Session, event: Event) -> RuleMatch | None: if event.type != PRIVILEGE_SUDO_TYPE: return None settings = get_settings() since = _now() - timedelta(minutes=settings.sac_privilege_spike_window_minutes) count = count_events_in_window( db, host_id=event.host_id, event_type=event.type, since=since, ) if count < settings.sac_privilege_spike_threshold: return None return RuleMatch( rule_id=RULE_PRIVILEGE_SPIKE, correlation_type=event.type, title=f"Privilege spike: {count} sudo commands", summary=( f"{count} событий {event.type} за {settings.sac_privilege_spike_window_minutes} мин " f"(порог {settings.sac_privilege_spike_threshold})" ), severity="warning" if count < settings.sac_privilege_spike_threshold * 2 else "high", ) def evaluate_host_silence(db: Session, event: Event) -> RuleMatch | None: if event.type == HEARTBEAT_TYPE: return None settings = get_settings() hb = last_heartbeat_at(db, event.host_id) if hb is None: return None if agent_status(hb, stale_minutes=settings.sac_heartbeat_stale_minutes, now=_now()) != "stale": return None host = db.get(Host, event.host_id) hostname = host.hostname if host else f"host#{event.host_id}" age_min = int((_now() - hb.replace(tzinfo=timezone.utc if hb.tzinfo is None else hb.tzinfo)).total_seconds() // 60) return RuleMatch( rule_id=RULE_HOST_SILENCE, correlation_type="host.silence", title=f"Host silence: {hostname}", summary=( f"Нет свежего agent.heartbeat более {settings.sac_heartbeat_stale_minutes} мин " f"(последний ~{age_min} мин назад)" ), severity="high", ) def evaluate_immediate_high(event: Event) -> RuleMatch | None: """Одиночные high/critical и известные типы (ban, mass bruteforce).""" problem_types = frozenset({"ssh.ip.banned", "ssh.bruteforce.mass"}) if event.severity not in frozenset({"high", "critical"}) and event.type not in problem_types: return None rule_id = "high_severity" if event.severity in frozenset({"high", "critical"}) else f"type:{event.type}" return RuleMatch( rule_id=rule_id, correlation_type=event.type, title=event.title, summary=event.summary, severity=event.severity, ) def resolve_host_silence_problems(db: Session, host_id: int) -> int: """Закрыть open/ack host silence при свежем heartbeat.""" from app.models import Problem problems = db.scalars( select(Problem).where( Problem.host_id == host_id, Problem.rule_id == RULE_HOST_SILENCE, Problem.status.in_(("open", "acknowledged")), ) ).all() for problem in problems: problem.status = "resolved" if problems: db.flush() return len(problems) def build_fingerprint(host_id: int, correlation_type: str, rule_id: str, suffix: str = "") -> str: base = f"h{host_id}" if suffix: base = f"{base}:{suffix}" return f"{base}:t{correlation_type}:r{rule_id}" def pick_rule_match(db: Session, event: Event) -> RuleMatch | None: """Первое сработавшее правило (приоритет: burst → spike → silence → immediate).""" for evaluator in ( evaluate_brute_force_burst, evaluate_privilege_spike, evaluate_host_silence, ): match = evaluator(db, event) if match is not None: return match return evaluate_immediate_high(event)