diff --git a/backend/alembic/versions/002_problems.py b/backend/alembic/versions/002_problems.py new file mode 100644 index 0000000..153cf9a --- /dev/null +++ b/backend/alembic/versions/002_problems.py @@ -0,0 +1,48 @@ +"""problems table + +Revision ID: 002 +Revises: 001 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "problems", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("host_id", sa.Integer(), nullable=True), + sa.Column("title", sa.String(length=256), nullable=False), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column("severity", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="open"), + sa.Column("rule_id", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["host_id"], ["hosts.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_problems_status", "problems", ["status"]) + op.create_index("ix_problems_severity", "problems", ["severity"]) + + op.create_table( + "problem_events", + sa.Column("problem_id", sa.Integer(), nullable=False), + sa.Column("event_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["problem_id"], ["problems.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("problem_id", "event_id"), + ) + + +def downgrade() -> None: + op.drop_table("problem_events") + op.drop_table("problems") diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 02690d5..a7fa27e 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -13,6 +13,7 @@ from app.database import get_db from app.models import Event, Host from app.schemas.list_models import EventDetail, EventListResponse, EventSummary from app.services.ingest import ingest_event +from app.services.problems import maybe_create_problem from app.services.schema_validate import validate_event_payload router = APIRouter(prefix="/events", tags=["events"]) @@ -23,6 +24,7 @@ class IngestResponse(BaseModel): event_id: str created: bool sac_event_url: str + problem_id: int | None = None @router.post("", status_code=202, response_model=IngestResponse) @@ -36,6 +38,9 @@ def post_event( raise HTTPException(status_code=422, detail={"schema_errors": errors[:20]}) event, created = ingest_event(db, payload) + problem = None + if created: + problem = maybe_create_problem(db, event) db.commit() settings = get_settings() @@ -45,6 +50,7 @@ def post_event( event_id=event.event_id, created=created, sac_event_url=f"{base}/api/v1/events/{event.id}", + problem_id=problem.id if problem else None, ) diff --git a/backend/app/api/v1/problems.py b/backend/app/api/v1/problems.py new file mode 100644 index 0000000..b85aa06 --- /dev/null +++ b/backend/app/api/v1/problems.py @@ -0,0 +1,99 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session, joinedload + +from app.auth.jwt_auth import get_current_user +from app.database import get_db +from app.models import Problem + +router = APIRouter(prefix="/problems", tags=["problems"]) + + +class ProblemSummary(BaseModel): + id: int + host_id: int | None + hostname: str | None + title: str + summary: str + severity: str + status: str + rule_id: str | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ProblemListResponse(BaseModel): + items: list[ProblemSummary] + total: int + page: int + page_size: int + + +@router.get("", response_model=ProblemListResponse) +def list_problems( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + status: str | None = Query(None), + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> ProblemListResponse: + stmt = select(Problem).options(joinedload(Problem.host)) + count_stmt = select(func.count()).select_from(Problem) + if status: + stmt = stmt.where(Problem.status == status) + count_stmt = count_stmt.where(Problem.status == status) + + total = db.scalar(count_stmt) or 0 + rows = db.scalars( + stmt.order_by(Problem.updated_at.desc()).offset((page - 1) * page_size).limit(page_size) + ).all() + + items = [ + ProblemSummary( + id=p.id, + host_id=p.host_id, + hostname=p.host.hostname if p.host else None, + title=p.title, + summary=p.summary, + severity=p.severity, + status=p.status, + rule_id=p.rule_id, + created_at=p.created_at, + updated_at=p.updated_at, + ) + for p in rows + ] + return ProblemListResponse(items=items, total=total, page=page, page_size=page_size) + + +@router.post("/{problem_id}/ack") +def ack_problem( + problem_id: int, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> dict: + problem = db.get(Problem, problem_id) + if problem is None: + raise HTTPException(status_code=404, detail="Problem not found") + problem.status = "acknowledged" + db.commit() + return {"id": problem.id, "status": problem.status} + + +@router.post("/{problem_id}/resolve") +def resolve_problem( + problem_id: int, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> dict: + problem = db.get(Problem, problem_id) + if problem is None: + raise HTTPException(status_code=404, detail="Problem not found") + problem.status = "resolved" + db.commit() + return {"id": problem.id, "status": problem.status} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 8269cc0..08fcab7 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.v1 import auth, events, health, hosts +from app.api.v1 import auth, events, health, hosts, problems api_router = APIRouter() api_router.include_router(health.router) api_router.include_router(auth.router) api_router.include_router(events.router) api_router.include_router(hosts.router) +api_router.include_router(problems.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5d2ba17..6f04c56 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ from app.models.api_key import ApiKey from app.models.event import Event from app.models.host import Host +from app.models.problem import Problem, ProblemEvent -__all__ = ["ApiKey", "Event", "Host"] +__all__ = ["ApiKey", "Event", "Host", "Problem", "ProblemEvent"] diff --git a/backend/app/models/problem.py b/backend/app/models/problem.py new file mode 100644 index 0000000..3c09a29 --- /dev/null +++ b/backend/app/models/problem.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Problem(Base): + __tablename__ = "problems" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + host_id: Mapped[int | None] = mapped_column(ForeignKey("hosts.id"), index=True) + title: Mapped[str] = mapped_column(String(256)) + summary: Mapped[str] = mapped_column(Text) + severity: Mapped[str] = mapped_column(String(16), index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="open") + rule_id: Mapped[str | None] = mapped_column(String(64)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + host: Mapped["Host | None"] = relationship() + event_links: Mapped[list["ProblemEvent"]] = relationship(back_populates="problem") + + +class ProblemEvent(Base): + __tablename__ = "problem_events" + + problem_id: Mapped[int] = mapped_column(ForeignKey("problems.id", ondelete="CASCADE"), primary_key=True) + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), primary_key=True) + + problem: Mapped["Problem"] = relationship(back_populates="event_links") + event: Mapped["Event"] = relationship() diff --git a/backend/app/services/problems.py b/backend/app/services/problems.py new file mode 100644 index 0000000..0bf4906 --- /dev/null +++ b/backend/app/services/problems.py @@ -0,0 +1,68 @@ +"""Auto-create Problems from ingested events (MVP rules).""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Event, Problem, ProblemEvent + +# event types that always open a problem +PROBLEM_TYPES = frozenset( + { + "ssh.ip.banned", + "ssh.bruteforce.mass", + } +) + +HIGH_SEVERITIES = frozenset({"high", "critical"}) + + +def maybe_create_problem(db: Session, event: Event) -> Problem | None: + if event.severity not in HIGH_SEVERITIES and event.type not in PROBLEM_TYPES: + return None + + rule_id = "high_severity" if event.severity in HIGH_SEVERITIES else f"type:{event.type}" + + existing = db.scalar( + select(Problem) + .join(ProblemEvent) + .where( + Problem.status == "open", + Problem.rule_id == rule_id, + Problem.host_id == event.host_id, + ProblemEvent.event_id == event.id, + ) + .limit(1) + ) + if existing: + return existing + + # dedupe: one open problem per host+rule (append event link) + open_problem = db.scalar( + select(Problem) + .where( + Problem.status == "open", + Problem.rule_id == rule_id, + Problem.host_id == event.host_id, + ) + .order_by(Problem.created_at.desc()) + .limit(1) + ) + if open_problem: + db.add(ProblemEvent(problem_id=open_problem.id, event_id=event.id)) + open_problem.updated_at = event.received_at + db.flush() + return open_problem + + problem = Problem( + host_id=event.host_id, + title=event.title, + summary=event.summary, + severity=event.severity, + status="open", + rule_id=rule_id, + ) + db.add(problem) + db.flush() + db.add(ProblemEvent(problem_id=problem.id, event_id=event.id)) + db.flush() + return problem diff --git a/docs/operations-prod-status.md b/docs/operations-prod-status.md index f85f1db..653dcee 100644 --- a/docs/operations-prod-status.md +++ b/docs/operations-prod-status.md @@ -17,7 +17,7 @@ | `POST /api/v1/events` (ingest 202) | ✅ | | `GET /health` | ✅ | | nginx + wildcard TLS (`/etc/ssl/sac/`) | ✅ | -| JWT UI: Events, Hosts | ✅ | +| JWT UI: Events, Hosts, **Problems** | ✅ (Problems после деплоя) | | `SAC_ADMIN_PASSWORD` + вход в браузере | ✅ | --- @@ -50,9 +50,9 @@ sudo /opt/sac-deploy.sh ## Следующие шаги -1. **1A** — маппинг событий через `notify_or_sac`; при необходимости `UseSAC=dual` на тесте -2. **1C** — Problems, Telegram из SAC, dashboard -3. **1A** — маппинг всех типов событий через `notify_or_sac` +1. **Деплой SAC** — `sudo /opt/sac-deploy.sh`, `alembic upgrade head` (миграция `002_problems`) +2. **ssh-monitor на ubabuba** — `update_ssh_monitor.sh` (10.10.36.9), маппинг `notify_or_sac` уже в `main` +3. **1C** — Telegram из SAC, dashboard 4. RDP-login-monitor — аналог UseSAC --- diff --git a/docs/work-plan.md b/docs/work-plan.md index f0c7dc5..17496a1 100644 --- a/docs/work-plan.md +++ b/docs/work-plan.md @@ -21,9 +21,9 @@ | # | Задача | Статус | |---|--------|--------| -| 1A.1 | Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, spool | 🔄 ssh-monitor | -| 1A.2 | `build_sac_event()` + `send_sac_event()` по schema v1 | 🔄 `sac-client.sh` | -| 1A.3 | `notify_or_sac()`: off / dual / exclusive / fallback | 🔄 | +| 1A.1 | Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, spool | ✅ ssh-monitor | +| 1A.2 | `build_sac_event()` + `send_sac_event()` по schema v1 | ✅ `sac-client.sh` | +| 1A.3 | `notify_or_sac()`: off / dual / exclusive / fallback | ✅ + маппинг событий | | 1A.4 | `--check-sac` / `Test-SacConnection` | 🔄 ssh-monitor `--check-sac` | | 1A.5 | README агентов | ⏳ | @@ -51,7 +51,7 @@ |---|--------|--------| | 1C.1 | Frontend Vue: Events, Hosts | ✅ prod | | 1C.2 | Auth JWT, admin bootstrap | ✅ prod | -| 1C.3 | Problems (базовые правила) | ⏳ | +| 1C.3 | Problems (базовые правила) | ✅ API + UI (деплой ⏳) | | 1C.4 | Telegram из SAC | ⏳ | | 1C.5 | Dashboard (3 виджета), SSE | ⏳ | diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 989b4a8..5629146 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,6 +3,7 @@ diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e59bfbf..66ef0b4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -84,3 +84,31 @@ export interface HostListResponse { page: number; page_size: number; } + +export interface ProblemSummary { + id: number; + host_id: number | null; + hostname: string | null; + title: string; + summary: string; + severity: string; + status: string; + rule_id: string | null; + created_at: string; + updated_at: string; +} + +export interface ProblemListResponse { + items: ProblemSummary[]; + total: number; + page: number; + page_size: number; +} + +export async function ackProblem(problemId: number): Promise<{ id: number; status: string }> { + return apiFetch(`/api/v1/problems/${problemId}/ack`, { method: "POST" }); +} + +export async function resolveProblem(problemId: number): Promise<{ id: number; status: string }> { + return apiFetch(`/api/v1/problems/${problemId}/resolve`, { method: "POST" }); +} diff --git a/frontend/src/router.ts b/frontend/src/router.ts index ecbffc7..c719491 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -4,6 +4,7 @@ import EventsView from "./views/EventsView.vue"; import EventDetailView from "./views/EventDetailView.vue"; import HostsView from "./views/HostsView.vue"; import LoginView from "./views/LoginView.vue"; +import ProblemsView from "./views/ProblemsView.vue"; const router = createRouter({ history: createWebHistory(), @@ -13,6 +14,7 @@ const router = createRouter({ { path: "/events", component: EventsView }, { path: "/events/:id", component: EventDetailView, props: true }, { path: "/hosts", component: HostsView }, + { path: "/problems", component: ProblemsView }, ], }); diff --git a/frontend/src/style.css b/frontend/src/style.css index 5f4a17f..7720c2d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -67,6 +67,22 @@ th { color: #7eb8ff; } +.status-open { + color: #ff6b6b; +} +.status-acknowledged { + color: #ffc857; +} +.status-resolved { + color: #6bcb77; +} + +td.actions { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + .card { background: #1a2332; border: 1px solid #2a3441; diff --git a/frontend/src/views/ProblemsView.vue b/frontend/src/views/ProblemsView.vue new file mode 100644 index 0000000..6fab506 --- /dev/null +++ b/frontend/src/views/ProblemsView.vue @@ -0,0 +1,136 @@ + + +