feat: close workstation login on RDG 303 disconnect (0.4.6)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -256,7 +256,7 @@ def post_event_terminate_session(
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
if not event_supports_session_terminate(event):
|
||||
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")
|
||||
|
||||
linux_cfg = get_effective_linux_admin_config(db)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.4.5"
|
||||
APP_VERSION = "0.4.6"
|
||||
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,4 +1,4 @@
|
||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||
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}`;
|
||||
|
||||
Reference in New Issue
Block a user