diff --git a/backend/app/services/daily_report_format.py b/backend/app/services/daily_report_format.py index 37b3928..72d23a1 100644 --- a/backend/app/services/daily_report_format.py +++ b/backend/app/services/daily_report_format.py @@ -17,8 +17,92 @@ SSH_BAN_TYPES = frozenset({"ssh.ip.banned"}) _SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:") _ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ") _AGENT_VERSION_LINE_RE = re.compile(r"(?m)^Agent version\s+", re.IGNORECASE) +_NOTIFICATION_SOURCE_RE = re.compile(r"(?m)^📡\s*Оповещение:\s*") +_LEGACY_SAC_SOURCE_RE = re.compile( + r"(?m)^Источник:\s*Security Alert Center\b.*$", + re.IGNORECASE, +) _DAILY_REPORT_TITLE_MARKER = "ЕЖЕДНЕВНЫЙ ОТЧЕТ" _SECTION_HEADER_RE = re.compile(r"^[📈🧾👥🖥️🕐]") +NOTIFICATION_SOURCE_PREFIX = "📡 Оповещение:" + + +def format_notification_source_plain( + *, + generated_by: str, + product: str | None = None, + product_version: str | None = None, +) -> str: + gb = (generated_by or "agent").strip().lower() + if gb == "sac": + return f"{NOTIFICATION_SOURCE_PREFIX} SAC (Security Alert Center)" + label = (product or "агент").strip() + version = (product_version or "").strip() + if version: + return f"{NOTIFICATION_SOURCE_PREFIX} агент ({label} {version})" + return f"{NOTIFICATION_SOURCE_PREFIX} агент ({label})" + + +def has_notification_source_line(text: str) -> bool: + return bool(_NOTIFICATION_SOURCE_RE.search(text or "")) + + +def strip_legacy_sac_source_line(text: str) -> str: + return _LEGACY_SAC_SOURCE_RE.sub("", text or "").strip() + + +def append_notification_source_plain( + body: str, + *, + generated_by: str, + product: str | None = None, + product_version: str | None = None, +) -> str: + text = strip_legacy_sac_source_line(body or "") + if has_notification_source_line(text): + return text + line = format_notification_source_plain( + generated_by=generated_by, + product=product, + product_version=product_version, + ) + if text: + return f"{text.rstrip()}\n\n{line}" + return line + + +def append_notification_source_html( + html_msg: str, + *, + generated_by: str, + product: str | None = None, + product_version: str | None = None, +) -> str: + plain_line = format_notification_source_plain( + generated_by=generated_by, + product=product, + product_version=product_version, + ) + if NOTIFICATION_SOURCE_PREFIX in (html_msg or ""): + return (html_msg or "").rstrip() + line = html.escape(plain_line) + msg = (html_msg or "").rstrip() + if msg: + return f"{msg}\n{line}" + return line + + +def resolve_product_label(host: Host | None, platform: Platform | None = None) -> tuple[str | None, str | None]: + if host is None: + return None, None + product = (host.product or "").strip() + if not product: + if platform == "ssh": + product = "ssh-monitor" + elif platform == "windows": + product = "rdp-login-monitor" + version = (host.product_version or "").strip() or None + return (product or None), version def host_server_line(host: Host | None) -> str: @@ -101,7 +185,7 @@ def ensure_agent_version_line(body: str, version: str | None) -> str: def _fix_active_users_header_count(header_line: str, user_count: int) -> str: stripped = header_line.rstrip() - if _ACTIVE_USERS_COUNT_RE.match(stripped): + if _ACTIVE_USERS_HEADER_RE.match(stripped.strip()): return re.sub( r"(\👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ\s*\()[^)]+(\))", rf"\g<1>{user_count}\2", @@ -143,7 +227,7 @@ def normalize_active_users_in_body(body: str) -> str: stripped = cur.strip() if not stripped: break - if stripped.startswith("Источник:"): + if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX): break if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"): break @@ -262,9 +346,18 @@ def build_report_body( lines.append("") lines.extend(_section_active_users(active_users, sac_generated=sac_generated)) - if sac_generated: - lines.append("") - lines.append("Источник: Security Alert Center (агрегация ingest за 24 ч).") + generated_by = "sac" if sac_generated else "agent" + product, version = resolve_product_label(host, platform) + if not sac_generated and agent_version: + version = agent_version + lines.append("") + lines.append( + format_notification_source_plain( + generated_by=generated_by, + product=product, + product_version=version, + ) + ) return "\n".join(lines) @@ -307,6 +400,21 @@ def normalize_daily_report_details( return details normalized_body = normalize_report_body(body, host, platform) + gb = str(details.get("generated_by") or "agent").strip().lower() + if gb not in ("agent", "sac"): + gb = "agent" + product, version = resolve_product_label(host, platform) + stats_raw = details.get("stats") + if isinstance(stats_raw, dict) and gb == "agent": + av = stats_raw.get("agent_version") or stats_raw.get("product_version") + if av: + version = str(av).strip() + normalized_body = append_notification_source_plain( + normalized_body, + generated_by=gb, + product=product, + product_version=version, + ) out = dict(details) out["report_body"] = normalized_body out["report_html"] = body_to_report_html(normalized_body) diff --git a/backend/app/services/telegram_notify.py b/backend/app/services/telegram_notify.py index 92f874d..9f0f13f 100644 --- a/backend/app/services/telegram_notify.py +++ b/backend/app/services/telegram_notify.py @@ -66,7 +66,8 @@ 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 для оповещений работает.\n" + "📡 Оповещение: SAC (Security Alert Center)", config=cfg, force=True, ) diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py index 9c64648..92604a6 100644 --- a/backend/app/services/telegram_templates.py +++ b/backend/app/services/telegram_templates.py @@ -8,7 +8,11 @@ from datetime import datetime from typing import Any from app.models import Event, Host, Problem -from app.services.daily_report_format import normalize_report_body +from app.services.daily_report_format import ( + append_notification_source_html, + normalize_report_body, + resolve_product_label, +) LOGON_TYPE_NAMES: dict[int, str] = { 2: "Интерактивный (консоль)", @@ -91,6 +95,57 @@ def _line(emoji: str, label: str, value: str) -> str: return f"{emoji} {label}: {value}\n" +def _event_generated_by(event: Event) -> str: + 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 _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: + product, version = _event_product_version(event) + return append_notification_source_html( + html_msg, + generated_by=_event_generated_by(event), + product=product, + product_version=version, + ) + + def format_rdp_login_html(event: Event) -> str: details = _details_dict(event) is_success = event.type == "rdp.login.success" @@ -222,34 +277,29 @@ def format_generic_event_html(event: Event) -> str: return msg.rstrip() -def format_daily_report_html(event: Event) -> str: - 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"{esc.strip()}") - 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) - - -def format_event_telegram_html(event: Event) -> str: +def _format_event_body_html(event: Event) -> str: if event.type in ("report.daily.ssh", "report.daily.rdp"): - return format_daily_report_html(event) + 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"{esc.strip()}") + 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"): @@ -265,6 +315,10 @@ def format_event_telegram_html(event: Event) -> str: 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) ok = event.type.endswith(".success") @@ -305,5 +359,5 @@ def format_problem_telegram_html(problem: Problem, event: Event | None = None) - "rdp.shadow.control.permission", "winrm.session.started", ): - msg += "\n" + format_event_telegram_html(event) - return msg.rstrip() + msg += "\n" + _format_event_body_html(event) + return append_notification_source_html(msg, generated_by="sac") diff --git a/backend/tests/test_daily_report.py b/backend/tests/test_daily_report.py index 0fdd85e..bbfd75e 100644 --- a/backend/tests/test_daily_report.py +++ b/backend/tests/test_daily_report.py @@ -247,7 +247,9 @@ def test_daily_report_telegram_html_uses_report_html(db_session): details={"report_html": "📊 OK
line"}, payload={}, ) - assert format_event_telegram_html(event) == "📊 OK\nline" + assert format_event_telegram_html(event) == ( + "📊 OK\nline\n📡 Оповещение: агент (ssh-monitor)" + ) def test_sanitize_telegram_html_strips_div_and_br(): diff --git a/backend/tests/test_daily_report_format.py b/backend/tests/test_daily_report_format.py index 4822c95..5ef3c45 100644 --- a/backend/tests/test_daily_report_format.py +++ b/backend/tests/test_daily_report_format.py @@ -4,8 +4,10 @@ from datetime import datetime, timezone from app.models import Host from app.services.daily_report_format import ( + append_notification_source_plain, build_report_body, enrich_stats_for_storage, + format_notification_source_plain, normalize_active_users_list, normalize_report_body, ) @@ -74,6 +76,28 @@ def test_build_report_body_windows_layout(): assert len(lines) == 2 +def test_build_report_body_includes_notification_source(): + host = Host(hostname="h1", display_name="H1", os_family="linux", product="ssh-monitor") + when = datetime(2026, 5, 29, 9, 0, 5, tzinfo=timezone.utc) + stats = enrich_stats_for_storage("ssh", {"successful_ssh": 0, "failed_ssh": 0, "sudo_commands": 0, "active_bans": 0}) + sac_body = build_report_body("ssh", host, stats, when, sac_generated=True) + assert "📡 Оповещение: SAC (Security Alert Center)" in sac_body + agent_body = build_report_body( + "ssh", + host, + {**stats, "agent_version": "1.2.11-SAC"}, + when, + sac_generated=False, + ) + assert "📡 Оповещение: агент (ssh-monitor 1.2.11-SAC)" in agent_body + + +def test_append_notification_source_plain_dedupes(): + line = format_notification_source_plain(generated_by="sac") + body = append_notification_source_plain(f"title\n\n{line}", generated_by="sac") + assert body.count("📡 Оповещение:") == 1 + + def test_normalize_active_users_list_splits_combined_line(): users = normalize_active_users_list(["👤 k.khodasevich 👤 papatramp"]) assert len(users) == 2 diff --git a/backend/tests/test_telegram_templates.py b/backend/tests/test_telegram_templates.py index 297f628..1bfbb31 100644 --- a/backend/tests/test_telegram_templates.py +++ b/backend/tests/test_telegram_templates.py @@ -65,6 +65,26 @@ def test_rdp_success_template(): text = format_event_telegram_html(event) assert "УСПЕШНЫЙ" in text assert "Сеть/RDP" in text + assert "📡 Оповещение: агент" in text + + +def test_event_telegram_includes_sac_source_for_sac_daily_report(): + host = Host(hostname="h1", display_name="H1", os_family="linux", product="ssh-monitor") + event = Event( + event_id="00000000-0000-4000-8000-000000000504", + host_id=1, + host=host, + occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc), + category="report", + type="report.daily.ssh", + severity="info", + title="Отчёт", + summary="short", + details={"generated_by": "sac", "report_body": "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА\nline"}, + payload={}, + ) + text = format_event_telegram_html(event) + assert "📡 Оповещение: SAC (Security Alert Center)" in text def test_rdp_shadow_control_template():