diff --git a/README.md b/README.md index c6fea97..d0c0043 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.12` · **Деплой:** `sudo /opt/sac-deploy.sh` +**Версия:** `0.5.14` · **Деплой:** `sudo /opt/sac-deploy.sh` ## Возможности diff --git a/README_en.md b/README_en.md index 9276321..88bd0a2 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.12` · **Deploy:** `sudo /opt/sac-deploy.sh` +**Version:** `0.5.14` · **Deploy:** `sudo /opt/sac-deploy.sh` ## Features diff --git a/backend/app/services/host_sessions.py b/backend/app/services/host_sessions.py index 5ce3c8d..ef062e9 100644 --- a/backend/app/services/host_sessions.py +++ b/backend/app/services/host_sessions.py @@ -409,28 +409,54 @@ def filter_windows_sessions_for_user( 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() +def _qwinsta_sam_account(value: str) -> str: + text = (value or "").strip() + if "\\" in text: + text = text.split("\\")[-1] + if "@" in text: + text = text.split("@")[0] + return text.casefold() - def norm_user(value: str) -> str: - return value.replace("B26\\", "").replace("b26\\", "").lower() + +def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]: + """Parse ``qwinsta`` output. + + Disconnected sessions often have an empty SESSIONNAME column, so the line + becomes ``USERNAME ID STATE`` (3 tokens). Older parsing required 4 tokens + and skipped those rows — that broke RDG flap auto-logoff for Disc sessions. + """ + rows: list[HostSessionRow] = [] + filter_sam = _qwinsta_sam_account(filter_user or "") + skip_users = frozenset({"services"}) for line in stdout.splitlines(): text = line.strip() if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"): continue parts = text.split() - if len(parts) < 4: + id_idx: int | None = None + sid = 0 + for i, part in enumerate(parts): + token = part.lstrip(">") + if token.isdigit(): + id_idx = i + sid = int(token) + break + if id_idx is None or id_idx < 1: continue - session_name = parts[0].lstrip(">") - user_name = parts[1] - try: - sid = int(parts[2]) - except ValueError: + state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else "" + if not state or state.casefold().startswith("listen"): continue - state = " ".join(parts[3:]) - if norm_filter and norm_filter not in norm_user(user_name): + before = parts[:id_idx] + if len(before) == 1: + session_name = "" + user_name = before[0].lstrip(">") + else: + session_name = before[0].lstrip(">") + user_name = before[1] + if user_name.casefold() in skip_users: + continue + if filter_sam and filter_sam not in _qwinsta_sam_account(user_name): continue rows.append( HostSessionRow( @@ -441,7 +467,7 @@ def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> li ) ) - if rows or not norm_filter: + if rows or not filter_sam: return rows return parse_qwinsta_sessions(stdout, filter_user=None) diff --git a/backend/app/services/rdp_flap_auto_disconnect.py b/backend/app/services/rdp_flap_auto_disconnect.py index fdb0fc6..85af63e 100644 --- a/backend/app/services/rdp_flap_auto_disconnect.py +++ b/backend/app/services/rdp_flap_auto_disconnect.py @@ -155,13 +155,23 @@ def _disconnect_on_workstation( login_event_id=login_event.id if login_event else None, ) - matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout, filter_user=user), user) + parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user) + matched = _sessions_for_user(parsed, user) if not matched and qwinsta.stdout.strip(): - matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout), user) + parsed = parse_qwinsta_sessions(qwinsta.stdout) + matched = _sessions_for_user(parsed, user) if not matched: + snippet = " ".join(qwinsta.stdout.split())[:240] + parsed_note = f", parsed={len(parsed)} session(s)" if parsed else "" + message = ( + f"No matching Windows session for user {user} on {workstation.hostname}" + f"{parsed_note}" + ) + if snippet: + message = f"{message}; qwinsta: {snippet}" return AutoDisconnectResult( ok=False, - message="No matching Windows session for user", + message=message, trigger_event_id=trigger_event.id, workstation_host_id=workstation.id, login_event_id=login_event.id if login_event else None, @@ -224,11 +234,13 @@ def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult try: workstation = resolve_client_workstation(db, rdg_success) except ClientWorkstationNotFoundError as exc: - return AutoDisconnectResult( + result = AutoDisconnectResult( ok=False, message=str(exc), trigger_event_id=event.id, ) + _mark_auto_disconnect(event, result=result) + return result rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1) login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None diff --git a/backend/app/version.py b/backend/app/version.py index 20c8587..853a99a 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.13" +APP_VERSION = "0.5.14" 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 e87b247..c68bd7a 100644 --- a/backend/tests/test_host_sessions.py +++ b/backend/tests/test_host_sessions.py @@ -74,6 +74,32 @@ def test_parse_qwinsta_sessions_filters_user(): assert filtered[0].session_id == "2" +def test_parse_qwinsta_sessions_disconnected_without_sessionname(): + """Disc rows often omit SESSIONNAME; auto flap logoff relies on these.""" + stdout = """SESSIONNAME USERNAME ID STATE + rdp-tcp#0 B26\\s.shelkovaya 2 Active + B26\\s.shelkovaya 5 Disc + rdp-tcp 65536 Listen + services 0 Disc +""" + rows = parse_qwinsta_sessions(stdout) + assert {(r.session_id, r.state.split()[0], r.session_name) for r in rows} == { + ("2", "Active", "rdp-tcp#0"), + ("5", "Disc", ""), + } + filtered = parse_qwinsta_sessions(stdout, filter_user=r"B26\s.shelkovaya") + assert [r.session_id for r in filtered] == ["2", "5"] + + +def test_parse_qwinsta_sessions_filters_domain_user(): + stdout = """SESSIONNAME USERNAME ID STATE + rdp-tcp#1 B26\\bob 3 Active +""" + rows = parse_qwinsta_sessions(stdout, filter_user=r"B26\bob") + assert len(rows) == 1 + assert rows[0].session_id == "3" + + def test_event_supports_session_terminate_types(): class HostStub: os_family = "linux" diff --git a/frontend/src/version.ts b/frontend/src/version.ts index e2ce694..d1ef23b 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.13"; +export const APP_VERSION = "0.5.14"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;