fix: show RDS break only on active or flap 302 events (0.3.4)
Hide Obryv RDS on 303 and on 302 after normal session end; keep for open sessions and RDG flap. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import Event
|
from app.models import Event
|
||||||
from app.services.rdg_client_host import find_windows_host_by_ipv4
|
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(
|
RDG_TYPES = frozenset(
|
||||||
{
|
{
|
||||||
@@ -115,7 +115,7 @@ def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo
|
|||||||
summary_parts.append(win_note)
|
summary_parts.append(win_note)
|
||||||
summary = " · ".join(summary_parts)
|
summary = " · ".join(summary_parts)
|
||||||
|
|
||||||
qwinsta_enabled = bool(internal_ip)
|
qwinsta_enabled = resolve_rdg_qwinsta_enabled(db, event)
|
||||||
|
|
||||||
return RdgDisplayInfo(
|
return RdgDisplayInfo(
|
||||||
title=title,
|
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:
|
def event_supports_rdg_client_qwinsta(event: Event, db: Session | None = None) -> bool:
|
||||||
if event.type not in RDG_TYPES:
|
from app.services.rdg_session_flap import resolve_rdg_qwinsta_enabled
|
||||||
return False
|
|
||||||
return bool(event_internal_ip(event))
|
return resolve_rdg_qwinsta_enabled(db, event)
|
||||||
|
|||||||
@@ -172,25 +172,73 @@ def find_rdg_end_after_success(db: Session, success_event: Event) -> Event | Non
|
|||||||
return None
|
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(
|
def resolve_rdg_flap_summary(
|
||||||
db: Session, event: Event
|
db: Session, event: Event
|
||||||
) -> tuple[bool, int | None, int | None]:
|
) -> tuple[bool, int | None, int | None]:
|
||||||
"""
|
"""
|
||||||
(rdg_flap, pair_event_id, qwinsta_event_id).
|
(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):
|
if event_has_rdg_flap(event):
|
||||||
pair_id = _stored_flap_pair_id(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:
|
if event.type in RDG_END_TYPES:
|
||||||
prior = find_rdg_success_before_end(db, event)
|
prior = find_rdg_success_before_end(db, event)
|
||||||
if prior is not None:
|
if prior is not None:
|
||||||
return True, prior.id, event.id
|
return True, prior.id, prior.id
|
||||||
|
|
||||||
if event.type == RDG_SUCCESS_TYPE:
|
if event.type == RDG_SUCCESS_TYPE:
|
||||||
end = find_rdg_end_after_success(db, event)
|
end = find_rdg_end_after_success(db, event)
|
||||||
if end is not None:
|
if end is not None:
|
||||||
return True, end.id, end.id
|
return True, end.id, event.id
|
||||||
|
|
||||||
return False, None, None
|
return False, None, None
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ def _require_win_admin(db: Session):
|
|||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def _require_rdg_client_qwinsta(event: Event) -> None:
|
def _require_rdg_client_qwinsta(db: Session, event: Event) -> None:
|
||||||
if event_supports_rdg_client_qwinsta(event):
|
if event_supports_rdg_client_qwinsta(event, db):
|
||||||
return
|
return
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -92,7 +92,7 @@ def _persist_command(
|
|||||||
|
|
||||||
|
|
||||||
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
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)
|
cfg = _require_win_admin(db)
|
||||||
client_host = _resolve_client(db, event)
|
client_host = _resolve_client(db, event)
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ def execute_logoff_via_winrm(
|
|||||||
session_id: int,
|
session_id: int,
|
||||||
requested_by: str,
|
requested_by: str,
|
||||||
) -> AgentCommand:
|
) -> AgentCommand:
|
||||||
_require_rdg_client_qwinsta(event)
|
_require_rdg_client_qwinsta(db, event)
|
||||||
cfg = _require_win_admin(db)
|
cfg = _require_win_admin(db)
|
||||||
client_host = _resolve_client(db, event)
|
client_host = _resolve_client(db, event)
|
||||||
|
|
||||||
|
|||||||
@@ -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.3.3"
|
APP_VERSION = "0.3.4"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
|||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.3.3"
|
assert APP_VERSION == "0.3.4"
|
||||||
assert APP_NAME == "Security Alert Center"
|
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"
|
||||||
|
|||||||
@@ -111,7 +111,15 @@ def test_event_supports_rdg_client_qwinsta_without_flap(db_session):
|
|||||||
db_session.add(gw)
|
db_session.add(gw)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
event = _rdg_event(db_session, gw=gw)
|
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(
|
@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 "")
|
event = _rdg_event(db_session, gw=gw, internal_ip=internal_ip or "")
|
||||||
if internal_ip is None:
|
if internal_ip is None:
|
||||||
event.details = {k: v for k, v in event.details.items() if k != "internal_ip"}
|
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
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.services.rdg_session_flap import (
|
|||||||
event_has_rdg_flap,
|
event_has_rdg_flap,
|
||||||
find_rdg_success_before_end,
|
find_rdg_success_before_end,
|
||||||
resolve_rdg_flap_summary,
|
resolve_rdg_flap_summary,
|
||||||
|
resolve_rdg_qwinsta_enabled,
|
||||||
)
|
)
|
||||||
from app.services.event_summary import event_to_summary
|
from app.services.event_summary import event_to_summary
|
||||||
from tests.test_ingest import VALID_EVENT
|
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_flap is True
|
||||||
assert end_pair == start.id
|
assert end_pair == start.id
|
||||||
assert end_qwinsta == end.id
|
assert end_qwinsta == start.id
|
||||||
|
|
||||||
assert start_flap is True
|
assert start_flap is True
|
||||||
assert start_pair == end.id
|
assert start_pair == end.id
|
||||||
assert start_qwinsta == end.id
|
assert start_qwinsta == start.id
|
||||||
|
|
||||||
end_summary = event_to_summary(end, db_session)
|
end_summary = event_to_summary(end, db_session)
|
||||||
start_summary = event_to_summary(start, db_session)
|
start_summary = event_to_summary(start, db_session)
|
||||||
assert end_summary.rdg_flap is True
|
assert end_summary.rdg_flap is True
|
||||||
assert start_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
|
||||||
|
|||||||
@@ -9,22 +9,21 @@ from app.models import Event, Host
|
|||||||
from app.services.winrm_connect import WinRmCmdResult
|
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 = Event(
|
||||||
event_id="ev-flap-1",
|
event_id="ev-rdg-302",
|
||||||
host_id=gw.id,
|
host_id=gw.id,
|
||||||
occurred_at=datetime.now(timezone.utc),
|
occurred_at=datetime.now(timezone.utc),
|
||||||
received_at=datetime.now(timezone.utc),
|
received_at=datetime.now(timezone.utc),
|
||||||
category="auth",
|
category="auth",
|
||||||
type="rdg.connection.disconnected",
|
type="rdg.connection.success",
|
||||||
severity="info",
|
severity="info",
|
||||||
title="RD Gateway event 303",
|
title="RD Gateway event 302",
|
||||||
summary="",
|
summary="",
|
||||||
payload={},
|
payload={},
|
||||||
details={
|
details={
|
||||||
"user": r"B26\papatramp",
|
"user": r"B26\papatramp",
|
||||||
"internal_ip": ws.ipv4,
|
"internal_ip": ws.ipv4,
|
||||||
"rdg_flap": True,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db_session.add(event)
|
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.add_all([ws, gw])
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
ws_id = ws.id
|
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"
|
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),
|
occurred_at=datetime.now(timezone.utc),
|
||||||
received_at=datetime.now(timezone.utc),
|
received_at=datetime.now(timezone.utc),
|
||||||
category="auth",
|
category="auth",
|
||||||
type="rdg.connection.disconnected",
|
type="rdg.connection.success",
|
||||||
severity="info",
|
severity="info",
|
||||||
title="303",
|
title="302",
|
||||||
summary="",
|
summary="",
|
||||||
payload={},
|
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.add(event)
|
||||||
db_session.commit()
|
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)
|
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "internal_ip" in response.json()["detail"].lower()
|
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
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ export function rdgAccessPathLabel(event: EventSummary): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function rdgQwinstaEventId(event: EventSummary): number | null {
|
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 null;
|
||||||
if (event.rdg_qwinsta_enabled) return event.id;
|
return event.rdg_flap_qwinsta_event_id ?? event.id;
|
||||||
if (event.rdg_flap) return event.id;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated use rdgQwinstaEventId */
|
/** @deprecated use rdgQwinstaEventId */
|
||||||
|
|||||||
@@ -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.3.3";
|
export const APP_VERSION = "0.3.4";
|
||||||
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