feat: HTML Telegram templates for RDP/SSH events (notif-30)

Agent-style messages from event details, parse_mode HTML, templates for
login/sudo/RDG and problems; unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-05-29 16:21:40 +10:00
parent ebb450be92
commit 1a70980920
5 changed files with 319 additions and 27 deletions
+29 -26
View File
@@ -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 для оповещений работает.",
"<b>SAC: тестовое сообщение</b>\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:
+206
View File
@@ -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"<b>{header}</b>\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"<b>{header}</b>\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"<b>{header}</b>\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"<b>🚨 SAC: {html_escape(event.title)}</b>\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"<b>{header}</b>\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 = "<b>🔥 SAC Problem</b>\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<b>{html_escape(problem.title)}</b>\n{html_escape(problem.summary)}"
if event is not None:
msg += f"\n\n<i>Триггер:</i> {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()
+81
View File
@@ -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<b>&") == "a&lt;b&gt;&amp;"
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 "<b>" 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
+2
View File
@@ -68,6 +68,8 @@ TELEGRAM_CHAT_ID=<chat>
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. Версии и доставка обновлений
+1 -1
View File
@@ -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 (отдельный эпик) |