feat: unify SSH and Windows daily report layout in SAC and UI

Agent-style report body for SAC aggregation; shared stats cards; RDP ban note in docs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 17:03:29 +10:00
parent 11195ae64f
commit 7ebbb9a9d1
8 changed files with 469 additions and 105 deletions
+105 -64
View File
@@ -16,6 +16,12 @@ from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.models import Event, Host
from app.services.daily_report_format import (
RDP_BAN_TYPES,
SSH_BAN_TYPES,
build_report_body,
enrich_stats_for_storage,
)
from app.services.ingest import ingest_event
from app.services.notify_dispatch import notify_daily_report
@@ -31,6 +37,7 @@ SSH_FAILED = "ssh.login.failed"
SSH_SUDO = "privilege.sudo.command"
RDP_SUCCESS = "rdp.login.success"
RDP_FAILED = "rdp.login.failed"
SESSION_LOGIND_NEW = "session.logind.new"
@dataclass(frozen=True)
@@ -92,6 +99,75 @@ def _user_from_details(details: dict[str, Any] | None) -> str:
return ""
def _format_user_line_ssh(details: dict[str, Any] | None) -> str | None:
user = _user_from_details(details)
if not user:
return None
if not details:
return f"👤 {user}"
tty = str(details.get("tty") or details.get("pts") or "").strip()
since = str(details.get("since") or details.get("session_since") or "").strip()
ip = _ip_from_details(details)
parts = [f"👤 {user}"]
if tty:
parts.append(f"| {tty}")
if since:
parts.append(f"| с {since}")
if ip:
parts.append(f"| 🌐 {ip}")
return " ".join(parts) if len(parts) > 1 else f"👤 {user}"
def _collect_active_users_ssh(events: list[Event]) -> list[str]:
lines: list[str] = []
seen: set[str] = set()
for ev in events:
if ev.type != SESSION_LOGIND_NEW:
continue
details = ev.details if isinstance(ev.details, dict) else None
line = _format_user_line_ssh(details)
if line and line not in seen:
lines.append(line)
seen.add(line)
if lines:
return lines
for ev in events:
if ev.type != SSH_SUCCESS:
continue
details = ev.details if isinstance(ev.details, dict) else None
user = _user_from_details(details)
if not user:
continue
key = user.lower()
if key in seen:
continue
seen.add(key)
ip = _ip_from_details(details)
line = f"👤 {user}"
if ip:
line += f" | 🌐 {ip}"
lines.append(line)
return lines
def _collect_active_users_rdp(events: list[Event]) -> list[str]:
users: list[str] = []
seen: set[str] = set()
for ev in events:
if ev.type != RDP_SUCCESS:
continue
details = ev.details if isinstance(ev.details, dict) else None
user = _user_from_details(details)
if not user:
continue
key = user.lower()
if key in seen:
continue
seen.add(key)
users.append(f"👤 {user}")
return users
def _aggregate_ssh(events: list[Event]) -> dict[str, Any]:
ok = failed = sudo = 0
failed_ips: Counter[str] = Counter()
@@ -106,94 +182,55 @@ def _aggregate_ssh(events: list[Event]) -> dict[str, Any]:
elif ev.type == SSH_SUDO:
sudo += 1
top_ips = [f"{ip}{cnt}" for ip, cnt in failed_ips.most_common(5)]
active_users = _collect_active_users_ssh(events)
return {
"successful_ssh": ok,
"failed_ssh": failed,
"sudo_commands": sudo,
"active_bans": sum(1 for e in events if e.type == "ssh.ip.banned"),
"active_bans": sum(1 for e in events if e.type in SSH_BAN_TYPES),
"top_failed_ips": top_ips,
"active_users": active_users,
}
def _aggregate_rdp(events: list[Event]) -> dict[str, Any]:
ok = failed = 0
failed_ips: Counter[str] = Counter()
users: set[str] = set()
for ev in events:
details = ev.details if isinstance(ev.details, dict) else None
if ev.type == RDP_SUCCESS:
ok += 1
u = _user_from_details(details)
if u:
users.add(u)
elif ev.type == RDP_FAILED:
failed += 1
ip = _ip_from_details(details)
if ip:
failed_ips[ip] += 1
u = _user_from_details(details)
if u:
users.add(u)
active_users = _collect_active_users_rdp(events)
unique = []
seen: set[str] = set()
for line in active_users:
u = line.replace("👤", "").strip().split("|")[0].strip()
if u.lower() not in seen:
seen.add(u.lower())
unique.append(u)
return {
"rdp_success": ok,
"rdp_failed": failed,
"unique_users": sorted(users),
"active_bans": sum(1 for e in events if e.type in RDP_BAN_TYPES),
"top_failed_ips": [f"{ip}{cnt}" for ip, cnt in failed_ips.most_common(5)],
"active_users": active_users,
"unique_users": unique,
}
def _build_report_body_ssh(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
label = host.display_name or host.hostname
lines = [
"📊 ЕЖЕДНЕВНЫЙ ОТЧЁТ SSH (SAC)",
f"🖥️ Хост: {label}",
f"🕐 Период: последние 24 ч (сводка на {when_local.strftime('%d.%m.%Y %H:%M')})",
"",
"📈 СТАТИСТИКА ИЗ INGEST:",
f"✅ Успешных SSH: {stats['successful_ssh']}",
f"❌ Неудачных SSH: {stats['failed_ssh']}",
f"⚠️ Sudo: {stats['sudo_commands']}",
f"🚫 Событий ban: {stats['active_bans']}",
"",
"🧾 ТОП IP (неудачные входы):",
]
top = stats.get("top_failed_ips") or []
if top:
lines.extend(f"{x}" for x in top)
else:
lines.append(" (нет данных)")
lines.append("")
lines.append("Источник: Security Alert Center (агрегация событий SAC).")
return "\n".join(lines)
enriched = enrich_stats_for_storage("ssh", stats)
return build_report_body("ssh", host, enriched, when_local, sac_generated=True)
def _build_report_body_rdp(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
label = host.display_name or host.hostname
users = stats.get("unique_users") or []
lines = [
"📊 ЕЖЕДНЕВНЫЙ ОТЧЁТ RDP (SAC)",
f"🖥️ Сервер: {label}",
f"🕐 Период: последние 24 ч (сводка на {when_local.strftime('%d.%m.%Y %H:%M')})",
"",
f"✅ Успешных RDP (ingest): {stats['rdp_success']}",
f"❌ Неудачных RDP: {stats['rdp_failed']}",
f"👥 Уникальных пользователей в событиях: {len(users)}",
"",
"🧾 ТОП IP (неудачные входы):",
]
top = stats.get("top_failed_ips") or []
if top:
lines.extend(f"{x}" for x in top)
else:
lines.append(" (нет данных)")
if users:
lines.append("")
lines.append("Уникальные логины:")
for u in users[:20]:
lines.append(f"{u}")
lines.append("")
lines.append("Источник: SAC ingest (активные сессии quser — только на агенте).")
return "\n".join(lines)
enriched = enrich_stats_for_storage("windows", stats)
return build_report_body("windows", host, enriched, when_local, sac_generated=True)
def _details_from_body(body: str, stats: dict[str, Any]) -> dict[str, Any]:
@@ -249,18 +286,21 @@ def generate_daily_report_for_host(db: Session, host: Host, settings: Settings |
when_local = _now_in_tz(cfg)
if report_type == "report.daily.ssh":
stats = _aggregate_ssh(events)
stats = enrich_stats_for_storage("ssh", _aggregate_ssh(events))
body = _build_report_body_ssh(host, stats, when_local)
title = "Ежедневный отчёт SSH (SAC)"
title = "Ежедневный отчёт SSH"
summary = (
f"SSH 24ч: успех {stats['successful_ssh']}, неудач {stats['failed_ssh']}, "
f"SSH 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, "
f"sudo {stats['sudo_commands']}"
)
else:
stats = _aggregate_rdp(events)
stats = enrich_stats_for_storage("windows", _aggregate_rdp(events))
body = _build_report_body_rdp(host, stats, when_local)
title = "Ежедневный отчёт RDP (SAC)"
summary = f"RDP 24ч: успех {stats['rdp_success']}, неудач {stats['rdp_failed']}"
title = "Ежедневный отчёт Windows"
summary = (
f"RDP 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, "
f"банов {stats['active_bans']}"
)
event_id = str(uuid.uuid4())
day_key = when_local.strftime("%Y-%m-%d")
@@ -274,8 +314,9 @@ def generate_daily_report_for_host(db: Session, host: Host, settings: Settings |
},
"host": {
"hostname": host.hostname,
"os_family": host.os_family or "linux",
"os_family": host.os_family or ("windows" if report_type == "report.daily.rdp" else "linux"),
"display_name": host.display_name,
"ipv4": host.ipv4,
},
"category": "report",
"type": report_type,
+125
View File
@@ -0,0 +1,125 @@
"""Единый текст и stats для report.daily.ssh / report.daily.rdp (агент и SAC)."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from app.models import Host
Platform = Literal["ssh", "windows"]
RDP_BAN_TYPES = frozenset({"rdp.ip.banned", "rdp.ip.ban"})
SSH_BAN_TYPES = frozenset({"ssh.ip.banned"})
def host_server_line(host: Host) -> str:
label = (host.display_name or host.hostname or "unknown").strip()
ip = (host.ipv4 or "").strip()
if ip:
return f"{label} ({ip})"
return label
def _section_top_ips(top: list[str]) -> list[str]:
lines = ["", "🧾 ТОП-5 IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"]
if top:
lines.extend(f" {line}" if not line.startswith(" ") else line for line in top[:5])
else:
lines.append(" (нет данных)")
return lines
def _section_active_users(users: list[str], *, sac_generated: bool) -> list[str]:
count = len(users)
lines = ["", f"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ ({count}):"]
if users:
for line in users:
lines.append(f" {line}" if not line.startswith(" ") else line)
elif sac_generated:
lines.append(
" (нет данных — полный список сессий только в отчёте агента; "
"в SAC — пользователи из событий входа за 24 ч, если были)"
)
else:
lines.append(" (нет данных)")
return lines
def build_report_body(
platform: Platform,
host: Host,
stats: dict[str, Any],
when_local: datetime,
*,
sac_generated: bool = False,
) -> str:
server = host_server_line(host)
time_str = when_local.strftime("%d.%m.%Y %H:%M:%S")
top = list(stats.get("top_failed_ips") or [])[:5]
active_users = list(stats.get("active_users") or [])
if platform == "ssh":
title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА"
ok = int(stats.get("successful_logins", stats.get("successful_ssh", 0)))
fail = int(stats.get("failed_logins", stats.get("failed_ssh", 0)))
sudo = int(stats.get("sudo_commands", 0))
bans = int(stats.get("active_bans", 0))
lines = [
title,
f"🖥️ Сервер: {server}",
f"🕐 Время отчета: {time_str}",
"",
"📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
f" ✅ Успешных SSH подключений: {ok}",
f" ❌ Неудачных попыток SSH: {fail}",
f" ⚠️ Команд через sudo: {sudo}",
f" 🚫 Активных банов (ipset): {bans}",
]
else:
title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS"
ok = int(stats.get("successful_logins", stats.get("rdp_success", 0)))
fail = int(stats.get("failed_logins", stats.get("rdp_failed", 0)))
bans = int(stats.get("active_bans", 0))
lines = [
title,
f"🖥️ Сервер: {server}",
f"🕐 Время отчета: {time_str}",
"",
"📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
f" ✅ Успешных RDP подключений: {ok}",
f" ❌ Неудачных попыток RDP: {fail}",
f" 🚫 Активных банов: {bans}",
]
lines.extend(_section_top_ips(top))
lines.extend(_section_active_users(active_users, sac_generated=sac_generated))
if sac_generated:
lines.append("")
lines.append("Источник: Security Alert Center (агрегация ingest за 24 ч).")
return "\n".join(lines)
def enrich_stats_for_storage(platform: Platform, stats: dict[str, Any]) -> dict[str, Any]:
"""Канонические поля + legacy-ключи для UI и старых отчётов."""
out = dict(stats)
out["platform"] = platform
if platform == "ssh":
ok = int(out.get("successful_logins", out.get("successful_ssh", 0)))
fail = int(out.get("failed_logins", out.get("failed_ssh", 0)))
out["successful_logins"] = ok
out["failed_logins"] = fail
out["successful_ssh"] = ok
out["failed_ssh"] = fail
else:
ok = int(out.get("successful_logins", out.get("rdp_success", 0)))
fail = int(out.get("failed_logins", out.get("rdp_failed", 0)))
out["successful_logins"] = ok
out["failed_logins"] = fail
out["rdp_success"] = ok
out["rdp_failed"] = fail
if "unique_users" in out and "active_users" not in out:
users = out.get("unique_users") or []
out["active_users"] = [f"👤 {u}" if not str(u).startswith("👤") else str(u) for u in users]
return out