diff --git a/backend/alembic/versions/008_notification_cooldown.py b/backend/alembic/versions/008_notification_cooldown.py new file mode 100644 index 0000000..b6eeee6 --- /dev/null +++ b/backend/alembic/versions/008_notification_cooldown.py @@ -0,0 +1,31 @@ +"""notification_cooldown for SAC notify dedup (F-NOT-03) + +Revision ID: 008 +Revises: 007 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "008" +down_revision: Union[str, None] = "007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notification_cooldown", + sa.Column("cooldown_key", sa.String(length=512), nullable=False), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("last_notified_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("cooldown_key"), + ) + op.create_index("ix_notification_cooldown_kind", "notification_cooldown", ["kind"]) + + +def downgrade() -> None: + op.drop_index("ix_notification_cooldown_kind", table_name="notification_cooldown") + op.drop_table("notification_cooldown") diff --git a/backend/app/config.py b/backend/app/config.py index 9f36371..27efb87 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -70,6 +70,11 @@ class Settings(BaseSettings): notify_min_severity: str = "warning" notify_channels: str = "telegram,webhook,email" + # F-NOT-03: cooldown исходящих оповещений (dedup_key / fingerprint) + sac_notify_cooldown_enabled: bool = True + sac_notify_event_cooldown_sec: int = 90 + sac_notify_problem_cooldown_sec: int = 300 + # Порог «живости» агента sac_heartbeat_stale_minutes: int = 780 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 52c8ac1..fbbb366 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,7 +2,17 @@ from app.models.api_key import ApiKey from app.models.event import Event from app.models.host import Host 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 -__all__ = ["ApiKey", "Event", "Host", "NotificationChannel", "NotificationPolicy", "Problem", "ProblemEvent"] +__all__ = [ + "ApiKey", + "Event", + "Host", + "NotificationChannel", + "NotificationCooldown", + "NotificationPolicy", + "Problem", + "ProblemEvent", +] diff --git a/backend/app/models/notification_cooldown.py b/backend/app/models/notification_cooldown.py new file mode 100644 index 0000000..a079d76 --- /dev/null +++ b/backend/app/models/notification_cooldown.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class NotificationCooldown(Base): + """Последняя отправка оповещения по ключу (dedup_key / fingerprint).""" + + __tablename__ = "notification_cooldown" + + cooldown_key: Mapped[str] = mapped_column(String(512), primary_key=True) + kind: Mapped[str] = mapped_column(String(16)) # event | problem + last_notified_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/services/notification_cooldown.py b/backend/app/services/notification_cooldown.py new file mode 100644 index 0000000..b0f2f43 --- /dev/null +++ b/backend/app/services/notification_cooldown.py @@ -0,0 +1,100 @@ +"""Cooldown / dedup for outbound notifications (F-NOT-03).""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models import Event, Problem +from app.models.notification_cooldown import NotificationCooldown + +logger = logging.getLogger(__name__) + +COOLDOWN_KIND_EVENT = "event" +COOLDOWN_KIND_PROBLEM = "problem" + +# Не душим служебные проверки ingest +_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test"}) + + +def _as_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def build_event_cooldown_key(event: Event) -> str: + if event.dedup_key and str(event.dedup_key).strip(): + return f"event:{event.dedup_key.strip()}" + + details: dict[str, Any] = event.details if isinstance(event.details, dict) else {} + host = event.host.hostname if event.host else "unknown" + ip = str(details.get("source_ip") or details.get("ip_address") or details.get("ip") or "").strip() + user = str(details.get("user") or details.get("username") or "").strip() + return f"event:{event.type}|{host}|{ip}|{user}" + + +def build_problem_cooldown_key(problem: Problem) -> str: + fp = (problem.fingerprint or "").strip() or f"id:{problem.id}" + return f"problem:{fp}" + + +def _cooldown_seconds_for_kind(kind: str) -> int: + settings = get_settings() + if not settings.sac_notify_cooldown_enabled: + return 0 + if kind == COOLDOWN_KIND_PROBLEM: + return max(0, int(settings.sac_notify_problem_cooldown_sec)) + return max(0, int(settings.sac_notify_event_cooldown_sec)) + + +def allow_notify(db: Session | None, *, key: str, kind: str) -> bool: + """True = можно слать сейчас; при True обновляет last_notified_at.""" + if kind == COOLDOWN_KIND_EVENT: + cooldown_sec = _cooldown_seconds_for_kind(COOLDOWN_KIND_EVENT) + else: + cooldown_sec = _cooldown_seconds_for_kind(COOLDOWN_KIND_PROBLEM) + + if cooldown_sec <= 0: + return True + if db is None: + return True + + now = datetime.now(timezone.utc) + row = db.get(NotificationCooldown, key) + if row is not None: + last = _as_utc(row.last_notified_at) + elapsed = (now - last).total_seconds() + if elapsed >= 0 and elapsed < cooldown_sec: + logger.info( + "notify cooldown skip kind=%s key=%s elapsed=%.0fs need=%ss", + kind, + key[:120], + elapsed, + cooldown_sec, + ) + return False + + if row is None: + db.add(NotificationCooldown(cooldown_key=key, kind=kind, last_notified_at=now)) + else: + row.kind = kind + row.last_notified_at = now + db.flush() + return True + + +def should_notify_event(event: Event, db: Session | None) -> bool: + if event.type in _EVENT_COOLDOWN_EXEMPT_TYPES: + return True + key = build_event_cooldown_key(event) + return allow_notify(db, key=key, kind=COOLDOWN_KIND_EVENT) + + +def should_notify_problem(problem: Problem, db: Session | None) -> bool: + key = build_problem_cooldown_key(problem) + return allow_notify(db, key=key, kind=COOLDOWN_KIND_PROBLEM) diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py index 22e4fa6..78c2e21 100644 --- a/backend/app/services/notify_dispatch.py +++ b/backend/app/services/notify_dispatch.py @@ -1,18 +1,19 @@ """Dispatch ingest notifications per global policy (severity → channels).""" +import logging + from sqlalchemy.orm import Session from app.models import Event, Problem from app.services import email_notify, telegram_notify, webhook_notify +from app.services.notification_cooldown import should_notify_event, should_notify_problem from app.services.notification_policy import get_effective_notification_policy from app.services.notification_severity import severity_meets_minimum -from app.services.notification_settings import CHANNEL_EMAIL, CHANNEL_TELEGRAM, CHANNEL_WEBHOOK + +logger = logging.getLogger(__name__) -def notify_event(event: Event, *, db: Session | None = None) -> None: - policy = get_effective_notification_policy(db) - if not severity_meets_minimum(event.severity, policy.min_severity): - return +def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None: if policy.use_telegram: telegram_notify.notify_event(event, db=db, apply_policy_gate=False) if policy.use_webhook: @@ -21,13 +22,28 @@ def notify_event(event: Event, *, db: Session | None = None) -> None: email_notify.notify_event(event, db=db, apply_policy_gate=False) -def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: - policy = get_effective_notification_policy(db) - if not severity_meets_minimum(problem.severity, policy.min_severity): - return +def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Session | None, policy) -> None: if policy.use_telegram: telegram_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) if policy.use_webhook: webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) if policy.use_email: email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) + + +def notify_event(event: Event, *, db: Session | None = None) -> None: + policy = get_effective_notification_policy(db) + if not severity_meets_minimum(event.severity, policy.min_severity): + return + if not should_notify_event(event, db): + return + _dispatch_event_channels(event, db=db, policy=policy) + + +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: + policy = get_effective_notification_policy(db) + if not severity_meets_minimum(problem.severity, policy.min_severity): + return + if not should_notify_problem(problem, db): + return + _dispatch_problem_channels(problem, event, db=db, policy=policy) diff --git a/backend/tests/test_notification_cooldown.py b/backend/tests/test_notification_cooldown.py new file mode 100644 index 0000000..75bab28 --- /dev/null +++ b/backend/tests/test_notification_cooldown.py @@ -0,0 +1,85 @@ +"""Notification cooldown / dedup (notif-31).""" + +from datetime import datetime, timedelta, timezone + +from app.config import get_settings +from app.models import Event +from app.models.notification_cooldown import NotificationCooldown +from app.services.notification_cooldown import ( + allow_notify, + build_event_cooldown_key, + build_problem_cooldown_key, + should_notify_event, +) +from app.models import Problem + + +def test_build_event_cooldown_key_prefers_dedup_key(): + event = Event( + event_id="00000000-0000-4000-8000-000000000601", + host_id=1, + occurred_at=datetime(2026, 5, 29, 16, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="t", + summary="s", + dedup_key="rdp|host|failed|10.0.0.1|admin", + payload={}, + ) + assert build_event_cooldown_key(event) == "event:rdp|host|failed|10.0.0.1|admin" + + +def test_allow_notify_blocks_within_cooldown(db_session, monkeypatch): + monkeypatch.setenv("SAC_NOTIFY_COOLDOWN_ENABLED", "true") + monkeypatch.setenv("SAC_NOTIFY_EVENT_COOLDOWN_SEC", "90") + get_settings.cache_clear() + + key = "event:test-key" + past = datetime.now(timezone.utc) - timedelta(seconds=30) + db_session.add(NotificationCooldown(cooldown_key=key, kind="event", last_notified_at=past)) + db_session.commit() + + assert allow_notify(db_session, key=key, kind="event") is False + + +def test_allow_notify_allows_after_cooldown(db_session, monkeypatch): + monkeypatch.setenv("SAC_NOTIFY_COOLDOWN_ENABLED", "true") + monkeypatch.setenv("SAC_NOTIFY_EVENT_COOLDOWN_SEC", "60") + get_settings.cache_clear() + + key = "event:old" + past = datetime.now(timezone.utc) - timedelta(seconds=120) + db_session.add(NotificationCooldown(cooldown_key=key, kind="event", last_notified_at=past)) + db_session.commit() + + assert allow_notify(db_session, key=key, kind="event") is True + + +def test_should_notify_event_exempt_agent_test(db_session, monkeypatch): + monkeypatch.setenv("SAC_NOTIFY_EVENT_COOLDOWN_SEC", "90") + get_settings.cache_clear() + + event = Event( + event_id="00000000-0000-4000-8000-000000000602", + host_id=1, + occurred_at=datetime(2026, 5, 29, 16, 0, tzinfo=timezone.utc), + category="agent", + type="agent.test", + severity="info", + title="t", + summary="s", + payload={}, + ) + assert should_notify_event(event, db_session) is True + + +def test_build_problem_cooldown_key(): + problem = Problem( + title="brute", + summary="x", + severity="high", + status="open", + fingerprint="host:1|rdp.login.failed|rule:brute", + ) + assert build_problem_cooldown_key(problem).startswith("problem:") diff --git a/backend/tests/test_notify_dispatch.py b/backend/tests/test_notify_dispatch.py index c92ce62..9f8cff7 100644 --- a/backend/tests/test_notify_dispatch.py +++ b/backend/tests/test_notify_dispatch.py @@ -8,7 +8,7 @@ from app.services import notify_dispatch from app.services.notification_policy import NotificationPolicyConfig -def test_notify_event_skipped_below_policy_severity(): +def test_notify_event_calls_selected_channels(): event = Event( event_id="00000000-0000-4000-8000-000000000401", host_id=1, @@ -60,3 +60,30 @@ def test_notify_event_calls_selected_channels(): mock_tg.notify_event.assert_called_once() mock_wh.notify_event.assert_called_once() mock_em.notify_event.assert_not_called() + + +def test_notify_event_skipped_by_cooldown(): + event = Event( + event_id="00000000-0000-4000-8000-000000000403", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="fail", + summary="send", + dedup_key="same-key", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=False, + use_email=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=False): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + notify_dispatch.notify_event(event) + mock_tg.notify_event.assert_not_called() diff --git a/deploy/env.native.example b/deploy/env.native.example index fda4691..3fc283d 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -48,6 +48,11 @@ SMTP_MIN_SEVERITY=high NOTIFY_MIN_SEVERITY=warning NOTIFY_CHANNELS=telegram,webhook,email +# Cooldown оповещений SAC (секунды; 0 = выкл. для kind) +SAC_NOTIFY_COOLDOWN_ENABLED=true +SAC_NOTIFY_EVENT_COOLDOWN_SEC=90 +SAC_NOTIFY_PROBLEM_COOLDOWN_SEC=300 + # Статус хоста по agent.heartbeat SAC_HEARTBEAT_STALE_MINUTES=780 diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 133803b..6f7e16f 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -237,7 +237,7 @@ Telegram у ssh/RDP использует ту же подпись, что и `di | privilege.sudo | `{product}\|{host}\|sudo\|{user}\|{hash(command)}` | | rdp.login.failed | `{product}\|{host}\|rdp.failed\|{ip}\|{user}` | -SAC применяет cooldown по `dedup_key` + правилам. +SAC применяет cooldown по `dedup_key` (события, по умолчанию **90 с**) и по `fingerprint` problem (**300 с**). Таблица `notification_cooldown`, env: `SAC_NOTIFY_EVENT_COOLDOWN_SEC`, `SAC_NOTIFY_PROBLEM_COOLDOWN_SEC`. В логах API: `notify cooldown skip`. --- diff --git a/docs/operations-prod-status.md b/docs/operations-prod-status.md index 98e4072..cd6b802 100644 --- a/docs/operations-prod-status.md +++ b/docs/operations-prod-status.md @@ -50,7 +50,7 @@ sudo /opt/sac-deploy.sh ## Следующие шаги (2026-05-29) -1. **Деплой SAC v0.5.0** — `sudo /opt/sac-deploy.sh` (миграции **`004`–`007`**: каналы + глобальное правило) +1. **Деплой SAC v0.5.0** — `sudo /opt/sac-deploy.sh` (миграции **`004`–`008`**: каналы, правило, cooldown) 2. **notif-02** — прогон [testing-e2e-checklist.md](testing-e2e-checklist.md) § «SAC Telegram» на prod 3. **Настройки UI** — `/settings`: Telegram `warning+`, кнопка «Проверить Telegram» 4. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах diff --git a/docs/todo-2026-05-29.md b/docs/todo-2026-05-29.md index 9538fde..5c5e2f9 100644 --- a/docs/todo-2026-05-29.md +++ b/docs/todo-2026-05-29.md @@ -53,7 +53,7 @@ | ID | Задача | |----|--------| | `notif-30` | Шаблон TG ближе к агенту (HTML, поля user/ip/logon_type из `details`) | ✅ `telegram_templates.py`, `parse_mode=HTML` | -| `notif-31` | Cooldown/dedup на уровне SAC (F-NOT-03) — не дублировать problem при burst | +| `notif-31` | Cooldown/dedup на уровне SAC (F-NOT-03) — не дублировать problem при burst | ✅ `notification_cooldown`, env 90/300 с | | `notif-32` | F-NOT-05: daily report из SAC при exclusive (отдельный эпик) | ---