"""SAC-generated daily reports from ingested events (F-NOT-05 / notif-32).""" from __future__ import annotations import html import logging import uuid from collections import Counter from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any from zoneinfo import ZoneInfo from sqlalchemy import select from sqlalchemy.orm import Session from app.config import Settings, get_settings from app.models import Event, Host from app.services.daily_report_format import ( RDP_BAN_TYPES, SSH_BAN_TYPES, build_report_body, enrich_stats_for_storage, ) from app.services.ingest import ingest_event from app.services.notify_dispatch import notify_daily_report logger = logging.getLogger(__name__) REPORT_TYPES = { "ssh-monitor": "report.daily.ssh", "rdp-login-monitor": "report.daily.rdp", } SSH_SUCCESS = "ssh.login.success" SSH_FAILED = "ssh.login.failed" SSH_SUDO = "privilege.sudo.command" RDP_SUCCESS = "rdp.login.success" RDP_FAILED = "rdp.login.failed" SESSION_LOGIND_NEW = "session.logind.new" @dataclass(frozen=True) class DailyReportResult: host_id: int hostname: str report_type: str event_id: str created: bool skipped_reason: str | None = None def _now_in_tz(settings: Settings) -> datetime: tz = ZoneInfo(settings.sac_daily_report_timezone.strip() or "Europe/Moscow") return datetime.now(timezone.utc).astimezone(tz) def _report_type_for_host(host: Host) -> str | None: return REPORT_TYPES.get((host.product or "").strip()) def _day_start_in_tz(settings: Settings, ref: datetime | None = None) -> datetime: local = ref or _now_in_tz(settings) start = local.replace(hour=0, minute=0, second=0, microsecond=0) return start.astimezone(timezone.utc) def host_has_report_today(db: Session, host_id: int, report_type: str, settings: Settings) -> bool: since = _day_start_in_tz(settings) row = db.scalar( select(Event.id) .where( Event.host_id == host_id, Event.type == report_type, Event.received_at >= since, ) .limit(1) ) return row is not None def _ip_from_details(details: dict[str, Any] | None) -> str: if not details: return "" for key in ("source_ip", "ip_address", "ip"): val = details.get(key) if val is not None and str(val).strip() not in ("", "-"): return str(val).strip() return "" def _user_from_details(details: dict[str, Any] | None) -> str: if not details: return "" for key in ("user", "username"): val = details.get(key) if val is not None and str(val).strip(): return str(val).strip() return "" def _format_user_line_ssh(details: dict[str, Any] | None) -> str | None: user = _user_from_details(details) if not user: return None if not details: return f"👤 {user}" tty = str(details.get("tty") or details.get("pts") or "").strip() since = str(details.get("since") or details.get("session_since") or "").strip() ip = _ip_from_details(details) parts = [f"👤 {user}"] if tty: parts.append(f"| {tty}") if since: parts.append(f"| с {since}") if ip: parts.append(f"| 🌐 {ip}") return " ".join(parts) if len(parts) > 1 else f"👤 {user}" def _collect_active_users_ssh(events: list[Event]) -> list[str]: lines: list[str] = [] seen: set[str] = set() for ev in events: if ev.type != SESSION_LOGIND_NEW: continue details = ev.details if isinstance(ev.details, dict) else None line = _format_user_line_ssh(details) if line and line not in seen: lines.append(line) seen.add(line) if lines: return lines for ev in events: if ev.type != SSH_SUCCESS: continue details = ev.details if isinstance(ev.details, dict) else None user = _user_from_details(details) if not user: continue key = user.lower() if key in seen: continue seen.add(key) ip = _ip_from_details(details) line = f"👤 {user}" if ip: line += f" | 🌐 {ip}" lines.append(line) return lines def _collect_active_users_rdp(events: list[Event]) -> list[str]: users: list[str] = [] seen: set[str] = set() for ev in events: if ev.type != RDP_SUCCESS: continue details = ev.details if isinstance(ev.details, dict) else None user = _user_from_details(details) if not user: continue key = user.lower() if key in seen: continue seen.add(key) users.append(f"👤 {user}") return users def _aggregate_ssh(events: list[Event]) -> dict[str, Any]: ok = failed = sudo = 0 failed_ips: Counter[str] = Counter() for ev in events: if ev.type == SSH_SUCCESS: ok += 1 elif ev.type == SSH_FAILED: failed += 1 ip = _ip_from_details(ev.details if isinstance(ev.details, dict) else None) if ip: failed_ips[ip] += 1 elif ev.type == SSH_SUDO: sudo += 1 top_ips = [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)] active_users = _collect_active_users_ssh(events) return { "successful_ssh": ok, "failed_ssh": failed, "sudo_commands": sudo, "active_bans": sum(1 for e in events if e.type in SSH_BAN_TYPES), "top_failed_ips": top_ips, "active_users": active_users, } def _aggregate_rdp(events: list[Event]) -> dict[str, Any]: ok = failed = 0 failed_ips: Counter[str] = Counter() for ev in events: details = ev.details if isinstance(ev.details, dict) else None if ev.type == RDP_SUCCESS: ok += 1 elif ev.type == RDP_FAILED: failed += 1 ip = _ip_from_details(details) if ip: failed_ips[ip] += 1 active_users = _collect_active_users_rdp(events) unique = [] seen: set[str] = set() for line in active_users: u = line.replace("👤", "").strip().split("|")[0].strip() if u.lower() not in seen: seen.add(u.lower()) unique.append(u) return { "rdp_success": ok, "rdp_failed": failed, "active_bans": sum(1 for e in events if e.type in RDP_BAN_TYPES), "top_failed_ips": [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)], "active_users": active_users, "unique_users": unique, } def _build_report_body_ssh(host: Host, stats: dict[str, Any], when_local: datetime) -> str: enriched = enrich_stats_for_storage("ssh", stats) return build_report_body("ssh", host, enriched, when_local, sac_generated=True) def _build_report_body_rdp(host: Host, stats: dict[str, Any], when_local: datetime) -> str: enriched = enrich_stats_for_storage("windows", stats) return build_report_body("windows", host, enriched, when_local, sac_generated=True) def _details_from_body(body: str, stats: dict[str, Any]) -> dict[str, Any]: escaped = html.escape(body) # UI (Vue) допускает div; для единообразия — обёртка без br (Telegram читает через sanitize) report_html = f'