Files
security-alert-center/backend/app/services/telegram_templates.py
T
PapaTramp bc77f82307 feat: notify RD Gateway events in Telegram outside min_severity
Route rdg.connection.* through notify_rdg_connection and add disconnected type.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:14:37 +10:00

475 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HTML Telegram message templates (RDP/SSH style, notif-30)."""
from __future__ import annotations
import html
import re
from datetime import datetime
from typing import Any
from app.models import Event, Host, Problem
from app.services.daily_report_format import (
append_notification_source_html,
normalize_report_body,
resolve_product_label,
)
LOGON_TYPE_NAMES: dict[int, str] = {
2: "Интерактивный (консоль)",
3: "Сеть/RDP (Network)",
4: "Пакетный (Batch)",
5: "Сервис (Service)",
7: "Разблокировка (Unlock)",
8: "Сетевой с явными данными",
9: "Новые учетные данные",
10: "Удаленный интерактивный (RDP)",
}
# Telegram parse_mode=HTML: только теги из Bot API (без div/br/p и т.д.)
_TELEGRAM_BR = re.compile(r"<br\s*/?>", re.I)
_TELEGRAM_DROP_TAGS = re.compile(
r"</?(?:div|p|ul|ol|li|hr|h[1-6]|table|tr|td|th|thead|tbody|body|html|head)\b[^>]*>",
re.I,
)
_TELEGRAM_SPAN_OPEN = re.compile(r"<span\b(?![^>]*\btg-spoiler\b)[^>]*>", re.I)
_TELEGRAM_SPAN_CLOSE = re.compile(r"</span>", re.I)
def sanitize_telegram_html(text: str) -> str:
"""Приводит HTML отчёта (UI/агент) к тегам, допустимым в Telegram."""
out = _TELEGRAM_BR.sub("\n", text)
out = _TELEGRAM_DROP_TAGS.sub("", out)
out = _TELEGRAM_SPAN_OPEN.sub("", out)
out = _TELEGRAM_SPAN_CLOSE.sub("", out)
return out.strip()
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 _host_display_name_base(display_name: str | None) -> str:
if not display_name or not display_name.strip():
return ""
return display_name.strip().split("(", 1)[0].strip()
def _rdp_workstation_is_redundant(workstation: str, host: Host | None) -> bool:
"""Скрыть Workstation Name, если пусто/«-» или совпадает с hostname/display_name сервера."""
ws = (workstation or "").strip()
if not ws or ws in ("-", "N/A", "?"):
return True
if host is None:
return False
ws_key = ws.casefold()
if host.hostname and ws_key == host.hostname.strip().casefold():
return True
dn = (host.display_name or "").strip()
if dn and ws_key == dn.casefold():
return True
base = _host_display_name_base(host.display_name)
if base and ws_key == base.casefold():
return True
return False
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 _event_content_author(event: Event) -> str:
"""Кто сгенерировал содержимое события (ingest / отчёт)."""
details = _details_dict(event)
gb = details.get("generated_by")
if isinstance(gb, str) and gb.strip().lower() in ("agent", "sac"):
return gb.strip().lower()
stats = details.get("stats")
if isinstance(stats, dict) and stats.get("sac_generated"):
return "sac"
if event.type in ("report.daily.rdp", "report.daily.ssh") and details.get("report_body"):
return "agent"
return "agent"
def _notification_delivered_by(event: Event) -> str:
"""Кто доставил сообщение в Telegram (footer «📡 Оповещение»).
Шаблоны здесь используются только при отправке из SAC. Агент добавляет footer сам.
"""
details = _details_dict(event)
via = details.get("telegram_via")
if isinstance(via, str) and via.strip().lower() in ("agent", "sac"):
return via.strip().lower()
if _event_content_author(event) == "sac":
return "sac"
# ingest от агента, канал Telegram — SAC (exclusive / notify_lifecycle)
return "sac"
def _event_product_version(event: Event) -> tuple[str | None, str | None]:
details = _details_dict(event)
platform = None
if event.type == "report.daily.ssh":
platform = "ssh"
elif event.type == "report.daily.rdp":
platform = "windows"
elif event.host is not None:
if event.host.os_family == "linux":
platform = "ssh"
elif event.host.os_family == "windows":
platform = "windows"
product, version = resolve_product_label(event.host, platform)
stats = details.get("stats")
if isinstance(stats, dict):
av = stats.get("agent_version") or stats.get("product_version")
if av:
version = str(av).strip()
payload = event.payload if isinstance(event.payload, dict) else {}
source = payload.get("source")
if isinstance(source, dict):
if source.get("product"):
product = str(source["product"]).strip()
if source.get("product_version"):
version = str(source["product_version"]).strip()
return product, version
def _append_event_source(html_msg: str, event: Event) -> str:
delivered = _notification_delivered_by(event)
product, version = _event_product_version(event)
return append_notification_source_html(
html_msg,
generated_by=delivered,
product=product if delivered == "agent" else None,
product_version=version if delivered == "agent" else None,
)
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_raw = _detail(details, "workstation_name", "computer_name")
workstation = html_escape(workstation_raw)
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))
if not _rdp_workstation_is_redundant(workstation_raw, 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_rdp_shadow_html(event: Event) -> str:
details = _details_dict(event)
action = _detail(details, "shadow_action", default="")
if action == "control_stopped" or event.type.endswith(".stopped"):
header = "🎭 RDS SHADOW CONTROL — остановлено"
elif action == "control_permission" or event.type.endswith(".permission"):
header = "🎭 RDS SHADOW CONTROL — разрешение"
else:
header = "🎭 RDS SHADOW CONTROL — начато"
msg = f"<b>{header}</b>\n"
msg += _line("🏢", "Сервер", host_label(event.host))
msg += _line("👤", "Администратор", html_escape(_detail(details, "shadower_user", "user")))
msg += _line("🎯", "Сессия пользователя", html_escape(_detail(details, "target_user", default="-")))
sid = _detail(details, "session_id", "target_session_id", default="")
if sid != "-":
msg += _line("🔢", "Session ID", html_escape(sid))
msg += _line("🕐", "Время", format_time(event.occurred_at))
win_id = _detail(details, "event_id_windows", default="")
if win_id != "-":
msg += f"🔢 Event ID: {html_escape(win_id)} (RemoteConnectionManager)"
return msg.rstrip()
def format_winrm_session_html(event: Event) -> str:
details = _details_dict(event)
msg = "<b>⚠️ WinRM / Enter-PSSession — удалённая shell</b>\n"
msg += _line("🏢", "Сервер", host_label(event.host))
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
ip = _detail(details, "source_ip", "ip_address", "ip")
if ip != "-":
msg += _line("🌐", "IP источника", html_escape(ip))
uri = _detail(details, "resource_uri", default="")
if uri != "-":
msg += _line("🔗", "ResourceUri", html_escape(uri))
msg += _line("🕐", "Время", format_time(event.occurred_at))
win_id = _detail(details, "event_id_windows", default="")
if win_id != "-":
msg += f"🔢 Event ID: {html_escape(win_id)} (WinRM Operational)"
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()
_LIFECYCLE_HEADERS: dict[str, str] = {
"started": "✅ Агент запущен",
"stopped": "⚠️ Агент остановлен",
"settings_reloaded": "🔄 Настройки агента перечитаны",
}
_LIFECYCLE_TRIGGER_LABELS: dict[str, str] = {
"boot": "загрузка ОС / задача планировщика",
"deploy_recycle": "обновление скрипта (recycle)",
"settings_reload": "graceful restart (settings)",
"manual_recycle": "ручной recycle",
"shutdown": "остановка процесса",
}
def _format_lifecycle_body_html(body: str) -> str:
text = body.replace("\r\n", "\n").strip()
if not text:
return ""
parts: list[str] = []
for i, line in enumerate(text.split("\n")):
esc = html.escape(line)
if i == 0 and line.strip():
parts.append(f"<b>{esc.strip()}</b>")
else:
parts.append(esc)
return "\n".join(parts).strip()
def format_lifecycle_html(event: Event) -> str:
details = _details_dict(event)
notification_body = details.get("notification_body")
if isinstance(notification_body, str) and notification_body.strip():
return _format_lifecycle_body_html(notification_body)
lifecycle = _detail(details, "lifecycle", default="started").strip().lower()
trigger = _detail(details, "trigger", default="").strip().lower()
header = _LIFECYCLE_HEADERS.get(lifecycle, f"📋 Lifecycle: {html_escape(lifecycle)}")
msg = f"<b>{header}</b>\n"
msg += _line("🏢", "Хост", host_label(event.host))
product = ""
version = ""
payload = event.payload if isinstance(event.payload, dict) else {}
source = payload.get("source")
if isinstance(source, dict):
product = str(source.get("product") or "").strip()
version = str(source.get("product_version") or "").strip()
if product or version:
label = " ".join(x for x in (product, version) if x).strip()
if label:
msg += _line("📦", "Версия", html_escape(label))
if trigger and trigger != "-":
trigger_human = _LIFECYCLE_TRIGGER_LABELS.get(trigger, trigger)
msg += _line("🔁", "Причина", html_escape(trigger_human))
msg += _line("🕐", "Время", format_time(event.occurred_at))
if event.summary:
msg += f"\n{html_escape(event.summary)}"
return msg.rstrip()
def _format_event_body_html(event: Event) -> str:
if event.type == "agent.lifecycle":
return format_lifecycle_html(event)
if event.type in ("report.daily.ssh", "report.daily.rdp"):
details = _details_dict(event)
platform = "windows" if event.type == "report.daily.rdp" else "ssh"
body = _detail(details, "report_body", default="")
if body and body != "-":
body = normalize_report_body(body, event.host, platform)
parts: list[str] = []
for i, line in enumerate(body.split("\n")):
esc = html.escape(line)
if i == 0 and "ЕЖЕДНЕВНЫЙ ОТЧЕТ" in line:
parts.append(f"<b>{esc.strip()}</b>")
else:
parts.append(esc)
return "\n".join(parts)
report_html = details.get("report_html")
if isinstance(report_html, str) and report_html.strip():
text = sanitize_telegram_html(report_html.strip())
return re.sub(r"\n{3,}", "\n\n", text)
body = _detail(details, "report_body", default=event.summary)
if body != "-":
return html.escape(body)
return format_generic_event_html(event)
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)
if event.type.startswith("rdp.shadow."):
return format_rdp_shadow_html(event)
if event.type.startswith("winrm."):
return format_winrm_session_html(event)
return format_generic_event_html(event)
def format_event_telegram_html(event: Event) -> str:
return _append_event_source(_format_event_body_html(event), event)
def _format_rdg_html(event: Event) -> str:
details = _details_dict(event)
if event.type.endswith(".success"):
header = "✅ RD Gateway: подключение"
elif event.type.endswith(".disconnected"):
header = "️ RD Gateway: отключение"
else:
header = "❌ 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",
"rdp.shadow.control.started",
"rdp.shadow.control.stopped",
"rdp.shadow.control.permission",
"winrm.session.started",
):
msg += "\n" + _format_event_body_html(event)
return append_notification_source_html(msg, generated_by="sac")