feat: RDG session flap rule, qwinsta/logoff queue and dashboard UI (0.9.13)
Detect 302→303 within 1–10s, Problem with 30s dedup, rdg_flap flag. Agent command poll API, admin qwinsta/logoff from Overview. Migration 016.
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
**Версия:** `0.9.12`
|
**Версия:** `0.9.13`
|
||||||
**Стек:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
**Стек:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
||||||
**Деплой:** `sudo /opt/sac-deploy.sh` (см. `deploy/sac-deploy.sh`)
|
**Деплой:** `sudo /opt/sac-deploy.sh` (см. `deploy/sac-deploy.sh`)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Self-hosted hub for collecting, storing, and displaying security events from Lin
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Version:** `0.9.12`
|
**Version:** `0.9.13`
|
||||||
**Stack:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
**Stack:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
||||||
**Deploy:** `sudo /opt/sac-deploy.sh` (see `deploy/sac-deploy.sh`)
|
**Deploy:** `sudo /opt/sac-deploy.sh` (see `deploy/sac-deploy.sh`)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Revision ID: 016
|
||||||
|
Revises: 015
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "016"
|
||||||
|
down_revision: Union[str, None] = "015"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"agent_commands",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("command_uuid", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("host_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("command_type", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("result_stdout", sa.Text(), nullable=True),
|
||||||
|
sa.Column("result_stderr", sa.Text(), nullable=True),
|
||||||
|
sa.Column("requested_by", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("command_uuid"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_agent_commands_command_uuid", "agent_commands", ["command_uuid"])
|
||||||
|
op.create_index("ix_agent_commands_host_id", "agent_commands", ["host_id"])
|
||||||
|
op.create_index("ix_agent_commands_status", "agent_commands", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_agent_commands_status", table_name="agent_commands")
|
||||||
|
op.drop_index("ix_agent_commands_host_id", table_name="agent_commands")
|
||||||
|
op.drop_index("ix_agent_commands_command_uuid", table_name="agent_commands")
|
||||||
|
op.drop_table("agent_commands")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.api_key import get_api_key_auth
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.agent_commands import (
|
||||||
|
command_to_dict,
|
||||||
|
complete_command,
|
||||||
|
get_command_for_host_poll,
|
||||||
|
resolve_host_by_agent_instance_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandResultBody(BaseModel):
|
||||||
|
status: str = Field(pattern="^(completed|failed)$")
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandsPollResponse(BaseModel):
|
||||||
|
commands: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/commands", response_model=AgentCommandsPollResponse)
|
||||||
|
def poll_agent_commands(
|
||||||
|
agent_instance_id: str = Query(..., min_length=1),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_api_key: str = Depends(get_api_key_auth),
|
||||||
|
) -> AgentCommandsPollResponse:
|
||||||
|
host = resolve_host_by_agent_instance_id(db, agent_instance_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown agent_instance_id")
|
||||||
|
pending = get_command_for_host_poll(db, host)
|
||||||
|
return AgentCommandsPollResponse(
|
||||||
|
commands=[command_to_dict(c, include_run_as=True) for c in pending]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/commands/{command_uuid}/result")
|
||||||
|
def post_agent_command_result(
|
||||||
|
command_uuid: str,
|
||||||
|
body: AgentCommandResultBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_api_key: str = Depends(get_api_key_auth),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
cmd = complete_command(
|
||||||
|
db,
|
||||||
|
command_uuid,
|
||||||
|
status=body.status,
|
||||||
|
stdout=body.stdout,
|
||||||
|
stderr=body.stderr,
|
||||||
|
)
|
||||||
|
if cmd is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Command not found")
|
||||||
|
db.commit()
|
||||||
|
return {"status": cmd.status, "command_uuid": cmd.command_uuid}
|
||||||
@@ -9,11 +9,17 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from app.auth.api_key import get_api_key_auth
|
from app.auth.api_key import get_api_key_auth
|
||||||
from app.auth.jwt_auth import get_current_user
|
from app.auth.jwt_auth import get_current_user, require_admin
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Event, Host
|
from app.models import Event, Host
|
||||||
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
||||||
|
from app.services.agent_commands import (
|
||||||
|
command_to_dict,
|
||||||
|
get_command_by_uuid,
|
||||||
|
queue_logoff,
|
||||||
|
queue_qwinsta,
|
||||||
|
)
|
||||||
from app.services.ingest import ingest_event
|
from app.services.ingest import ingest_event
|
||||||
from app.services.event_summary import event_to_summary
|
from app.services.event_summary import event_to_summary
|
||||||
from app.services.problems import maybe_create_problem
|
from app.services.problems import maybe_create_problem
|
||||||
@@ -168,6 +174,94 @@ def list_events(
|
|||||||
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
|
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandResponse(BaseModel):
|
||||||
|
command_uuid: str
|
||||||
|
command_type: str
|
||||||
|
status: str
|
||||||
|
result_stdout: str | None = None
|
||||||
|
result_stderr: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
completed_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LogoffActionBody(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
|
||||||
|
def post_event_qwinsta(
|
||||||
|
event_db_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_admin),
|
||||||
|
) -> AgentCommandResponse:
|
||||||
|
event = db.get(Event, event_db_id)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
cmd = queue_qwinsta(db, event, requested_by=str(user))
|
||||||
|
db.commit()
|
||||||
|
data = command_to_dict(cmd)
|
||||||
|
return AgentCommandResponse(
|
||||||
|
command_uuid=data["id"],
|
||||||
|
command_type=data["type"],
|
||||||
|
status=data["status"],
|
||||||
|
result_stdout=data.get("result_stdout"),
|
||||||
|
result_stderr=data.get("result_stderr"),
|
||||||
|
created_at=data.get("created_at"),
|
||||||
|
completed_at=data.get("completed_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_db_id}/actions/logoff", response_model=AgentCommandResponse)
|
||||||
|
def post_event_logoff(
|
||||||
|
event_db_id: int,
|
||||||
|
body: LogoffActionBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_admin),
|
||||||
|
) -> AgentCommandResponse:
|
||||||
|
event = db.get(Event, event_db_id)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
cmd = queue_logoff(
|
||||||
|
db,
|
||||||
|
event,
|
||||||
|
session_id=body.session_id,
|
||||||
|
requested_by=str(user),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
data = command_to_dict(cmd)
|
||||||
|
return AgentCommandResponse(
|
||||||
|
command_uuid=data["id"],
|
||||||
|
command_type=data["type"],
|
||||||
|
status=data["status"],
|
||||||
|
result_stdout=data.get("result_stdout"),
|
||||||
|
result_stderr=data.get("result_stderr"),
|
||||||
|
created_at=data.get("created_at"),
|
||||||
|
completed_at=data.get("completed_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{event_db_id}/actions/{command_uuid}", response_model=AgentCommandResponse)
|
||||||
|
def get_event_action_status(
|
||||||
|
event_db_id: int,
|
||||||
|
command_uuid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(get_current_user),
|
||||||
|
) -> AgentCommandResponse:
|
||||||
|
cmd = get_command_by_uuid(db, command_uuid)
|
||||||
|
if cmd is None or cmd.event_id != event_db_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Command not found")
|
||||||
|
data = command_to_dict(cmd)
|
||||||
|
return AgentCommandResponse(
|
||||||
|
command_uuid=data["id"],
|
||||||
|
command_type=data["type"],
|
||||||
|
status=data["status"],
|
||||||
|
result_stdout=data.get("result_stdout"),
|
||||||
|
result_stderr=data.get("result_stderr"),
|
||||||
|
created_at=data.get("created_at"),
|
||||||
|
completed_at=data.get("completed_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{event_db_id}", response_model=EventDetail)
|
@router.get("/{event_db_id}", response_model=EventDetail)
|
||||||
def get_event(
|
def get_event(
|
||||||
event_db_id: int,
|
event_db_id: int,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
|
from app.api.v1 import agent, auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(agent.router)
|
||||||
api_router.include_router(events.router)
|
api_router.include_router(events.router)
|
||||||
api_router.include_router(hosts.router)
|
api_router.include_router(hosts.router)
|
||||||
api_router.include_router(problems.router)
|
api_router.include_router(problems.router)
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ class Settings(BaseSettings):
|
|||||||
sac_privilege_spike_window_minutes: int = 10
|
sac_privilege_spike_window_minutes: int = 10
|
||||||
sac_privilege_spike_threshold: int = 10
|
sac_privilege_spike_threshold: int = 10
|
||||||
|
|
||||||
|
# rule:rdg_session_flap — RD Gateway 302→303 within window
|
||||||
|
sac_rdg_flap_window_min_sec: int = 1
|
||||||
|
sac_rdg_flap_window_max_sec: int = 10
|
||||||
|
sac_rdg_flap_dedup_sec: int = 30
|
||||||
|
|
||||||
|
# Windows admin for agent qwinsta/logoff (domain-wide)
|
||||||
|
sac_win_admin_user: str = ""
|
||||||
|
sac_win_admin_password: str = ""
|
||||||
|
|
||||||
# Retention (app.jobs.retention / systemd timer)
|
# Retention (app.jobs.retention / systemd timer)
|
||||||
sac_events_retention_days: int = 90
|
sac_events_retention_days: int = 90
|
||||||
sac_problems_retention_days: int = 180
|
sac_problems_retention_days: int = 180
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from app.models.agent_command import AgentCommand
|
||||||
from app.models.api_key import ApiKey
|
from app.models.api_key import ApiKey
|
||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
from app.models.host import Host
|
from app.models.host import Host
|
||||||
@@ -16,6 +17,7 @@ from app.models.mobile_refresh_token import MobileRefreshToken
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApiKey",
|
"ApiKey",
|
||||||
|
"AgentCommand",
|
||||||
"Event",
|
"Event",
|
||||||
"Host",
|
"Host",
|
||||||
"NotificationChannel",
|
"NotificationChannel",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommand(Base):
|
||||||
|
__tablename__ = "agent_commands"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
command_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True)
|
||||||
|
host_id: Mapped[int] = mapped_column(ForeignKey("hosts.id", ondelete="CASCADE"), index=True)
|
||||||
|
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
command_type: Mapped[str] = mapped_column(String(32))
|
||||||
|
params: Mapped[dict | None] = mapped_column(JSONB, default=dict)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
|
||||||
|
result_stdout: Mapped[str | None] = mapped_column(Text)
|
||||||
|
result_stderr: Mapped[str | None] = mapped_column(Text)
|
||||||
|
requested_by: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
host: Mapped["Host"] = relationship(back_populates="agent_commands")
|
||||||
|
event: Mapped["Event | None"] = relationship()
|
||||||
@@ -28,3 +28,4 @@ class Host(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
events: Mapped[list["Event"]] = relationship(back_populates="host")
|
events: Mapped[list["Event"]] = relationship(back_populates="host")
|
||||||
|
agent_commands: Mapped[list["AgentCommand"]] = relationship(back_populates="host")
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class EventSummary(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
summary: str
|
summary: str
|
||||||
actor_user: str | None = None
|
actor_user: str | None = None
|
||||||
|
rdg_flap: bool = False
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Queue and resolve agent commands (qwinsta, logoff)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import AgentCommand, Event, Host
|
||||||
|
from app.services.rdg_session_flap import event_has_rdg_flap
|
||||||
|
|
||||||
|
|
||||||
|
def _win_admin_configured() -> bool:
|
||||||
|
settings = get_settings()
|
||||||
|
return bool(settings.sac_win_admin_user.strip() and settings.sac_win_admin_password.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def win_admin_run_as() -> dict[str, str] | None:
|
||||||
|
if not _win_admin_configured():
|
||||||
|
return None
|
||||||
|
settings = get_settings()
|
||||||
|
return {
|
||||||
|
"user": settings.sac_win_admin_user.strip(),
|
||||||
|
"password": settings.sac_win_admin_password,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
|
||||||
|
out = {
|
||||||
|
"id": cmd.command_uuid,
|
||||||
|
"type": cmd.command_type,
|
||||||
|
"params": cmd.params if isinstance(cmd.params, dict) else {},
|
||||||
|
"status": cmd.status,
|
||||||
|
"result_stdout": cmd.result_stdout,
|
||||||
|
"result_stderr": cmd.result_stderr,
|
||||||
|
"created_at": cmd.created_at.isoformat() if cmd.created_at else None,
|
||||||
|
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
||||||
|
}
|
||||||
|
if include_run_as and cmd.status == "pending":
|
||||||
|
run_as = win_admin_run_as()
|
||||||
|
if run_as:
|
||||||
|
out["run_as"] = run_as
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def queue_qwinsta(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||||
|
if not event_has_rdg_flap(event):
|
||||||
|
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
||||||
|
if not _win_admin_configured():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="SAC_WIN_ADMIN_USER / SAC_WIN_ADMIN_PASSWORD not configured",
|
||||||
|
)
|
||||||
|
details = event.details if isinstance(event.details, dict) else {}
|
||||||
|
user = details.get("user")
|
||||||
|
cmd = AgentCommand(
|
||||||
|
command_uuid=str(uuid.uuid4()),
|
||||||
|
host_id=event.host_id,
|
||||||
|
event_id=event.id,
|
||||||
|
command_type="qwinsta",
|
||||||
|
params={"user": user} if user else {},
|
||||||
|
status="pending",
|
||||||
|
requested_by=requested_by,
|
||||||
|
)
|
||||||
|
db.add(cmd)
|
||||||
|
db.flush()
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def queue_logoff(
|
||||||
|
db: Session,
|
||||||
|
event: Event,
|
||||||
|
*,
|
||||||
|
session_id: int,
|
||||||
|
requested_by: str,
|
||||||
|
) -> AgentCommand:
|
||||||
|
if not event_has_rdg_flap(event):
|
||||||
|
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
||||||
|
if not _win_admin_configured():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="SAC_WIN_ADMIN_USER / SAC_WIN_ADMIN_PASSWORD not configured",
|
||||||
|
)
|
||||||
|
cmd = AgentCommand(
|
||||||
|
command_uuid=str(uuid.uuid4()),
|
||||||
|
host_id=event.host_id,
|
||||||
|
event_id=event.id,
|
||||||
|
command_type="logoff",
|
||||||
|
params={"session_id": session_id},
|
||||||
|
status="pending",
|
||||||
|
requested_by=requested_by,
|
||||||
|
)
|
||||||
|
db.add(cmd)
|
||||||
|
db.flush()
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(AgentCommand)
|
||||||
|
.where(
|
||||||
|
AgentCommand.host_id == host.id,
|
||||||
|
AgentCommand.status == "pending",
|
||||||
|
)
|
||||||
|
.order_by(AgentCommand.created_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_host_by_agent_instance_id(db: Session, agent_instance_id: str) -> Host | None:
|
||||||
|
if not agent_instance_id.strip():
|
||||||
|
return None
|
||||||
|
return db.scalar(select(Host).where(Host.agent_instance_id == agent_instance_id.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def complete_command(
|
||||||
|
db: Session,
|
||||||
|
command_uuid: str,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
stdout: str | None = None,
|
||||||
|
stderr: str | None = None,
|
||||||
|
) -> AgentCommand | None:
|
||||||
|
cmd = db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||||
|
if cmd is None:
|
||||||
|
return None
|
||||||
|
if cmd.status != "pending":
|
||||||
|
return cmd
|
||||||
|
cmd.status = status
|
||||||
|
cmd.result_stdout = stdout
|
||||||
|
cmd.result_stderr = stderr
|
||||||
|
cmd.completed_at = datetime.now(timezone.utc)
|
||||||
|
db.flush()
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def get_command_by_uuid(db: Session, command_uuid: str) -> AgentCommand | None:
|
||||||
|
return db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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.rdg_session_flap import event_has_rdg_flap
|
||||||
|
|
||||||
|
|
||||||
def event_to_summary(event: Event) -> EventSummary:
|
def event_to_summary(event: Event) -> EventSummary:
|
||||||
@@ -20,4 +21,5 @@ def event_to_summary(event: Event) -> EventSummary:
|
|||||||
title=event.title,
|
title=event.title,
|
||||||
summary=event.summary,
|
summary=event.summary,
|
||||||
actor_user=extract_event_actor_user(event.type, event.details),
|
actor_user=extract_event_actor_user(event.type, event.details),
|
||||||
|
rdg_flap=event_has_rdg_flap(event),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
|
|||||||
RULE_BRUTE_FORCE = "rule:brute_force_burst"
|
RULE_BRUTE_FORCE = "rule:brute_force_burst"
|
||||||
RULE_PRIVILEGE_SPIKE = "rule:privilege_spike"
|
RULE_PRIVILEGE_SPIKE = "rule:privilege_spike"
|
||||||
RULE_HOST_SILENCE = "rule:host_silence"
|
RULE_HOST_SILENCE = "rule:host_silence"
|
||||||
|
RULE_RDG_SESSION_FLAP = "rule:rdg_session_flap"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -204,8 +205,11 @@ def build_fingerprint(host_id: int, correlation_type: str, rule_id: str, suffix:
|
|||||||
|
|
||||||
|
|
||||||
def pick_rule_match(db: Session, event: Event) -> RuleMatch | None:
|
def pick_rule_match(db: Session, event: Event) -> RuleMatch | None:
|
||||||
"""Первое сработавшее правило (приоритет: burst → spike → silence → immediate)."""
|
"""Первое сработавшее правило (приоритет: rdg flap → burst → spike → silence → immediate)."""
|
||||||
|
from app.services.rdg_session_flap import evaluate_rdg_session_flap
|
||||||
|
|
||||||
for evaluator in (
|
for evaluator in (
|
||||||
|
evaluate_rdg_session_flap,
|
||||||
evaluate_brute_force_burst,
|
evaluate_brute_force_burst,
|
||||||
evaluate_privilege_spike,
|
evaluate_privilege_spike,
|
||||||
evaluate_host_silence,
|
evaluate_host_silence,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Auto-create Problems from ingested events (rules + correlation)."""
|
"""Auto-create Problems from ingested events (rules + correlation)."""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -11,6 +11,7 @@ from app.services.host_health import HEARTBEAT_TYPE
|
|||||||
from app.services.problem_rules import (
|
from app.services.problem_rules import (
|
||||||
RuleMatch,
|
RuleMatch,
|
||||||
RULE_HOST_SILENCE,
|
RULE_HOST_SILENCE,
|
||||||
|
RULE_RDG_SESSION_FLAP,
|
||||||
build_fingerprint,
|
build_fingerprint,
|
||||||
pick_rule_match,
|
pick_rule_match,
|
||||||
resolve_host_silence_problems,
|
resolve_host_silence_problems,
|
||||||
@@ -18,8 +19,6 @@ from app.services.problem_rules import (
|
|||||||
|
|
||||||
|
|
||||||
def _correlation_cutoff(now: datetime) -> datetime:
|
def _correlation_cutoff(now: datetime) -> datetime:
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
minutes = get_settings().sac_problem_correlation_window_minutes
|
minutes = get_settings().sac_problem_correlation_window_minutes
|
||||||
return now - timedelta(minutes=minutes)
|
return now - timedelta(minutes=minutes)
|
||||||
|
|
||||||
@@ -66,6 +65,9 @@ def open_or_append_problem(
|
|||||||
match.fingerprint_suffix,
|
match.fingerprint_suffix,
|
||||||
)
|
)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
if match.rule_id == RULE_RDG_SESSION_FLAP:
|
||||||
|
cutoff = now - timedelta(seconds=get_settings().sac_rdg_flap_dedup_sec)
|
||||||
|
else:
|
||||||
cutoff = _correlation_cutoff(now)
|
cutoff = _correlation_cutoff(now)
|
||||||
|
|
||||||
open_problem = db.scalar(
|
open_problem = db.scalar(
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""RDG 302→303 session flap: detect, flag event, build Problem match."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Event
|
||||||
|
from app.services.problem_rules import RULE_RDG_SESSION_FLAP, RuleMatch
|
||||||
|
|
||||||
|
RDG_SUCCESS_TYPE = "rdg.connection.success"
|
||||||
|
RDG_END_TYPES = frozenset({"rdg.connection.disconnected", "rdg.connection.failed"})
|
||||||
|
|
||||||
|
|
||||||
|
def _event_user(event: Event) -> str:
|
||||||
|
details = event.details if isinstance(event.details, dict) else {}
|
||||||
|
user = details.get("user")
|
||||||
|
return str(user).strip() if user else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _event_internal_ip(event: Event) -> str:
|
||||||
|
details = event.details if isinstance(event.details, dict) else {}
|
||||||
|
for key in ("internal_ip", "client_ip", "ip_address"):
|
||||||
|
val = details.get(key)
|
||||||
|
if val:
|
||||||
|
return str(val).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _users_match(end_event: Event, success_event: Event) -> bool:
|
||||||
|
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
|
||||||
|
|
||||||
|
|
||||||
|
def _internal_ips_compatible(end_event: Event, success_event: Event) -> bool:
|
||||||
|
end_ip = _event_internal_ip(end_event)
|
||||||
|
success_ip = _event_internal_ip(success_event)
|
||||||
|
if not end_ip or not success_ip:
|
||||||
|
return True
|
||||||
|
return end_ip == success_ip
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(dt: datetime) -> datetime:
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def find_rdg_success_before_end(db: Session, end_event: Event) -> Event | None:
|
||||||
|
if end_event.type not in RDG_END_TYPES:
|
||||||
|
return None
|
||||||
|
settings = get_settings()
|
||||||
|
min_sec = settings.sac_rdg_flap_window_min_sec
|
||||||
|
max_sec = settings.sac_rdg_flap_window_max_sec
|
||||||
|
end_at = _as_utc(end_event.occurred_at)
|
||||||
|
|
||||||
|
window_start = end_at - timedelta(seconds=max_sec)
|
||||||
|
window_end = end_at - timedelta(seconds=min_sec)
|
||||||
|
|
||||||
|
candidates = db.scalars(
|
||||||
|
select(Event)
|
||||||
|
.where(
|
||||||
|
Event.host_id == end_event.host_id,
|
||||||
|
Event.type == RDG_SUCCESS_TYPE,
|
||||||
|
Event.occurred_at >= window_start,
|
||||||
|
Event.occurred_at <= window_end,
|
||||||
|
Event.id != end_event.id,
|
||||||
|
)
|
||||||
|
.order_by(Event.occurred_at.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for prior in candidates:
|
||||||
|
if not _users_match(end_event, prior):
|
||||||
|
continue
|
||||||
|
if not _internal_ips_compatible(end_event, prior):
|
||||||
|
continue
|
||||||
|
prior_at = _as_utc(prior.occurred_at)
|
||||||
|
delta = (end_at - prior_at).total_seconds()
|
||||||
|
if min_sec <= delta <= max_sec:
|
||||||
|
return prior
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_rdg_flap(event: Event, *, pair_event: Event) -> None:
|
||||||
|
details = dict(event.details) if isinstance(event.details, dict) else {}
|
||||||
|
details["rdg_flap"] = True
|
||||||
|
details["rdg_flap_pair_event_id"] = pair_event.id
|
||||||
|
event.details = details
|
||||||
|
flag_modified(event, "details")
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_rdg_session_flap(db: Session, event: Event) -> RuleMatch | None:
|
||||||
|
prior = find_rdg_success_before_end(db, event)
|
||||||
|
if prior is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mark_rdg_flap(event, pair_event=prior)
|
||||||
|
user = _event_user(event)
|
||||||
|
internal_ip = _event_internal_ip(event)
|
||||||
|
ip_note = f", client {internal_ip}" if internal_ip else ""
|
||||||
|
delta_sec = int((_as_utc(event.occurred_at) - _as_utc(prior.occurred_at)).total_seconds())
|
||||||
|
return RuleMatch(
|
||||||
|
rule_id=RULE_RDG_SESSION_FLAP,
|
||||||
|
correlation_type="rdg.session.flap",
|
||||||
|
fingerprint_suffix=f"u{user}:ip{internal_ip or 'any'}",
|
||||||
|
title=f"RDG session flap: {user}",
|
||||||
|
summary=(
|
||||||
|
f"302→303 за {delta_sec} с "
|
||||||
|
f"({user}{ip_note}). Возможна зависшая сессия на ПК пользователя — qwinsta/logoff."
|
||||||
|
),
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def event_has_rdg_flap(event: Event) -> bool:
|
||||||
|
details = event.details if isinstance(event.details, dict) else {}
|
||||||
|
return details.get("rdg_flap") is True
|
||||||
@@ -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.9.12"
|
APP_VERSION = "0.9.13"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
|||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.9.12"
|
assert APP_VERSION == "0.9.13"
|
||||||
assert APP_NAME == "Security Alert Center"
|
assert APP_NAME == "Security Alert Center"
|
||||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.9.12"
|
assert APP_VERSION_LABEL == "Security Alert Center v.0.9.13"
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Tests for RDG 302→303 session flap rule."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Problem
|
||||||
|
from app.services.ingest import ingest_event
|
||||||
|
from app.services.problems import maybe_create_problem
|
||||||
|
from app.services.problem_rules import RULE_RDG_SESSION_FLAP
|
||||||
|
from app.services.rdg_session_flap import (
|
||||||
|
evaluate_rdg_session_flap,
|
||||||
|
event_has_rdg_flap,
|
||||||
|
find_rdg_success_before_end,
|
||||||
|
)
|
||||||
|
from tests.test_ingest import VALID_EVENT
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(**overrides):
|
||||||
|
base = {
|
||||||
|
**VALID_EVENT,
|
||||||
|
"event_id": str(uuid.uuid4()),
|
||||||
|
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest(db, occurred_at: datetime, **overrides):
|
||||||
|
payload = _payload(**overrides)
|
||||||
|
payload["occurred_at"] = occurred_at.isoformat()
|
||||||
|
event, _ = ingest_event(db, payload)
|
||||||
|
db.flush()
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rdg_settings(monkeypatch):
|
||||||
|
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
|
||||||
|
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
|
||||||
|
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
yield
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rdg_flap_detects_302_then_303(db_session, rdg_settings):
|
||||||
|
t0 = datetime.now(timezone.utc)
|
||||||
|
user = "B26\\test.user"
|
||||||
|
details = {"user": user, "internal_ip": "192.168.163.32"}
|
||||||
|
|
||||||
|
_ingest(
|
||||||
|
db_session,
|
||||||
|
t0,
|
||||||
|
type="rdg.connection.success",
|
||||||
|
category="auth",
|
||||||
|
severity="info",
|
||||||
|
title="RD Gateway event 302",
|
||||||
|
summary="302",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
end = _ingest(
|
||||||
|
db_session,
|
||||||
|
t0 + timedelta(seconds=4),
|
||||||
|
type="rdg.connection.disconnected",
|
||||||
|
category="auth",
|
||||||
|
severity="info",
|
||||||
|
title="RD Gateway event 303",
|
||||||
|
summary="303",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
match = evaluate_rdg_session_flap(db_session, end)
|
||||||
|
assert match is not None
|
||||||
|
assert match.rule_id == RULE_RDG_SESSION_FLAP
|
||||||
|
assert event_has_rdg_flap(end) is True
|
||||||
|
|
||||||
|
problem, created = maybe_create_problem(db_session, end)
|
||||||
|
assert created is True
|
||||||
|
assert problem.rule_id == RULE_RDG_SESSION_FLAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_rdg_flap_ignores_gap_over_10_sec(db_session, rdg_settings):
|
||||||
|
t0 = datetime.now(timezone.utc)
|
||||||
|
user = "B26\\slow.user"
|
||||||
|
details = {"user": user}
|
||||||
|
|
||||||
|
_ingest(
|
||||||
|
db_session,
|
||||||
|
t0,
|
||||||
|
type="rdg.connection.success",
|
||||||
|
category="auth",
|
||||||
|
severity="info",
|
||||||
|
title="302",
|
||||||
|
summary="302",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
end = _ingest(
|
||||||
|
db_session,
|
||||||
|
t0 + timedelta(seconds=15),
|
||||||
|
type="rdg.connection.disconnected",
|
||||||
|
category="auth",
|
||||||
|
severity="info",
|
||||||
|
title="303",
|
||||||
|
summary="303",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert find_rdg_success_before_end(db_session, end) is None
|
||||||
|
assert evaluate_rdg_session_flap(db_session, end) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rdg_flap_dedup_within_30_sec(db_session, rdg_settings):
|
||||||
|
t0 = datetime.now(timezone.utc)
|
||||||
|
user = "B26\\dup.user"
|
||||||
|
details = {"user": user}
|
||||||
|
|
||||||
|
def pair(at: datetime):
|
||||||
|
_ingest(
|
||||||
|
db_session,
|
||||||
|
at,
|
||||||
|
type="rdg.connection.success",
|
||||||
|
category="auth",
|
||||||
|
severity="info",
|
||||||
|
title="302",
|
||||||
|
summary="302",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
return _ingest(
|
||||||
|
db_session,
|
||||||
|
at + timedelta(seconds=5),
|
||||||
|
type="rdg.connection.disconnected",
|
||||||
|
category="auth",
|
||||||
|
severity="info",
|
||||||
|
title="303",
|
||||||
|
summary="303",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
end1 = pair(t0)
|
||||||
|
maybe_create_problem(db_session, end1)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
end2 = pair(t0 + timedelta(seconds=10))
|
||||||
|
maybe_create_problem(db_session, end2)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
open_count = db_session.scalar(
|
||||||
|
select(func.count()).select_from(Problem).where(
|
||||||
|
Problem.rule_id == RULE_RDG_SESSION_FLAP,
|
||||||
|
Problem.status == "open",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert open_count == 1
|
||||||
@@ -79,6 +79,15 @@ SAC_BRUTE_FORCE_THRESHOLD=30
|
|||||||
SAC_PRIVILEGE_SPIKE_WINDOW_MINUTES=10
|
SAC_PRIVILEGE_SPIKE_WINDOW_MINUTES=10
|
||||||
SAC_PRIVILEGE_SPIKE_THRESHOLD=10
|
SAC_PRIVILEGE_SPIKE_THRESHOLD=10
|
||||||
|
|
||||||
|
# rule:rdg_session_flap (302→303)
|
||||||
|
SAC_RDG_FLAP_WINDOW_MIN_SEC=1
|
||||||
|
SAC_RDG_FLAP_WINDOW_MAX_SEC=10
|
||||||
|
SAC_RDG_FLAP_DEDUP_SEC=30
|
||||||
|
|
||||||
|
# qwinsta/logoff на Windows-хостах (доменный admin)
|
||||||
|
# SAC_WIN_ADMIN_USER=B26\\Administrator
|
||||||
|
# SAC_WIN_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Retention (systemd sac-retention.timer)
|
# Retention (systemd sac-retention.timer)
|
||||||
SAC_EVENTS_RETENTION_DAYS=90
|
SAC_EVENTS_RETENTION_DAYS=90
|
||||||
SAC_PROBLEMS_RETENTION_DAYS=180
|
SAC_PROBLEMS_RETENTION_DAYS=180
|
||||||
|
|||||||
+11
-11
@@ -282,18 +282,18 @@ Authorization: Bearer sac_xxx
|
|||||||
|
|
||||||
## 8. Фазы реализации
|
## 8. Фазы реализации
|
||||||
|
|
||||||
| Фаза | Содержание | Репозитории |
|
| Фаза | Содержание | Статус |
|
||||||
|------|------------|-------------|
|
|------|------------|--------|
|
||||||
| **1** | `rule:rdg_session_flap`, Problem, notify, флаг `rdg_flap` на event | SAC |
|
| **1** | `rule:rdg_session_flap`, Problem, notify, флаг `rdg_flap` | ✅ SAC |
|
||||||
| **2** | Колонка «Действия» на «Обзоре»; кнопка qwinsta (disabled до фазы 3) | SAC UI |
|
| **2** | Кнопка **qwinsta** на «Обзоре» | ✅ SAC UI |
|
||||||
| **3** | Poll API, очередь команд, доменный admin в Settings | SAC + RDP-login-monitor |
|
| **3** | Poll API, `agent_commands`, env admin | ✅ SAC; ⏳ RDP-login-monitor |
|
||||||
| **4** | qwinsta modal, logoff (match user + выбор строки) | SAC UI + RDP agent |
|
| **4** | logoff в модалке | ✅ SAC UI |
|
||||||
| **5** | Settings «Обновления агентов», режим A/B | SAC |
|
| **5** | Settings «Обновления агентов» | ⏳ |
|
||||||
| **6** | Self-update B1 (Windows + Linux) | SAC + оба агента |
|
| **6** | Self-update B1 | ⏳ |
|
||||||
| **7** | SSH bootstrap + WinRM fallback B2 | SAC |
|
| **7** | SSH bootstrap + WinRM fallback | ⏳ |
|
||||||
| **8** | Desired config per host (П.3) | SAC + оба агента |
|
| **8** | Desired config per host | ⏳ |
|
||||||
|
|
||||||
**Старт разработки:** фаза **1** (детект без команд агента).
|
**Миграция:** `016_agent_commands`. Env: `SAC_RDG_FLAP_*`, `SAC_WIN_ADMIN_USER`, `SAC_WIN_ADMIN_PASSWORD`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,34 @@ export interface EventSummary {
|
|||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
actor_user?: string | null;
|
actor_user?: string | null;
|
||||||
|
rdg_flap?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentCommandResponse {
|
||||||
|
command_uuid: string;
|
||||||
|
command_type: string;
|
||||||
|
status: string;
|
||||||
|
result_stdout?: string | null;
|
||||||
|
result_stderr?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postEventQwinsta(eventId: number): Promise<AgentCommandResponse> {
|
||||||
|
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/qwinsta`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postEventLogoff(eventId: number, sessionId: number): Promise<AgentCommandResponse> {
|
||||||
|
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/logoff`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ session_id: sessionId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchEventAction(eventId: number, commandUuid: string): Promise<AgentCommandResponse> {
|
||||||
|
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/${commandUuid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventListResponse {
|
export interface EventListResponse {
|
||||||
|
|||||||
@@ -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.9.12";
|
export const APP_VERSION = "0.9.13";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -245,6 +245,8 @@
|
|||||||
|
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
|
|
||||||
|
<th>Действия</th>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</thead>
|
</thead>
|
||||||
@@ -281,12 +283,70 @@
|
|||||||
|
|
||||||
<td>{{ e.title }}</td>
|
<td>{{ e.title }}</td>
|
||||||
|
|
||||||
|
<td class="dash-actions">
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="e.rdg_flap"
|
||||||
|
type="button"
|
||||||
|
class="secondary dash-qwinsta-btn"
|
||||||
|
:disabled="qwinstaLoadingId === e.id"
|
||||||
|
@click="runQwinsta(e)"
|
||||||
|
>
|
||||||
|
qwinsta
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<div v-if="qwinstaModal.open" class="modal-backdrop" @click.self="closeQwinstaModal">
|
||||||
|
<div class="modal-card qwinsta-modal" role="dialog" aria-modal="true">
|
||||||
|
<h3>qwinsta — событие #{{ qwinstaModal.eventId }}</h3>
|
||||||
|
<p v-if="qwinstaModal.error" class="error">{{ qwinstaModal.error }}</p>
|
||||||
|
<p v-else-if="qwinstaModal.loading">Ожидание ответа агента…</p>
|
||||||
|
<template v-else>
|
||||||
|
<pre v-if="qwinstaModal.stdout" class="qwinsta-raw">{{ qwinstaModal.stdout }}</pre>
|
||||||
|
<p v-else class="muted">Нет вывода</p>
|
||||||
|
<table v-if="qwinstaModal.sessions.length" class="dash-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>SESSION</th>
|
||||||
|
<th>USER</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>STATE</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="s in qwinstaModal.sessions" :key="s.id">
|
||||||
|
<td>{{ s.sessionName }}</td>
|
||||||
|
<td>{{ s.userName }}</td>
|
||||||
|
<td>{{ s.id }}</td>
|
||||||
|
<td>{{ s.state }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger"
|
||||||
|
:disabled="qwinstaModal.logoffId === s.id"
|
||||||
|
@click="runLogoff(s.id)"
|
||||||
|
>
|
||||||
|
logoff
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="secondary" @click="closeQwinstaModal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="filters" style="margin-top: 1rem">
|
<div class="filters" style="margin-top: 1rem">
|
||||||
|
|
||||||
<button type="button" class="secondary" @click="load">Обновить всё</button>
|
<button type="button" class="secondary" @click="load">Обновить всё</button>
|
||||||
@@ -305,7 +365,16 @@
|
|||||||
|
|
||||||
import { onMounted, onUnmounted, ref } from "vue";
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
|
||||||
import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api";
|
import {
|
||||||
|
apiFetch,
|
||||||
|
fetchEventAction,
|
||||||
|
getToken,
|
||||||
|
postEventLogoff,
|
||||||
|
postEventQwinsta,
|
||||||
|
type AgentCommandResponse,
|
||||||
|
type DashboardSummary,
|
||||||
|
type EventSummary,
|
||||||
|
} from "../api";
|
||||||
import { useSacVersion } from "../composables/useSacVersion";
|
import { useSacVersion } from "../composables/useSacVersion";
|
||||||
import { formatServerName } from "../utils/hostDisplay";
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
|
|
||||||
@@ -327,6 +396,166 @@ let eventSource: EventSource | null = null;
|
|||||||
|
|
||||||
let refreshingRecent = false;
|
let refreshingRecent = false;
|
||||||
|
|
||||||
|
let qwinstaPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const qwinstaLoadingId = ref<number | null>(null);
|
||||||
|
|
||||||
|
type QwinstaSessionRow = {
|
||||||
|
sessionName: string;
|
||||||
|
userName: string;
|
||||||
|
id: number;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const qwinstaModal = ref({
|
||||||
|
open: false,
|
||||||
|
eventId: 0,
|
||||||
|
actorUser: "",
|
||||||
|
commandUuid: "",
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
stdout: "",
|
||||||
|
sessions: [] as QwinstaSessionRow[],
|
||||||
|
logoffId: null as number | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseQwinstaSessions(stdout: string, actorUser: string): QwinstaSessionRow[] {
|
||||||
|
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
const rows: QwinstaSessionRow[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length < 4) continue;
|
||||||
|
const sessionName = parts[0];
|
||||||
|
const userName = parts[1];
|
||||||
|
const id = Number.parseInt(parts[2], 10);
|
||||||
|
const state = parts.slice(3).join(" ");
|
||||||
|
if (!Number.isFinite(id)) continue;
|
||||||
|
const norm = (s: string) => s.replace(/^B26\\/i, "").toLowerCase();
|
||||||
|
const matchUser = actorUser && norm(userName).includes(norm(actorUser));
|
||||||
|
if (actorUser && !matchUser) continue;
|
||||||
|
rows.push({ sessionName, userName, id, state });
|
||||||
|
}
|
||||||
|
if (rows.length === 0 && stdout.trim()) {
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length < 4) continue;
|
||||||
|
const id = Number.parseInt(parts[2], 10);
|
||||||
|
if (!Number.isFinite(id)) continue;
|
||||||
|
rows.push({
|
||||||
|
sessionName: parts[0],
|
||||||
|
userName: parts[1],
|
||||||
|
id,
|
||||||
|
state: parts.slice(3).join(" "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopQwinstaPoll() {
|
||||||
|
if (qwinstaPollTimer) {
|
||||||
|
clearInterval(qwinstaPollTimer);
|
||||||
|
qwinstaPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQwinstaModal() {
|
||||||
|
stopQwinstaPoll();
|
||||||
|
qwinstaModal.value.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCommandResult(eventId: number, actorUser: string, cmd: AgentCommandResponse) {
|
||||||
|
if (cmd.status === "pending") return false;
|
||||||
|
qwinstaModal.value.loading = false;
|
||||||
|
if (cmd.status === "failed") {
|
||||||
|
qwinstaModal.value.error = cmd.result_stderr || cmd.result_stdout || "Команда завершилась с ошибкой";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
qwinstaModal.value.stdout = cmd.result_stdout || "";
|
||||||
|
qwinstaModal.value.sessions = parseQwinstaSessions(qwinstaModal.value.stdout, actorUser);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: string) {
|
||||||
|
stopQwinstaPoll();
|
||||||
|
qwinstaPollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const cmd = await fetchEventAction(eventId, commandUuid);
|
||||||
|
if (applyCommandResult(eventId, actorUser, cmd)) {
|
||||||
|
stopQwinstaPoll();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
qwinstaModal.value.loading = false;
|
||||||
|
qwinstaModal.value.error = e instanceof Error ? e.message : "Ошибка опроса команды";
|
||||||
|
stopQwinstaPoll();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQwinsta(event: EventSummary) {
|
||||||
|
qwinstaLoadingId.value = event.id;
|
||||||
|
qwinstaModal.value = {
|
||||||
|
open: true,
|
||||||
|
eventId: event.id,
|
||||||
|
actorUser: event.actor_user || "",
|
||||||
|
commandUuid: "",
|
||||||
|
loading: true,
|
||||||
|
error: "",
|
||||||
|
stdout: "",
|
||||||
|
sessions: [],
|
||||||
|
logoffId: null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const cmd = await postEventQwinsta(event.id);
|
||||||
|
qwinstaModal.value.commandUuid = cmd.command_uuid;
|
||||||
|
if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollQwinstaCommand(event.id, cmd.command_uuid, event.actor_user || "");
|
||||||
|
} catch (e) {
|
||||||
|
qwinstaModal.value.loading = false;
|
||||||
|
qwinstaModal.value.error = e instanceof Error ? e.message : "Не удалось отправить qwinsta";
|
||||||
|
} finally {
|
||||||
|
qwinstaLoadingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLogoff(sessionId: number) {
|
||||||
|
const modal = qwinstaModal.value;
|
||||||
|
if (!modal.eventId) return;
|
||||||
|
if (!window.confirm(`Завершить сеанс ID ${sessionId}?`)) return;
|
||||||
|
modal.logoffId = sessionId;
|
||||||
|
modal.error = "";
|
||||||
|
try {
|
||||||
|
const cmd = await postEventLogoff(modal.eventId, sessionId);
|
||||||
|
if (cmd.status === "pending") {
|
||||||
|
const deadline = Date.now() + 60000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
const polled = await fetchEventAction(modal.eventId, cmd.command_uuid);
|
||||||
|
if (polled.status !== "pending") {
|
||||||
|
if (polled.status === "failed") {
|
||||||
|
modal.error = polled.result_stderr || "logoff failed";
|
||||||
|
} else {
|
||||||
|
modal.loading = true;
|
||||||
|
modal.stdout = "";
|
||||||
|
modal.sessions = [];
|
||||||
|
const again = await postEventQwinsta(modal.eventId);
|
||||||
|
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
modal.error = e instanceof Error ? e.message : "logoff error";
|
||||||
|
} finally {
|
||||||
|
modal.logoffId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDt(iso: string) {
|
function formatDt(iso: string) {
|
||||||
|
|
||||||
return new Date(iso).toLocaleString("ru-RU");
|
return new Date(iso).toLocaleString("ru-RU");
|
||||||
@@ -553,6 +782,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
||||||
|
stopQwinstaPoll();
|
||||||
|
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
|
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
@@ -587,6 +818,53 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dash-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-qwinsta-btn {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
background: var(--card-bg, #1e2430);
|
||||||
|
border: 1px solid var(--border, #3a4556);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 720px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qwinsta-raw {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow: auto;
|
||||||
|
background: #0d1117;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user