diff --git a/backend/alembic/versions/010_event_severity_overrides.py b/backend/alembic/versions/010_event_severity_overrides.py new file mode 100644 index 0000000..00810ca --- /dev/null +++ b/backend/alembic/versions/010_event_severity_overrides.py @@ -0,0 +1,29 @@ +"""event_severity_overrides table + +Revision ID: 010 +Revises: 009 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "010" +down_revision: Union[str, None] = "009" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "event_severity_overrides", + sa.Column("event_type", sa.String(length=128), nullable=False), + sa.Column("severity", sa.String(length=16), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("event_type"), + ) + + +def downgrade() -> None: + op.drop_table("event_severity_overrides") diff --git a/backend/app/api/v1/dashboards.py b/backend/app/api/v1/dashboards.py index e5ae341..7bd35d8 100644 --- a/backend/app/api/v1/dashboards.py +++ b/backend/app/api/v1/dashboards.py @@ -10,6 +10,7 @@ from app.config import get_settings from app.database import get_db from app.models import Event, Host, Problem from app.schemas.list_models import EventSummary +from app.services.event_summary import event_to_summary from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts router = APIRouter(prefix="/dashboards", tags=["dashboards"]) @@ -24,27 +25,13 @@ def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]: .order_by(Event.received_at.desc()) .limit(limit) ).all() - return [ - EventSummary( - id=e.id, - event_id=e.event_id, - host_id=e.host_id, - hostname=e.host.hostname, - occurred_at=e.occurred_at, - received_at=e.received_at, - category=e.category, - type=e.type, - severity=e.severity, - title=e.title, - summary=e.summary, - ) - for e in rows - ] + return [event_to_summary(e) for e in rows] class TopHostItem(BaseModel): host_id: int hostname: str + display_name: str | None = None count: int @@ -108,16 +95,16 @@ def dashboard_summary( ) top_host_rows = db.execute( - select(Host.id, Host.hostname, func.count()) + select(Host.id, Host.hostname, Host.display_name, func.count()) .select_from(Event) .join(Host, Event.host_id == Host.id) .where(Event.received_at >= since) - .group_by(Host.id, Host.hostname) + .group_by(Host.id, Host.hostname, Host.display_name) .order_by(func.count().desc()) .limit(10) ).all() top_hosts = [ - TopHostItem(host_id=row[0], hostname=row[1], count=row[2]) for row in top_host_rows + TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows ] top_type_rows = db.execute( diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 48fc293..13aacd4 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -15,6 +15,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.event_summary import event_to_summary from app.services.problems import maybe_create_problem from app.services.schema_validate import validate_event_payload from app.services.notify_dispatch import ( @@ -133,8 +134,8 @@ def list_events( count_stmt = count_stmt.where(Event.host_id == host_id) if hostname: like = f"%{hostname}%" - stmt = stmt.where(Host.hostname.ilike(like)) - count_stmt = count_stmt.where(Host.hostname.ilike(like)) + stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) + count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) dt_from = _parse_optional_dt(from_time) dt_to = _parse_optional_dt(to_time) if dt_from: @@ -155,22 +156,7 @@ def list_events( .limit(page_size) ).all() - items = [ - EventSummary( - id=e.id, - event_id=e.event_id, - host_id=e.host_id, - hostname=e.host.hostname, - occurred_at=e.occurred_at, - received_at=e.received_at, - category=e.category, - type=e.type, - severity=e.severity, - title=e.title, - summary=e.summary, - ) - for e in rows - ] + items = [event_to_summary(e) for e in rows] return EventListResponse(items=items, total=total, page=page, page_size=page_size) @@ -190,6 +176,7 @@ def get_event( event_id=event.event_id, host_id=event.host_id, hostname=event.host.hostname, + display_name=event.host.display_name, occurred_at=event.occurred_at, received_at=event.received_at, category=event.category, diff --git a/backend/app/api/v1/problems.py b/backend/app/api/v1/problems.py index f4b56a9..2765cb3 100644 --- a/backend/app/api/v1/problems.py +++ b/backend/app/api/v1/problems.py @@ -1,174 +1,351 @@ -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 Event, Host, Problem, ProblemEvent - -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 - fingerprint: str - event_count: int - last_seen_at: datetime - created_at: datetime - updated_at: datetime - - -class ProblemListResponse(BaseModel): - items: list[ProblemSummary] - total: int - page: int - page_size: int - - -class ProblemEventItem(BaseModel): - id: int - event_id: str - occurred_at: datetime - type: str - severity: str - title: str - summary: str - - -class ProblemDetail(ProblemSummary): - events: list[ProblemEventItem] - - -def _problem_summary(p: Problem) -> ProblemSummary: - return 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, - fingerprint=p.fingerprint, - event_count=p.event_count, - last_seen_at=p.last_seen_at, - created_at=p.created_at, - updated_at=p.updated_at, - ) - - -@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), - severity: str | None = Query(None), - host_id: int | None = Query(None), - hostname: str | None = Query(None), - db: Session = Depends(get_db), - _user: str = Depends(get_current_user), -) -> ProblemListResponse: - stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host)) - count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True) - - if status: - stmt = stmt.where(Problem.status == status) - count_stmt = count_stmt.where(Problem.status == status) - if severity: - stmt = stmt.where(Problem.severity == severity) - count_stmt = count_stmt.where(Problem.severity == severity) - if host_id is not None: - stmt = stmt.where(Problem.host_id == host_id) - count_stmt = count_stmt.where(Problem.host_id == host_id) - if hostname: - like = f"%{hostname}%" - stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) - count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) - - total = db.scalar(count_stmt) or 0 - rows = db.scalars( - stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size) - ).all() - - items = [_problem_summary(p) for p in rows] - return ProblemListResponse(items=items, total=total, page=page, page_size=page_size) - - -@router.get("/{problem_id}", response_model=ProblemDetail) -def get_problem( - problem_id: int, - db: Session = Depends(get_db), - _user: str = Depends(get_current_user), -) -> ProblemDetail: - problem = db.scalar( - select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host)) - ) - if problem is None: - raise HTTPException(status_code=404, detail="Problem not found") - - event_rows = db.scalars( - select(Event) - .join(ProblemEvent, ProblemEvent.event_id == Event.id) - .where(ProblemEvent.problem_id == problem_id) - .order_by(Event.occurred_at.desc()) - ).all() - - base = _problem_summary(problem) - return ProblemDetail( - **base.model_dump(), - events=[ - ProblemEventItem( - id=e.id, - event_id=e.event_id, - occurred_at=e.occurred_at, - type=e.type, - severity=e.severity, - title=e.title, - summary=e.summary, - ) - for e in event_rows - ], - ) - - -@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") - if problem.status == "resolved": - raise HTTPException(status_code=409, detail="Problem already resolved") - 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") - if problem.status == "resolved": - raise HTTPException(status_code=409, detail="Problem already resolved") - problem.status = "resolved" - db.commit() - return {"id": problem.id, "status": problem.status} - \ No newline at end of file +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 Event, Host, Problem, ProblemEvent + + + +router = APIRouter(prefix="/problems", tags=["problems"]) + + + + + +class ProblemSummary(BaseModel): + + id: int + + host_id: int | None + + hostname: str | None + + display_name: str | None = None + + title: str + + summary: str + + severity: str + + status: str + + rule_id: str | None + + fingerprint: str + + event_count: int + + last_seen_at: datetime + + created_at: datetime + + updated_at: datetime + + + + + +class ProblemListResponse(BaseModel): + + items: list[ProblemSummary] + + total: int + + page: int + + page_size: int + + + + + +class ProblemEventItem(BaseModel): + + id: int + + event_id: str + + occurred_at: datetime + + type: str + + severity: str + + title: str + + summary: str + + + + + +class ProblemDetail(ProblemSummary): + + events: list[ProblemEventItem] + + + + + +def _problem_summary(p: Problem) -> ProblemSummary: + + return ProblemSummary( + + id=p.id, + + host_id=p.host_id, + + hostname=p.host.hostname if p.host else None, + + display_name=p.host.display_name if p.host else None, + + title=p.title, + + summary=p.summary, + + severity=p.severity, + + status=p.status, + + rule_id=p.rule_id, + + fingerprint=p.fingerprint, + + event_count=p.event_count, + + last_seen_at=p.last_seen_at, + + created_at=p.created_at, + + updated_at=p.updated_at, + + ) + + + + + +@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), + + severity: str | None = Query(None), + + host_id: int | None = Query(None), + + hostname: str | None = Query(None), + + db: Session = Depends(get_db), + + _user: str = Depends(get_current_user), + +) -> ProblemListResponse: + + stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host)) + + count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True) + + + + if status: + + stmt = stmt.where(Problem.status == status) + + count_stmt = count_stmt.where(Problem.status == status) + + if severity: + + stmt = stmt.where(Problem.severity == severity) + + count_stmt = count_stmt.where(Problem.severity == severity) + + if host_id is not None: + + stmt = stmt.where(Problem.host_id == host_id) + + count_stmt = count_stmt.where(Problem.host_id == host_id) + + if hostname: + + like = f"%{hostname}%" + + stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) + + count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) + + + + total = db.scalar(count_stmt) or 0 + + rows = db.scalars( + + stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size) + + ).all() + + + + items = [_problem_summary(p) for p in rows] + + return ProblemListResponse(items=items, total=total, page=page, page_size=page_size) + + + + + +@router.get("/{problem_id}", response_model=ProblemDetail) + +def get_problem( + + problem_id: int, + + db: Session = Depends(get_db), + + _user: str = Depends(get_current_user), + +) -> ProblemDetail: + + problem = db.scalar( + + select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host)) + + ) + + if problem is None: + + raise HTTPException(status_code=404, detail="Problem not found") + + + + event_rows = db.scalars( + + select(Event) + + .join(ProblemEvent, ProblemEvent.event_id == Event.id) + + .where(ProblemEvent.problem_id == problem_id) + + .order_by(Event.occurred_at.desc()) + + ).all() + + + + base = _problem_summary(problem) + + return ProblemDetail( + + **base.model_dump(), + + events=[ + + ProblemEventItem( + + id=e.id, + + event_id=e.event_id, + + occurred_at=e.occurred_at, + + type=e.type, + + severity=e.severity, + + title=e.title, + + summary=e.summary, + + ) + + for e in event_rows + + ], + + ) + + + + + +@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") + + if problem.status == "resolved": + + raise HTTPException(status_code=409, detail="Problem already resolved") + + 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") + + if problem.status == "resolved": + + raise HTTPException(status_code=409, detail="Problem already resolved") + + problem.status = "resolved" + + db.commit() + + return {"id": problem.id, "status": problem.status} + + diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index bca5da2..c19c997 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -23,7 +23,11 @@ from app.services.notification_settings import ( upsert_webhook_channel, ) from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message -from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message +from app.services.event_severity_overrides import ( + SOURCE_DB, + list_severity_override_items, + replace_severity_overrides, +) router = APIRouter(prefix="/settings", tags=["settings"]) @@ -328,3 +332,63 @@ def test_email_settings( except EmailSendError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP") + + +class EventSeverityOverrideRow(BaseModel): + event_type: str + default_severity: str + override_severity: str | None = None + + +class EventSeverityOverridesResponse(BaseModel): + items: list[EventSeverityOverrideRow] + source: str = Field(description="db — overrides хранятся в PostgreSQL") + + +class EventSeverityOverridesUpdate(BaseModel): + overrides: dict[str, str | None] = Field( + description="event_type → severity; null или пустая строка сбрасывает override", + ) + + +@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse) +def get_severity_overrides( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> EventSeverityOverridesResponse: + items = list_severity_override_items(db) + return EventSeverityOverridesResponse( + items=[ + EventSeverityOverrideRow( + event_type=i.event_type, + default_severity=i.default_severity, + override_severity=i.override_severity, + ) + for i in items + ], + source=SOURCE_DB, + ) + + +@router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse) +def update_severity_overrides( + body: EventSeverityOverridesUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> EventSeverityOverridesResponse: + try: + items = replace_severity_overrides(db, body.overrides) + db.commit() + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return EventSeverityOverridesResponse( + items=[ + EventSeverityOverrideRow( + event_type=i.event_type, + default_severity=i.default_severity, + override_severity=i.override_severity, + ) + for i in items + ], + source=SOURCE_DB, + ) diff --git a/backend/app/constants/event_types.py b/backend/app/constants/event_types.py new file mode 100644 index 0000000..58a9a26 --- /dev/null +++ b/backend/app/constants/event_types.py @@ -0,0 +1,37 @@ +"""Known SAC event types and default severities from agents (schema v1 / agent-integration).""" + +from __future__ import annotations + +DEFAULT_EVENT_SEVERITIES: dict[str, str] = { + # Agent / lifecycle + "agent.heartbeat": "info", + "agent.lifecycle": "info", + "agent.test": "info", + "agent.recovered": "info", + # SSH + "ssh.login.success": "info", + "ssh.login.failed": "warning", + "ssh.ip.banned": "high", + "ssh.ip.bruteforce.threshold": "warning", + "ssh.bruteforce.mass": "high", + "privilege.sudo.command": "warning", + "session.logind.new": "info", + "session.logind.removed": "info", + "session.logind.failed": "warning", + "report.daily.ssh": "info", + # RDP / Windows + "rdp.login.success": "info", + "rdp.login.failed": "warning", + "rdp.shadow.control.started": "warning", + "rdp.shadow.control.stopped": "info", + "rdp.shadow.control.permission": "warning", + "winrm.session.started": "warning", + "auth.explicit.credentials": "warning", + "report.daily.rdp": "info", + "rdp.ip.banned": "high", + "rdp.ip.ban": "high", + # RD Gateway + "rdg.connection.success": "info", + "rdg.connection.disconnected": "info", + "rdg.connection.failed": "warning", +} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ec12872..641d9b4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.notification_channel import NotificationChannel from app.models.notification_cooldown import NotificationCooldown from app.models.notification_policy import NotificationPolicy from app.models.problem import Problem, ProblemEvent +from app.models.event_severity_override import EventSeverityOverride from app.models.user import User __all__ = [ @@ -17,4 +18,5 @@ __all__ = [ "Problem", "ProblemEvent", "User", + "EventSeverityOverride", ] diff --git a/backend/app/models/event_severity_override.py b/backend/app/models/event_severity_override.py new file mode 100644 index 0000000..b448f3d --- /dev/null +++ b/backend/app/models/event_severity_override.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class EventSeverityOverride(Base): + __tablename__ = "event_severity_overrides" + + event_type: Mapped[str] = mapped_column(String(128), primary_key=True) + severity: Mapped[str] = mapped_column(String(16), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py index c6d276f..280027b 100644 --- a/backend/app/schemas/list_models.py +++ b/backend/app/schemas/list_models.py @@ -32,6 +32,7 @@ class EventSummary(BaseModel): event_id: str host_id: int hostname: str + display_name: str | None = None occurred_at: datetime received_at: datetime category: str diff --git a/backend/app/services/event_severity_overrides.py b/backend/app/services/event_severity_overrides.py new file mode 100644 index 0000000..770fd9c --- /dev/null +++ b/backend/app/services/event_severity_overrides.py @@ -0,0 +1,84 @@ +"""Per event-type severity overrides (admin Settings UI).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app.constants.event_types import DEFAULT_EVENT_SEVERITIES +from app.models.event_severity_override import EventSeverityOverride +from app.services.notification_settings import VALID_SEVERITIES + +SOURCE_DB = "db" + + +@dataclass(frozen=True) +class EventSeverityOverrideItem: + event_type: str + default_severity: str + override_severity: str | None + + +def get_override_map(db: Session) -> dict[str, str]: + rows = db.scalars(select(EventSeverityOverride)).all() + return {row.event_type: row.severity for row in rows} + + +def apply_severity_override(payload: dict, db: Session) -> dict: + """Return payload copy with severity replaced when override exists.""" + event_type = str(payload.get("type") or "") + if not event_type: + return payload + override_map = get_override_map(db) + override = override_map.get(event_type) + if not override: + return payload + agent_severity = str(payload.get("severity") or "info") + if agent_severity == override: + return payload + out = dict(payload) + details = dict(out.get("details") or {}) + details["severity_agent"] = agent_severity + out["details"] = details + out["severity"] = override + return out + + +def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]: + override_map = get_override_map(db) + types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map) + items: list[EventSeverityOverrideItem] = [] + for event_type in sorted(types): + default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info") + items.append( + EventSeverityOverrideItem( + event_type=event_type, + default_severity=default, + override_severity=override_map.get(event_type), + ) + ) + return items + + +def replace_severity_overrides(db: Session, overrides: dict[str, str | None]) -> list[EventSeverityOverrideItem]: + for event_type, severity in overrides.items(): + normalized_type = event_type.strip() + if not normalized_type: + raise ValueError("event_type is required") + if severity is None or severity == "": + db.execute( + delete(EventSeverityOverride).where(EventSeverityOverride.event_type == normalized_type) + ) + continue + if severity not in VALID_SEVERITIES: + raise ValueError(f"severity must be one of: {sorted(VALID_SEVERITIES)}") + row = db.get(EventSeverityOverride, normalized_type) + if row is None: + row = EventSeverityOverride(event_type=normalized_type, severity=severity) + db.add(row) + else: + row.severity = severity + db.flush() + return list_severity_override_items(db) diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py new file mode 100644 index 0000000..7d1c768 --- /dev/null +++ b/backend/app/services/event_summary.py @@ -0,0 +1,20 @@ +from app.models.event import Event +from app.schemas.list_models import EventSummary + + +def event_to_summary(event: Event) -> EventSummary: + host = event.host + return EventSummary( + id=event.id, + event_id=event.event_id, + host_id=event.host_id, + hostname=host.hostname, + display_name=host.display_name, + occurred_at=event.occurred_at, + received_at=event.received_at, + category=event.category, + type=event.type, + severity=event.severity, + title=event.title, + summary=event.summary, + ) diff --git a/backend/app/services/ingest.py b/backend/app/services/ingest.py index 747bad0..087416e 100644 --- a/backend/app/services/ingest.py +++ b/backend/app/services/ingest.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from app.models import Event, Host from app.services.daily_report_format import normalize_daily_report_details +from app.services.event_severity_overrides import apply_severity_override DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"}) @@ -75,6 +76,7 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]: return existing, False host = upsert_host(db, payload) + payload = apply_severity_override(payload, db) details = payload.get("details") if payload.get("type") in DAILY_REPORT_TYPES: details = normalize_daily_report_details(details, host, payload["type"]) diff --git a/backend/app/version.py b/backend/app/version.py index 54c9262..37fd662 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.6.0" +APP_VERSION = "0.7.0" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_event_severity_overrides.py b/backend/tests/test_event_severity_overrides.py new file mode 100644 index 0000000..ea1bdd0 --- /dev/null +++ b/backend/tests/test_event_severity_overrides.py @@ -0,0 +1,53 @@ +"""Tests for per-event-type severity overrides.""" + +import uuid + +from app.models.event_severity_override import EventSeverityOverride + +VALID_EVENT = { + "schema_version": "1.0", + "event_id": "550e8400-e29b-41d4-a716-446655440000", + "occurred_at": "2026-05-27T10:00:00+03:00", + "source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"}, + "host": {"hostname": "test-host", "os_family": "windows"}, + "category": "auth", + "type": "rdp.login.success", + "severity": "info", + "title": "RDP login", + "summary": "pytest", +} + + +def test_severity_override_applied_on_ingest(client, auth_headers, jwt_headers, db_session): + db_session.add(EventSeverityOverride(event_type="rdp.login.success", severity="warning")) + db_session.commit() + + event_id = str(uuid.uuid4()) + payload = {**VALID_EVENT, "event_id": event_id} + r = client.post("/api/v1/events", json=payload, headers=auth_headers) + assert r.status_code == 201 + + listed = client.get("/api/v1/events", headers=jwt_headers, params={"type": "rdp.login.success"}) + row = next(i for i in listed.json()["items"] if i["event_id"] == event_id) + assert row["severity"] == "warning" + + +def test_severity_overrides_settings_api(client, jwt_headers, db_session): + db_session.add(EventSeverityOverride(event_type="ssh.login.failed", severity="high")) + db_session.commit() + + r = client.get("/api/v1/settings/notifications/severity-overrides", headers=jwt_headers) + assert r.status_code == 200 + items = {i["event_type"]: i for i in r.json()["items"]} + assert items["ssh.login.failed"]["override_severity"] == "high" + assert items["rdp.login.success"]["default_severity"] == "info" + + put = client.put( + "/api/v1/settings/notifications/severity-overrides", + headers=jwt_headers, + json={"overrides": {"rdp.login.success": "warning", "ssh.login.failed": None}}, + ) + assert put.status_code == 200 + updated = {i["event_type"]: i for i in put.json()["items"]} + assert updated["rdp.login.success"]["override_severity"] == "warning" + assert updated["ssh.login.failed"]["override_severity"] is None diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index b4ec9f4..3ecb3ba 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.6.0" + assert APP_VERSION == "0.7.0" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.6.0" + assert APP_VERSION_LABEL == "Security Alert Center v.0.7.0" diff --git a/docs/testing-e2e-checklist.md b/docs/testing-e2e-checklist.md index 33387ca..eeff8ed 100644 --- a/docs/testing-e2e-checklist.md +++ b/docs/testing-e2e-checklist.md @@ -106,4 +106,4 @@ curl -sS -X POST "$BASE/api/v1/events" \ |-----------|--------| | ssh-monitor | 1.2.6-SAC | | RDP-login-monitor | 1.2.20-SAC | -| security-alert-center | 0.6.0 (`backend/app/version.py`) | +| security-alert-center | 0.7.0 (`backend/app/version.py`) | diff --git a/frontend/package.json b/frontend/package.json index da167dc..3fd9200 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "sac-ui", "private": true, - "version": "0.6.0", + "version": "0.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e9e7af0..557e563 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -132,6 +132,7 @@ export interface EventSummary { event_id: string; host_id: number; hostname: string; + display_name?: string | null; occurred_at: string; received_at: string; category: string; @@ -180,6 +181,7 @@ export interface ProblemSummary { id: number; host_id: number | null; hostname: string | null; + display_name?: string | null; title: string; summary: string; severity: string; @@ -228,6 +230,7 @@ export async function resolveProblem(problemId: number): Promise<{ id: number; s export interface TopHostItem { host_id: number; hostname: string; + display_name?: string | null; count: number; } diff --git a/frontend/src/utils/hostDisplay.ts b/frontend/src/utils/hostDisplay.ts new file mode 100644 index 0000000..91f97e4 --- /dev/null +++ b/frontend/src/utils/hostDisplay.ts @@ -0,0 +1,5 @@ +/** Display name from host card (SAC «Имя сервера»). */ +export function formatServerName(displayName?: string | null): string { + const name = displayName?.trim(); + return name || "—"; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 4960971..36d05c4 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,3 +1,3 @@ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.6.0"; +export const APP_VERSION = "0.7.0"; 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 6dbe751..ce8e50c 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -234,6 +234,8 @@
{{ error }}
@@ -23,6 +23,7 @@{{ e.type }}{{ error }}
@@ -27,6 +27,7 @@{{ p.rule_id ?? "—" }}