fix: hide ????????? after session terminate (0.3.8)
Persist session_terminated in event details, expose in API, and update Events/Dashboard UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
|
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
|
||||||
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
|
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
|
||||||
|
|
||||||
**Версия:** `0.3.7` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
**Версия:** `0.3.8` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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) |
|
| **security-alert-center** | SAC server (Ubuntu 24.04) |
|
||||||
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
|
| [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
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -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.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
|
||||||
from app.services.host_sessions import (
|
from app.services.host_sessions import (
|
||||||
event_session_id,
|
event_session_id,
|
||||||
|
event_session_terminated,
|
||||||
event_supports_session_terminate,
|
event_supports_session_terminate,
|
||||||
|
mark_event_session_terminated,
|
||||||
terminate_session_for_event,
|
terminate_session_for_event,
|
||||||
)
|
)
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
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")
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
if not event_supports_session_terminate(event):
|
if not event_supports_session_terminate(event):
|
||||||
raise HTTPException(status_code=400, detail="Event type does not support session terminate")
|
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)
|
linux_cfg = get_effective_linux_admin_config(db)
|
||||||
win_cfg = get_effective_win_admin_config(db)
|
win_cfg = get_effective_win_admin_config(db)
|
||||||
@@ -277,21 +281,17 @@ def post_event_terminate_session(
|
|||||||
except SshHostTargetMissingError as exc:
|
except SshHostTargetMissingError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
if hasattr(result, "exit_code"):
|
response = EventSessionTerminateResponse(
|
||||||
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(
|
|
||||||
ok=result.ok,
|
ok=result.ok,
|
||||||
message=result.message,
|
message=result.message,
|
||||||
target=getattr(result, "target", None),
|
target=getattr(result, "target", None),
|
||||||
stdout=getattr(result, "stdout", None) or None,
|
stdout=getattr(result, "stdout", None) or None,
|
||||||
stderr=getattr(result, "stderr", 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)
|
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class EventSummary(BaseModel):
|
|||||||
rdg_flap_qwinsta_event_id: int | None = None
|
rdg_flap_qwinsta_event_id: int | None = None
|
||||||
rdg_access_path: str | None = None
|
rdg_access_path: str | None = None
|
||||||
rdg_qwinsta_enabled: bool = False
|
rdg_qwinsta_enabled: bool = False
|
||||||
|
session_terminated: bool = False
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
from app.schemas.list_models import EventSummary
|
from app.schemas.list_models import EventSummary
|
||||||
from app.services.event_actor_user import extract_event_actor_user
|
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_display import build_rdg_display
|
||||||
from app.services.rdg_session_flap import resolve_rdg_flap_summary
|
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_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
|
||||||
rdg_access_path=rdg_access_path,
|
rdg_access_path=rdg_access_path,
|
||||||
rdg_qwinsta_enabled=rdg_qwinsta_enabled,
|
rdg_qwinsta_enabled=rdg_qwinsta_enabled,
|
||||||
|
session_terminated=event_session_terminated(event),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.models import Event, Host
|
from app.models import Event, Host
|
||||||
from app.services.event_actor_user import extract_event_actor_user
|
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_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_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_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE)
|
||||||
_LOGIND_STATES = frozenset(
|
_LOGIND_STATES = frozenset(
|
||||||
@@ -77,6 +81,22 @@ def _event_login_user(event: Event) -> str:
|
|||||||
return str(_details_dict(event).get("user") or "").strip()
|
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:
|
def event_session_id(event: Event) -> str | None:
|
||||||
details = _details_dict(event)
|
details = _details_dict(event)
|
||||||
for key in ("session_id", "sid"):
|
for key in ("session_id", "sid"):
|
||||||
|
|||||||
@@ -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.7"
|
APP_VERSION = "0.3.8"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ from types import SimpleNamespace
|
|||||||
from app.services.host_sessions import (
|
from app.services.host_sessions import (
|
||||||
HostSessionRow,
|
HostSessionRow,
|
||||||
_event_login_user,
|
_event_login_user,
|
||||||
|
event_session_terminated,
|
||||||
event_supports_session_terminate,
|
event_supports_session_terminate,
|
||||||
filter_logind_session_rows,
|
filter_logind_session_rows,
|
||||||
|
mark_event_session_terminated,
|
||||||
parse_loginctl_sessions,
|
parse_loginctl_sessions,
|
||||||
parse_loginctl_sessions_json,
|
parse_loginctl_sessions_json,
|
||||||
parse_qwinsta_sessions,
|
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"),
|
win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"),
|
||||||
)
|
)
|
||||||
assert result.ok is True
|
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"
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export interface EventSummary {
|
|||||||
rdg_flap_qwinsta_event_id?: number | null;
|
rdg_flap_qwinsta_event_id?: number | null;
|
||||||
rdg_access_path?: string | null;
|
rdg_access_path?: string | null;
|
||||||
rdg_qwinsta_enabled?: boolean;
|
rdg_qwinsta_enabled?: boolean;
|
||||||
|
session_terminated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentCommandResponse {
|
export interface AgentCommandResponse {
|
||||||
|
|||||||
@@ -14,3 +14,19 @@ export function eventSupportsSessionTerminate(event: Pick<EventSummary, "type">)
|
|||||||
WINDOWS_SESSION_EVENT_TYPES.has(event.type)
|
WINDOWS_SESSION_EVENT_TYPES.has(event.type)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function showEventSessionTerminateButton(
|
||||||
|
event: Pick<EventSummary, "type" | "session_terminated">,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.7";
|
export const APP_VERSION = "0.3.8";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -299,7 +299,7 @@
|
|||||||
Обрыв RDS
|
Обрыв RDS
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="eventSupportsSessionTerminate(e)"
|
v-if="showEventSessionTerminateButton(e)"
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary dash-qwinsta-btn"
|
class="secondary dash-qwinsta-btn"
|
||||||
:disabled="sessionTerminateLoadingId === e.id"
|
:disabled="sessionTerminateLoadingId === e.id"
|
||||||
@@ -342,6 +342,7 @@ import { onMounted, onUnmounted, ref } from "vue";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
|
ApiError,
|
||||||
getToken,
|
getToken,
|
||||||
postEventTerminateSession,
|
postEventTerminateSession,
|
||||||
type DashboardSummary,
|
type DashboardSummary,
|
||||||
@@ -353,7 +354,7 @@ import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
|||||||
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||||
import { formatServerName } from "../utils/hostDisplay";
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||||
import { eventSupportsSessionTerminate } from "../utils/sessionActions";
|
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
|
||||||
|
|
||||||
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
||||||
const sessionTerminateLoadingId = ref<number | null>(null);
|
const sessionTerminateLoadingId = ref<number | null>(null);
|
||||||
@@ -386,8 +387,13 @@ async function runSessionTerminate(event: EventSummary) {
|
|||||||
const res = await postEventTerminateSession(event.id);
|
const res = await postEventTerminateSession(event.id);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
window.alert(res.message);
|
window.alert(res.message);
|
||||||
|
} else {
|
||||||
|
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 409) {
|
||||||
|
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
|
||||||
|
}
|
||||||
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
|
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
|
||||||
} finally {
|
} finally {
|
||||||
sessionTerminateLoadingId.value = null;
|
sessionTerminateLoadingId.value = null;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
Обрыв RDS
|
Обрыв RDS
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="eventSupportsSessionTerminate(e)"
|
v-if="showEventSessionTerminateButton(e)"
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary events-qwinsta-btn"
|
class="secondary events-qwinsta-btn"
|
||||||
:disabled="sessionTerminateLoadingId === e.id"
|
:disabled="sessionTerminateLoadingId === e.id"
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||||
import { apiFetch, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api";
|
import { apiFetch, ApiError, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api";
|
||||||
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
|
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
|
||||||
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
||||||
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||||
@@ -120,7 +120,7 @@ import {
|
|||||||
type EventsListState,
|
type EventsListState,
|
||||||
} from "../utils/eventsListQuery";
|
} from "../utils/eventsListQuery";
|
||||||
import { formatServerName } from "../utils/hostDisplay";
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
import { eventSupportsSessionTerminate } from "../utils/sessionActions";
|
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -227,8 +227,13 @@ async function runSessionTerminate(event: EventSummary) {
|
|||||||
const res = await postEventTerminateSession(event.id);
|
const res = await postEventTerminateSession(event.id);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
window.alert(res.message);
|
window.alert(res.message);
|
||||||
|
} else {
|
||||||
|
markEventSessionTerminatedInList(data.value?.items, event.id);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 409) {
|
||||||
|
markEventSessionTerminatedInList(data.value?.items, event.id);
|
||||||
|
}
|
||||||
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
|
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
|
||||||
} finally {
|
} finally {
|
||||||
sessionTerminateLoadingId.value = null;
|
sessionTerminateLoadingId.value = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user