diff --git a/backend/app/services/telegram_notify.py b/backend/app/services/telegram_notify.py index 59f9b34..92f874d 100644 --- a/backend/app/services/telegram_notify.py +++ b/backend/app/services/telegram_notify.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from app.models import Event, Problem from app.services.notification_severity import severity_meets_minimum from app.services.notification_settings import TelegramConfig, get_effective_telegram_config +from app.services.telegram_templates import format_event_telegram_html, format_problem_telegram_html logger = logging.getLogger(__name__) @@ -20,7 +21,13 @@ class TelegramSendError(Exception): self.status_code = status_code -def send_telegram_text(message: str, *, config: TelegramConfig | None = None, force: bool = False) -> None: +def send_telegram_text( + message: str, + *, + config: TelegramConfig | None = None, + force: bool = False, + parse_mode: str | None = "HTML", +) -> None: cfg = config or get_effective_telegram_config() if not force and not cfg.active: return @@ -30,14 +37,23 @@ def send_telegram_text(message: str, *, config: TelegramConfig | None = None, fo return url = f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage" - payload = {"chat_id": cfg.chat_id, "text": message, "disable_web_page_preview": True} + payload: dict[str, str | bool] = { + "chat_id": cfg.chat_id, + "text": message, + "disable_web_page_preview": True, + } + if parse_mode: + payload["parse_mode"] = parse_mode try: with httpx.Client(timeout=8.0) as client: response = client.post(url, json=payload) response.raise_for_status() except httpx.HTTPStatusError as exc: detail = exc.response.text[:200] if exc.response is not None else str(exc) - raise TelegramSendError(f"Telegram API HTTP {exc.response.status_code}: {detail}", status_code=exc.response.status_code) from exc + raise TelegramSendError( + f"Telegram API HTTP {exc.response.status_code}: {detail}", + status_code=exc.response.status_code, + ) from exc except Exception as exc: logger.exception("telegram send failed") raise TelegramSendError(str(exc)) from exc @@ -50,7 +66,7 @@ def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None: if not cfg.configured: raise TelegramNotConfiguredError("Не задан bot token или chat_id") send_telegram_text( - "✅ SAC: тестовое сообщение\nКанал Telegram для оповещений работает.", + "✅ SAC: тестовое сообщение\nКанал Telegram для оповещений работает.", config=cfg, force=True, ) @@ -62,39 +78,26 @@ def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: return if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity): return - host = event.host.hostname if event.host else "unknown" - message = ( - f"🚨 SAC событие\n" - f"Host: {host}\n" - f"Severity: {event.severity}\n" - f"Type: {event.type}\n" - f"Title: {event.title}\n" - f"Summary: {event.summary}" - ) + message = format_event_telegram_html(event) try: send_telegram_text(message, config=cfg) except TelegramSendError: logger.exception("notify_event telegram failed event_id=%s", event.event_id) -def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: +def notify_problem( + problem: Problem, + event: Event | None = None, + *, + db: Session | None = None, + apply_policy_gate: bool = True, +) -> None: cfg = get_effective_telegram_config(db) if not cfg.active: return if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity): return - host = problem.host.hostname if problem.host else "unknown" - related = "" - if event is not None: - related = f"\nEvent: {event.type} ({event.severity})" - message = ( - f"🔥 SAC Problem opened\n" - f"Host: {host}\n" - f"Severity: {problem.severity}\n" - f"Rule: {problem.rule_id or '-'}\n" - f"Title: {problem.title}\n" - f"Summary: {problem.summary}{related}" - ) + message = format_problem_telegram_html(problem, event) try: send_telegram_text(message, config=cfg) except TelegramSendError: diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py new file mode 100644 index 0000000..567cd7b --- /dev/null +++ b/backend/app/services/telegram_templates.py @@ -0,0 +1,206 @@ +"""HTML Telegram message templates (RDP/SSH style, notif-30).""" + +from __future__ import annotations + +import html +from datetime import datetime +from typing import Any + +from app.models import Event, Host, Problem + +LOGON_TYPE_NAMES: dict[int, str] = { + 2: "Интерактивный (консоль)", + 3: "Сеть/RDP (Network)", + 4: "Пакетный (Batch)", + 5: "Сервис (Service)", + 7: "Разблокировка (Unlock)", + 8: "Сетевой с явными данными", + 9: "Новые учетные данные", + 10: "Удаленный интерактивный (RDP)", +} + + +def html_escape(value: Any) -> str: + if value is None: + return "" + return html.escape(str(value), quote=False) + + +def _details_dict(event: Event | None) -> dict[str, Any]: + if event is None or not isinstance(event.details, dict): + return {} + return event.details + + +def _detail(details: dict[str, Any], *keys: str, default: str = "-") -> str: + for key in keys: + val = details.get(key) + if val is not None and str(val).strip() not in ("", "-"): + return str(val).strip() + return default + + +def logon_type_label(logon_type: Any) -> str: + try: + code = int(logon_type) + except (TypeError, ValueError): + return html_escape(logon_type) if logon_type not in (None, "") else "-" + name = LOGON_TYPE_NAMES.get(code, f"Тип {code}") + return f"{html_escape(name)} ({code})" + + +def host_label(host: Host | None, *, fallback: str = "unknown") -> str: + if host is None: + return html_escape(fallback) + if host.display_name and host.display_name.strip(): + return html_escape(host.display_name.strip()) + return html_escape(host.hostname) + + +def format_time(dt: datetime | None) -> str: + if dt is None: + return "-" + try: + return html_escape(dt.strftime("%d.%m.%Y %H:%M:%S")) + except Exception: + return html_escape(str(dt)) + + +def _line(emoji: str, label: str, value: str) -> str: + return f"{emoji} {label}: {value}\n" + + +def format_rdp_login_html(event: Event) -> str: + details = _details_dict(event) + is_success = event.type == "rdp.login.success" + header = "✅ УСПЕШНЫЙ ВХОД" if is_success else "❌ НЕУДАЧНАЯ ПОПЫТКА" + win_id = _detail(details, "event_id_windows", default="") + if not win_id and event.type == "rdp.login.failed": + win_id = "4625" + elif not win_id and is_success: + win_id = "4624" + + user = html_escape(_detail(details, "user", "username")) + ip = html_escape(_detail(details, "ip_address", "source_ip", "ip")) + workstation = html_escape(_detail(details, "workstation_name", "computer_name")) + process = html_escape(_detail(details, "process_name", "process", default="")) + logon = logon_type_label(details.get("logon_type")) + + msg = f"{header}\n" + msg += _line("👤", "Пользователь", user) + msg += _line("🏢", "Сервер", host_label(event.host)) + msg += _line("🖥️", "Рабочая станция", workstation) + msg += _line("🌐", "IP адрес", ip) + if process and process != "-": + msg += _line("⚙️", "Процесс", process) + msg += _line("🔑", "Тип входа", logon) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + if win_id: + msg += f"🔢 Event ID: {html_escape(win_id)}" + return msg.rstrip() + + +def format_ssh_auth_html(event: Event) -> str: + details = _details_dict(event) + is_success = event.type == "ssh.login.success" + header = "✅ SSH: успешный вход" if is_success else "❌ SSH: неудачная попытка" + + user = html_escape(_detail(details, "user", "username")) + ip = html_escape(_detail(details, "source_ip", "ip_address", "ip")) + port = html_escape(_detail(details, "port", default="")) + attempt = _detail(details, "attempt_number", default="") + max_attempts = _detail(details, "max_attempts", default="") + + msg = f"{header}\n" + msg += _line("👤", "Пользователь", user) + msg += _line("🏢", "Хост", host_label(event.host)) + msg += _line("🌐", "IP", ip) + if port and port != "-": + msg += _line("🔌", "Порт", port) + if attempt != "-" and max_attempts != "-": + msg += _line("🔢", "Попытка", f"{html_escape(attempt)} / {html_escape(max_attempts)}") + msg += _line("🕐", "Время", format_time(event.occurred_at)) + msg += f"📋 {html_escape(event.type)}" + return msg.rstrip() + + +def format_sudo_html(event: Event) -> str: + details = _details_dict(event) + risk = _detail(details, "risk_level", default="") + header = "⚠️ SUDO" + if risk and risk.lower() == "critical": + header = "🔥 SUDO (critical)" + + msg = f"{header}\n" + msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username"))) + msg += _line("🏢", "Хост", host_label(event.host)) + msg += _line("▶️", "Команда", html_escape(_detail(details, "command", default=event.summary[:200]))) + run_as = _detail(details, "run_as", default="") + if run_as != "-": + msg += _line("👥", "От имени", html_escape(run_as)) + pwd = _detail(details, "pwd", default="") + if pwd != "-": + msg += _line("📁", "Каталог", html_escape(pwd)) + if risk != "-": + msg += _line("⚡", "Risk", html_escape(risk)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + return msg.rstrip() + + +def format_generic_event_html(event: Event) -> str: + sev = event.severity.upper() + msg = f"🚨 SAC: {html_escape(event.title)}\n" + msg += _line("🏢", "Хост", host_label(event.host)) + msg += _line("📋", "Тип", html_escape(event.type)) + msg += _line("⚡", "Severity", html_escape(sev)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + if event.summary: + msg += f"\n{html_escape(event.summary)}" + return msg.rstrip() + + +def format_event_telegram_html(event: Event) -> str: + if event.type in ("rdp.login.success", "rdp.login.failed"): + return format_rdp_login_html(event) + if event.type in ("ssh.login.success", "ssh.login.failed"): + return format_ssh_auth_html(event) + if event.type == "privilege.sudo.command": + return format_sudo_html(event) + if event.type.startswith("rdg."): + return _format_rdg_html(event) + return format_generic_event_html(event) + + +def _format_rdg_html(event: Event) -> str: + details = _details_dict(event) + ok = event.type.endswith(".success") + header = "✅ RD Gateway: подключение" if ok else "❌ RD Gateway: ошибка" + msg = f"{header}\n" + msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username"))) + msg += _line("🏢", "Хост", host_label(event.host)) + msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address"))) + msg += _line("🏠", "Внутренний IP", html_escape(_detail(details, "internal_ip", default="-"))) + err = _detail(details, "gateway_error_code", "error_code", default="") + if err != "-": + msg += _line("⚠️", "Код ошибки", html_escape(err)) + msg += _line("🕐", "Время", format_time(event.occurred_at)) + win_id = _detail(details, "event_id_windows", default="") + if win_id != "-": + msg += f"\n🔢 Event ID: {html_escape(win_id)}" + return msg.rstrip() + + +def format_problem_telegram_html(problem: Problem, event: Event | None = None) -> str: + msg = "🔥 SAC Problem\n" + msg += _line("🏢", "Хост", host_label(problem.host)) + msg += _line("📏", "Severity", html_escape(problem.severity.upper())) + if problem.rule_id: + msg += _line("📐", "Правило", html_escape(problem.rule_id)) + msg += _line("🔢", "Событий", html_escape(problem.event_count)) + msg += _line("🕐", "Last seen", format_time(problem.last_seen_at)) + msg += f"\n{html_escape(problem.title)}\n{html_escape(problem.summary)}" + if event is not None: + msg += f"\n\nТриггер: {html_escape(event.type)} ({html_escape(event.severity)})" + if event.type in ("rdp.login.success", "rdp.login.failed", "ssh.login.success", "ssh.login.failed"): + msg += "\n" + format_event_telegram_html(event) + return msg.rstrip() diff --git a/backend/tests/test_telegram_templates.py b/backend/tests/test_telegram_templates.py new file mode 100644 index 0000000..e4120f9 --- /dev/null +++ b/backend/tests/test_telegram_templates.py @@ -0,0 +1,81 @@ +"""Telegram HTML templates (notif-30).""" + +from datetime import datetime, timezone + +from app.models import Event, Host +from app.services.telegram_templates import format_event_telegram_html, format_rdp_login_html, html_escape + + +def test_html_escape_special_chars(): + assert html_escape("a&") == "a<b>&" + + +def test_rdp_failed_template_includes_user_ip_logon(): + host = Host(hostname="K6A-DC3", display_name="UNMS Kalina", os_family="windows") + event = Event( + event_id="00000000-0000-4000-8000-000000000501", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 10, 30, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="RDP login failed", + summary="4625 user from 10.0.0.5", + details={ + "user": "DOMAIN\\admin", + "ip_address": "10.0.0.5", + "logon_type": 10, + "event_id_windows": 4625, + "workstation_name": "CLIENT01", + }, + payload={}, + ) + text = format_rdp_login_html(event) + assert "НЕУДАЧНАЯ" in text + assert "DOMAIN\\admin" in text or "DOMAIN" in text + assert "10.0.0.5" in text + assert "Удаленный интерактивный" in text + assert "(10)" in text + assert "UNMS Kalina" in text + assert "CLIENT01" in text + assert "4625" in text + assert "" in text + + +def test_rdp_success_template(): + event = Event( + event_id="00000000-0000-4000-8000-000000000502", + host_id=1, + occurred_at=datetime(2026, 5, 29, 11, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.success", + severity="info", + title="ok", + summary="ok", + details={"user": "u1", "ip_address": "1.2.3.4", "logon_type": 3}, + payload={}, + ) + text = format_event_telegram_html(event) + assert "УСПЕШНЫЙ" in text + assert "Сеть/RDP" in text + + +def test_ssh_failed_template(): + event = Event( + event_id="00000000-0000-4000-8000-000000000503", + host_id=1, + occurred_at=datetime(2026, 5, 29, 12, 0, tzinfo=timezone.utc), + category="auth", + type="ssh.login.failed", + severity="warning", + title="ssh fail", + summary="fail", + details={"user": "root", "source_ip": "10.10.36.9", "port": 22, "attempt_number": 3, "max_attempts": 5}, + payload={}, + ) + text = format_event_telegram_html(event) + assert "SSH" in text + assert "root" in text + assert "10.10.36.9" in text + assert "3 / 5" in text diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 25956db..133803b 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -68,6 +68,8 @@ TELEGRAM_CHAT_ID= Events и problems используют **одно** глобальное правило (`notification_policy` в БД, миграция `007`). +Telegram из SAC отправляется с **`parse_mode=HTML`** (как у RDP-login-monitor): для `rdp.login.*` — пользователь, IP, `logon_type`, рабочая станция, Event ID Windows; для `ssh.login.*` и `privilege.sudo.command` — поля из `details`. + UI **Настройки** (`/settings`) — правило + параметры каналов (JWT admin). После деплоя: `alembic upgrade head`. ### 1.4. Версии и доставка обновлений diff --git a/docs/todo-2026-05-29.md b/docs/todo-2026-05-29.md index 7400ed2..9538fde 100644 --- a/docs/todo-2026-05-29.md +++ b/docs/todo-2026-05-29.md @@ -52,7 +52,7 @@ | ID | Задача | |----|--------| -| `notif-30` | Шаблон TG ближе к агенту (HTML, поля user/ip/logon_type из `details`) | +| `notif-30` | Шаблон TG ближе к агенту (HTML, поля user/ip/logon_type из `details`) | ✅ `telegram_templates.py`, `parse_mode=HTML` | | `notif-31` | Cooldown/dedup на уровне SAC (F-NOT-03) — не дублировать problem при burst | | `notif-32` | F-NOT-05: daily report из SAC при exclusive (отдельный эпик) |