diff --git a/README.md b/README.md index b5915cd..53f97b9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## Статус -**Версия:** `0.9.12` +**Версия:** `0.9.13` **Стек:** FastAPI, PostgreSQL, Vue 3, JWT, SSE **Деплой:** `sudo /opt/sac-deploy.sh` (см. `deploy/sac-deploy.sh`) diff --git a/README_en.md b/README_en.md index 096a5bf..54697bc 100644 --- a/README_en.md +++ b/README_en.md @@ -14,7 +14,7 @@ Self-hosted hub for collecting, storing, and displaying security events from Lin ## Status -**Version:** `0.9.12` +**Version:** `0.9.13` **Stack:** FastAPI, PostgreSQL, Vue 3, JWT, SSE **Deploy:** `sudo /opt/sac-deploy.sh` (see `deploy/sac-deploy.sh`) diff --git a/backend/alembic/versions/016_agent_commands.py b/backend/alembic/versions/016_agent_commands.py new file mode 100644 index 0000000..e98387e --- /dev/null +++ b/backend/alembic/versions/016_agent_commands.py @@ -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") diff --git a/backend/app/api/v1/agent.py b/backend/app/api/v1/agent.py new file mode 100644 index 0000000..7020843 --- /dev/null +++ b/backend/app/api/v1/agent.py @@ -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} diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 96e7596..fffa58d 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -9,11 +9,17 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session, joinedload 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.database import get_db from app.models import Event, Host 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.event_summary import event_to_summary 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) +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) def get_event( event_db_id: int, diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index c0c32e5..55b3657 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,10 +1,11 @@ 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.include_router(health.router) api_router.include_router(auth.router) +api_router.include_router(agent.router) api_router.include_router(events.router) api_router.include_router(hosts.router) api_router.include_router(problems.router) diff --git a/backend/app/config.py b/backend/app/config.py index 7945eb2..290d04d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -102,6 +102,15 @@ class Settings(BaseSettings): sac_privilege_spike_window_minutes: 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) sac_events_retention_days: int = 90 sac_problems_retention_days: int = 180 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 39283b8..8539a22 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,3 +1,4 @@ +from app.models.agent_command import AgentCommand from app.models.api_key import ApiKey from app.models.event import Event from app.models.host import Host @@ -16,6 +17,7 @@ from app.models.mobile_refresh_token import MobileRefreshToken __all__ = [ "ApiKey", + "AgentCommand", "Event", "Host", "NotificationChannel", diff --git a/backend/app/models/agent_command.py b/backend/app/models/agent_command.py new file mode 100644 index 0000000..96f1e2c --- /dev/null +++ b/backend/app/models/agent_command.py @@ -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() diff --git a/backend/app/models/host.py b/backend/app/models/host.py index 19097d8..1d3b0e0 100644 --- a/backend/app/models/host.py +++ b/backend/app/models/host.py @@ -28,3 +28,4 @@ class Host(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) events: Mapped[list["Event"]] = relationship(back_populates="host") + agent_commands: Mapped[list["AgentCommand"]] = relationship(back_populates="host") diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py index 253d598..819fdd2 100644 --- a/backend/app/schemas/list_models.py +++ b/backend/app/schemas/list_models.py @@ -55,6 +55,7 @@ class EventSummary(BaseModel): title: str summary: str actor_user: str | None = None + rdg_flap: bool = False model_config = {"from_attributes": True} diff --git a/backend/app/services/agent_commands.py b/backend/app/services/agent_commands.py new file mode 100644 index 0000000..a790f0e --- /dev/null +++ b/backend/app/services/agent_commands.py @@ -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)) diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py index 44dcef6..bfc7ee4 100644 --- a/backend/app/services/event_summary.py +++ b/backend/app/services/event_summary.py @@ -1,6 +1,7 @@ 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 def event_to_summary(event: Event) -> EventSummary: @@ -20,4 +21,5 @@ 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), ) diff --git a/backend/app/services/problem_rules.py b/backend/app/services/problem_rules.py index 2e77e42..ca70f73 100644 --- a/backend/app/services/problem_rules.py +++ b/backend/app/services/problem_rules.py @@ -18,6 +18,7 @@ PRIVILEGE_SUDO_TYPE = "privilege.sudo.command" RULE_BRUTE_FORCE = "rule:brute_force_burst" RULE_PRIVILEGE_SPIKE = "rule:privilege_spike" RULE_HOST_SILENCE = "rule:host_silence" +RULE_RDG_SESSION_FLAP = "rule:rdg_session_flap" @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: - """Первое сработавшее правило (приоритет: burst → spike → silence → immediate).""" + """Первое сработавшее правило (приоритет: rdg flap → burst → spike → silence → immediate).""" + from app.services.rdg_session_flap import evaluate_rdg_session_flap + for evaluator in ( + evaluate_rdg_session_flap, evaluate_brute_force_burst, evaluate_privilege_spike, evaluate_host_silence, diff --git a/backend/app/services/problems.py b/backend/app/services/problems.py index b3b3987..838fc28 100644 --- a/backend/app/services/problems.py +++ b/backend/app/services/problems.py @@ -1,6 +1,6 @@ """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.orm import Session @@ -11,6 +11,7 @@ from app.services.host_health import HEARTBEAT_TYPE from app.services.problem_rules import ( RuleMatch, RULE_HOST_SILENCE, + RULE_RDG_SESSION_FLAP, build_fingerprint, pick_rule_match, resolve_host_silence_problems, @@ -18,8 +19,6 @@ from app.services.problem_rules import ( def _correlation_cutoff(now: datetime) -> datetime: - from datetime import timedelta - minutes = get_settings().sac_problem_correlation_window_minutes return now - timedelta(minutes=minutes) @@ -66,7 +65,10 @@ def open_or_append_problem( match.fingerprint_suffix, ) now = datetime.now(timezone.utc) - cutoff = _correlation_cutoff(now) + if match.rule_id == RULE_RDG_SESSION_FLAP: + cutoff = now - timedelta(seconds=get_settings().sac_rdg_flap_dedup_sec) + else: + cutoff = _correlation_cutoff(now) open_problem = db.scalar( select(Problem) diff --git a/backend/app/services/rdg_session_flap.py b/backend/app/services/rdg_session_flap.py new file mode 100644 index 0000000..66730f7 --- /dev/null +++ b/backend/app/services/rdg_session_flap.py @@ -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 diff --git a/backend/app/version.py b/backend/app/version.py index d659bfe..4d23964 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.9.12" +APP_VERSION = "0.9.13" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index e38553a..9411e90 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.9.12" + assert APP_VERSION == "0.9.13" 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" diff --git a/backend/tests/test_rdg_session_flap.py b/backend/tests/test_rdg_session_flap.py new file mode 100644 index 0000000..3cfae43 --- /dev/null +++ b/backend/tests/test_rdg_session_flap.py @@ -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 diff --git a/deploy/env.native.example b/deploy/env.native.example index 644fa12..242f45d 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -79,6 +79,15 @@ SAC_BRUTE_FORCE_THRESHOLD=30 SAC_PRIVILEGE_SPIKE_WINDOW_MINUTES=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) SAC_EVENTS_RETENTION_DAYS=90 SAC_PROBLEMS_RETENTION_DAYS=180 diff --git a/docs/agent-control-plane.md b/docs/agent-control-plane.md index 54bf500..db035ea 100644 --- a/docs/agent-control-plane.md +++ b/docs/agent-control-plane.md @@ -282,18 +282,18 @@ Authorization: Bearer sac_xxx ## 8. Фазы реализации -| Фаза | Содержание | Репозитории | -|------|------------|-------------| -| **1** | `rule:rdg_session_flap`, Problem, notify, флаг `rdg_flap` на event | SAC | -| **2** | Колонка «Действия» на «Обзоре»; кнопка qwinsta (disabled до фазы 3) | SAC UI | -| **3** | Poll API, очередь команд, доменный admin в Settings | SAC + RDP-login-monitor | -| **4** | qwinsta modal, logoff (match user + выбор строки) | SAC UI + RDP agent | -| **5** | Settings «Обновления агентов», режим A/B | SAC | -| **6** | Self-update B1 (Windows + Linux) | SAC + оба агента | -| **7** | SSH bootstrap + WinRM fallback B2 | SAC | -| **8** | Desired config per host (П.3) | SAC + оба агента | +| Фаза | Содержание | Статус | +|------|------------|--------| +| **1** | `rule:rdg_session_flap`, Problem, notify, флаг `rdg_flap` | ✅ SAC | +| **2** | Кнопка **qwinsta** на «Обзоре» | ✅ SAC UI | +| **3** | Poll API, `agent_commands`, env admin | ✅ SAC; ⏳ RDP-login-monitor | +| **4** | logoff в модалке | ✅ SAC UI | +| **5** | Settings «Обновления агентов» | ⏳ | +| **6** | Self-update B1 | ⏳ | +| **7** | SSH bootstrap + WinRM fallback | ⏳ | +| **8** | Desired config per host | ⏳ | -**Старт разработки:** фаза **1** (детект без команд агента). +**Миграция:** `016_agent_commands`. Env: `SAC_RDG_FLAP_*`, `SAC_WIN_ADMIN_USER`, `SAC_WIN_ADMIN_PASSWORD`. --- diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 76d4665..c0755f0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -169,6 +169,34 @@ export interface EventSummary { title: string; summary: string; 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 { + return apiFetch(`/api/v1/events/${eventId}/actions/qwinsta`, { + method: "POST", + }); +} + +export function postEventLogoff(eventId: number, sessionId: number): Promise { + return apiFetch(`/api/v1/events/${eventId}/actions/logoff`, { + method: "POST", + body: JSON.stringify({ session_id: sessionId }), + }); +} + +export function fetchEventAction(eventId: number, commandUuid: string): Promise { + return apiFetch(`/api/v1/events/${eventId}/actions/${commandUuid}`); } export interface EventListResponse { diff --git a/frontend/src/version.ts b/frontend/src/version.ts index a8b4ae3..27713a5 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.9.12"; +export const APP_VERSION = "0.9.13"; 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 d918210..83879b7 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -245,6 +245,8 @@ Title + Действия + @@ -281,12 +283,70 @@ {{ e.title }} + + + + + + + +
@@ -305,7 +365,16 @@ 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 { formatServerName } from "../utils/hostDisplay"; @@ -327,6 +396,166 @@ let eventSource: EventSource | null = null; let refreshingRecent = false; +let qwinstaPollTimer: ReturnType | null = null; + +const qwinstaLoadingId = ref(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) { return new Date(iso).toLocaleString("ru-RU"); @@ -553,6 +782,8 @@ onMounted(() => { onUnmounted(() => { + stopQwinstaPoll(); + eventSource?.close(); 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; +} +