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 @@ + + + + + diff --git a/frontend/src/composables/useRdgQwinsta.ts b/frontend/src/composables/useRdgQwinsta.ts index e9cd1a1..f5de5ca 100644 --- a/frontend/src/composables/useRdgQwinsta.ts +++ b/frontend/src/composables/useRdgQwinsta.ts @@ -114,10 +114,11 @@ export function useRdgQwinsta() { } async function runQwinsta(event: EventSummary) { + const targetId = event.rdg_flap_qwinsta_event_id ?? event.id; qwinstaLoadingId.value = event.id; qwinstaModal.value = { open: true, - eventId: event.id, + eventId: targetId, actorUser: event.actor_user || "", commandUuid: "", target: "", @@ -130,13 +131,13 @@ export function useRdgQwinsta() { logoffId: null, }; try { - const cmd = await postEventQwinsta(event.id); + const cmd = await postEventQwinsta(targetId); applyCommandMeta(qwinstaModal.value, cmd); qwinstaModal.value.commandUuid = cmd.command_uuid; - if (applyCommandResult(event.id, event.actor_user || "", cmd)) { + if (applyCommandResult(targetId, event.actor_user || "", cmd)) { return; } - pollQwinstaCommand(event.id, cmd.command_uuid, event.actor_user || ""); + pollQwinstaCommand(targetId, cmd.command_uuid, event.actor_user || ""); } catch (e) { qwinstaModal.value.loading = false; qwinstaModal.value.error = e instanceof Error ? e.message : "Не удалось отправить qwinsta"; diff --git a/frontend/src/utils/rdgFlap.ts b/frontend/src/utils/rdgFlap.ts new file mode 100644 index 0000000..fb10778 --- /dev/null +++ b/frontend/src/utils/rdgFlap.ts @@ -0,0 +1,15 @@ +import type { EventSummary } from "../api"; + +export function hasRdgFlapUi(event: EventSummary): boolean { + return Boolean(event.rdg_flap || event.rdg_flap_qwinsta_event_id); +} + +export function rdgFlapQwinstaEventId(event: EventSummary): number | null { + if (event.rdg_flap_qwinsta_event_id) return event.rdg_flap_qwinsta_event_id; + return event.rdg_flap ? event.id : null; +} + +export function rdgFlapPairLabel(event: EventSummary): string { + if (!event.rdg_flap_pair_event_id) return ""; + return event.type === "rdg.connection.success" ? "→" : "←"; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 82fa1ed..65cd5cb 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.20.4"; +export const APP_VERSION = "0.20.5"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 46ac85e..ee6b6d2 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -281,12 +281,15 @@ {{ e.severity }} - {{ e.title }} + + + {{ e.title }} +