fix: show RDG flap badge on 302 and 303 events in SAC UI (0.20.5)
Seaca surfaced flap via Problems; the web UI only flagged 303 in DB and had no visible label. Resolve flap pairs at read time and show badge plus qwinsta on both sides of 302→303.
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user