diff --git a/README.md b/README.md index 900a5e5..c6fea97 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ | **security-alert-center** | Сервер SAC (Ubuntu 24.04) | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент | -**Версия:** `0.5.0` · **Деплой:** `sudo /opt/sac-deploy.sh` +**Версия:** `0.5.12` · **Деплой:** `sudo /opt/sac-deploy.sh` ## Возможности diff --git a/README_en.md b/README_en.md index 0be94ce..9276321 100644 --- a/README_en.md +++ b/README_en.md @@ -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.0` · **Deploy:** `sudo /opt/sac-deploy.sh` +**Version:** `0.5.12` · **Deploy:** `sudo /opt/sac-deploy.sh` ## Features diff --git a/backend/app/constants/event_types.py b/backend/app/constants/event_types.py index 33abcf0..ad01692 100644 --- a/backend/app/constants/event_types.py +++ b/backend/app/constants/event_types.py @@ -23,6 +23,7 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = { # RDP / Windows "rdp.login.success": "info", "rdp.login.failed": "warning", + "rdp.session.logoff": "info", "rdp.shadow.control.started": "warning", "rdp.shadow.control.stopped": "info", "rdp.shadow.control.permission": "warning", diff --git a/backend/app/services/event_actor_user.py b/backend/app/services/event_actor_user.py index b697e33..2a48a17 100644 --- a/backend/app/services/event_actor_user.py +++ b/backend/app/services/event_actor_user.py @@ -15,6 +15,7 @@ ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset( "session.logind.failed", "rdp.login.success", "rdp.login.failed", + "rdp.session.logoff", "rdp.shadow.control.started", "rdp.shadow.control.stopped", "rdp.shadow.control.permission", diff --git a/backend/app/services/host_sessions.py b/backend/app/services/host_sessions.py index 5715994..5ce3c8d 100644 --- a/backend/app/services/host_sessions.py +++ b/backend/app/services/host_sessions.py @@ -84,6 +84,10 @@ def _event_login_user(event: Event) -> str: def event_session_terminated(event: Event, db: Session | None = None) -> bool: + from app.services.rdp_session_logoff import ( + event_closed_by_logoff, + resolve_workstation_login_closed_by_logoff, + ) from app.services.rdg_workstation_session import ( event_closed_by_rdg, resolve_workstation_login_closed, @@ -97,7 +101,11 @@ def event_session_terminated(event: Event, db: Session | None = None) -> bool: return True if event_closed_by_rdg(event): return True + if event_closed_by_logoff(event): + return True if db is not None and event.type == "rdp.login.success": + if resolve_workstation_login_closed_by_logoff(db, event): + return True return resolve_workstation_login_closed(db, event) return False @@ -376,6 +384,31 @@ def terminate_linux_session( return last +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 windows_user_matches_session(login_user: str, session_user: str) -> bool: + login = _normalize_sam_account(login_user) + session = _normalize_sam_account(session_user) + return bool(login and session and login == session) + + +def filter_windows_sessions_for_user( + sessions: list[HostSessionRow], + login_user: str, +) -> list[HostSessionRow]: + user = (login_user or "").strip() + if not user: + return sessions + return [s for s in sessions if windows_user_matches_session(user, s.user)] + + def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]: rows: list[HostSessionRow] = [] norm_filter = (filter_user or "").strip().lower() @@ -475,11 +508,17 @@ def terminate_session_for_event( sessions, qwinsta = list_windows_sessions(host, win_cfg) if not qwinsta or not qwinsta.ok: raise ValueError(qwinsta.message if qwinsta else "qwinsta failed") - matched = [s for s in sessions if user.lower() in s.user.lower()] if user else sessions + matched = filter_windows_sessions_for_user(sessions, user) if user else sessions if len(matched) == 1: sid = matched[0].session_id elif not matched: - raise ValueError("No matching Windows session for user") + # Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто». + return WinRmCmdResult( + ok=True, + message="На хосте нет активной сессии пользователя (уже вышел из RDP)", + target=qwinsta.target, + stdout=qwinsta.stdout, + ) else: raise ValueError("Multiple sessions; specify session_id") result = terminate_windows_session(host, win_cfg, sid) diff --git a/backend/app/services/ingest.py b/backend/app/services/ingest.py index ea25099..43c1793 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.rdp_session_logoff import close_workstation_session_for_rdp_logoff from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"}) @@ -128,4 +129,5 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]: process_agent_update_ingest(db, host, payload.get("type", ""), details) close_workstation_session_for_rdg_end(db, event) + close_workstation_session_for_rdp_logoff(db, event) return event, True diff --git a/backend/app/services/rdg_workstation_session.py b/backend/app/services/rdg_workstation_session.py index 20a4e8c..2cbc2f4 100644 --- a/backend/app/services/rdg_workstation_session.py +++ b/backend/app/services/rdg_workstation_session.py @@ -56,13 +56,17 @@ def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> Non def _login_already_closed(login_event: Event) -> bool: + from app.services.rdp_session_logoff import event_closed_by_logoff + 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) + if event_closed_by_rdg(login_event): + return True + return event_closed_by_logoff(login_event) def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None: diff --git a/backend/app/services/rdp_session_logoff.py b/backend/app/services/rdp_session_logoff.py new file mode 100644 index 0000000..c338266 --- /dev/null +++ b/backend/app/services/rdp_session_logoff.py @@ -0,0 +1,148 @@ +"""Correlate direct RDP logoff (Security 4634/4647) with workstation rdp.login.success.""" + +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_workstation_session import ( + WORKSTATION_LOGIN_TYPE, + event_closed_by_rdg, + users_match_rdg, +) + +SESSION_CLOSED_BY_LOGOFF_AT_KEY = "session_closed_by_logoff_at" +SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY = "session_closed_by_logoff_event_id" + +LOGOFF_EVENT_TYPE = "rdp.session.logoff" +LOGOFF_EVENT_TYPES = frozenset({LOGOFF_EVENT_TYPE}) + + +def event_closed_by_logoff(event: Event) -> bool: + details = _details_dict(event) + at = details.get(SESSION_CLOSED_BY_LOGOFF_AT_KEY) + return at is not None and str(at).strip() != "" + + +def mark_login_closed_by_logoff(login_event: Event, *, logoff_event: Event) -> None: + details = dict(_details_dict(login_event)) + details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] = logoff_event.occurred_at.isoformat() + details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] = logoff_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 + if event_closed_by_rdg(login_event): + return True + return event_closed_by_logoff(login_event) + + +def find_workstation_login_for_logoff(db: Session, logoff_event: Event) -> Event | None: + if logoff_event.type not in LOGOFF_EVENT_TYPES: + return None + + logoff_user = _event_login_user(logoff_event) + if not logoff_user: + return None + logoff_at = logoff_event.occurred_at + host_id = logoff_event.host_id + if host_id is None: + return None + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == host_id, + Event.type == WORKSTATION_LOGIN_TYPE, + Event.occurred_at <= logoff_at, + Event.id != logoff_event.id, + ) + .order_by(Event.occurred_at.desc()) + ).all() + + logoff_details = _details_dict(logoff_event) + logoff_ip = str(logoff_details.get("ip_address") or "").strip() + + for login in candidates: + if _login_already_closed(login): + continue + login_user = _event_login_user(login) + if not users_match_rdg(login_user, logoff_user): + continue + if logoff_ip and logoff_ip not in ("", "-"): + login_ip = str(_details_dict(login).get("ip_address") or "").strip() + if login_ip and login_ip not in ("", "-") and login_ip != logoff_ip: + continue + return login + return None + + +def find_logoff_after_workstation_login(db: Session, login_event: Event) -> Event | None: + """Runtime lookup for historical logins without persisted close flag.""" + if login_event.type != WORKSTATION_LOGIN_TYPE: + return None + if _login_already_closed(login_event): + return None + + host_id = login_event.host_id + if host_id is None: + return None + + login_user = _event_login_user(login_event) + if not login_user: + return None + login_at = login_event.occurred_at + login_ip = str(_details_dict(login_event).get("ip_address") or "").strip() + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == host_id, + Event.type == LOGOFF_EVENT_TYPE, + Event.occurred_at >= login_at, + Event.id != login_event.id, + ) + .order_by(Event.occurred_at.asc()) + ).all() + + for logoff in candidates: + if not users_match_rdg(_event_login_user(logoff), login_user): + continue + logoff_ip = str(_details_dict(logoff).get("ip_address") or "").strip() + if login_ip and login_ip not in ("", "-") and logoff_ip and logoff_ip not in ("", "-"): + if login_ip != logoff_ip: + continue + return logoff + return None + + +def resolve_workstation_login_closed_by_logoff(db: Session, login_event: Event) -> bool: + if event_closed_by_logoff(login_event): + return True + return find_logoff_after_workstation_login(db, login_event) is not None + + +def close_workstation_session_for_rdp_logoff(db: Session, logoff_event: Event) -> Event | None: + """On direct RDP logoff, mark matching open workstation login as session-closed.""" + if logoff_event.type not in LOGOFF_EVENT_TYPES: + return None + + login = find_workstation_login_for_logoff(db, logoff_event) + if login is None: + return None + mark_login_closed_by_logoff(login, logoff_event=logoff_event) + return login diff --git a/backend/app/version.py b/backend/app/version.py index 964ca1c..ceb6b6c 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.5.10" +APP_VERSION = "0.5.12" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_host_sessions.py b/backend/tests/test_host_sessions.py index 3018be0..e87b247 100644 --- a/backend/tests/test_host_sessions.py +++ b/backend/tests/test_host_sessions.py @@ -8,6 +8,7 @@ from app.services.host_sessions import ( event_session_terminated, event_supports_session_terminate, filter_logind_session_rows, + filter_windows_sessions_for_user, mark_event_session_terminated, parse_loginctl_sessions, parse_loginctl_sessions_json, @@ -94,6 +95,48 @@ def test_event_login_user_without_orm_actor_user_attr(): assert _event_login_user(event) == "papatramp" +def test_filter_windows_sessions_for_user_matches_domain(): + rows = [ + HostSessionRow(session_id="2", user="B26\\papatramp", state="Active"), + HostSessionRow(session_id="3", user="B26\\alice", state="Active"), + ] + matched = filter_windows_sessions_for_user(rows, "papatramp") + assert len(matched) == 1 + assert matched[0].session_id == "2" + + +def test_terminate_session_for_event_windows_already_logged_off(monkeypatch): + host = SimpleNamespace( + os_family="windows", + product="rdp-login-monitor", + hostname="BIV-PC", + ipv4="192.168.165.39", + display_name=None, + inventory={}, + ) + event = SimpleNamespace( + type="rdp.login.success", + details={"user": "papatramp"}, + host=host, + ) + + def fake_list(_host, _cfg): + return [], WinRmCmdResult(ok=True, message="ok", target="BIV-PC", stdout="SESSIONNAME USERNAME ID STATE") + + monkeypatch.setattr( + "app.services.host_sessions.list_windows_sessions", + fake_list, + ) + + result = terminate_session_for_event( + event, + linux_cfg=LinuxAdminConfig(user="", password="", source="test"), + win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"), + ) + assert result.ok is True + assert "уже вышел" in result.message + + def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch): host = SimpleNamespace( os_family="windows", diff --git a/backend/tests/test_rdp_session_logoff.py b/backend/tests/test_rdp_session_logoff.py new file mode 100644 index 0000000..6752ddf --- /dev/null +++ b/backend/tests/test_rdp_session_logoff.py @@ -0,0 +1,229 @@ +"""Tests for direct RDP logoff → workstation rdp.login.success correlation.""" + +import uuid +from datetime import datetime, timedelta, timezone + +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.rdp_session_logoff import ( + SESSION_CLOSED_BY_LOGOFF_AT_KEY, + SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY, + close_workstation_session_for_rdp_logoff, + find_logoff_after_workstation_login, + resolve_workstation_login_closed_by_logoff, +) +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 + + +def test_logoff_marks_workstation_login_closed_on_ingest(db_session): + t0 = datetime.now(timezone.utc) + user = r"B26\papatramp" + host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"} + source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"} + + login = _ingest( + db_session, + t0 + timedelta(seconds=1), + host=host, + source=source, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="4624", + details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10}, + ) + logoff = _ingest( + db_session, + t0 + timedelta(hours=1), + host=host, + source=source, + type="rdp.session.logoff", + category="auth", + severity="info", + title="RDP session logoff", + summary="4634", + details={ + "user": user, + "ip_address": "192.168.160.3", + "logon_type": 10, + "event_id_windows": 4634, + }, + ) + + assert login.details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] == logoff.occurred_at.isoformat() + assert login.details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] == logoff.id + assert event_session_terminated(login, db=db_session) is True + assert event_to_summary(login, db_session).session_terminated is True + + +def test_user_mismatch_does_not_close_login(db_session): + t0 = datetime.now(timezone.utc) + host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"} + source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"} + + login = _ingest( + db_session, + t0, + host=host, + source=source, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="4624", + details={"user": r"B26\Alice"}, + ) + logoff = _ingest( + db_session, + t0 + timedelta(hours=1), + host=host, + source=source, + type="rdp.session.logoff", + category="auth", + severity="info", + title="RDP session logoff", + summary="4634", + details={"user": r"B26\Bob", "logon_type": 10, "event_id_windows": 4634}, + ) + db_session.refresh(login) + + assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None + assert event_session_terminated(login, db=db_session) is False + + +def test_ip_mismatch_does_not_close_login_when_both_ips_present(db_session): + t0 = datetime.now(timezone.utc) + user = r"B26\papatramp" + host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"} + source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"} + + login = _ingest( + db_session, + t0, + host=host, + source=source, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="4624", + details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10}, + ) + logoff = _ingest( + db_session, + t0 + timedelta(hours=1), + host=host, + source=source, + type="rdp.session.logoff", + category="auth", + severity="info", + title="RDP session logoff", + summary="4634", + details={"user": user, "ip_address": "192.168.160.99", "logon_type": 10, "event_id_windows": 4634}, + ) + db_session.refresh(login) + + assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None + assert event_session_terminated(login, db=db_session) is False + + +def test_runtime_resolve_for_historical_login_without_flag(db_session): + from app.models import Event, Host + + t0 = datetime.now(timezone.utc) + user = r"B26\papatramp" + host = Host( + hostname="BIV-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.165.39", + ) + db_session.add(host) + db_session.commit() + + login = Event( + event_id=str(uuid.uuid4()), + host_id=host.id, + occurred_at=t0 + timedelta(seconds=1), + received_at=t0, + category="auth", + type="rdp.login.success", + severity="info", + title="RDP login", + summary="4624", + payload={}, + details={"user": "papatramp", "ip_address": "192.168.160.3"}, + ) + logoff = Event( + event_id=str(uuid.uuid4()), + host_id=host.id, + occurred_at=t0 + timedelta(hours=2), + received_at=t0, + category="auth", + type="rdp.session.logoff", + severity="info", + title="4634", + summary="4634", + payload={}, + details={"user": user, "ip_address": "192.168.160.3", "event_id_windows": 4634}, + ) + db_session.add_all([login, logoff]) + db_session.commit() + + assert resolve_workstation_login_closed_by_logoff(db_session, login) is True + assert find_logoff_after_workstation_login(db_session, login) is not None + assert event_to_summary(login, db_session).session_terminated is True + + +def test_sam_domain_user_match_on_logoff(db_session): + t0 = datetime.now(timezone.utc) + host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"} + source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"} + + login = _ingest( + db_session, + t0, + host=host, + source=source, + type="rdp.login.success", + category="auth", + severity="info", + title="RDP login", + summary="4624", + details={"user": r"B26\papatramp", "logon_type": 10}, + ) + _ingest( + db_session, + t0 + timedelta(minutes=30), + host=host, + source=source, + type="rdp.session.logoff", + category="auth", + severity="info", + title="RDP session logoff", + summary="4647", + details={"user": "papatramp", "logon_type": 10, "event_id_windows": 4647}, + ) + db_session.refresh(login) + + assert event_session_terminated(login, db=db_session) is True diff --git a/docs/agent-integration.md b/docs/agent-integration.md index f95583f..ab2eee3 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -185,6 +185,7 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 |---------|--------|------------| | 4624 успех | `rdp.login.success` | info | | 4625 неудача | `rdp.login.failed` | warning | +| 4634 / 4647 выход (прямой RDP) | `rdp.session.logoff` | info | | RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** | | RCM **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** | | RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** | diff --git a/docs/event-schema-v1.json b/docs/event-schema-v1.json index c867a07..fd7e354 100644 --- a/docs/event-schema-v1.json +++ b/docs/event-schema-v1.json @@ -94,6 +94,7 @@ "session.logind.new", "rdp.login.success", "rdp.login.failed", + "rdp.session.logoff", "rdp.shadow.control.started", "rdp.shadow.control.stopped", "rdp.shadow.control.permission", diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 646b08d..5439fac 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.5.0"; +export const APP_VERSION = "0.5.12"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/schemas/event-schema-v1.json b/schemas/event-schema-v1.json index 65e6b44..883129a 100644 --- a/schemas/event-schema-v1.json +++ b/schemas/event-schema-v1.json @@ -94,6 +94,7 @@ "session.logind.new", "rdp.login.success", "rdp.login.failed", + "rdp.session.logoff", "rdp.shadow.control.started", "rdp.shadow.control.stopped", "rdp.shadow.control.permission",