diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 6dadb78..7a0803c 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -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) diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py index d7ccfe5..0c49bf9 100644 --- a/backend/app/services/event_summary.py +++ b/backend/app/services/event_summary.py @@ -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), ) diff --git a/backend/app/services/host_sessions.py b/backend/app/services/host_sessions.py index ba3763a..5715994 100644 --- a/backend/app/services/host_sessions.py +++ b/backend/app/services/host_sessions.py @@ -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: diff --git a/backend/app/services/ingest.py b/backend/app/services/ingest.py index f5ed5f8..ea25099 100644 --- a/backend/app/services/ingest.py +++ b/backend/app/services/ingest.py @@ -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 diff --git a/backend/app/services/rdg_workstation_session.py b/backend/app/services/rdg_workstation_session.py new file mode 100644 index 0000000..20a4e8c --- /dev/null +++ b/backend/app/services/rdg_workstation_session.py @@ -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 diff --git a/backend/app/version.py b/backend/app/version.py index 520c99c..2752425 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.4.5" +APP_VERSION = "0.4.6" 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 new file mode 100644 index 0000000..acadb25 --- /dev/null +++ b/backend/tests/test_rdg_workstation_session.py @@ -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 diff --git a/frontend/src/version.ts b/frontend/src/version.ts index cb20833..401e843 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.4.5"; +export const APP_VERSION = "0.4.6"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;