feat: webhook notification channel with UI and ingest dispatch
Add webhook config (DB/env), JSON POST on events/problems, settings API, Settings UI section, and notify_dispatch for multi-channel ingest. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,7 +17,7 @@ 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
|
||||
from app.services.telegram_notify import notify_event, notify_problem
|
||||
from app.services.notify_dispatch import notify_event, notify_problem
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
logger = logging.getLogger("sac.ingest")
|
||||
|
||||
+190
-106
@@ -1,106 +1,190 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.services.notification_settings import (
|
||||
VALID_SEVERITIES,
|
||||
TelegramConfig,
|
||||
get_effective_telegram_config,
|
||||
upsert_telegram_channel,
|
||||
)
|
||||
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= visible_tail:
|
||||
return "*" * len(text)
|
||||
return f"{'*' * 8}…{text[-visible_tail:]}"
|
||||
|
||||
|
||||
class TelegramSettingsResponse(BaseModel):
|
||||
enabled: bool
|
||||
configured: bool
|
||||
chat_id_hint: str | None = None
|
||||
bot_token_hint: str | None = None
|
||||
min_severity: str
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class NotificationSettingsResponse(BaseModel):
|
||||
telegram: TelegramSettingsResponse
|
||||
|
||||
|
||||
class TelegramSettingsUpdate(BaseModel):
|
||||
enabled: bool
|
||||
min_severity: str
|
||||
bot_token: str | None = None
|
||||
chat_id: str | None = None
|
||||
|
||||
|
||||
class TelegramTestResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = "Тестовое сообщение отправлено в Telegram"
|
||||
|
||||
|
||||
def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse:
|
||||
return TelegramSettingsResponse(
|
||||
enabled=cfg.enabled,
|
||||
configured=cfg.configured,
|
||||
chat_id_hint=_mask_secret(cfg.chat_id),
|
||||
bot_token_hint=_mask_secret(cfg.bot_token),
|
||||
min_severity=cfg.min_severity,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
||||
def get_notification_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> NotificationSettingsResponse:
|
||||
cfg = get_effective_telegram_config(db)
|
||||
return NotificationSettingsResponse(telegram=_telegram_to_response(cfg))
|
||||
|
||||
|
||||
@router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
|
||||
def update_telegram_settings(
|
||||
body: TelegramSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> TelegramSettingsResponse:
|
||||
if body.min_severity not in VALID_SEVERITIES:
|
||||
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
|
||||
try:
|
||||
cfg = upsert_telegram_channel(
|
||||
db,
|
||||
enabled=body.enabled,
|
||||
min_severity=body.min_severity,
|
||||
bot_token=body.bot_token,
|
||||
chat_id=body.chat_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return _telegram_to_response(cfg)
|
||||
|
||||
|
||||
@router.post("/notifications/telegram/test", response_model=TelegramTestResponse)
|
||||
def test_telegram_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> TelegramTestResponse:
|
||||
cfg = get_effective_telegram_config(db)
|
||||
try:
|
||||
send_telegram_test_message(config=cfg)
|
||||
except TelegramNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except TelegramSendError as exc:
|
||||
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
|
||||
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||
return TelegramTestResponse()
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
from app.services.notification_settings import (
|
||||
|
||||
VALID_SEVERITIES,
|
||||
|
||||
TelegramConfig,
|
||||
|
||||
WebhookConfig,
|
||||
|
||||
get_effective_telegram_config,
|
||||
|
||||
get_effective_webhook_config,
|
||||
|
||||
upsert_telegram_channel,
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
|
||||
|
||||
text = value.strip()
|
||||
|
||||
if not text:
|
||||
|
||||
return None
|
||||
|
||||
if len(text) <= visible_tail:
|
||||
|
||||
return "*" * len(text)
|
||||
|
||||
return f"{'*' * 8}…{text[-visible_tail:]}"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _mask_url(value: str) -> str | None:
|
||||
|
||||
text = value.strip()
|
||||
|
||||
if not text:
|
||||
|
||||
return None
|
||||
|
||||
if len(text) <= 20:
|
||||
|
||||
return f"{'*' * 6}…{text[-4:]}"
|
||||
|
||||
return f"{text[:16]}…{text[-6:]}"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TelegramSettingsResponse(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
|
||||
configured: bool
|
||||
|
||||
chat_id_hint: str | None = None
|
||||
|
||||
bot_token_hint: str | None = None
|
||||
|
||||
min_severity: str
|
||||
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class WebhookSettingsResponse(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
|
||||
configured: bool
|
||||
|
||||
url_hint: str | None = None
|
||||
|
||||
secret_header: str | None = None
|
||||
|
||||
secret_hint: str | None = None
|
||||
|
||||
min_severity: str
|
||||
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class NotificationSettingsResponse(BaseModel):
|
||||
|
||||
telegram: TelegramSettingsResponse
|
||||
|
||||
webhook: WebhookSettingsResponse
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TelegramSettingsUpdate(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
|
||||
min_severity: str
|
||||
|
||||
bot_token: str | None = None
|
||||
|
||||
chat_id: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class WebhookSettingsUpdate(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
|
||||
min_severity: str
|
||||
|
||||
url: str | None = None
|
||||
|
||||
secret_header: str | None = None
|
||||
|
||||
secret: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ChannelTestResponse(BaseModel):
|
||||
|
||||
ok: bool = True
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse:
|
||||
|
||||
return TelegramSettingsResponse(
|
||||
|
||||
enabled=cfg.enabled,
|
||||
|
||||
configured=cfg.configured,
|
||||
|
||||
chat_id_hint=_mask_secret(cfg.chat_id),
|
||||
|
||||
bot_token_hint=_mask_secret(cfg.bot_token),
|
||||
|
||||
min_severity=cfg.min_severity,
|
||||
|
||||
source=cfg.source,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse:
|
||||
|
||||
|
||||
@@ -50,7 +50,13 @@ class Settings(BaseSettings):
|
||||
telegram_chat_id: str = ""
|
||||
telegram_min_severity: str = "high"
|
||||
|
||||
# Порог «живости» агента: agent.heartbeat раз в ~12 ч (ssh-monitor)
|
||||
webhook_enabled: bool = False
|
||||
webhook_url: str = ""
|
||||
webhook_secret_header: str = ""
|
||||
webhook_secret: str = ""
|
||||
webhook_min_severity: str = "high"
|
||||
|
||||
# Порог «живости» агента
|
||||
sac_heartbeat_stale_minutes: int = 780
|
||||
|
||||
# Окно корреляции Problems: host + type + rule в одном open Problem
|
||||
|
||||
@@ -14,6 +14,9 @@ class NotificationChannel(Base):
|
||||
min_severity: Mapped[str] = mapped_column(String(16), default="warning")
|
||||
bot_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
webhook_secret_header: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
webhook_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models.notification_channel import NotificationChannel
|
||||
|
||||
VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"})
|
||||
CHANNEL_TELEGRAM = "telegram"
|
||||
CHANNEL_WEBHOOK = "webhook"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -31,6 +32,24 @@ class TelegramConfig:
|
||||
return self.enabled and self.configured
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebhookConfig:
|
||||
enabled: bool
|
||||
url: str
|
||||
secret_header: str
|
||||
secret: str
|
||||
min_severity: str
|
||||
source: str # env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.url.strip())
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.enabled and self.configured
|
||||
|
||||
|
||||
def _telegram_from_env() -> TelegramConfig:
|
||||
settings = get_settings()
|
||||
return TelegramConfig(
|
||||
@@ -107,3 +126,88 @@ def upsert_telegram_channel(
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_telegram_config(db)
|
||||
|
||||
|
||||
def _webhook_from_env() -> WebhookConfig:
|
||||
settings = get_settings()
|
||||
return WebhookConfig(
|
||||
enabled=bool(settings.webhook_enabled),
|
||||
url=settings.webhook_url.strip(),
|
||||
secret_header=settings.webhook_secret_header.strip(),
|
||||
secret=settings.webhook_secret.strip(),
|
||||
min_severity=settings.webhook_min_severity.strip() or "high",
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_webhook_config(db: Session | None = None) -> WebhookConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_webhook_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK))
|
||||
env_cfg = _webhook_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
url = (row.webhook_url or "").strip() or env_cfg.url
|
||||
secret_header = (row.webhook_secret_header or "").strip() or env_cfg.secret_header
|
||||
secret = (row.webhook_secret or "").strip() or env_cfg.secret
|
||||
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
|
||||
if min_sev not in VALID_SEVERITIES:
|
||||
min_sev = env_cfg.min_severity
|
||||
|
||||
return WebhookConfig(
|
||||
enabled=bool(row.enabled),
|
||||
url=url,
|
||||
secret_header=secret_header,
|
||||
secret=secret,
|
||||
min_severity=min_sev,
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
def upsert_webhook_channel(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
url: str | None = None,
|
||||
secret_header: str | None = None,
|
||||
secret: str | None = None,
|
||||
min_severity: str | None = None,
|
||||
) -> WebhookConfig:
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK))
|
||||
env_cfg = _webhook_from_env()
|
||||
|
||||
if row is None:
|
||||
row = NotificationChannel(
|
||||
channel=CHANNEL_WEBHOOK,
|
||||
enabled=env_cfg.enabled,
|
||||
min_severity=env_cfg.min_severity,
|
||||
webhook_url=env_cfg.url or None,
|
||||
webhook_secret_header=env_cfg.secret_header or None,
|
||||
webhook_secret=env_cfg.secret or None,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if enabled is not None:
|
||||
row.enabled = enabled
|
||||
if min_severity is not None:
|
||||
if min_severity not in VALID_SEVERITIES:
|
||||
raise ValueError(f"invalid min_severity: {min_severity}")
|
||||
row.min_severity = min_severity
|
||||
if url is not None and url.strip():
|
||||
row.webhook_url = url.strip()
|
||||
if secret_header is not None:
|
||||
row.webhook_secret_header = secret_header.strip() or None
|
||||
if secret is not None and secret.strip():
|
||||
row.webhook_secret = secret.strip()
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_webhook_config(db)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Dispatch ingest notifications to all configured channels."""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Problem
|
||||
from app.services import telegram_notify, webhook_notify
|
||||
|
||||
|
||||
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||
telegram_notify.notify_event(event, db=db)
|
||||
webhook_notify.notify_event(event, db=db)
|
||||
|
||||
|
||||
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
|
||||
telegram_notify.notify_problem(problem, event, db=db)
|
||||
webhook_notify.notify_problem(problem, event, db=db)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Generic JSON webhook notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Problem
|
||||
from app.services.notification_settings import WebhookConfig, get_effective_webhook_config
|
||||
from app.services.telegram_notify import SEVERITY_ORDER
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookNotConfiguredError(Exception):
|
||||
"""Webhook disabled or missing URL."""
|
||||
|
||||
|
||||
class WebhookSendError(Exception):
|
||||
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _severity_value(value: str) -> int:
|
||||
return SEVERITY_ORDER.get(value, 0)
|
||||
|
||||
|
||||
def _should_notify_severity(severity: str, *, config: WebhookConfig) -> bool:
|
||||
return _severity_value(severity) >= _severity_value(config.min_severity)
|
||||
|
||||
|
||||
def _host_payload(entity: Event | Problem) -> dict[str, Any]:
|
||||
host = entity.host
|
||||
if host is None:
|
||||
return {"hostname": "unknown", "display_name": None}
|
||||
return {"hostname": host.hostname, "display_name": host.display_name}
|
||||
|
||||
|
||||
def build_event_payload(event: Event) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": "event",
|
||||
"event_id": event.event_id,
|
||||
"type": event.type,
|
||||
"severity": event.severity,
|
||||
"category": event.category,
|
||||
"title": event.title,
|
||||
"summary": event.summary,
|
||||
"host": _host_payload(event),
|
||||
"occurred_at": event.occurred_at.isoformat() if event.occurred_at else None,
|
||||
}
|
||||
|
||||
|
||||
def build_problem_payload(problem: Problem, event: Event | None = None) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"kind": "problem",
|
||||
"problem_id": problem.id,
|
||||
"rule_id": problem.rule_id,
|
||||
"severity": problem.severity,
|
||||
"status": problem.status,
|
||||
"title": problem.title,
|
||||
"summary": problem.summary,
|
||||
"host": _host_payload(problem),
|
||||
"opened_at": problem.opened_at.isoformat() if problem.opened_at else None,
|
||||
}
|
||||
if event is not None:
|
||||
payload["trigger_event"] = {
|
||||
"event_id": event.event_id,
|
||||
"type": event.type,
|
||||
"severity": event.severity,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def send_webhook_payload(payload: dict[str, Any], *, config: WebhookConfig | None = None, force: bool = False) -> None:
|
||||
cfg = config or get_effective_webhook_config()
|
||||
if not force and not cfg.active:
|
||||
return
|
||||
if not cfg.url:
|
||||
if force:
|
||||
raise WebhookNotConfiguredError("Webhook: не задан URL")
|
||||
return
|
||||
|
||||
headers = {"Content-Type": "application/json", "User-Agent": "SecurityAlertCenter/1.0"}
|
||||
if cfg.secret_header and cfg.secret:
|
||||
headers[cfg.secret_header] = cfg.secret
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.post(cfg.url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text[:200] if exc.response is not None else str(exc)
|
||||
raise WebhookSendError(
|
||||
f"Webhook HTTP {exc.response.status_code}: {detail}",
|
||||
status_code=exc.response.status_code,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("webhook send failed")
|
||||
raise WebhookSendError(str(exc)) from exc
|
||||
|
||||
|
||||
def send_webhook_test_message(*, config: WebhookConfig | None = None) -> None:
|
||||
cfg = config or get_effective_webhook_config()
|
||||
if not cfg.enabled:
|
||||
raise WebhookNotConfiguredError("Webhook отключён (enabled=false)")
|
||||
if not cfg.configured:
|
||||
raise WebhookNotConfiguredError("Не задан webhook URL")
|
||||
send_webhook_payload(
|
||||
{
|
||||
"kind": "test",
|
||||
"message": "SAC webhook test",
|
||||
"source": "security-alert-center",
|
||||
},
|
||||
config=cfg,
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||
cfg = get_effective_webhook_config(db)
|
||||
if not cfg.active or not _should_notify_severity(event.severity, config=cfg):
|
||||
return
|
||||
try:
|
||||
send_webhook_payload(build_event_payload(event), config=cfg)
|
||||
except WebhookSendError:
|
||||
logger.exception("notify_event webhook failed event_id=%s", event.event_id)
|
||||
|
||||
|
||||
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
|
||||
cfg = get_effective_webhook_config(db)
|
||||
if not cfg.active or not _should_notify_severity(problem.severity, config=cfg):
|
||||
return
|
||||
try:
|
||||
send_webhook_payload(build_problem_payload(problem, event), config=cfg)
|
||||
except WebhookSendError:
|
||||
logger.exception("notify_problem webhook failed problem_id=%s", problem.id)
|
||||
Reference in New Issue
Block a user