"""Единый текст и stats для report.daily.ssh / report.daily.rdp (агент и SAC).""" from __future__ import annotations import html import re 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"}) _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: if host is None: return "unknown" label = (host.display_name or host.hostname or "unknown").strip() ip = (host.ipv4 or "").strip() if ip: return f"{label} ({ip})" return label def collapse_blank_lines(text: str, *, max_run: int = 1) -> str: lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") out: list[str] = [] blank_run = 0 for line in lines: if not line.strip(): blank_run += 1 if blank_run <= max_run: out.append("") continue blank_run = 0 out.append(line.rstrip()) return "\n".join(out).strip() def split_active_user_tokens(entry: str) -> list[str]: """Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента).""" entry = entry.strip() if not entry: return [] if entry.count("👤") <= 1: line = entry if entry.startswith("👤") else f"👤 {entry}" return [f" {line}"] parts = re.split(r"(?=👤)", entry) result: list[str] = [] for part in parts: part = part.strip() if not part: continue if not part.startswith("👤"): part = f"👤 {part}" result.append(f" {part}") return result def normalize_active_users_list(users: list[Any] | None) -> list[str]: out: list[str] = [] seen: set[str] = set() for raw in users or []: for line in split_active_user_tokens(str(raw)): key = line.strip().lower() if key and key not in seen: seen.add(key) out.append(line) return out def format_agent_version_line(version: str) -> str: v = (version or "").strip() if not v: return "" return f"Agent version {v}" def ensure_agent_version_line(body: str, version: str | None) -> str: line = format_agent_version_line(version or "") if not line: return body if _AGENT_VERSION_LINE_RE.search(body): return body lines = body.replace("\r\n", "\n").split("\n") for i, ln in enumerate(lines): if _DAILY_REPORT_TITLE_MARKER in ln: lines.insert(i + 1, line) return "\n".join(lines) return body def _fix_active_users_header_count(header_line: str, user_count: int) -> str: stripped = header_line.rstrip() if _ACTIVE_USERS_HEADER_RE.match(stripped.strip()): return re.sub( r"(\👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ\s*\()[^)]+(\))", rf"\g<1>{user_count}\2", stripped, count=1, ) return stripped def ensure_server_line(body: str, host: Host | None) -> str: server = host_server_line(host) if not server or server == "unknown": return body if _SERVER_LINE_RE.search(body): return body lines = body.replace("\r\n", "\n").split("\n") for i, line in enumerate(lines): if "ЕЖЕДНЕВНЫЙ ОТЧЕТ" in line: insert_at = i + 1 if insert_at < len(lines) and _AGENT_VERSION_LINE_RE.match(lines[insert_at].strip()): insert_at += 1 lines.insert(insert_at, f"🖥️ Сервер: {server}") return "\n".join(lines) return f"🖥️ Сервер: {server}\n{body}" def normalize_active_users_in_body(body: str) -> str: lines = body.replace("\r\n", "\n").split("\n") out: list[str] = [] i = 0 while i < len(lines): line = lines[i] if _ACTIVE_USERS_HEADER_RE.match(line.strip()): out.append(line) i += 1 user_lines: list[str] = [] while i < len(lines): cur = lines[i] stripped = cur.strip() if not stripped: break if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX): break if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"): break if stripped.startswith("(нет данных"): out.append(cur) i += 1 break user_lines.extend(split_active_user_tokens(cur)) i += 1 if user_lines: out[-1] = _fix_active_users_header_count(out[-1], len(user_lines)) out.extend(user_lines) continue out.append(line) i += 1 return "\n".join(out) def normalize_report_body(body: str, host: Host | None, platform: Platform) -> str: """Приводит текст отчёта (агент/SAC) к единому компактному виду.""" text = collapse_blank_lines(body.replace("\r\n", "\n")) agent_version = host.product_version if host else None text = ensure_agent_version_line(text, agent_version) text = ensure_server_line(text, host) text = normalize_active_users_in_body(text) return collapse_blank_lines(text) def body_to_report_html(body: str) -> str: escaped = html.escape(body) return f'