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:
@@ -16,6 +16,12 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.models import Event, Host
|
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.ingest import ingest_event
|
||||||
from app.services.notify_dispatch import notify_daily_report
|
from app.services.notify_dispatch import notify_daily_report
|
||||||
|
|
||||||
@@ -31,6 +37,7 @@ SSH_FAILED = "ssh.login.failed"
|
|||||||
SSH_SUDO = "privilege.sudo.command"
|
SSH_SUDO = "privilege.sudo.command"
|
||||||
RDP_SUCCESS = "rdp.login.success"
|
RDP_SUCCESS = "rdp.login.success"
|
||||||
RDP_FAILED = "rdp.login.failed"
|
RDP_FAILED = "rdp.login.failed"
|
||||||
|
SESSION_LOGIND_NEW = "session.logind.new"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -92,6 +99,75 @@ def _user_from_details(details: dict[str, Any] | None) -> str:
|
|||||||
return ""
|
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]:
|
def _aggregate_ssh(events: list[Event]) -> dict[str, Any]:
|
||||||
ok = failed = sudo = 0
|
ok = failed = sudo = 0
|
||||||
failed_ips: Counter[str] = Counter()
|
failed_ips: Counter[str] = Counter()
|
||||||
@@ -106,94 +182,55 @@ def _aggregate_ssh(events: list[Event]) -> dict[str, Any]:
|
|||||||
elif ev.type == SSH_SUDO:
|
elif ev.type == SSH_SUDO:
|
||||||
sudo += 1
|
sudo += 1
|
||||||
top_ips = [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)]
|
top_ips = [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)]
|
||||||
|
active_users = _collect_active_users_ssh(events)
|
||||||
return {
|
return {
|
||||||
"successful_ssh": ok,
|
"successful_ssh": ok,
|
||||||
"failed_ssh": failed,
|
"failed_ssh": failed,
|
||||||
"sudo_commands": sudo,
|
"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,
|
"top_failed_ips": top_ips,
|
||||||
|
"active_users": active_users,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _aggregate_rdp(events: list[Event]) -> dict[str, Any]:
|
def _aggregate_rdp(events: list[Event]) -> dict[str, Any]:
|
||||||
ok = failed = 0
|
ok = failed = 0
|
||||||
failed_ips: Counter[str] = Counter()
|
failed_ips: Counter[str] = Counter()
|
||||||
users: set[str] = set()
|
|
||||||
for ev in events:
|
for ev in events:
|
||||||
details = ev.details if isinstance(ev.details, dict) else None
|
details = ev.details if isinstance(ev.details, dict) else None
|
||||||
if ev.type == RDP_SUCCESS:
|
if ev.type == RDP_SUCCESS:
|
||||||
ok += 1
|
ok += 1
|
||||||
u = _user_from_details(details)
|
|
||||||
if u:
|
|
||||||
users.add(u)
|
|
||||||
elif ev.type == RDP_FAILED:
|
elif ev.type == RDP_FAILED:
|
||||||
failed += 1
|
failed += 1
|
||||||
ip = _ip_from_details(details)
|
ip = _ip_from_details(details)
|
||||||
if ip:
|
if ip:
|
||||||
failed_ips[ip] += 1
|
failed_ips[ip] += 1
|
||||||
u = _user_from_details(details)
|
active_users = _collect_active_users_rdp(events)
|
||||||
if u:
|
unique = []
|
||||||
users.add(u)
|
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 {
|
return {
|
||||||
"rdp_success": ok,
|
"rdp_success": ok,
|
||||||
"rdp_failed": failed,
|
"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)],
|
"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:
|
def _build_report_body_ssh(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
|
||||||
label = host.display_name or host.hostname
|
enriched = enrich_stats_for_storage("ssh", stats)
|
||||||
lines = [
|
return build_report_body("ssh", host, enriched, when_local, sac_generated=True)
|
||||||
"📊 ЕЖЕДНЕВНЫЙ ОТЧЁТ 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)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_report_body_rdp(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
|
def _build_report_body_rdp(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
|
||||||
label = host.display_name or host.hostname
|
enriched = enrich_stats_for_storage("windows", stats)
|
||||||
users = stats.get("unique_users") or []
|
return build_report_body("windows", host, enriched, when_local, sac_generated=True)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _details_from_body(body: str, stats: dict[str, Any]) -> dict[str, Any]:
|
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)
|
when_local = _now_in_tz(cfg)
|
||||||
if report_type == "report.daily.ssh":
|
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)
|
body = _build_report_body_ssh(host, stats, when_local)
|
||||||
title = "Ежедневный отчёт SSH (SAC)"
|
title = "Ежедневный отчёт SSH"
|
||||||
summary = (
|
summary = (
|
||||||
f"SSH 24ч: успех {stats['successful_ssh']}, неудач {stats['failed_ssh']}, "
|
f"SSH 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, "
|
||||||
f"sudo {stats['sudo_commands']}"
|
f"sudo {stats['sudo_commands']}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
stats = _aggregate_rdp(events)
|
stats = enrich_stats_for_storage("windows", _aggregate_rdp(events))
|
||||||
body = _build_report_body_rdp(host, stats, when_local)
|
body = _build_report_body_rdp(host, stats, when_local)
|
||||||
title = "Ежедневный отчёт RDP (SAC)"
|
title = "Ежедневный отчёт Windows"
|
||||||
summary = f"RDP 24ч: успех {stats['rdp_success']}, неудач {stats['rdp_failed']}"
|
summary = (
|
||||||
|
f"RDP 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, "
|
||||||
|
f"банов {stats['active_bans']}"
|
||||||
|
)
|
||||||
|
|
||||||
event_id = str(uuid.uuid4())
|
event_id = str(uuid.uuid4())
|
||||||
day_key = when_local.strftime("%Y-%m-%d")
|
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": {
|
"host": {
|
||||||
"hostname": host.hostname,
|
"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,
|
"display_name": host.display_name,
|
||||||
|
"ipv4": host.ipv4,
|
||||||
},
|
},
|
||||||
"category": "report",
|
"category": "report",
|
||||||
"type": report_type,
|
"type": report_type,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -145,6 +145,44 @@ def test_generate_creates_report_and_notifies(db_session):
|
|||||||
assert ev.details.get("generated_by") == "sac"
|
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):
|
def test_run_daily_reports_respects_hour(db_session):
|
||||||
h = _ssh_host(db_session)
|
h = _ssh_host(db_session)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -172,7 +172,8 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
|||||||
- `user`, `source_ip`, `port`, `attempt_number`, `max_attempts`
|
- `user`, `source_ip`, `port`, `attempt_number`, `max_attempts`
|
||||||
- `sudo`: `run_as`, `command`, `pwd`, `risk_level`
|
- `sudo`: `run_as`, `command`, `pwd`, `risk_level`
|
||||||
- `ban`: `ban_until`, `enable_ip_ban` (SSH / ipset)
|
- `ban`: `ban_until`, `enable_ip_ban` (SSH / ipset)
|
||||||
- `brute`: `window_sec`, `fails_in_window`
|
- RDP-login-monitor: автобан IP в схеме **пока не стандартизирован** (`rdp.ip.banned` зарезервирован в SAC для будущего); в отчёте Windows строка «Активных банов» = 0 или число событий `rdp.ip.banned`, если агент начнёт слать
|
||||||
|
- `brute`: `window_sec`, `fails_in_window`
|
||||||
- `whitelist_matched`: boolean
|
- `whitelist_matched`: boolean
|
||||||
|
|
||||||
### 3.2. RDP-login-monitor
|
### 3.2. RDP-login-monitor
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="report-card">
|
<div class="report-card">
|
||||||
<div v-if="statsEntries.length" class="report-stats">
|
<div v-if="statEntries.length" class="report-stats">
|
||||||
<div v-for="item in statsEntries" :key="item.key" class="report-stat">
|
<div v-for="item in statEntries" :key="item.key" class="report-stat">
|
||||||
<div class="report-stat-value">{{ item.value }}</div>
|
<div class="report-stat-value">{{ item.value }}</div>
|
||||||
<div class="report-stat-label">{{ item.label }}</div>
|
<div class="report-stat-label">{{ item.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="activeUserLines.length && !plainBody && !htmlContent" class="report-active-users">
|
||||||
|
<li v-for="(line, idx) in activeUserLines" :key="idx">{{ line }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div v-if="htmlContent" class="report-body-html" v-html="htmlContent" />
|
<div v-if="htmlContent" class="report-body-html" v-html="htmlContent" />
|
||||||
|
|
||||||
<pre v-else-if="plainBody" class="report-body-plain">{{ plainBody }}</pre>
|
<pre v-else-if="plainBody" class="report-body-plain">{{ plainBody }}</pre>
|
||||||
@@ -14,7 +18,7 @@
|
|||||||
<p v-else-if="summary && summary !== 'Отчёт за сутки'" class="report-fallback">{{ summary }}</p>
|
<p v-else-if="summary && summary !== 'Отчёт за сутки'" class="report-fallback">{{ summary }}</p>
|
||||||
|
|
||||||
<p v-else class="report-empty">
|
<p v-else class="report-empty">
|
||||||
Полный текст отчёта не сохранён (старая версия агента). Обновите ssh-monitor и дождитесь следующего
|
Полный текст отчёта не сохранён (старая версия агента). Обновите агент и дождитесь следующего
|
||||||
ежедневного отчёта.
|
ежедневного отчёта.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,11 +27,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import {
|
import {
|
||||||
|
reportActiveUserLines,
|
||||||
reportBodyFromDetails,
|
reportBodyFromDetails,
|
||||||
reportHtmlFromDetails,
|
reportHtmlFromDetails,
|
||||||
|
reportStatEntries,
|
||||||
reportStatsFromDetails,
|
reportStatsFromDetails,
|
||||||
sanitizeAgentHtml,
|
sanitizeAgentHtml,
|
||||||
type DailyReportStats,
|
|
||||||
} from "../utils/reportDisplay";
|
} from "../utils/reportDisplay";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -36,37 +41,9 @@ const props = defineProps<{
|
|||||||
details: Record<string, unknown> | null;
|
details: Record<string, unknown> | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const stats = computed(() => reportStatsFromDetails(props.details));
|
const stats = computed(() => reportStatsFromDetails(props.details, props.type));
|
||||||
|
const statEntries = computed(() => reportStatEntries(stats.value, props.type));
|
||||||
const statLabels: Record<keyof DailyReportStats, string> = {
|
const activeUserLines = computed(() => reportActiveUserLines(stats.value, props.type));
|
||||||
successful_ssh: "Успешных SSH",
|
|
||||||
failed_ssh: "Неудачных SSH",
|
|
||||||
sudo_commands: "Sudo",
|
|
||||||
active_bans: "Активных банов",
|
|
||||||
active_sessions: "Сессий (SSH)",
|
|
||||||
active_sessions_rdp: "Сессий (RDP)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statsEntries = computed(() => {
|
|
||||||
const s = stats.value;
|
|
||||||
if (!s) return [];
|
|
||||||
const out: { key: string; label: string; value: string | number }[] = [];
|
|
||||||
for (const [key, label] of Object.entries(statLabels)) {
|
|
||||||
const k = key as keyof DailyReportStats;
|
|
||||||
const v = s[k];
|
|
||||||
if (typeof v === "number") {
|
|
||||||
out.push({ key, label, value: v });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(s.unique_users) && s.unique_users.length) {
|
|
||||||
out.push({
|
|
||||||
key: "unique_users",
|
|
||||||
label: "Уникальные логины",
|
|
||||||
value: s.unique_users.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
|
|
||||||
const htmlContent = computed(() => {
|
const htmlContent = computed(() => {
|
||||||
const raw = reportHtmlFromDetails(props.details);
|
const raw = reportHtmlFromDetails(props.details);
|
||||||
|
|||||||
@@ -308,6 +308,19 @@ pre {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-active-users {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-active-users li {
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.report-body-html {
|
.report-body-html {
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -16,24 +16,121 @@ export function sanitizeAgentHtml(html: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyReportStats {
|
export interface DailyReportStats {
|
||||||
successful_ssh?: number;
|
platform?: "ssh" | "windows";
|
||||||
failed_ssh?: number;
|
successful_logins?: number;
|
||||||
|
failed_logins?: number;
|
||||||
sudo_commands?: number;
|
sudo_commands?: number;
|
||||||
active_bans?: number;
|
active_bans?: number;
|
||||||
|
top_failed_ips?: string[];
|
||||||
|
active_users?: string[];
|
||||||
|
/** legacy */
|
||||||
|
successful_ssh?: number;
|
||||||
|
failed_ssh?: number;
|
||||||
|
rdp_success?: number;
|
||||||
|
rdp_failed?: number;
|
||||||
|
unique_users?: string[];
|
||||||
active_sessions?: number;
|
active_sessions?: number;
|
||||||
active_sessions_rdp?: number;
|
active_sessions_rdp?: number;
|
||||||
unique_users?: string[];
|
}
|
||||||
|
|
||||||
|
export interface NormalizedReportStat {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDailyReportType(type: string): boolean {
|
export function isDailyReportType(type: string): boolean {
|
||||||
return type === "report.daily.ssh" || type === "report.daily.rdp";
|
return type === "report.daily.ssh" || type === "report.daily.rdp";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reportStatsFromDetails(details: Record<string, unknown> | null): DailyReportStats | null {
|
export function reportPlatform(type: string): "ssh" | "windows" {
|
||||||
|
return type === "report.daily.rdp" ? "windows" : "ssh";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Приводит stats агента/SAC к единым полям для карточки. */
|
||||||
|
export function normalizeReportStats(
|
||||||
|
stats: DailyReportStats | null,
|
||||||
|
type: string,
|
||||||
|
): DailyReportStats | null {
|
||||||
|
if (!stats) return null;
|
||||||
|
const platform = stats.platform ?? reportPlatform(type);
|
||||||
|
const out: DailyReportStats = { ...stats, platform };
|
||||||
|
|
||||||
|
if (platform === "ssh") {
|
||||||
|
out.successful_logins = stats.successful_logins ?? stats.successful_ssh ?? 0;
|
||||||
|
out.failed_logins = stats.failed_logins ?? stats.failed_ssh ?? 0;
|
||||||
|
out.sudo_commands = stats.sudo_commands ?? 0;
|
||||||
|
} else {
|
||||||
|
out.successful_logins = stats.successful_logins ?? stats.rdp_success ?? 0;
|
||||||
|
out.failed_logins = stats.failed_logins ?? stats.rdp_failed ?? 0;
|
||||||
|
if (!out.active_users?.length && stats.unique_users?.length) {
|
||||||
|
out.active_users = stats.unique_users.map((u) =>
|
||||||
|
u.startsWith("👤") ? u : `👤 ${u}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.active_bans = stats.active_bans ?? 0;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportStatsFromDetails(
|
||||||
|
details: Record<string, unknown> | null,
|
||||||
|
type = "report.daily.ssh",
|
||||||
|
): DailyReportStats | null {
|
||||||
if (!details || typeof details.stats !== "object" || details.stats === null) {
|
if (!details || typeof details.stats !== "object" || details.stats === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return details.stats as DailyReportStats;
|
return normalizeReportStats(details.stats as DailyReportStats, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportStatEntries(
|
||||||
|
stats: DailyReportStats | null,
|
||||||
|
type: string,
|
||||||
|
): NormalizedReportStat[] {
|
||||||
|
const s = normalizeReportStats(stats, type);
|
||||||
|
if (!s) return [];
|
||||||
|
const platform = s.platform ?? reportPlatform(type);
|
||||||
|
const entries: NormalizedReportStat[] = [
|
||||||
|
{
|
||||||
|
key: "successful",
|
||||||
|
label: platform === "ssh" ? "Успешных SSH" : "Успешных RDP",
|
||||||
|
value: s.successful_logins ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failed",
|
||||||
|
label: platform === "ssh" ? "Неудачных SSH" : "Неудачных RDP",
|
||||||
|
value: s.failed_logins ?? 0,
|
||||||
|
},
|
||||||
|
{ key: "bans", label: "Активных банов", value: s.active_bans ?? 0 },
|
||||||
|
];
|
||||||
|
if (platform === "ssh") {
|
||||||
|
entries.splice(2, 0, {
|
||||||
|
key: "sudo",
|
||||||
|
label: "Команд sudo",
|
||||||
|
value: s.sudo_commands ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const users = s.active_users ?? [];
|
||||||
|
if (users.length) {
|
||||||
|
entries.push({
|
||||||
|
key: "active_users",
|
||||||
|
label: "Активные пользователи",
|
||||||
|
value: users.length,
|
||||||
|
});
|
||||||
|
} else if (typeof s.active_sessions === "number" && s.active_sessions > 0) {
|
||||||
|
entries.push({
|
||||||
|
key: "active_sessions",
|
||||||
|
label: "Сессий (SSH)",
|
||||||
|
value: s.active_sessions,
|
||||||
|
});
|
||||||
|
} else if (typeof s.active_sessions_rdp === "number" && s.active_sessions_rdp > 0) {
|
||||||
|
entries.push({
|
||||||
|
key: "active_sessions_rdp",
|
||||||
|
label: "Сессий (RDP)",
|
||||||
|
value: s.active_sessions_rdp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reportHtmlFromDetails(details: Record<string, unknown> | null): string | null {
|
export function reportHtmlFromDetails(details: Record<string, unknown> | null): string | null {
|
||||||
@@ -47,3 +144,12 @@ export function reportBodyFromDetails(details: Record<string, unknown> | null):
|
|||||||
const body = details.report_body;
|
const body = details.report_body;
|
||||||
return typeof body === "string" && body.trim() ? body : null;
|
return typeof body === "string" && body.trim() ? body : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reportActiveUserLines(
|
||||||
|
stats: DailyReportStats | null,
|
||||||
|
type: string,
|
||||||
|
): string[] {
|
||||||
|
const s = normalizeReportStats(stats, type);
|
||||||
|
if (!s?.active_users?.length) return [];
|
||||||
|
return s.active_users;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user