diff --git a/backend/app/api/v1/dashboards.py b/backend/app/api/v1/dashboards.py
index 8774762..c0724e4 100644
--- a/backend/app/api/v1/dashboards.py
+++ b/backend/app/api/v1/dashboards.py
@@ -25,7 +25,7 @@ def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
.order_by(Event.received_at.desc())
.limit(limit)
).all()
- return [event_to_summary(e) for e in rows]
+ return [event_to_summary(e, db) for e in rows]
class TopHostItem(BaseModel):
diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py
index 2fc51a6..6119025 100644
--- a/backend/app/api/v1/events.py
+++ b/backend/app/api/v1/events.py
@@ -170,7 +170,7 @@ def list_events(
.limit(page_size)
).all()
- items = [event_to_summary(e) for e in rows]
+ items = [event_to_summary(e, db) for e in rows]
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
@@ -266,7 +266,7 @@ def get_event(
)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
- base = event_to_summary(event)
+ base = event_to_summary(event, db)
return EventDetail(
**base.model_dump(),
details=event.details,
diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py
index 79005a6..59c5c7b 100644
--- a/backend/app/schemas/list_models.py
+++ b/backend/app/schemas/list_models.py
@@ -71,6 +71,8 @@ class EventSummary(BaseModel):
summary: str
actor_user: str | None = None
rdg_flap: bool = False
+ rdg_flap_pair_event_id: int | None = None
+ rdg_flap_qwinsta_event_id: int | None = None
model_config = {"from_attributes": True}
diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py
index bfc7ee4..605d89e 100644
--- a/backend/app/services/event_summary.py
+++ b/backend/app/services/event_summary.py
@@ -1,11 +1,21 @@
+from sqlalchemy.orm import Session
+
from app.models.event import Event
from app.schemas.list_models import EventSummary
from app.services.event_actor_user import extract_event_actor_user
-from app.services.rdg_session_flap import event_has_rdg_flap
+from app.services.rdg_session_flap import resolve_rdg_flap_summary
-def event_to_summary(event: Event) -> EventSummary:
+def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
host = event.host
+ rdg_flap = False
+ rdg_flap_pair_event_id: int | None = None
+ rdg_flap_qwinsta_event_id: int | None = None
+ if db is not None:
+ rdg_flap, rdg_flap_pair_event_id, rdg_flap_qwinsta_event_id = resolve_rdg_flap_summary(
+ db, event
+ )
+
return EventSummary(
id=event.id,
event_id=event.event_id,
@@ -21,5 +31,7 @@ def event_to_summary(event: Event) -> EventSummary:
title=event.title,
summary=event.summary,
actor_user=extract_event_actor_user(event.type, event.details),
- rdg_flap=event_has_rdg_flap(event),
+ rdg_flap=rdg_flap,
+ rdg_flap_pair_event_id=rdg_flap_pair_event_id,
+ rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
)
diff --git a/backend/app/services/rdg_session_flap.py b/backend/app/services/rdg_session_flap.py
index 24049f4..d6c195e 100644
--- a/backend/app/services/rdg_session_flap.py
+++ b/backend/app/services/rdg_session_flap.py
@@ -122,3 +122,75 @@ def evaluate_rdg_session_flap(db: Session, event: Event) -> RuleMatch | None:
def event_has_rdg_flap(event: Event) -> bool:
details = event.details if isinstance(event.details, dict) else {}
return details.get("rdg_flap") is True
+
+
+def _stored_flap_pair_id(event: Event) -> int | None:
+ details = event.details if isinstance(event.details, dict) else {}
+ raw = details.get("rdg_flap_pair_event_id")
+ if raw is None:
+ return None
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ return None
+
+
+def find_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
+ """303 с rdg_flap, у которого пара — этот 302 (или вычисляется по окну)."""
+ if success_event.type != RDG_SUCCESS_TYPE:
+ return None
+ settings = get_settings()
+ min_sec = settings.sac_rdg_flap_window_min_sec
+ max_sec = settings.sac_rdg_flap_window_max_sec
+ start_at = _as_utc(success_event.occurred_at)
+ window_start = start_at + timedelta(seconds=min_sec)
+ window_end = 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 >= window_start,
+ Event.occurred_at <= window_end,
+ 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
+ pair_id = _stored_flap_pair_id(end)
+ if pair_id == success_event.id:
+ return end
+ prior = find_rdg_success_before_end(db, end)
+ if prior is not None and prior.id == success_event.id:
+ return end
+ return None
+
+
+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.
+ """
+ if event_has_rdg_flap(event):
+ pair_id = _stored_flap_pair_id(event)
+ return True, pair_id, event.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
+
+ 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 False, None, None
diff --git a/backend/app/version.py b/backend/app/version.py
index 09905d6..8512034 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.20.4"
+APP_VERSION = "0.20.5"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
diff --git a/backend/tests/test_rdg_session_flap.py b/backend/tests/test_rdg_session_flap.py
index 3cfae43..8b7b5cf 100644
--- a/backend/tests/test_rdg_session_flap.py
+++ b/backend/tests/test_rdg_session_flap.py
@@ -14,7 +14,9 @@ from app.services.rdg_session_flap import (
evaluate_rdg_session_flap,
event_has_rdg_flap,
find_rdg_success_before_end,
+ resolve_rdg_flap_summary,
)
+from app.services.event_summary import event_to_summary
from tests.test_ingest import VALID_EVENT
@@ -156,3 +158,49 @@ def test_rdg_flap_dedup_within_30_sec(db_session, rdg_settings):
)
)
assert open_count == 1
+
+
+def test_resolve_rdg_flap_summary_for_302_and_303(db_session, rdg_settings):
+ t0 = datetime.now(timezone.utc)
+ user = "B26\\pair.user"
+ details = {"user": user, "internal_ip": "192.168.163.48"}
+
+ start = _ingest(
+ db_session,
+ t0,
+ type="rdg.connection.success",
+ category="auth",
+ severity="warning",
+ title="RD Gateway event 302",
+ summary="302",
+ details=details,
+ )
+ end = _ingest(
+ db_session,
+ t0 + timedelta(seconds=5),
+ type="rdg.connection.disconnected",
+ category="auth",
+ severity="info",
+ title="RD Gateway event 303",
+ summary="303",
+ details=details,
+ )
+ evaluate_rdg_session_flap(db_session, end)
+ db_session.flush()
+
+ end_flap, end_pair, end_qwinsta = resolve_rdg_flap_summary(db_session, end)
+ start_flap, start_pair, start_qwinsta = resolve_rdg_flap_summary(db_session, start)
+
+ assert end_flap is True
+ assert end_pair == start.id
+ assert end_qwinsta == end.id
+
+ assert start_flap is True
+ assert start_pair == end.id
+ assert start_qwinsta == end.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
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 11f0858..d94861e 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -170,6 +170,8 @@ export interface EventSummary {
summary: string;
actor_user?: string | null;
rdg_flap?: boolean;
+ rdg_flap_pair_event_id?: number | null;
+ rdg_flap_qwinsta_event_id?: number | null;
}
export interface AgentCommandResponse {
diff --git a/frontend/src/components/RdgFlapBadge.vue b/frontend/src/components/RdgFlapBadge.vue
new file mode 100644
index 0000000..c97c3e0
--- /dev/null
+++ b/frontend/src/components/RdgFlapBadge.vue
@@ -0,0 +1,54 @@
+
+
+ RDG flap
+
+ {{ pairArrow }}
+