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():