diff --git a/backend/app/services/daily_report.py b/backend/app/services/daily_report.py index 31a651f..471cba7 100644 --- a/backend/app/services/daily_report.py +++ b/backend/app/services/daily_report.py @@ -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, diff --git a/backend/app/services/daily_report_format.py b/backend/app/services/daily_report_format.py new file mode 100644 index 0000000..9833175 --- /dev/null +++ b/backend/app/services/daily_report_format.py @@ -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 diff --git a/backend/tests/test_daily_report.py b/backend/tests/test_daily_report.py index f300fe8..0fdd85e 100644 --- a/backend/tests/test_daily_report.py +++ b/backend/tests/test_daily_report.py @@ -145,6 +145,44 @@ def test_generate_creates_report_and_notifies(db_session): assert ev.details.get("generated_by") == "sac" +def test_generate_rdp_report_unified_format(db_session): + h = Host( + hostname="win-srv", + display_name="RDCB", + ipv4="10.0.0.5", + os_family="windows", + product="rdp-login-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db_session.add(h) + db_session.flush() + now = datetime.now(timezone.utc) + db_session.add( + Event( + event_id=str(uuid.uuid4()), + host_id=h.id, + occurred_at=now, + category="auth", + type="rdp.login.success", + severity="info", + title="ok", + summary="", + details={"user": "admin"}, + payload={}, + ) + ) + db_session.commit() + cfg = _cfg() + with patch("app.services.daily_report.notify_daily_report"): + res = generate_daily_report_for_host(db_session, h, cfg) + assert res and res.created + ev = db_session.scalar(select(Event).where(Event.type == "report.daily.rdp")) + body = ev.details.get("report_body", "") + assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" in body + assert "RDCB (10.0.0.5)" in body + assert "АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ" in body + + def test_run_daily_reports_respects_hour(db_session): h = _ssh_host(db_session) db_session.commit() diff --git a/backend/tests/test_daily_report_format.py b/backend/tests/test_daily_report_format.py new file mode 100644 index 0000000..125beca --- /dev/null +++ b/backend/tests/test_daily_report_format.py @@ -0,0 +1,63 @@ +"""Unified daily report text format.""" + +from datetime import datetime, timezone + +from app.models import Host +from app.services.daily_report_format import build_report_body, enrich_stats_for_storage + + +def test_build_report_body_ssh_matches_agent_layout(): + host = Host( + hostname="haproxy", + display_name="HaProxy Kalina", + ipv4="192.168.160.117", + 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": 5, + "active_bans": 0, + "top_failed_ips": [], + "active_users": [ + "👤 papatramp | pts/0 | с 2026-05-27 12:28 | 🌐 192.168.160.3", + ], + }, + ) + body = build_report_body("ssh", host, stats, when, sac_generated=True) + assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА" in body + assert "HaProxy Kalina (192.168.160.117)" in body + assert "Команд через sudo: 5" in body + assert "АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (1)" in body + assert "papatramp" in body + + +def test_build_report_body_windows_layout(): + host = Host( + hostname="WIN01", + display_name="K6A-DC3", + ipv4="10.0.0.10", + os_family="windows", + product="rdp-login-monitor", + ) + when = datetime(2026, 5, 29, 9, 0, 5, tzinfo=timezone.utc) + stats = enrich_stats_for_storage( + "windows", + { + "rdp_success": 1, + "rdp_failed": 2, + "active_bans": 0, + "top_failed_ips": ["1.2.3.4 — 2"], + "active_users": ["👤 DOMAIN\\user1", "👤 user2"], + }, + ) + body = build_report_body("windows", host, stats, when, sac_generated=True) + assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" in body + assert "K6A-DC3 (10.0.0.10)" in body + assert "Успешных RDP подключений: 1" in body + assert "АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body + assert "user2" in body diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 3bbbe93..ee4352b 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -172,7 +172,8 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 - `user`, `source_ip`, `port`, `attempt_number`, `max_attempts` - `sudo`: `run_as`, `command`, `pwd`, `risk_level` -- `ban`: `ban_until`, `enable_ip_ban` +- `ban`: `ban_until`, `enable_ip_ban` (SSH / ipset) +- RDP-login-monitor: автобан IP в схеме **пока не стандартизирован** (`rdp.ip.banned` зарезервирован в SAC для будущего); в отчёте Windows строка «Активных банов» = 0 или число событий `rdp.ip.banned`, если агент начнёт слать - `brute`: `window_sec`, `fails_in_window` - `whitelist_matched`: boolean diff --git a/frontend/src/components/ReportBodyCard.vue b/frontend/src/components/ReportBodyCard.vue index db4c6de..e1f97a1 100644 --- a/frontend/src/components/ReportBodyCard.vue +++ b/frontend/src/components/ReportBodyCard.vue @@ -1,12 +1,16 @@