feat: SAC 0.7.0 display_name columns and event severity overrides

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-01 10:21:43 +10:00
parent 8de5d14cfb
commit de29270a25
25 changed files with 850 additions and 222 deletions
@@ -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")
+6 -19
View File
@@ -10,6 +10,7 @@ from app.config import get_settings
from app.database import get_db from app.database import get_db
from app.models import Event, Host, Problem from app.models import Event, Host, Problem
from app.schemas.list_models import EventSummary 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 from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
router = APIRouter(prefix="/dashboards", tags=["dashboards"]) 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()) .order_by(Event.received_at.desc())
.limit(limit) .limit(limit)
).all() ).all()
return [ return [event_to_summary(e) for e in rows]
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
]
class TopHostItem(BaseModel): class TopHostItem(BaseModel):
host_id: int host_id: int
hostname: str hostname: str
display_name: str | None = None
count: int count: int
@@ -108,16 +95,16 @@ def dashboard_summary(
) )
top_host_rows = db.execute( 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) .select_from(Event)
.join(Host, Event.host_id == Host.id) .join(Host, Event.host_id == Host.id)
.where(Event.received_at >= since) .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()) .order_by(func.count().desc())
.limit(10) .limit(10)
).all() ).all()
top_hosts = [ 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( top_type_rows = db.execute(
+5 -18
View File
@@ -15,6 +15,7 @@ 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.ingest import ingest_event 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.problems import maybe_create_problem
from app.services.schema_validate import validate_event_payload from app.services.schema_validate import validate_event_payload
from app.services.notify_dispatch import ( from app.services.notify_dispatch import (
@@ -133,8 +134,8 @@ def list_events(
count_stmt = count_stmt.where(Event.host_id == host_id) count_stmt = count_stmt.where(Event.host_id == host_id)
if hostname: if hostname:
like = f"%{hostname}%" like = f"%{hostname}%"
stmt = 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)) count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
dt_from = _parse_optional_dt(from_time) dt_from = _parse_optional_dt(from_time)
dt_to = _parse_optional_dt(to_time) dt_to = _parse_optional_dt(to_time)
if dt_from: if dt_from:
@@ -155,22 +156,7 @@ def list_events(
.limit(page_size) .limit(page_size)
).all() ).all()
items = [ items = [event_to_summary(e) for e in rows]
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 EventListResponse(items=items, total=total, page=page, page_size=page_size) return EventListResponse(items=items, total=total, page=page, page_size=page_size)
@@ -190,6 +176,7 @@ def get_event(
event_id=event.event_id, event_id=event.event_id,
host_id=event.host_id, host_id=event.host_id,
hostname=event.host.hostname, hostname=event.host.hostname,
display_name=event.host.display_name,
occurred_at=event.occurred_at, occurred_at=event.occurred_at,
received_at=event.received_at, received_at=event.received_at,
category=event.category, category=event.category,
+351 -174
View File
@@ -1,174 +1,351 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from app.auth.jwt_auth import get_current_user from app.auth.jwt_auth import get_current_user
from app.database import get_db from app.database import get_db
from app.models import Event, Host, Problem, ProblemEvent from app.models import Event, Host, Problem, ProblemEvent
router = APIRouter(prefix="/problems", tags=["problems"]) router = APIRouter(prefix="/problems", tags=["problems"])
class ProblemSummary(BaseModel): class ProblemSummary(BaseModel):
id: int id: int
host_id: int | None host_id: int | None
hostname: str | None hostname: str | None
title: str display_name: str | None = None
summary: str title: str
severity: str summary: str
status: str severity: str
rule_id: str | None status: str
fingerprint: str rule_id: str | None
event_count: int fingerprint: str
last_seen_at: datetime event_count: int
created_at: datetime last_seen_at: datetime
updated_at: datetime created_at: datetime
updated_at: datetime
class ProblemListResponse(BaseModel):
items: list[ProblemSummary] class ProblemListResponse(BaseModel):
total: int items: list[ProblemSummary]
page: int total: int
page_size: int page: int
page_size: int
class ProblemEventItem(BaseModel):
id: int class ProblemEventItem(BaseModel):
event_id: str id: int
occurred_at: datetime event_id: str
type: str occurred_at: datetime
severity: str type: str
title: str severity: str
summary: str title: str
summary: str
class ProblemDetail(ProblemSummary):
events: list[ProblemEventItem] class ProblemDetail(ProblemSummary):
events: list[ProblemEventItem]
def _problem_summary(p: Problem) -> ProblemSummary:
return ProblemSummary( def _problem_summary(p: Problem) -> ProblemSummary:
id=p.id, return ProblemSummary(
host_id=p.host_id, id=p.id,
hostname=p.host.hostname if p.host else None, host_id=p.host_id,
title=p.title, hostname=p.host.hostname if p.host else None,
summary=p.summary, display_name=p.host.display_name if p.host else None,
severity=p.severity, title=p.title,
status=p.status, summary=p.summary,
rule_id=p.rule_id, severity=p.severity,
fingerprint=p.fingerprint, status=p.status,
event_count=p.event_count, rule_id=p.rule_id,
last_seen_at=p.last_seen_at, fingerprint=p.fingerprint,
created_at=p.created_at, event_count=p.event_count,
updated_at=p.updated_at, 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), @router.get("", response_model=ProblemListResponse)
page_size: int = Query(50, ge=1, le=200), def list_problems(
status: str | None = Query(None), page: int = Query(1, ge=1),
severity: str | None = Query(None), page_size: int = Query(50, ge=1, le=200),
host_id: int | None = Query(None), status: str | None = Query(None),
hostname: str | None = Query(None), severity: str | None = Query(None),
db: Session = Depends(get_db), host_id: int | None = Query(None),
_user: str = Depends(get_current_user), hostname: str | None = Query(None),
) -> ProblemListResponse: db: Session = Depends(get_db),
stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host)) _user: str = Depends(get_current_user),
count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True) ) -> ProblemListResponse:
stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host))
if status: count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True)
stmt = stmt.where(Problem.status == status)
count_stmt = count_stmt.where(Problem.status == status) if status:
if severity: 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}
+65 -1
View File
@@ -23,7 +23,11 @@ from app.services.notification_settings import (
upsert_webhook_channel, upsert_webhook_channel,
) )
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message 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"]) router = APIRouter(prefix="/settings", tags=["settings"])
@@ -328,3 +332,63 @@ def test_email_settings(
except EmailSendError as exc: except EmailSendError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP") 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,
)
+37
View File
@@ -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",
}
+2
View File
@@ -5,6 +5,7 @@ from app.models.notification_channel import NotificationChannel
from app.models.notification_cooldown import NotificationCooldown from app.models.notification_cooldown import NotificationCooldown
from app.models.notification_policy import NotificationPolicy from app.models.notification_policy import NotificationPolicy
from app.models.problem import Problem, ProblemEvent from app.models.problem import Problem, ProblemEvent
from app.models.event_severity_override import EventSeverityOverride
from app.models.user import User from app.models.user import User
__all__ = [ __all__ = [
@@ -17,4 +18,5 @@ __all__ = [
"Problem", "Problem",
"ProblemEvent", "ProblemEvent",
"User", "User",
"EventSeverityOverride",
] ]
@@ -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,
)
+1
View File
@@ -32,6 +32,7 @@ class EventSummary(BaseModel):
event_id: str event_id: str
host_id: int host_id: int
hostname: str hostname: str
display_name: str | None = None
occurred_at: datetime occurred_at: datetime
received_at: datetime received_at: datetime
category: str category: str
@@ -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)
+20
View File
@@ -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,
)
+2
View File
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from app.models import Event, Host from app.models import Event, Host
from app.services.daily_report_format import normalize_daily_report_details 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"}) 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 return existing, False
host = upsert_host(db, payload) host = upsert_host(db, payload)
payload = apply_severity_override(payload, db)
details = payload.get("details") details = payload.get("details")
if payload.get("type") in DAILY_REPORT_TYPES: if payload.get("type") in DAILY_REPORT_TYPES:
details = normalize_daily_report_details(details, host, payload["type"]) details = normalize_daily_report_details(details, host, payload["type"])
+1 -1
View File
@@ -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.6.0" APP_VERSION = "0.7.0"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
@@ -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
+2 -2
View File
@@ -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.6.0" assert APP_VERSION == "0.7.0"
assert APP_NAME == "Security Alert Center" 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"
+1 -1
View File
@@ -106,4 +106,4 @@ curl -sS -X POST "$BASE/api/v1/events" \
|-----------|--------| |-----------|--------|
| ssh-monitor | 1.2.6-SAC | | ssh-monitor | 1.2.6-SAC |
| RDP-login-monitor | 1.2.20-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`) |
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sac-ui", "name": "sac-ui",
"private": true, "private": true,
"version": "0.6.0", "version": "0.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+3
View File
@@ -132,6 +132,7 @@ export interface EventSummary {
event_id: string; event_id: string;
host_id: number; host_id: number;
hostname: string; hostname: string;
display_name?: string | null;
occurred_at: string; occurred_at: string;
received_at: string; received_at: string;
category: string; category: string;
@@ -180,6 +181,7 @@ export interface ProblemSummary {
id: number; id: number;
host_id: number | null; host_id: number | null;
hostname: string | null; hostname: string | null;
display_name?: string | null;
title: string; title: string;
summary: string; summary: string;
severity: string; severity: string;
@@ -228,6 +230,7 @@ export async function resolveProblem(problemId: number): Promise<{ id: number; s
export interface TopHostItem { export interface TopHostItem {
host_id: number; host_id: number;
hostname: string; hostname: string;
display_name?: string | null;
count: number; count: number;
} }
+5
View File
@@ -0,0 +1,5 @@
/** Display name from host card (SAC «Имя сервера»). */
export function formatServerName(displayName?: string | null): string {
const name = displayName?.trim();
return name || "—";
}
+1 -1
View File
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center"; 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}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+5
View File
@@ -234,6 +234,8 @@
<th>Хост</th> <th>Хост</th>
<th>Имя сервера</th>
<th>Severity</th> <th>Severity</th>
<th>Title</th> <th>Title</th>
@@ -264,6 +266,8 @@
</td> </td>
<td>{{ formatServerName(e.display_name) }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td> <td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td>{{ e.title }}</td> <td>{{ e.title }}</td>
@@ -293,6 +297,7 @@
import { onMounted, onUnmounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api"; import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api";
import { formatServerName } from "../utils/hostDisplay";
+4 -1
View File
@@ -10,7 +10,7 @@
<option>critical</option> <option>critical</option>
</select> </select>
<input v-model="typeFilter" placeholder="type" @keyup.enter="load(1)" /> <input v-model="typeFilter" placeholder="type" @keyup.enter="load(1)" />
<input v-model="hostnameFilter" placeholder="hostname" @keyup.enter="load(1)" /> <input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
<button type="button" @click="load(1)">Применить</button> <button type="button" @click="load(1)">Применить</button>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
@@ -23,6 +23,7 @@
<th>ID</th> <th>ID</th>
<th>Время</th> <th>Время</th>
<th>Хост</th> <th>Хост</th>
<th>Имя сервера</th>
<th>Severity</th> <th>Severity</th>
<th>Type</th> <th>Type</th>
<th>Title</th> <th>Title</th>
@@ -35,6 +36,7 @@
</td> </td>
<td>{{ formatDt(e.occurred_at) }}</td> <td>{{ formatDt(e.occurred_at) }}</td>
<td>{{ e.hostname }}</td> <td>{{ e.hostname }}</td>
<td>{{ formatServerName(e.display_name) }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td> <td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td> <td><code>{{ e.type }}</code></td>
<td>{{ e.title }}</td> <td>{{ e.title }}</td>
@@ -60,6 +62,7 @@
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { apiFetch, type EventListResponse } from "../api"; import { apiFetch, type EventListResponse } from "../api";
import { formatServerName } from "../utils/hostDisplay";
const route = useRoute(); const route = useRoute();
+4 -1
View File
@@ -14,7 +14,7 @@
<option>high</option> <option>high</option>
<option>critical</option> <option>critical</option>
</select> </select>
<input v-model="hostnameFilter" placeholder="hostname" @keyup.enter="load(1)" /> <input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
<button type="button" @click="load(1)">Применить</button> <button type="button" @click="load(1)">Применить</button>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
@@ -27,6 +27,7 @@
<th>ID</th> <th>ID</th>
<th>Обновлено</th> <th>Обновлено</th>
<th>Хост</th> <th>Хост</th>
<th>Имя сервера</th>
<th>Severity</th> <th>Severity</th>
<th>Status</th> <th>Status</th>
<th>Rule</th> <th>Rule</th>
@@ -41,6 +42,7 @@
</td> </td>
<td>{{ formatDt(p.updated_at) }}</td> <td>{{ formatDt(p.updated_at) }}</td>
<td>{{ p.hostname ?? "—" }}</td> <td>{{ p.hostname ?? "—" }}</td>
<td>{{ formatServerName(p.display_name) }}</td>
<td :class="'sev-' + p.severity">{{ p.severity }}</td> <td :class="'sev-' + p.severity">{{ p.severity }}</td>
<td :class="'status-' + p.status">{{ p.status }}</td> <td :class="'status-' + p.status">{{ p.status }}</td>
<td><code>{{ p.rule_id ?? "—" }}</code></td> <td><code>{{ p.rule_id ?? "—" }}</code></td>
@@ -88,6 +90,7 @@
import { onMounted, ref, watch } from "vue"; import { onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ackProblem, apiFetch, resolveProblem, type ProblemListResponse } from "../api"; import { ackProblem, apiFetch, resolveProblem, type ProblemListResponse } from "../api";
import { formatServerName } from "../utils/hostDisplay";
const route = useRoute(); const route = useRoute();
+4 -1
View File
@@ -6,7 +6,7 @@
</p> </p>
<div class="filters"> <div class="filters">
<input v-model="hostname" placeholder="hostname" @keyup.enter="load(1)" /> <input v-model="hostname" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
<select v-model="reportType" @change="load(1)"> <select v-model="reportType" @change="load(1)">
<option value="report.daily.ssh">SSH (report.daily.ssh)</option> <option value="report.daily.ssh">SSH (report.daily.ssh)</option>
<option value="report.daily.rdp">RDP (report.daily.rdp)</option> <option value="report.daily.rdp">RDP (report.daily.rdp)</option>
@@ -23,6 +23,7 @@
<tr> <tr>
<th>Время</th> <th>Время</th>
<th>Хост</th> <th>Хост</th>
<th>Имя сервера</th>
<th>Тип</th> <th>Тип</th>
<th>Сводка</th> <th>Сводка</th>
</tr> </tr>
@@ -40,6 +41,7 @@
<td> <td>
<strong>{{ e.hostname }}</strong> <strong>{{ e.hostname }}</strong>
</td> </td>
<td>{{ formatServerName(e.display_name) }}</td>
<td><code>{{ e.type }}</code></td> <td><code>{{ e.type }}</code></td>
<td class="report-summary-cell">{{ e.summary }}</td> <td class="report-summary-cell">{{ e.summary }}</td>
</tr> </tr>
@@ -64,6 +66,7 @@
import { onMounted, ref, watch } from "vue"; import { onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { apiFetch, type EventListResponse } from "../api"; import { apiFetch, type EventListResponse } from "../api";
import { formatServerName } from "../utils/hostDisplay";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
+145 -1
View File
@@ -53,6 +53,47 @@
</form> </form>
</section> </section>
<section class="card settings-card">
<h2>Severity по типу события</h2>
<p class="settings-hint settings-hint-top">
Переопределение значимости для новых событий после ingest. Исходное severity агента сохраняется в
<code>details.severity_agent</code>. Пустое значение как прислал агент (см. «По умолчанию»).
</p>
<div class="settings-severity-toolbar">
<input v-model="severityFilter" type="search" placeholder="Фильтр по type…" />
<button type="button" :disabled="savingSeverity" @click="saveSeverityOverrides">
{{ savingSeverity ? "Сохранение" : "Сохранить severity" }}
</button>
</div>
<div class="settings-severity-table-wrap">
<table class="settings-severity-table">
<thead>
<tr>
<th>Тип события</th>
<th>По умолчанию</th>
<th>Effective</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredSeverityRows" :key="row.event_type">
<td><code>{{ row.event_type }}</code></td>
<td>{{ row.default_severity }}</td>
<td>
<select v-model="row.selected">
<option value="">по умолчанию агента</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<p class="settings-meta">Записей: {{ filteredSeverityRows.length }} / {{ severityRows.length }}</p>
</section>
<section class="card settings-card"> <section class="card settings-card">
<h2>Telegram</h2> <h2>Telegram</h2>
<form class="settings-form" @submit.prevent="saveTelegram"> <form class="settings-form" @submit.prevent="saveTelegram">
@@ -250,11 +291,22 @@ interface NotificationPolicy {
source: string; source: string;
} }
interface SeverityOverrideRowApi {
event_type: string;
default_severity: string;
override_severity: string | null;
}
interface SeverityOverrideRowUi extends SeverityOverrideRowApi {
selected: string;
}
const loading = ref(true); const loading = ref(true);
const savingPolicy = ref(false); const savingPolicy = ref(false);
const savingTelegram = ref(false); const savingTelegram = ref(false);
const savingWebhook = ref(false); const savingWebhook = ref(false);
const savingEmail = ref(false); const savingEmail = ref(false);
const savingSeverity = ref(false);
const testingTelegram = ref(false); const testingTelegram = ref(false);
const testingWebhook = ref(false); const testingWebhook = ref(false);
const testingEmail = ref(false); const testingEmail = ref(false);
@@ -264,6 +316,8 @@ const telegramLoaded = ref<TelegramSettings | null>(null);
const webhookLoaded = ref<WebhookSettings | null>(null); const webhookLoaded = ref<WebhookSettings | null>(null);
const emailLoaded = ref<EmailSettings | null>(null); const emailLoaded = ref<EmailSettings | null>(null);
const policyLoaded = ref<NotificationPolicy | null>(null); const policyLoaded = ref<NotificationPolicy | null>(null);
const severityRows = ref<SeverityOverrideRowUi[]>([]);
const severityFilter = ref("");
const policyForm = reactive({ const policyForm = reactive({
min_severity: "warning", min_severity: "warning",
@@ -323,6 +377,58 @@ const mailToPlaceholder = computed(() =>
emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com", emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com",
); );
const filteredSeverityRows = computed(() => {
const q = severityFilter.value.trim().toLowerCase();
if (!q) return severityRows.value;
return severityRows.value.filter((row) => row.event_type.toLowerCase().includes(q));
});
function mapSeverityRows(items: SeverityOverrideRowApi[]): SeverityOverrideRowUi[] {
return items.map((row) => ({
...row,
selected: row.override_severity ?? "",
}));
}
async function loadSeverityOverrides() {
const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>(
"/api/v1/settings/notifications/severity-overrides",
);
severityRows.value = mapSeverityRows(data.items);
}
async function saveSeverityOverrides() {
savingSeverity.value = true;
error.value = "";
success.value = "";
try {
const overrides: Record<string, string | null> = {};
for (const row of severityRows.value) {
const prev = row.override_severity ?? "";
const next = row.selected.trim();
if (next === prev) continue;
overrides[row.event_type] = next || null;
}
if (Object.keys(overrides).length === 0) {
error.value = "Нет изменений severity для сохранения";
return;
}
const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>(
"/api/v1/settings/notifications/severity-overrides",
{
method: "PUT",
body: JSON.stringify({ overrides }),
},
);
severityRows.value = mapSeverityRows(data.items);
success.value = "Severity по типам событий сохранён.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения severity";
} finally {
savingSeverity.value = false;
}
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
@@ -357,6 +463,7 @@ async function load() {
emailForm.mail_to = ""; emailForm.mail_to = "";
emailForm.smtp_starttls = data.email.smtp_starttls; emailForm.smtp_starttls = data.email.smtp_starttls;
emailForm.smtp_ssl = data.email.smtp_ssl; emailForm.smtp_ssl = data.email.smtp_ssl;
await loadSeverityOverrides();
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки"; error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally { } finally {
@@ -522,7 +629,7 @@ onMounted(load);
<style scoped> <style scoped>
.settings-page { .settings-page {
max-width: 42rem; max-width: 52rem;
} }
.settings-intro { .settings-intro {
@@ -614,4 +721,41 @@ onMounted(load);
.success { .success {
color: #6dd196; color: #6dd196;
} }
.settings-severity-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
align-items: center;
}
.settings-severity-toolbar input[type="search"] {
flex: 1;
min-width: 12rem;
}
.settings-severity-table-wrap {
max-height: 24rem;
overflow: auto;
border: 1px solid #2a3441;
border-radius: 6px;
}
.settings-severity-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.settings-severity-table th,
.settings-severity-table td {
text-align: left;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #2a3441;
}
.settings-severity-table select {
min-width: 11rem;
}
</style> </style>