feat: show notification source (agent vs SAC) in alerts and reports
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,8 +17,92 @@ SSH_BAN_TYPES = frozenset({"ssh.ip.banned"})
|
|||||||
_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:")
|
_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:")
|
||||||
_ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ")
|
_ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ")
|
||||||
_AGENT_VERSION_LINE_RE = re.compile(r"(?m)^Agent version\s+", re.IGNORECASE)
|
_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 = "ЕЖЕДНЕВНЫЙ ОТЧЕТ"
|
_DAILY_REPORT_TITLE_MARKER = "ЕЖЕДНЕВНЫЙ ОТЧЕТ"
|
||||||
_SECTION_HEADER_RE = re.compile(r"^[📈🧾👥🖥️🕐]")
|
_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:
|
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:
|
def _fix_active_users_header_count(header_line: str, user_count: int) -> str:
|
||||||
stripped = header_line.rstrip()
|
stripped = header_line.rstrip()
|
||||||
if _ACTIVE_USERS_COUNT_RE.match(stripped):
|
if _ACTIVE_USERS_HEADER_RE.match(stripped.strip()):
|
||||||
return re.sub(
|
return re.sub(
|
||||||
r"(\👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ\s*\()[^)]+(\))",
|
r"(\👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ\s*\()[^)]+(\))",
|
||||||
rf"\g<1>{user_count}\2",
|
rf"\g<1>{user_count}\2",
|
||||||
@@ -143,7 +227,7 @@ def normalize_active_users_in_body(body: str) -> str:
|
|||||||
stripped = cur.strip()
|
stripped = cur.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
break
|
break
|
||||||
if stripped.startswith("Источник:"):
|
if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX):
|
||||||
break
|
break
|
||||||
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
|
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
|
||||||
break
|
break
|
||||||
@@ -262,9 +346,18 @@ def build_report_body(
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.extend(_section_active_users(active_users, sac_generated=sac_generated))
|
lines.extend(_section_active_users(active_users, sac_generated=sac_generated))
|
||||||
|
|
||||||
if sac_generated:
|
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("")
|
||||||
lines.append("Источник: Security Alert Center (агрегация ingest за 24 ч).")
|
lines.append(
|
||||||
|
format_notification_source_plain(
|
||||||
|
generated_by=generated_by,
|
||||||
|
product=product,
|
||||||
|
product_version=version,
|
||||||
|
)
|
||||||
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -307,6 +400,21 @@ def normalize_daily_report_details(
|
|||||||
return details
|
return details
|
||||||
|
|
||||||
normalized_body = normalize_report_body(body, host, platform)
|
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 = dict(details)
|
||||||
out["report_body"] = normalized_body
|
out["report_body"] = normalized_body
|
||||||
out["report_html"] = body_to_report_html(normalized_body)
|
out["report_html"] = body_to_report_html(normalized_body)
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None:
|
|||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise TelegramNotConfiguredError("Не задан bot token или chat_id")
|
raise TelegramNotConfiguredError("Не задан bot token или chat_id")
|
||||||
send_telegram_text(
|
send_telegram_text(
|
||||||
"✅ <b>SAC: тестовое сообщение</b>\nКанал Telegram для оповещений работает.",
|
"✅ <b>SAC: тестовое сообщение</b>\nКанал Telegram для оповещений работает.\n"
|
||||||
|
"📡 Оповещение: SAC (Security Alert Center)",
|
||||||
config=cfg,
|
config=cfg,
|
||||||
force=True,
|
force=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.models import Event, Host, Problem
|
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] = {
|
LOGON_TYPE_NAMES: dict[int, str] = {
|
||||||
2: "Интерактивный (консоль)",
|
2: "Интерактивный (консоль)",
|
||||||
@@ -91,6 +95,57 @@ def _line(emoji: str, label: str, value: str) -> str:
|
|||||||
return f"{emoji} {label}: {value}\n"
|
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:
|
def format_rdp_login_html(event: Event) -> str:
|
||||||
details = _details_dict(event)
|
details = _details_dict(event)
|
||||||
is_success = event.type == "rdp.login.success"
|
is_success = event.type == "rdp.login.success"
|
||||||
@@ -222,7 +277,8 @@ def format_generic_event_html(event: Event) -> str:
|
|||||||
return msg.rstrip()
|
return msg.rstrip()
|
||||||
|
|
||||||
|
|
||||||
def format_daily_report_html(event: Event) -> str:
|
def _format_event_body_html(event: Event) -> str:
|
||||||
|
if event.type in ("report.daily.ssh", "report.daily.rdp"):
|
||||||
details = _details_dict(event)
|
details = _details_dict(event)
|
||||||
platform = "windows" if event.type == "report.daily.rdp" else "ssh"
|
platform = "windows" if event.type == "report.daily.rdp" else "ssh"
|
||||||
body = _detail(details, "report_body", default="")
|
body = _detail(details, "report_body", default="")
|
||||||
@@ -236,7 +292,6 @@ def format_daily_report_html(event: Event) -> str:
|
|||||||
else:
|
else:
|
||||||
parts.append(esc)
|
parts.append(esc)
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
report_html = details.get("report_html")
|
report_html = details.get("report_html")
|
||||||
if isinstance(report_html, str) and report_html.strip():
|
if isinstance(report_html, str) and report_html.strip():
|
||||||
text = sanitize_telegram_html(report_html.strip())
|
text = sanitize_telegram_html(report_html.strip())
|
||||||
@@ -245,11 +300,6 @@ def format_daily_report_html(event: Event) -> str:
|
|||||||
if body != "-":
|
if body != "-":
|
||||||
return html.escape(body)
|
return html.escape(body)
|
||||||
return format_generic_event_html(event)
|
return format_generic_event_html(event)
|
||||||
|
|
||||||
|
|
||||||
def format_event_telegram_html(event: Event) -> str:
|
|
||||||
if event.type in ("report.daily.ssh", "report.daily.rdp"):
|
|
||||||
return format_daily_report_html(event)
|
|
||||||
if event.type in ("rdp.login.success", "rdp.login.failed"):
|
if event.type in ("rdp.login.success", "rdp.login.failed"):
|
||||||
return format_rdp_login_html(event)
|
return format_rdp_login_html(event)
|
||||||
if event.type in ("ssh.login.success", "ssh.login.failed"):
|
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)
|
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:
|
def _format_rdg_html(event: Event) -> str:
|
||||||
details = _details_dict(event)
|
details = _details_dict(event)
|
||||||
ok = event.type.endswith(".success")
|
ok = event.type.endswith(".success")
|
||||||
@@ -305,5 +359,5 @@ def format_problem_telegram_html(problem: Problem, event: Event | None = None) -
|
|||||||
"rdp.shadow.control.permission",
|
"rdp.shadow.control.permission",
|
||||||
"winrm.session.started",
|
"winrm.session.started",
|
||||||
):
|
):
|
||||||
msg += "\n" + format_event_telegram_html(event)
|
msg += "\n" + _format_event_body_html(event)
|
||||||
return msg.rstrip()
|
return append_notification_source_html(msg, generated_by="sac")
|
||||||
|
|||||||
@@ -247,7 +247,9 @@ def test_daily_report_telegram_html_uses_report_html(db_session):
|
|||||||
details={"report_html": "<b>📊 OK</b><br>line"},
|
details={"report_html": "<b>📊 OK</b><br>line"},
|
||||||
payload={},
|
payload={},
|
||||||
)
|
)
|
||||||
assert format_event_telegram_html(event) == "<b>📊 OK</b>\nline"
|
assert format_event_telegram_html(event) == (
|
||||||
|
"<b>📊 OK</b>\nline\n📡 Оповещение: агент (ssh-monitor)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_telegram_html_strips_div_and_br():
|
def test_sanitize_telegram_html_strips_div_and_br():
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from app.models import Host
|
from app.models import Host
|
||||||
from app.services.daily_report_format import (
|
from app.services.daily_report_format import (
|
||||||
|
append_notification_source_plain,
|
||||||
build_report_body,
|
build_report_body,
|
||||||
enrich_stats_for_storage,
|
enrich_stats_for_storage,
|
||||||
|
format_notification_source_plain,
|
||||||
normalize_active_users_list,
|
normalize_active_users_list,
|
||||||
normalize_report_body,
|
normalize_report_body,
|
||||||
)
|
)
|
||||||
@@ -74,6 +76,28 @@ def test_build_report_body_windows_layout():
|
|||||||
assert len(lines) == 2
|
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():
|
def test_normalize_active_users_list_splits_combined_line():
|
||||||
users = normalize_active_users_list(["👤 k.khodasevich 👤 papatramp"])
|
users = normalize_active_users_list(["👤 k.khodasevich 👤 papatramp"])
|
||||||
assert len(users) == 2
|
assert len(users) == 2
|
||||||
|
|||||||
@@ -65,6 +65,26 @@ def test_rdp_success_template():
|
|||||||
text = format_event_telegram_html(event)
|
text = format_event_telegram_html(event)
|
||||||
assert "УСПЕШНЫЙ" in text
|
assert "УСПЕШНЫЙ" in text
|
||||||
assert "Сеть/RDP" 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():
|
def test_rdp_shadow_control_template():
|
||||||
|
|||||||
Reference in New Issue
Block a user