diff --git a/README.md b/README.md index 85bdf59..53f4758 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ | **security-alert-center** | Сервер SAC (Ubuntu 24.04) | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент | -**Версия:** `0.3.7` · **Деплой:** `sudo /opt/sac-deploy.sh` +**Версия:** `0.3.8` · **Деплой:** `sudo /opt/sac-deploy.sh` ## Возможности diff --git a/README_en.md b/README_en.md index da84fdb..0a0aa2b 100644 --- a/README_en.md +++ b/README_en.md @@ -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) | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client | -**Version:** `0.3.7` · **Deploy:** `sudo /opt/sac-deploy.sh` +**Version:** `0.3.8` · **Deploy:** `sudo /opt/sac-deploy.sh` ## Features diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 926d831..6dadb78 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -22,7 +22,9 @@ from app.services.agent_commands import ( from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm from app.services.host_sessions import ( event_session_id, + event_session_terminated, event_supports_session_terminate, + mark_event_session_terminated, terminate_session_for_event, ) from app.services.linux_admin_settings import get_effective_linux_admin_config @@ -254,6 +256,8 @@ def post_event_terminate_session( raise HTTPException(status_code=404, detail="Event not found") if not event_supports_session_terminate(event): raise HTTPException(status_code=400, detail="Event type does not support session terminate") + if event_session_terminated(event): + raise HTTPException(status_code=409, detail="Session already terminated for this event") linux_cfg = get_effective_linux_admin_config(db) win_cfg = get_effective_win_admin_config(db) @@ -277,21 +281,17 @@ def post_event_terminate_session( except SshHostTargetMissingError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - if hasattr(result, "exit_code"): - return EventSessionTerminateResponse( - ok=result.ok, - message=result.message, - target=getattr(result, "target", None), - stdout=getattr(result, "stdout", None) or None, - stderr=getattr(result, "stderr", None) or None, - ) - return EventSessionTerminateResponse( + response = EventSessionTerminateResponse( ok=result.ok, message=result.message, target=getattr(result, "target", None), stdout=getattr(result, "stdout", None) or None, stderr=getattr(result, "stderr", None) or None, ) + if result.ok: + mark_event_session_terminated(event, by_username=user.username) + db.commit() + return response @router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse) diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py index 44ba739..793a4df 100644 --- a/backend/app/schemas/list_models.py +++ b/backend/app/schemas/list_models.py @@ -83,6 +83,7 @@ class EventSummary(BaseModel): rdg_flap_qwinsta_event_id: int | None = None rdg_access_path: str | None = None rdg_qwinsta_enabled: bool = False + session_terminated: bool = False model_config = {"from_attributes": True} diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py index 6d117c0..d7ccfe5 100644 --- a/backend/app/services/event_summary.py +++ b/backend/app/services/event_summary.py @@ -3,6 +3,7 @@ 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.host_sessions import event_session_terminated from app.services.rdg_display import build_rdg_display from app.services.rdg_session_flap import resolve_rdg_flap_summary @@ -48,4 +49,5 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary: rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id, rdg_access_path=rdg_access_path, rdg_qwinsta_enabled=rdg_qwinsta_enabled, + session_terminated=event_session_terminated(event), ) diff --git a/backend/app/services/host_sessions.py b/backend/app/services/host_sessions.py index a394938..ba3763a 100644 --- a/backend/app/services/host_sessions.py +++ b/backend/app/services/host_sessions.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import re from dataclasses import dataclass +from datetime import datetime, timezone from app.models import Event, Host from app.services.event_actor_user import extract_event_actor_user @@ -37,6 +38,9 @@ SESSION_EVENT_TYPES_LINUX = frozenset( ) SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"}) +SESSION_TERMINATED_AT_KEY = "session_terminated_at" +SESSION_TERMINATED_BY_KEY = "session_terminated_by" + _LOGIND_SESSION_ID_RE = re.compile(r"^\d+$|^c\d+$", re.IGNORECASE) _LOGIND_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE) _LOGIND_STATES = frozenset( @@ -77,6 +81,22 @@ def _event_login_user(event: Event) -> str: return str(_details_dict(event).get("user") or "").strip() +def event_session_terminated(event: Event) -> bool: + details = _details_dict(event) + if details.get("session_terminated") is True: + return True + at = details.get(SESSION_TERMINATED_AT_KEY) + return at is not None and str(at).strip() != "" + + +def mark_event_session_terminated(event: Event, *, by_username: str | None = None) -> None: + details = dict(_details_dict(event)) + details[SESSION_TERMINATED_AT_KEY] = datetime.now(timezone.utc).isoformat() + if by_username and by_username.strip(): + details[SESSION_TERMINATED_BY_KEY] = by_username.strip() + event.details = details + + def event_session_id(event: Event) -> str | None: details = _details_dict(event) for key in ("session_id", "sid"): diff --git a/backend/app/version.py b/backend/app/version.py index 6433570..4cb3115 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.3.7" +APP_VERSION = "0.3.8" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_host_sessions.py b/backend/tests/test_host_sessions.py index 5d2b24f..3018be0 100644 --- a/backend/tests/test_host_sessions.py +++ b/backend/tests/test_host_sessions.py @@ -5,8 +5,10 @@ from types import SimpleNamespace from app.services.host_sessions import ( HostSessionRow, _event_login_user, + event_session_terminated, event_supports_session_terminate, filter_logind_session_rows, + mark_event_session_terminated, parse_loginctl_sessions, parse_loginctl_sessions_json, parse_qwinsta_sessions, @@ -130,3 +132,18 @@ def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch): win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"), ) assert result.ok is True + + +def test_event_session_terminated_flag(): + event = SimpleNamespace( + type="rdp.login.success", + details={"session_terminated_at": "2026-06-24T10:00:00+00:00"}, + ) + assert event_session_terminated(event) is True + + fresh = SimpleNamespace(type="rdp.login.success", details={"user": "alice"}) + assert event_session_terminated(fresh) is False + + mark_event_session_terminated(fresh, by_username="admin") + assert event_session_terminated(fresh) is True + assert fresh.details["session_terminated_by"] == "admin" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b795f39..e4f7da0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -189,6 +189,7 @@ export interface EventSummary { rdg_flap_qwinsta_event_id?: number | null; rdg_access_path?: string | null; rdg_qwinsta_enabled?: boolean; + session_terminated?: boolean; } export interface AgentCommandResponse { diff --git a/frontend/src/utils/sessionActions.ts b/frontend/src/utils/sessionActions.ts index 6055905..d7e908c 100644 --- a/frontend/src/utils/sessionActions.ts +++ b/frontend/src/utils/sessionActions.ts @@ -14,3 +14,19 @@ export function eventSupportsSessionTerminate(event: Pick) WINDOWS_SESSION_EVENT_TYPES.has(event.type) ); } + +export function showEventSessionTerminateButton( + event: Pick, +): boolean { + return eventSupportsSessionTerminate(event) && !event.session_terminated; +} + +export function markEventSessionTerminatedInList( + items: EventSummary[] | undefined, + eventId: number, +): void { + const item = items?.find((row) => row.id === eventId); + if (item) { + item.session_terminated = true; + } +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 2d47a6f..0b4b5f1 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.3.7"; +export const APP_VERSION = "0.3.8"; 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 d4d86af..f185e3c 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -299,7 +299,7 @@ Обрыв RDS