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
@@ -256,7 +256,7 @@ def post_event_terminate_session(
raise HTTPException(status_code=404, detail="Event not found") raise HTTPException(status_code=404, detail="Event not found")
if not event_supports_session_terminate(event): if not event_supports_session_terminate(event):
raise HTTPException(status_code=400, detail="Event type does not support session terminate") raise HTTPException(status_code=400, detail="Event type does not support session terminate")
if event_session_terminated(event): if event_session_terminated(event, db=db):
raise HTTPException(status_code=409, detail="Session already terminated for this event") raise HTTPException(status_code=409, detail="Session already terminated for this event")
linux_cfg = get_effective_linux_admin_config(db) linux_cfg = get_effective_linux_admin_config(db)
+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_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
rdg_access_path=rdg_access_path, rdg_access_path=rdg_access_path,
rdg_qwinsta_enabled=rdg_qwinsta_enabled, 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 dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.models import Event, Host from app.models import Event, Host
from app.services.event_actor_user import extract_event_actor_user from app.services.event_actor_user import extract_event_actor_user
from app.services.linux_admin_settings import LinuxAdminConfig 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() 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) details = _details_dict(event)
if details.get("session_terminated") is True: if details.get("session_terminated") is True:
return True return True
at = details.get(SESSION_TERMINATED_AT_KEY) 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: 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.daily_report_format import normalize_daily_report_details
from app.services.event_severity_overrides import apply_severity_override 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.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"}) 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 raise
process_agent_update_ingest(db, host, payload.get("type", ""), details) process_agent_update_ingest(db, host, payload.get("type", ""), details)
close_workstation_session_for_rdg_end(db, event)
return event, True 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
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.4.5" APP_VERSION = "0.4.6"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
@@ -0,0 +1,268 @@
"""Tests for RDG 303 → workstation rdp.login.success correlation."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from app.config import get_settings
from app.models import Host
from app.services.event_summary import event_to_summary
from app.services.host_sessions import event_session_terminated
from app.services.ingest import ingest_event
from app.services.rdg_workstation_session import (
SESSION_CLOSED_BY_RDG_AT_KEY,
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY,
close_workstation_session_for_rdg_end,
find_rdg_end_after_workstation_login,
resolve_workstation_login_closed,
)
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
@pytest.fixture
def rdg_settings(monkeypatch):
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def rdg_hosts(db_session):
ws = Host(
hostname="TSA-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.163.100",
)
gw = Host(
hostname="K6A-DC3",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.40",
)
db_session.add_all([ws, gw])
db_session.commit()
return ws, gw
def test_rdg_end_marks_workstation_login_closed_on_ingest(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
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.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": user},
)
end = _ingest(
db_session,
t0 + timedelta(hours=2),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="RD Gateway event 303",
summary="303",
details={"user": user, "internal_ip": internal_ip},
)
assert login.details[SESSION_CLOSED_BY_RDG_AT_KEY] == end.occurred_at.isoformat()
assert login.details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] == end.id
assert event_session_terminated(login, db=db_session) is True
summary = event_to_summary(login, db_session)
assert summary.session_terminated is True
def test_rdg_flap_does_not_close_workstation_login(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
internal_ip = ws.ipv4
details = {"user": user, "internal_ip": internal_ip}
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.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": user},
)
_ingest(
db_session,
t0,
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
_ingest(
db_session,
t0 + timedelta(seconds=4),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
db_session.refresh(login)
assert SESSION_CLOSED_BY_RDG_AT_KEY not in (login.details or {})
assert event_session_terminated(login, db=db_session) is False
assert event_to_summary(login, db_session).session_terminated is False
def test_runtime_resolve_for_historical_login_without_flag(db_session, rdg_settings, rdg_hosts):
ws, gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
internal_ip = ws.ipv4
from app.models import Event
login = Event(
event_id=str(uuid.uuid4()),
host_id=ws.id,
occurred_at=t0 + timedelta(seconds=1),
received_at=t0,
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="1149",
payload={},
details={"user": "TSA"},
)
end = Event(
event_id=str(uuid.uuid4()),
host_id=gw.id,
occurred_at=t0 + timedelta(hours=2),
received_at=t0,
category="auth",
type="rdg.connection.disconnected",
severity="info",
title="303",
summary="303",
payload={},
details={"user": user, "internal_ip": internal_ip},
)
db_session.add_all([login, end])
db_session.commit()
assert resolve_workstation_login_closed(db_session, login) is True
assert find_rdg_end_after_workstation_login(db_session, login) is not None
assert event_to_summary(login, db_session).session_terminated is True
def test_user_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
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.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": r"B26\Alice"},
)
end = _ingest(
db_session,
t0 + timedelta(hours=1),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details={"user": r"B26\Bob", "internal_ip": internal_ip},
)
db_session.refresh(login)
assert close_workstation_session_for_rdg_end(db_session, end) is None
assert event_session_terminated(login, db=db_session) is False
def test_ip_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
login = _ingest(
db_session,
t0,
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": user},
)
end = _ingest(
db_session,
t0 + timedelta(hours=1),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details={"user": user, "internal_ip": "192.168.163.200"},
)
db_session.refresh(login)
assert close_workstation_session_for_rdg_end(db_session, end) is None
assert event_session_terminated(login, db=db_session) is False
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center"; export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.4.5"; export const APP_VERSION = "0.4.6";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;