fix: parse Disc qwinsta rows so RDG flap auto-logoff works (0.5.14)
Empty SESSIONNAME broke session matching; auto-disconnect failed with 'No matching Windows session'.
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.12` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
**Версия:** `0.5.14` · **Деплой:** `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.12` · **Deploy:** `sudo /opt/sac-deploy.sh`
|
**Version:** `0.5.14` · **Deploy:** `sudo /opt/sac-deploy.sh`
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -409,28 +409,54 @@ def filter_windows_sessions_for_user(
|
|||||||
return [s for s in sessions if windows_user_matches_session(user, s.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]:
|
def _qwinsta_sam_account(value: str) -> str:
|
||||||
rows: list[HostSessionRow] = []
|
text = (value or "").strip()
|
||||||
norm_filter = (filter_user or "").strip().lower()
|
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():
|
for line in stdout.splitlines():
|
||||||
text = line.strip()
|
text = line.strip()
|
||||||
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
|
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
|
||||||
continue
|
continue
|
||||||
parts = text.split()
|
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
|
continue
|
||||||
session_name = parts[0].lstrip(">")
|
state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
|
||||||
user_name = parts[1]
|
if not state or state.casefold().startswith("listen"):
|
||||||
try:
|
|
||||||
sid = int(parts[2])
|
|
||||||
except ValueError:
|
|
||||||
continue
|
continue
|
||||||
state = " ".join(parts[3:])
|
before = parts[:id_idx]
|
||||||
if norm_filter and norm_filter not in norm_user(user_name):
|
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
|
continue
|
||||||
rows.append(
|
rows.append(
|
||||||
HostSessionRow(
|
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 rows
|
||||||
|
|
||||||
return parse_qwinsta_sessions(stdout, filter_user=None)
|
return parse_qwinsta_sessions(stdout, filter_user=None)
|
||||||
|
|||||||
@@ -155,13 +155,23 @@ def _disconnect_on_workstation(
|
|||||||
login_event_id=login_event.id if login_event else None,
|
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():
|
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:
|
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(
|
return AutoDisconnectResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
message="No matching Windows session for user",
|
message=message,
|
||||||
trigger_event_id=trigger_event.id,
|
trigger_event_id=trigger_event.id,
|
||||||
workstation_host_id=workstation.id,
|
workstation_host_id=workstation.id,
|
||||||
login_event_id=login_event.id if login_event else None,
|
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:
|
try:
|
||||||
workstation = resolve_client_workstation(db, rdg_success)
|
workstation = resolve_client_workstation(db, rdg_success)
|
||||||
except ClientWorkstationNotFoundError as exc:
|
except ClientWorkstationNotFoundError as exc:
|
||||||
return AutoDisconnectResult(
|
result = AutoDisconnectResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
message=str(exc),
|
message=str(exc),
|
||||||
trigger_event_id=event.id,
|
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)
|
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
|
login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None
|
||||||
|
|||||||
@@ -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.13"
|
APP_VERSION = "0.5.14"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -74,6 +74,32 @@ def test_parse_qwinsta_sessions_filters_user():
|
|||||||
assert filtered[0].session_id == "2"
|
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():
|
def test_event_supports_session_terminate_types():
|
||||||
class HostStub:
|
class HostStub:
|
||||||
os_family = "linux"
|
os_family = "linux"
|
||||||
|
|||||||
@@ -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.13";
|
export const APP_VERSION = "0.5.14";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user