feat: close workstation login on RDG 303 disconnect (0.4.6)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-26 08:16:06 +10:00
parent fd1be5e7e4
commit 606bf53019
8 changed files with 448 additions and 6 deletions
+1 -1
View File
@@ -49,5 +49,5 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
rdg_access_path=rdg_access_path,
rdg_qwinsta_enabled=rdg_qwinsta_enabled,
session_terminated=event_session_terminated(event),
session_terminated=event_session_terminated(event, db=db),
)
+15 -2
View File
@@ -7,6 +7,8 @@ import re
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.models import Event, Host
from app.services.event_actor_user import extract_event_actor_user
from app.services.linux_admin_settings import LinuxAdminConfig
@@ -81,12 +83,23 @@ def _event_login_user(event: Event) -> str:
return str(_details_dict(event).get("user") or "").strip()
def event_session_terminated(event: Event) -> bool:
def event_session_terminated(event: Event, db: Session | None = None) -> bool:
from app.services.rdg_workstation_session import (
event_closed_by_rdg,
resolve_workstation_login_closed,
)
details = _details_dict(event)
if details.get("session_terminated") is True:
return True
at = details.get(SESSION_TERMINATED_AT_KEY)
return at is not None and str(at).strip() != ""
if at is not None and str(at).strip() != "":
return True
if event_closed_by_rdg(event):
return True
if db is not None and event.type == "rdp.login.success":
return resolve_workstation_login_closed(db, event)
return False
def mark_event_session_terminated(event: Event, *, by_username: str | None = None) -> None:
+2
View File
@@ -9,6 +9,7 @@ from app.services.agent_update import process_agent_update_ingest
from app.services.daily_report_format import normalize_daily_report_details
from app.services.event_severity_overrides import apply_severity_override
from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
@@ -126,4 +127,5 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
raise
process_agent_update_ingest(db, host, payload.get("type", ""), details)
close_workstation_session_for_rdg_end(db, event)
return event, True
@@ -0,0 +1,159 @@
"""Correlate RDG 303/303-failed with workstation rdp.login.success (1149)."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import Event
from app.services.host_sessions import (
SESSION_TERMINATED_AT_KEY,
_details_dict,
_event_login_user,
)
from app.services.rdg_client_host import find_windows_host_by_ipv4
from app.services.rdg_session_flap import (
RDG_END_TYPES,
_event_user,
event_internal_ip,
find_rdg_success_before_end,
)
SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at"
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id"
WORKSTATION_LOGIN_TYPE = "rdp.login.success"
def normalize_sam_account(user: str) -> str:
text = (user or "").strip()
if "\\" in text:
return text.split("\\")[-1].strip().casefold()
if "@" in text:
return text.split("@")[0].strip().casefold()
return text.casefold()
def users_match_rdg(login_user: str, rdg_user: str) -> bool:
left = normalize_sam_account(login_user)
right = normalize_sam_account(rdg_user)
return bool(left and right and left == right)
def event_closed_by_rdg(event: Event) -> bool:
details = _details_dict(event)
at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY)
return at is not None and str(at).strip() != ""
def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> None:
details = dict(_details_dict(login_event))
details[SESSION_CLOSED_BY_RDG_AT_KEY] = rdg_end_event.occurred_at.isoformat()
details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] = rdg_end_event.id
login_event.details = details
flag_modified(login_event, "details")
def _login_already_closed(login_event: Event) -> bool:
details = _details_dict(login_event)
if details.get("session_terminated") is True:
return True
at = details.get(SESSION_TERMINATED_AT_KEY)
if at is not None and str(at).strip() != "":
return True
return event_closed_by_rdg(login_event)
def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
if rdg_end_event.type not in RDG_END_TYPES:
return None
internal_ip = event_internal_ip(rdg_end_event)
if not internal_ip:
return None
client_host = find_windows_host_by_ipv4(db, internal_ip)
if client_host is None:
return None
rdg_user = _event_user(rdg_end_event)
if not rdg_user:
return None
end_at = rdg_end_event.occurred_at
candidates = db.scalars(
select(Event)
.where(
Event.host_id == client_host.id,
Event.type == WORKSTATION_LOGIN_TYPE,
Event.occurred_at <= end_at,
Event.id != rdg_end_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
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):
continue
return login
return None
def find_rdg_end_after_workstation_login(db: Session, login_event: Event) -> Event | None:
"""Runtime lookup for historical events without persisted close flag."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
if _login_already_closed(login_event):
return None
host = login_event.host
if host is None or not host.ipv4:
return None
workstation_ip = host.ipv4.strip()
login_user = _event_login_user(login_event)
if not login_user:
return None
login_at = login_event.occurred_at
candidates = db.scalars(
select(Event)
.where(
Event.type.in_(RDG_END_TYPES),
Event.occurred_at >= login_at,
Event.id != login_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for end in candidates:
if event_internal_ip(end) != workstation_ip:
continue
if not users_match_rdg(_event_user(end), login_user):
continue
if find_rdg_success_before_end(db, end) is not None:
continue
return end
return None
def resolve_workstation_login_closed(db: Session, login_event: Event) -> bool:
if event_closed_by_rdg(login_event):
return True
return find_rdg_end_after_workstation_login(db, login_event) is not None
def close_workstation_session_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
"""On RDG disconnect, mark matching workstation login as session-closed."""
if rdg_end_event.type not in RDG_END_TYPES:
return None
if find_rdg_success_before_end(db, rdg_end_event) is not None:
return None
login = find_workstation_login_for_rdg_end(db, rdg_end_event)
if login is None:
return None
mark_login_closed_by_rdg(login, rdg_end_event=rdg_end_event)
return login