feat: close rdp.login.success on rdp.session.logoff (v0.5.12)
Ingest correlates direct RDP logoff with open login events on the same host and user. Terminate-session treats missing qwinsta as already logged off.
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
|
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
|
||||||
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
|
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
|
||||||
|
|
||||||
**Версия:** `0.5.0` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
**Версия:** `0.5.12` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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) |
|
| **security-alert-center** | SAC server (Ubuntu 24.04) |
|
||||||
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
|
| [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
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
|
|||||||
# RDP / Windows
|
# RDP / Windows
|
||||||
"rdp.login.success": "info",
|
"rdp.login.success": "info",
|
||||||
"rdp.login.failed": "warning",
|
"rdp.login.failed": "warning",
|
||||||
|
"rdp.session.logoff": "info",
|
||||||
"rdp.shadow.control.started": "warning",
|
"rdp.shadow.control.started": "warning",
|
||||||
"rdp.shadow.control.stopped": "info",
|
"rdp.shadow.control.stopped": "info",
|
||||||
"rdp.shadow.control.permission": "warning",
|
"rdp.shadow.control.permission": "warning",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset(
|
|||||||
"session.logind.failed",
|
"session.logind.failed",
|
||||||
"rdp.login.success",
|
"rdp.login.success",
|
||||||
"rdp.login.failed",
|
"rdp.login.failed",
|
||||||
|
"rdp.session.logoff",
|
||||||
"rdp.shadow.control.started",
|
"rdp.shadow.control.started",
|
||||||
"rdp.shadow.control.stopped",
|
"rdp.shadow.control.stopped",
|
||||||
"rdp.shadow.control.permission",
|
"rdp.shadow.control.permission",
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ def _event_login_user(event: Event) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def event_session_terminated(event: Event, db: Session | None = None) -> bool:
|
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 (
|
from app.services.rdg_workstation_session import (
|
||||||
event_closed_by_rdg,
|
event_closed_by_rdg,
|
||||||
resolve_workstation_login_closed,
|
resolve_workstation_login_closed,
|
||||||
@@ -97,7 +101,11 @@ def event_session_terminated(event: Event, db: Session | None = None) -> bool:
|
|||||||
return True
|
return True
|
||||||
if event_closed_by_rdg(event):
|
if event_closed_by_rdg(event):
|
||||||
return True
|
return True
|
||||||
|
if event_closed_by_logoff(event):
|
||||||
|
return True
|
||||||
if db is not None and event.type == "rdp.login.success":
|
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 resolve_workstation_login_closed(db, event)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -376,6 +384,31 @@ def terminate_linux_session(
|
|||||||
return last
|
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]:
|
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
|
||||||
rows: list[HostSessionRow] = []
|
rows: list[HostSessionRow] = []
|
||||||
norm_filter = (filter_user or "").strip().lower()
|
norm_filter = (filter_user or "").strip().lower()
|
||||||
@@ -475,11 +508,17 @@ def terminate_session_for_event(
|
|||||||
sessions, qwinsta = list_windows_sessions(host, win_cfg)
|
sessions, qwinsta = list_windows_sessions(host, win_cfg)
|
||||||
if not qwinsta or not qwinsta.ok:
|
if not qwinsta or not qwinsta.ok:
|
||||||
raise ValueError(qwinsta.message if qwinsta else "qwinsta failed")
|
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:
|
if len(matched) == 1:
|
||||||
sid = matched[0].session_id
|
sid = matched[0].session_id
|
||||||
elif not matched:
|
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:
|
else:
|
||||||
raise ValueError("Multiple sessions; specify session_id")
|
raise ValueError("Multiple sessions; specify session_id")
|
||||||
result = terminate_windows_session(host, win_cfg, sid)
|
result = terminate_windows_session(host, win_cfg, sid)
|
||||||
|
|||||||
@@ -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.rdp_session_logoff import close_workstation_session_for_rdp_logoff
|
||||||
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
|
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"})
|
||||||
@@ -128,4 +129,5 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
|
|||||||
|
|
||||||
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)
|
close_workstation_session_for_rdg_end(db, event)
|
||||||
|
close_workstation_session_for_rdp_logoff(db, event)
|
||||||
return event, True
|
return event, True
|
||||||
|
|||||||
@@ -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:
|
def _login_already_closed(login_event: Event) -> bool:
|
||||||
|
from app.services.rdp_session_logoff import event_closed_by_logoff
|
||||||
|
|
||||||
details = _details_dict(login_event)
|
details = _details_dict(login_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)
|
||||||
if at is not None and str(at).strip() != "":
|
if at is not None and str(at).strip() != "":
|
||||||
return True
|
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:
|
def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.5.10"
|
APP_VERSION = "0.5.12"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.services.host_sessions import (
|
|||||||
event_session_terminated,
|
event_session_terminated,
|
||||||
event_supports_session_terminate,
|
event_supports_session_terminate,
|
||||||
filter_logind_session_rows,
|
filter_logind_session_rows,
|
||||||
|
filter_windows_sessions_for_user,
|
||||||
mark_event_session_terminated,
|
mark_event_session_terminated,
|
||||||
parse_loginctl_sessions,
|
parse_loginctl_sessions,
|
||||||
parse_loginctl_sessions_json,
|
parse_loginctl_sessions_json,
|
||||||
@@ -94,6 +95,48 @@ def test_event_login_user_without_orm_actor_user_attr():
|
|||||||
assert _event_login_user(event) == "papatramp"
|
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):
|
def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch):
|
||||||
host = SimpleNamespace(
|
host = SimpleNamespace(
|
||||||
os_family="windows",
|
os_family="windows",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -185,6 +185,7 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
|||||||
| 4624 успех | `rdp.login.success` | info |
|
| 4624 успех | `rdp.login.success` | info |
|
||||||
| 4625 неудача | `rdp.login.failed` | warning |
|
| 4625 неудача | `rdp.login.failed` | warning |
|
||||||
| 4634 / 4647 выход (прямой RDP) | `rdp.session.logoff` | info |
|
| 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 **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** |
|
||||||
| RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** |
|
| RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** |
|
||||||
| WinRM **91** inbound shell (Enter-PSSession) | `winrm.session.started` | **warning** |
|
| WinRM **91** inbound shell (Enter-PSSession) | `winrm.session.started` | **warning** |
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
"session.logind.new",
|
"session.logind.new",
|
||||||
"rdp.login.success",
|
"rdp.login.success",
|
||||||
"rdp.login.failed",
|
"rdp.login.failed",
|
||||||
|
"rdp.session.logoff",
|
||||||
"rdp.shadow.control.started",
|
"rdp.shadow.control.started",
|
||||||
"rdp.shadow.control.stopped",
|
"rdp.shadow.control.stopped",
|
||||||
"rdp.shadow.control.permission",
|
"rdp.shadow.control.permission",
|
||||||
|
|||||||
@@ -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.5.0";
|
export const APP_VERSION = "0.5.12";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
"session.logind.new",
|
"session.logind.new",
|
||||||
"rdp.login.success",
|
"rdp.login.success",
|
||||||
"rdp.login.failed",
|
"rdp.login.failed",
|
||||||
|
"rdp.session.logoff",
|
||||||
"rdp.shadow.control.started",
|
"rdp.shadow.control.started",
|
||||||
"rdp.shadow.control.stopped",
|
"rdp.shadow.control.stopped",
|
||||||
"rdp.shadow.control.permission",
|
"rdp.shadow.control.permission",
|
||||||
|
|||||||
Reference in New Issue
Block a user