From 52125a5a98aa655d47c328410db4958234282341 Mon Sep 17 00:00:00 2001 From: PTah Date: Tue, 14 Jul 2026 10:45:56 +1000 Subject: [PATCH] fix: enrich empty rdp.login.success user from RDG 302 (0.5.15) RCM 1149 often leaves Param1/Param2 empty through RD Gateway; backfill details.user from matching RDG success within 5 minutes, and still close empty-user sessions on RDG end. --- README.md | 2 +- README_en.md | 2 +- backend/app/services/ingest.py | 9 ++ .../app/services/rdg_workstation_session.py | 131 +++++++++++++++++- backend/app/version.py | 2 +- backend/tests/test_rdg_workstation_session.py | 110 +++++++++++++++ frontend/src/version.ts | 2 +- 7 files changed, 252 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d0c0043..c272dce 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ | **security-alert-center** | Сервер SAC (Ubuntu 24.04) | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент | -**Версия:** `0.5.14` · **Деплой:** `sudo /opt/sac-deploy.sh` +**Версия:** `0.5.15` · **Деплой:** `sudo /opt/sac-deploy.sh` ## Возможности diff --git a/README_en.md b/README_en.md index 88bd0a2..d30cab4 100644 --- a/README_en.md +++ b/README_en.md @@ -13,7 +13,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre | **security-alert-center** | SAC server (Ubuntu 24.04) | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client | -**Version:** `0.5.14` · **Deploy:** `sudo /opt/sac-deploy.sh` +**Version:** `0.5.15` · **Deploy:** `sudo /opt/sac-deploy.sh` ## Features diff --git a/backend/app/services/ingest.py b/backend/app/services/ingest.py index 43c1793..72fbd6a 100644 --- a/backend/app/services/ingest.py +++ b/backend/app/services/ingest.py @@ -128,6 +128,15 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]: raise process_agent_update_ingest(db, host, payload.get("type", ""), details) + from app.services.rdg_workstation_session import ( + enrich_empty_login_from_rdg_success, + enrich_workstation_login_user_from_rdg, + ) + + if event.host is None: + event.host = host + enrich_workstation_login_user_from_rdg(db, event) + enrich_empty_login_from_rdg_success(db, event) close_workstation_session_for_rdg_end(db, event) close_workstation_session_for_rdp_logoff(db, event) return event, True diff --git a/backend/app/services/rdg_workstation_session.py b/backend/app/services/rdg_workstation_session.py index 2cbc2f4..d57a0b1 100644 --- a/backend/app/services/rdg_workstation_session.py +++ b/backend/app/services/rdg_workstation_session.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import timedelta, timezone + from sqlalchemy import select from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified @@ -15,6 +17,7 @@ from app.services.host_sessions import ( from app.services.rdg_client_host import find_windows_host_by_ipv4 from app.services.rdg_session_flap import ( RDG_END_TYPES, + RDG_SUCCESS_TYPE, _event_user, event_internal_ip, find_rdg_success_before_end, @@ -22,10 +25,22 @@ from app.services.rdg_session_flap import ( SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at" SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id" +USER_ENRICHED_FROM_RDG_EVENT_ID_KEY = "user_enriched_from_rdg_event_id" + +# RCM 1149 via RD Gateway often has empty Param1/Param2; RDG 302 has the account. +RDG_LOGIN_USER_ENRICH_WINDOW = timedelta(minutes=5) WORKSTATION_LOGIN_TYPE = "rdp.login.success" +def _as_utc(dt): + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + def normalize_sam_account(user: str) -> str: text = (user or "").strip() if "\\" in text: @@ -41,6 +56,115 @@ def users_match_rdg(login_user: str, rdg_user: str) -> bool: return bool(left and right and left == right) +def _login_user_missing(event: Event) -> bool: + details = _details_dict(event) + for key in ("user", "username"): + val = details.get(key) + if val is not None and str(val).strip() not in ("", "-"): + return False + return True + + +def _apply_rdg_user_to_login(login_event: Event, *, rdg_event: Event, rdg_user: str) -> None: + details = dict(_details_dict(login_event)) + details["user"] = rdg_user + details[USER_ENRICHED_FROM_RDG_EVENT_ID_KEY] = rdg_event.id + login_event.details = details + flag_modified(login_event, "details") + summary = (login_event.summary or "").strip() + if summary.startswith("RCM 1149") and rdg_user not in summary: + rest = summary[len("RCM 1149") :].strip() + login_event.summary = f"RCM 1149 {rdg_user} {rest}".strip() + + +def find_rdg_success_for_workstation_login(db: Session, login_event: Event) -> Event | None: + """Nearest RDG 302 for this workstation IP within the enrich window.""" + if login_event.type != WORKSTATION_LOGIN_TYPE: + return None + host = login_event.host + if host is None or not (host.ipv4 or "").strip(): + return None + workstation_ip = host.ipv4.strip() + login_at = _as_utc(login_event.occurred_at) + window_start = login_at - RDG_LOGIN_USER_ENRICH_WINDOW + window_end = login_at + RDG_LOGIN_USER_ENRICH_WINDOW + + candidates = db.scalars( + select(Event) + .where( + Event.type == RDG_SUCCESS_TYPE, + Event.occurred_at >= window_start, + Event.occurred_at <= window_end, + Event.id != login_event.id, + ) + .order_by(Event.occurred_at.desc()) + ).all() + + best: Event | None = None + best_delta: timedelta | None = None + for rdg in candidates: + if event_internal_ip(rdg) != workstation_ip: + continue + if not _event_user(rdg): + continue + delta = abs(_as_utc(rdg.occurred_at) - login_at) + if best is None or best_delta is None or delta < best_delta: + best = rdg + best_delta = delta + return best + + +def enrich_workstation_login_user_from_rdg(db: Session, login_event: Event) -> Event | None: + """Fill details.user when RCM 1149 EventLog left Param1/Param2 empty (seen on some Win10 Pro).""" + if login_event.type != WORKSTATION_LOGIN_TYPE: + return None + if not _login_user_missing(login_event): + return None + rdg = find_rdg_success_for_workstation_login(db, login_event) + if rdg is None: + return None + rdg_user = _event_user(rdg) + if not rdg_user: + return None + _apply_rdg_user_to_login(login_event, rdg_event=rdg, rdg_user=rdg_user) + return login_event + + +def enrich_empty_login_from_rdg_success(db: Session, rdg_success_event: Event) -> Event | None: + """Backfill empty workstation 1149 when RDG 302 is ingested after it.""" + if rdg_success_event.type != RDG_SUCCESS_TYPE: + return None + internal_ip = event_internal_ip(rdg_success_event) + rdg_user = _event_user(rdg_success_event) + if not internal_ip or not rdg_user: + return None + client_host = find_windows_host_by_ipv4(db, internal_ip) + if client_host is None: + return None + + rdg_at = _as_utc(rdg_success_event.occurred_at) + window_start = rdg_at - RDG_LOGIN_USER_ENRICH_WINDOW + window_end = rdg_at + RDG_LOGIN_USER_ENRICH_WINDOW + candidates = db.scalars( + select(Event) + .where( + Event.host_id == client_host.id, + Event.type == WORKSTATION_LOGIN_TYPE, + Event.occurred_at >= window_start, + Event.occurred_at <= window_end, + Event.id != rdg_success_event.id, + ) + .order_by(Event.occurred_at.desc()) + ).all() + + for login in candidates: + if not _login_user_missing(login): + continue + _apply_rdg_user_to_login(login, rdg_event=rdg_success_event, rdg_user=rdg_user) + return login + return None + + def event_closed_by_rdg(event: Event) -> bool: details = _details_dict(event) at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY) @@ -98,8 +222,11 @@ def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Eve for login in candidates: if _login_already_closed(login): continue - login_user = _event_login_user(login) - if not users_match_rdg(login_user, rdg_user): + login_user = (_event_login_user(login) or "").strip() + if login_user in ("", "-"): + login_user = "" + # RCM 1149 may lack user (empty Param1); still close by workstation IP + open session. + if login_user and not users_match_rdg(login_user, rdg_user): continue return login return None diff --git a/backend/app/version.py b/backend/app/version.py index 853a99a..5d31c16 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.5.14" +APP_VERSION = "0.5.15" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_rdg_workstation_session.py b/backend/tests/test_rdg_workstation_session.py index acadb25..421f173 100644 --- a/backend/tests/test_rdg_workstation_session.py +++ b/backend/tests/test_rdg_workstation_session.py @@ -266,3 +266,113 @@ def test_ip_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts): assert close_workstation_session_for_rdg_end(db_session, end) is None assert event_session_terminated(login, db=db_session) is False + + +def test_empty_rcm1149_user_enriched_from_prior_rdg302(db_session, rdg_settings, rdg_hosts): + """COMM-PC class: EventLog 1149 has empty Param1; RDG 302 already has the account.""" + ws, _gw = rdg_hosts + t0 = datetime.now(timezone.utc) + user = r"B26\s.shelkovaya" + internal_ip = ws.ipv4 + + _ingest( + db_session, + t0, + host={"hostname": "K6A-DC3", "os_family": "windows"}, + source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"}, + type="rdg.connection.success", + category="auth", + severity="warning", + title="RDG 302", + summary="302", + details={"user": user, "internal_ip": internal_ip}, + ) + login = _ingest( + db_session, + t0 + timedelta(seconds=3), + host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip}, + source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"}, + type="rdp.login.success", + category="auth", + severity="warning", + title="RDP connection (RCM 1149)", + summary="RCM 1149 - 192.168.160.40", + details={"user": "-", "ip_address": "192.168.160.40", "event_id_windows": 1149}, + ) + db_session.refresh(login) + + assert login.details["user"] == user + assert login.details.get("user_enriched_from_rdg_event_id") + summary = event_to_summary(login, db_session) + assert summary.actor_user == user + + +def test_empty_rcm1149_backfilled_when_rdg302_arrives_later(db_session, rdg_settings, rdg_hosts): + ws, _gw = rdg_hosts + t0 = datetime.now(timezone.utc) + user = r"B26\s.shelkovaya" + internal_ip = ws.ipv4 + + login = _ingest( + db_session, + t0 + timedelta(seconds=1), + host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip}, + source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"}, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP connection (RCM 1149)", + summary="RCM 1149 - 1.2.3.4", + details={"user": "-", "event_id_windows": 1149}, + ) + assert login.details["user"] == "-" + + _ingest( + db_session, + t0, + host={"hostname": "K6A-DC3", "os_family": "windows"}, + source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"}, + type="rdg.connection.success", + category="auth", + severity="info", + title="RDG 302", + summary="302", + details={"user": user, "internal_ip": internal_ip}, + ) + db_session.refresh(login) + assert login.details["user"] == user + + +def test_empty_user_login_still_closed_by_rdg303(db_session, rdg_settings, rdg_hosts): + ws, _gw = rdg_hosts + t0 = datetime.now(timezone.utc) + user = r"B26\s.shelkovaya" + internal_ip = ws.ipv4 + + login = _ingest( + db_session, + t0, + host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip}, + source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"}, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP connection (RCM 1149)", + summary="RCM 1149", + details={"user": "-", "event_id_windows": 1149}, + ) + end = _ingest( + db_session, + t0 + timedelta(minutes=5), + host={"hostname": "K6A-DC3", "os_family": "windows"}, + source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"}, + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details={"user": user, "internal_ip": internal_ip}, + ) + db_session.refresh(login) + assert login.details.get(SESSION_CLOSED_BY_RDG_AT_KEY) == end.occurred_at.isoformat() + assert event_session_terminated(login, db=db_session) is True diff --git a/frontend/src/version.ts b/frontend/src/version.ts index d1ef23b..0ba83bb 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.5.14"; +export const APP_VERSION = "0.5.15"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;