diff --git a/backend/app/services/rdg_display.py b/backend/app/services/rdg_display.py index f00eb86..76bd568 100644 --- a/backend/app/services/rdg_display.py +++ b/backend/app/services/rdg_display.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from app.config import get_settings from app.models import Event from app.services.rdg_client_host import find_windows_host_by_ipv4 -from app.services.rdg_session_flap import event_internal_ip +from app.services.rdg_session_flap import event_internal_ip, resolve_rdg_qwinsta_enabled RDG_TYPES = frozenset( { @@ -115,7 +115,7 @@ def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo summary_parts.append(win_note) summary = " · ".join(summary_parts) - qwinsta_enabled = bool(internal_ip) + qwinsta_enabled = resolve_rdg_qwinsta_enabled(db, event) return RdgDisplayInfo( title=title, @@ -126,7 +126,7 @@ def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo ) -def event_supports_rdg_client_qwinsta(event: Event) -> bool: - if event.type not in RDG_TYPES: - return False - return bool(event_internal_ip(event)) +def event_supports_rdg_client_qwinsta(event: Event, db: Session | None = None) -> bool: + from app.services.rdg_session_flap import resolve_rdg_qwinsta_enabled + + return resolve_rdg_qwinsta_enabled(db, event) diff --git a/backend/app/services/rdg_session_flap.py b/backend/app/services/rdg_session_flap.py index d6c195e..892d5c3 100644 --- a/backend/app/services/rdg_session_flap.py +++ b/backend/app/services/rdg_session_flap.py @@ -172,25 +172,73 @@ def find_rdg_end_after_success(db: Session, success_event: Event) -> Event | Non return None +def find_normal_rdg_end_after_success(db: Session, success_event: Event) -> Event | None: + """303 после 302 с паузой больше flap-окна — штатное завершение сессии.""" + if success_event.type != RDG_SUCCESS_TYPE: + return None + settings = get_settings() + max_sec = settings.sac_rdg_flap_window_max_sec + start_at = _as_utc(success_event.occurred_at) + after_flap = start_at + timedelta(seconds=max_sec) + + candidates = db.scalars( + select(Event) + .where( + Event.host_id == success_event.host_id, + Event.type.in_(RDG_END_TYPES), + Event.occurred_at > after_flap, + Event.id != success_event.id, + ) + .order_by(Event.occurred_at.asc()) + ).all() + + for end in candidates: + if not _users_match(end, success_event): + continue + if not _internal_ips_compatible(end, success_event): + continue + return end + return None + + +def resolve_rdg_qwinsta_enabled(db: Session | None, event: Event) -> bool: + """Кнопка qwinsta/logoff только на 302, пока сессия может быть активна (или RDG flap).""" + if event.type in RDG_END_TYPES: + return False + if event.type != RDG_SUCCESS_TYPE: + return False + if not _event_internal_ip(event): + return False + if db is None: + return True + if find_rdg_end_after_success(db, event) is not None: + return True + if find_normal_rdg_end_after_success(db, event) is not None: + return False + return True + + def resolve_rdg_flap_summary( db: Session, event: Event ) -> tuple[bool, int | None, int | None]: """ (rdg_flap, pair_event_id, qwinsta_event_id). - qwinsta_event_id — всегда 303; для 302 указывает на связанный end-event. + qwinsta_event_id — id события 302 для qwinsta (на 303 кнопку не показываем). """ if event_has_rdg_flap(event): pair_id = _stored_flap_pair_id(event) - return True, pair_id, event.id + if event.type == RDG_SUCCESS_TYPE: + return True, pair_id, event.id + return True, pair_id, pair_id if event.type in RDG_END_TYPES: prior = find_rdg_success_before_end(db, event) if prior is not None: - return True, prior.id, event.id + return True, prior.id, prior.id if event.type == RDG_SUCCESS_TYPE: end = find_rdg_end_after_success(db, event) if end is not None: - return True, end.id, end.id + return True, end.id, event.id return False, None, None diff --git a/backend/app/services/rdg_winrm_actions.py b/backend/app/services/rdg_winrm_actions.py index 137d0d8..048d28f 100644 --- a/backend/app/services/rdg_winrm_actions.py +++ b/backend/app/services/rdg_winrm_actions.py @@ -31,8 +31,8 @@ def _require_win_admin(db: Session): return cfg -def _require_rdg_client_qwinsta(event: Event) -> None: - if event_supports_rdg_client_qwinsta(event): +def _require_rdg_client_qwinsta(db: Session, event: Event) -> None: + if event_supports_rdg_client_qwinsta(event, db): return raise HTTPException( status_code=400, @@ -92,7 +92,7 @@ def _persist_command( def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand: - _require_rdg_client_qwinsta(event) + _require_rdg_client_qwinsta(db, event) cfg = _require_win_admin(db) client_host = _resolve_client(db, event) @@ -135,7 +135,7 @@ def execute_logoff_via_winrm( session_id: int, requested_by: str, ) -> AgentCommand: - _require_rdg_client_qwinsta(event) + _require_rdg_client_qwinsta(db, event) cfg = _require_win_admin(db) client_host = _resolve_client(db, event) diff --git a/backend/app/version.py b/backend/app/version.py index 517d4de..2168010 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.3.3" +APP_VERSION = "0.3.4" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index cf96900..96d72b9 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.3.3" + assert APP_VERSION == "0.3.4" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.3.3" + assert APP_VERSION_LABEL == "Security Alert Center v.0.3.4" diff --git a/backend/tests/test_rdg_display.py b/backend/tests/test_rdg_display.py index 5d13fc7..42a3beb 100644 --- a/backend/tests/test_rdg_display.py +++ b/backend/tests/test_rdg_display.py @@ -111,7 +111,15 @@ def test_event_supports_rdg_client_qwinsta_without_flap(db_session): db_session.add(gw) db_session.commit() event = _rdg_event(db_session, gw=gw) - assert event_supports_rdg_client_qwinsta(event) is True + assert event_supports_rdg_client_qwinsta(event, db_session) is True + + +def test_event_supports_rdg_client_qwinsta_not_on_disconnect(db_session): + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + event = _rdg_event(db_session, gw=gw, event_type="rdg.connection.disconnected", win_id=303) + assert event_supports_rdg_client_qwinsta(event, db_session) is False @pytest.mark.parametrize( @@ -125,4 +133,4 @@ def test_event_supports_rdg_client_qwinsta_requires_internal_ip(db_session, inte event = _rdg_event(db_session, gw=gw, internal_ip=internal_ip or "") if internal_ip is None: event.details = {k: v for k, v in event.details.items() if k != "internal_ip"} - assert event_supports_rdg_client_qwinsta(event) is False + assert event_supports_rdg_client_qwinsta(event, db_session) is False diff --git a/backend/tests/test_rdg_session_flap.py b/backend/tests/test_rdg_session_flap.py index 8b7b5cf..ea632ec 100644 --- a/backend/tests/test_rdg_session_flap.py +++ b/backend/tests/test_rdg_session_flap.py @@ -15,6 +15,7 @@ from app.services.rdg_session_flap import ( event_has_rdg_flap, find_rdg_success_before_end, resolve_rdg_flap_summary, + resolve_rdg_qwinsta_enabled, ) from app.services.event_summary import event_to_summary from tests.test_ingest import VALID_EVENT @@ -193,14 +194,70 @@ def test_resolve_rdg_flap_summary_for_302_and_303(db_session, rdg_settings): assert end_flap is True assert end_pair == start.id - assert end_qwinsta == end.id + assert end_qwinsta == start.id assert start_flap is True assert start_pair == end.id - assert start_qwinsta == end.id + assert start_qwinsta == start.id end_summary = event_to_summary(end, db_session) start_summary = event_to_summary(start, db_session) assert end_summary.rdg_flap is True assert start_summary.rdg_flap is True - assert start_summary.rdg_flap_qwinsta_event_id == end.id + assert start_summary.rdg_flap_qwinsta_event_id == start.id + assert start_summary.rdg_qwinsta_enabled is True + assert end_summary.rdg_qwinsta_enabled is False + + +def test_rdg_qwinsta_disabled_after_normal_session_end(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + user = "B26\\normal.user" + details = {"user": user, "internal_ip": "192.168.163.49"} + + start = _ingest( + db_session, + t0, + type="rdg.connection.success", + category="auth", + severity="info", + title="302", + summary="302", + details=details, + ) + end = _ingest( + db_session, + t0 + timedelta(minutes=20), + type="rdg.connection.disconnected", + category="auth", + severity="info", + title="303", + summary="303", + details=details, + ) + + assert resolve_rdg_qwinsta_enabled(db_session, start) is False + assert resolve_rdg_qwinsta_enabled(db_session, end) is False + + start_summary = event_to_summary(start, db_session) + end_summary = event_to_summary(end, db_session) + assert start_summary.rdg_qwinsta_enabled is False + assert end_summary.rdg_qwinsta_enabled is False + + +def test_rdg_qwinsta_enabled_while_session_open(db_session, rdg_settings): + t0 = datetime.now(timezone.utc) + details = {"user": r"B26\active.user", "internal_ip": "192.168.163.50"} + + start = _ingest( + db_session, + t0, + type="rdg.connection.success", + category="auth", + severity="info", + title="302", + summary="302", + details=details, + ) + + assert resolve_rdg_qwinsta_enabled(db_session, start) is True + assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True diff --git a/backend/tests/test_rdg_winrm_actions.py b/backend/tests/test_rdg_winrm_actions.py index d79d279..e274046 100644 --- a/backend/tests/test_rdg_winrm_actions.py +++ b/backend/tests/test_rdg_winrm_actions.py @@ -9,22 +9,21 @@ from app.models import Event, Host from app.services.winrm_connect import WinRmCmdResult -def _flap_event(db_session, *, gw: Host, ws: Host) -> Event: +def _rdg_success_event(db_session, *, gw: Host, ws: Host) -> Event: event = Event( - event_id="ev-flap-1", + event_id="ev-rdg-302", host_id=gw.id, occurred_at=datetime.now(timezone.utc), received_at=datetime.now(timezone.utc), category="auth", - type="rdg.connection.disconnected", + type="rdg.connection.success", severity="info", - title="RD Gateway event 303", + title="RD Gateway event 302", summary="", payload={}, details={ "user": r"B26\papatramp", "internal_ip": ws.ipv4, - "rdg_flap": True, }, ) db_session.add(event) @@ -50,7 +49,7 @@ def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monke db_session.add_all([ws, gw]) db_session.commit() ws_id = ws.id - event = _flap_event(db_session, gw=gw, ws=ws) + event = _rdg_success_event(db_session, gw=gw, ws=ws) qwinsta_out = " SESSIONNAME USERNAME ID STATE\r\n rdp-tcp#0 B26\\papatramp 2 Active\r\n" @@ -96,12 +95,12 @@ def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatc occurred_at=datetime.now(timezone.utc), received_at=datetime.now(timezone.utc), category="auth", - type="rdg.connection.disconnected", + type="rdg.connection.success", severity="info", - title="303", + title="302", summary="", payload={}, - details={"user": r"B26\user", "internal_ip": "192.168.160.999", "rdg_flap": True}, + details={"user": r"B26\user", "internal_ip": "192.168.160.999"}, ) db_session.add(event) db_session.commit() @@ -185,3 +184,40 @@ def test_qwinsta_rejects_rdg_without_internal_ip(jwt_headers, client, db_session response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) assert response.status_code == 400 assert "internal_ip" in response.json()["detail"].lower() + + +def test_qwinsta_rejects_rdg_303_disconnect(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + ws = Host( + hostname="Andrisonova-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.160.113", + ) + 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() + + event = Event( + event_id="ev-rdg-303", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type="rdg.connection.disconnected", + severity="info", + title="303", + summary="", + payload={}, + details={"user": r"B26\papatramp", "internal_ip": ws.ipv4}, + ) + db_session.add(event) + db_session.commit() + + response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) + assert response.status_code == 400 diff --git a/frontend/src/utils/rdgFlap.ts b/frontend/src/utils/rdgFlap.ts index 49eda75..29b2afe 100644 --- a/frontend/src/utils/rdgFlap.ts +++ b/frontend/src/utils/rdgFlap.ts @@ -13,10 +13,8 @@ export function rdgAccessPathLabel(event: EventSummary): string { } export function rdgQwinstaEventId(event: EventSummary): number | null { - if (event.rdg_flap_qwinsta_event_id) return event.rdg_flap_qwinsta_event_id; - if (event.rdg_qwinsta_enabled) return event.id; - if (event.rdg_flap) return event.id; - return null; + if (!event.rdg_qwinsta_enabled) return null; + return event.rdg_flap_qwinsta_event_id ?? event.id; } /** @deprecated use rdgQwinstaEventId */ diff --git a/frontend/src/version.ts b/frontend/src/version.ts index eb271ce..9a6f563 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.3.3"; +export const APP_VERSION = "0.3.4"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;