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.
This commit is contained in:
@@ -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`
|
||||
|
||||
## Возможности
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user