diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py
index c35d945..66b7a30 100644
--- a/backend/app/api/v1/events.py
+++ b/backend/app/api/v1/events.py
@@ -17,7 +17,9 @@ 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.notify_dispatch import notify_event, notify_problem
+from app.services.notify_dispatch import notify_daily_report, notify_event, notify_problem
+
+DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
router = APIRouter(prefix="/events", tags=["events"])
logger = logging.getLogger("sac.ingest")
@@ -64,7 +66,10 @@ def post_event(
problem_created = False
if created:
problem, problem_created = maybe_create_problem(db, event)
- notify_event(event, db=db)
+ if event.type in DAILY_REPORT_EVENT_TYPES:
+ notify_daily_report(event, db=db)
+ else:
+ notify_event(event, db=db)
if problem is not None and problem_created:
notify_problem(problem, event, db=db)
logger.info("ingest created event_id=%s type=%s host_id=%s", event.event_id, event.type, event.host_id)
diff --git a/backend/app/config.py b/backend/app/config.py
index 27efb87..f94f629 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -75,6 +75,13 @@ class Settings(BaseSettings):
sac_notify_event_cooldown_sec: int = 90
sac_notify_problem_cooldown_sec: int = 300
+ # F-NOT-05: суточные отчёты из агрегации событий SAC (для exclusive / без отчёта агента)
+ sac_daily_report_enabled: bool = True
+ sac_daily_report_hour: int = 9
+ sac_daily_report_timezone: str = "Europe/Moscow"
+ sac_daily_report_skip_if_agent_sent: bool = True
+ sac_daily_report_require_activity: bool = True
+
# Порог «живости» агента
sac_heartbeat_stale_minutes: int = 780
diff --git a/backend/app/jobs/daily_report.py b/backend/app/jobs/daily_report.py
new file mode 100644
index 0000000..fec063e
--- /dev/null
+++ b/backend/app/jobs/daily_report.py
@@ -0,0 +1,45 @@
+"""CLI: python -m app.jobs.daily_report [--force]"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import sys
+
+from app.database import SessionLocal
+from app.services.daily_report import run_daily_reports
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("sac.daily_report")
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Generate SAC daily reports from DB events")
+ parser.add_argument(
+ "--force",
+ action="store_true",
+ help="Ignore SAC_DAILY_REPORT_HOUR (for manual run / tests)",
+ )
+ args = parser.parse_args(argv)
+
+ db = SessionLocal()
+ try:
+ results = run_daily_reports(db, force=args.force)
+ created = [r for r in results if r.created]
+ logger.info("created %s reports", len(created))
+ for r in created:
+ logger.info(" %s %s event_id=%s", r.hostname, r.report_type, r.event_id)
+ skipped = [r for r in results if not r.created and r.skipped_reason]
+ for r in skipped:
+ logger.info(" skip %s: %s", r.hostname, r.skipped_reason)
+ return 0
+ except Exception:
+ db.rollback()
+ logger.exception("daily report job failed")
+ return 1
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/backend/app/services/daily_report.py b/backend/app/services/daily_report.py
new file mode 100644
index 0000000..7f7bd09
--- /dev/null
+++ b/backend/app/services/daily_report.py
@@ -0,0 +1,328 @@
+"""SAC-generated daily reports from ingested events (F-NOT-05 / notif-32)."""
+
+from __future__ import annotations
+
+import html
+import logging
+import uuid
+from collections import Counter
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.config import Settings, get_settings
+from app.models import Event, Host
+from app.services.ingest import ingest_event
+from app.services.notify_dispatch import notify_daily_report
+
+logger = logging.getLogger(__name__)
+
+REPORT_TYPES = {
+ "ssh-monitor": "report.daily.ssh",
+ "rdp-login-monitor": "report.daily.rdp",
+}
+
+SSH_SUCCESS = "ssh.login.success"
+SSH_FAILED = "ssh.login.failed"
+SSH_SUDO = "privilege.sudo.command"
+RDP_SUCCESS = "rdp.login.success"
+RDP_FAILED = "rdp.login.failed"
+
+
+@dataclass(frozen=True)
+class DailyReportResult:
+ host_id: int
+ hostname: str
+ report_type: str
+ event_id: str
+ created: bool
+ skipped_reason: str | None = None
+
+
+def _now_in_tz(settings: Settings) -> datetime:
+ tz = ZoneInfo(settings.sac_daily_report_timezone.strip() or "Europe/Moscow")
+ return datetime.now(timezone.utc).astimezone(tz)
+
+
+def _report_type_for_host(host: Host) -> str | None:
+ return REPORT_TYPES.get((host.product or "").strip())
+
+
+def _day_start_in_tz(settings: Settings, ref: datetime | None = None) -> datetime:
+ local = ref or _now_in_tz(settings)
+ start = local.replace(hour=0, minute=0, second=0, microsecond=0)
+ return start.astimezone(timezone.utc)
+
+
+def host_has_report_today(db: Session, host_id: int, report_type: str, settings: Settings) -> bool:
+ since = _day_start_in_tz(settings)
+ row = db.scalar(
+ select(Event.id)
+ .where(
+ Event.host_id == host_id,
+ Event.type == report_type,
+ Event.received_at >= since,
+ )
+ .limit(1)
+ )
+ return row is not None
+
+
+def _ip_from_details(details: dict[str, Any] | None) -> str:
+ if not details:
+ return ""
+ for key in ("source_ip", "ip_address", "ip"):
+ val = details.get(key)
+ if val is not None and str(val).strip() not in ("", "-"):
+ return str(val).strip()
+ return ""
+
+
+def _user_from_details(details: dict[str, Any] | None) -> str:
+ if not details:
+ return ""
+ for key in ("user", "username"):
+ val = details.get(key)
+ if val is not None and str(val).strip():
+ return str(val).strip()
+ return ""
+
+
+def _aggregate_ssh(events: list[Event]) -> dict[str, Any]:
+ ok = failed = sudo = 0
+ failed_ips: Counter[str] = Counter()
+ for ev in events:
+ if ev.type == SSH_SUCCESS:
+ ok += 1
+ elif ev.type == SSH_FAILED:
+ failed += 1
+ ip = _ip_from_details(ev.details if isinstance(ev.details, dict) else None)
+ if ip:
+ failed_ips[ip] += 1
+ elif ev.type == SSH_SUDO:
+ sudo += 1
+ top_ips = [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)]
+ return {
+ "successful_ssh": ok,
+ "failed_ssh": failed,
+ "sudo_commands": sudo,
+ "active_bans": sum(1 for e in events if e.type == "ssh.ip.banned"),
+ "top_failed_ips": top_ips,
+ }
+
+
+def _aggregate_rdp(events: list[Event]) -> dict[str, Any]:
+ ok = failed = 0
+ failed_ips: Counter[str] = Counter()
+ users: set[str] = set()
+ for ev in events:
+ details = ev.details if isinstance(ev.details, dict) else None
+ if ev.type == RDP_SUCCESS:
+ ok += 1
+ u = _user_from_details(details)
+ if u:
+ users.add(u)
+ elif ev.type == RDP_FAILED:
+ failed += 1
+ ip = _ip_from_details(details)
+ if ip:
+ failed_ips[ip] += 1
+ u = _user_from_details(details)
+ if u:
+ users.add(u)
+ return {
+ "rdp_success": ok,
+ "rdp_failed": failed,
+ "unique_users": sorted(users),
+ "top_failed_ips": [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)],
+ }
+
+
+def _build_report_body_ssh(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
+ label = host.display_name or host.hostname
+ lines = [
+ "📊 ЕЖЕДНЕВНЫЙ ОТЧЁТ SSH (SAC)",
+ f"🖥️ Хост: {label}",
+ f"🕐 Период: последние 24 ч (сводка на {when_local.strftime('%d.%m.%Y %H:%M')})",
+ "",
+ "📈 СТАТИСТИКА ИЗ INGEST:",
+ f"✅ Успешных SSH: {stats['successful_ssh']}",
+ f"❌ Неудачных SSH: {stats['failed_ssh']}",
+ f"⚠️ Sudo: {stats['sudo_commands']}",
+ f"🚫 Событий ban: {stats['active_bans']}",
+ "",
+ "🧾 ТОП IP (неудачные входы):",
+ ]
+ top = stats.get("top_failed_ips") or []
+ if top:
+ lines.extend(f" • {x}" for x in top)
+ else:
+ lines.append(" (нет данных)")
+ lines.append("")
+ lines.append("Источник: Security Alert Center (агрегация событий SAC).")
+ return "\n".join(lines)
+
+
+def _build_report_body_rdp(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
+ label = host.display_name or host.hostname
+ users = stats.get("unique_users") or []
+ lines = [
+ "📊 ЕЖЕДНЕВНЫЙ ОТЧЁТ RDP (SAC)",
+ f"🖥️ Сервер: {label}",
+ f"🕐 Период: последние 24 ч (сводка на {when_local.strftime('%d.%m.%Y %H:%M')})",
+ "",
+ f"✅ Успешных RDP (ingest): {stats['rdp_success']}",
+ f"❌ Неудачных RDP: {stats['rdp_failed']}",
+ f"👥 Уникальных пользователей в событиях: {len(users)}",
+ "",
+ "🧾 ТОП IP (неудачные входы):",
+ ]
+ top = stats.get("top_failed_ips") or []
+ if top:
+ lines.extend(f" • {x}" for x in top)
+ else:
+ lines.append(" (нет данных)")
+ if users:
+ lines.append("")
+ lines.append("Уникальные логины:")
+ for u in users[:20]:
+ lines.append(f" • {u}")
+ lines.append("")
+ lines.append("Источник: SAC ingest (активные сессии quser — только на агенте).")
+ return "\n".join(lines)
+
+
+def _details_from_body(body: str, stats: dict[str, Any]) -> dict[str, Any]:
+ escaped = html.escape(body)
+ report_html = '
' + escaped.replace("\n", "
\n") + "
"
+ return {
+ "stats": stats,
+ "report_body": body,
+ "report_format": "plain",
+ "report_html": report_html,
+ "generated_by": "sac",
+ }
+
+
+def _events_last_24h(db: Session, host_id: int) -> list[Event]:
+ since = datetime.now(timezone.utc) - timedelta(hours=24)
+ return list(
+ db.scalars(
+ select(Event)
+ .where(Event.host_id == host_id, Event.occurred_at >= since)
+ .order_by(Event.occurred_at.desc())
+ ).all()
+ )
+
+
+def generate_daily_report_for_host(db: Session, host: Host, settings: Settings | None = None) -> DailyReportResult | None:
+ cfg = settings or get_settings()
+ report_type = _report_type_for_host(host)
+ if report_type is None:
+ return None
+
+ if cfg.sac_daily_report_skip_if_agent_sent and host_has_report_today(db, host.id, report_type, cfg):
+ return DailyReportResult(
+ host_id=host.id,
+ hostname=host.hostname,
+ report_type=report_type,
+ event_id="",
+ created=False,
+ skipped_reason="agent_report_exists_today",
+ )
+
+ events = _events_last_24h(db, host.id)
+ if not events and cfg.sac_daily_report_require_activity:
+ return DailyReportResult(
+ host_id=host.id,
+ hostname=host.hostname,
+ report_type=report_type,
+ event_id="",
+ created=False,
+ skipped_reason="no_events_24h",
+ )
+
+ when_local = _now_in_tz(cfg)
+ if report_type == "report.daily.ssh":
+ stats = _aggregate_ssh(events)
+ body = _build_report_body_ssh(host, stats, when_local)
+ title = "Ежедневный отчёт SSH (SAC)"
+ summary = (
+ f"SSH 24ч: успех {stats['successful_ssh']}, неудач {stats['failed_ssh']}, "
+ f"sudo {stats['sudo_commands']}"
+ )
+ else:
+ stats = _aggregate_rdp(events)
+ body = _build_report_body_rdp(host, stats, when_local)
+ title = "Ежедневный отчёт RDP (SAC)"
+ summary = f"RDP 24ч: успех {stats['rdp_success']}, неудач {stats['rdp_failed']}"
+
+ event_id = str(uuid.uuid4())
+ day_key = when_local.strftime("%Y-%m-%d")
+ payload = {
+ "schema_version": "1.0",
+ "event_id": event_id,
+ "occurred_at": datetime.now(timezone.utc).isoformat(),
+ "source": {
+ "product": host.product or "unknown",
+ "product_version": host.product_version or "sac",
+ },
+ "host": {
+ "hostname": host.hostname,
+ "os_family": host.os_family or "linux",
+ "display_name": host.display_name,
+ },
+ "category": "report",
+ "type": report_type,
+ "severity": "info",
+ "title": title,
+ "summary": summary,
+ "details": _details_from_body(body, stats),
+ "dedup_key": f"sac|{host.id}|{report_type}|{day_key}",
+ }
+
+ event, created = ingest_event(db, payload)
+ if created:
+ notify_daily_report(event, db=db)
+ return DailyReportResult(
+ host_id=host.id,
+ hostname=host.hostname,
+ report_type=report_type,
+ event_id=event.event_id,
+ created=created,
+ skipped_reason=None if created else "duplicate_event_id",
+ )
+
+
+def run_daily_reports(db: Session, settings: Settings | None = None, *, force: bool = False) -> list[DailyReportResult]:
+ cfg = settings or get_settings()
+ if not cfg.sac_daily_report_enabled:
+ logger.info("daily report disabled (SAC_DAILY_REPORT_ENABLED=false)")
+ return []
+
+ local_now = _now_in_tz(cfg)
+ if not force and local_now.hour < cfg.sac_daily_report_hour:
+ logger.info(
+ "daily report skipped: local hour %s < SAC_DAILY_REPORT_HOUR=%s",
+ local_now.hour,
+ cfg.sac_daily_report_hour,
+ )
+ return []
+
+ hosts = db.scalars(select(Host).order_by(Host.hostname)).all()
+ results: list[DailyReportResult] = []
+ for host in hosts:
+ try:
+ res = generate_daily_report_for_host(db, host, cfg)
+ if res is not None:
+ results.append(res)
+ except Exception:
+ logger.exception("daily report failed host_id=%s hostname=%s", host.id, host.hostname)
+ db.commit()
+ created_n = sum(1 for r in results if r.created)
+ logger.info("daily report finished hosts=%s created=%s", len(results), created_n)
+ return results
diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py
index 78c2e21..6f7c703 100644
--- a/backend/app/services/notify_dispatch.py
+++ b/backend/app/services/notify_dispatch.py
@@ -47,3 +47,11 @@ def notify_problem(problem: Problem, event: Event | None = None, *, db: Session
if not should_notify_problem(problem, db):
return
_dispatch_problem_channels(problem, event, db=db, policy=policy)
+
+
+def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
+ """Оповещение по суточному отчёту (severity=info, вне порога policy)."""
+ policy = get_effective_notification_policy(db)
+ if not should_notify_event(event, db):
+ return
+ _dispatch_event_channels(event, db=db, policy=policy)
diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py
index 567cd7b..abf841d 100644
--- a/backend/app/services/telegram_templates.py
+++ b/backend/app/services/telegram_templates.py
@@ -159,7 +159,25 @@ def format_generic_event_html(event: Event) -> str:
return msg.rstrip()
+def format_daily_report_html(event: Event) -> str:
+ details = _details_dict(event)
+ report_html = details.get("report_html")
+ if isinstance(report_html, str) and report_html.strip():
+ # Уже HTML от агента или SAC
+ return report_html.strip()
+ body = _detail(details, "report_body", default=event.summary)
+ if body != "-":
+ escaped = html.escape(body)
+ return (
+ f"📊 {html_escape(event.title)}\n"
+ f'{escaped.replace(chr(10), "
")}
'
+ )
+ return format_generic_event_html(event)
+
+
def format_event_telegram_html(event: Event) -> str:
+ if event.type in ("report.daily.ssh", "report.daily.rdp"):
+ return format_daily_report_html(event)
if event.type in ("rdp.login.success", "rdp.login.failed"):
return format_rdp_login_html(event)
if event.type in ("ssh.login.success", "ssh.login.failed"):
diff --git a/backend/tests/test_daily_report.py b/backend/tests/test_daily_report.py
new file mode 100644
index 0000000..0921ecd
--- /dev/null
+++ b/backend/tests/test_daily_report.py
@@ -0,0 +1,212 @@
+"""SAC daily report aggregation (F-NOT-05 / notif-32)."""
+
+import uuid
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+from sqlalchemy import select
+
+from app.config import Settings
+from app.models import Event, Host
+from app.services import notify_dispatch
+from app.services.daily_report import (
+ _aggregate_ssh,
+ generate_daily_report_for_host,
+ host_has_report_today,
+ run_daily_reports,
+)
+from app.services.notification_policy import NotificationPolicyConfig
+from app.services.telegram_templates import format_event_telegram_html
+
+
+def _ssh_host(db) -> Host:
+ h = Host(
+ hostname="pilot-ssh",
+ os_family="linux",
+ product="ssh-monitor",
+ last_seen_at=datetime.now(timezone.utc),
+ )
+ db.add(h)
+ db.flush()
+ return h
+
+
+def _cfg(**overrides) -> Settings:
+ base = {
+ "sac_daily_report_enabled": True,
+ "sac_daily_report_hour": 9,
+ "sac_daily_report_timezone": "UTC",
+ "sac_daily_report_skip_if_agent_sent": False,
+ "sac_daily_report_require_activity": False,
+ }
+ base.update(overrides)
+ return Settings(**base)
+
+
+def test_aggregate_ssh_counts_types(db_session):
+ now = datetime.now(timezone.utc)
+ events = [
+ Event(
+ event_id=str(uuid.uuid4()),
+ host_id=1,
+ occurred_at=now,
+ category="auth",
+ type="ssh.login.success",
+ severity="info",
+ title="ok",
+ summary="",
+ payload={},
+ ),
+ Event(
+ event_id=str(uuid.uuid4()),
+ host_id=1,
+ occurred_at=now,
+ category="auth",
+ type="ssh.login.failed",
+ severity="warning",
+ title="fail",
+ summary="",
+ details={"source_ip": "10.0.0.1"},
+ payload={},
+ ),
+ Event(
+ event_id=str(uuid.uuid4()),
+ host_id=1,
+ occurred_at=now,
+ category="privilege",
+ type="privilege.sudo.command",
+ severity="warning",
+ title="sudo",
+ summary="",
+ payload={},
+ ),
+ ]
+ stats = _aggregate_ssh(events)
+ assert stats["successful_ssh"] == 1
+ assert stats["failed_ssh"] == 1
+ assert stats["sudo_commands"] == 1
+ assert "10.0.0.1" in stats["top_failed_ips"][0]
+
+
+def test_skip_when_agent_report_today(db_session):
+ h = _ssh_host(db_session)
+ cfg = _cfg(sac_daily_report_skip_if_agent_sent=True)
+ now = datetime.now(timezone.utc)
+ db_session.add(
+ Event(
+ event_id=str(uuid.uuid4()),
+ host_id=h.id,
+ occurred_at=now,
+ received_at=now,
+ category="report",
+ type="report.daily.ssh",
+ severity="info",
+ title="agent",
+ summary="from agent",
+ details={"generated_by": "agent"},
+ payload={},
+ )
+ )
+ db_session.commit()
+ assert host_has_report_today(db_session, h.id, "report.daily.ssh", cfg) is True
+ res = generate_daily_report_for_host(db_session, h, cfg)
+ assert res is not None
+ assert res.created is False
+ assert res.skipped_reason == "agent_report_exists_today"
+
+
+def test_generate_creates_report_and_notifies(db_session):
+ h = _ssh_host(db_session)
+ now = datetime.now(timezone.utc)
+ db_session.add(
+ Event(
+ event_id=str(uuid.uuid4()),
+ host_id=h.id,
+ occurred_at=now - timedelta(hours=1),
+ category="auth",
+ type="ssh.login.failed",
+ severity="warning",
+ title="fail",
+ summary="x",
+ details={"source_ip": "1.2.3.4"},
+ payload={},
+ )
+ )
+ db_session.commit()
+ cfg = _cfg()
+ with patch("app.services.daily_report.notify_daily_report") as mock_notify:
+ res = generate_daily_report_for_host(db_session, h, cfg)
+ assert res is not None
+ assert res.created is True
+ assert res.report_type == "report.daily.ssh"
+ mock_notify.assert_called_once()
+ ev = db_session.scalar(select(Event).where(Event.type == "report.daily.ssh"))
+ assert ev is not None
+ assert ev.details.get("generated_by") == "sac"
+
+
+def test_run_daily_reports_respects_hour(db_session):
+ h = _ssh_host(db_session)
+ db_session.commit()
+ cfg = _cfg(sac_daily_report_hour=23)
+ early = datetime(2026, 5, 29, 8, 0, tzinfo=timezone.utc)
+ with patch("app.services.daily_report._now_in_tz", return_value=early):
+ out = run_daily_reports(db_session, cfg, force=False)
+ assert out == []
+
+
+def test_run_daily_reports_force_bypasses_hour(db_session):
+ _ssh_host(db_session)
+ db_session.commit()
+ cfg = _cfg(sac_daily_report_hour=23)
+ early = datetime(2026, 5, 29, 8, 0, tzinfo=timezone.utc)
+ with patch("app.services.daily_report._now_in_tz", return_value=early):
+ with patch("app.services.daily_report.generate_daily_report_for_host") as mock_gen:
+ mock_gen.return_value = None
+ run_daily_reports(db_session, cfg, force=True)
+ mock_gen.assert_called()
+
+
+def test_notify_daily_report_bypasses_min_severity():
+ event = Event(
+ event_id="00000000-0000-4000-8000-000000000601",
+ host_id=1,
+ occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
+ category="report",
+ type="report.daily.ssh",
+ severity="info",
+ title="report",
+ summary="s",
+ dedup_key="sac|1|report.daily.ssh|2026-05-29",
+ 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=True):
+ with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
+ notify_dispatch.notify_daily_report(event)
+ mock_tg.notify_event.assert_called_once()
+
+
+def test_daily_report_telegram_html_uses_report_html(db_session):
+ h = Host(hostname="h1", display_name="H1", os_family="linux")
+ event = Event(
+ event_id=str(uuid.uuid4()),
+ host_id=1,
+ host=h,
+ occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
+ category="report",
+ type="report.daily.ssh",
+ severity="info",
+ title="Отчёт",
+ summary="short",
+ details={"report_html": "📊 OK
line"},
+ payload={},
+ )
+ assert format_event_telegram_html(event) == "📊 OK
line"
diff --git a/backend/tests/test_ingest.py b/backend/tests/test_ingest.py
index d16625f..d678b51 100644
--- a/backend/tests/test_ingest.py
+++ b/backend/tests/test_ingest.py
@@ -108,3 +108,28 @@ def test_ingest_invalid_payload_422(client, auth_headers):
)
assert r.status_code == 422
assert "schema_errors" in r.json()["detail"]
+
+
+def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
+ from unittest.mock import patch
+
+ from app.api.v1 import events as events_api
+
+ event_id = str(uuid.uuid4())
+ payload = {
+ **VALID_EVENT,
+ "event_id": event_id,
+ "category": "report",
+ "type": "report.daily.ssh",
+ "severity": "info",
+ "title": "Daily SSH report",
+ "summary": "stats",
+ "details": {"generated_by": "agent", "report_body": "line1"},
+ }
+ with patch.object(events_api, "notify_daily_report") as mock_daily:
+ with patch.object(events_api, "notify_event") as mock_event:
+ r = client.post("/api/v1/events", json=payload, headers=auth_headers)
+ assert r.status_code == 201
+ mock_daily.assert_called_once()
+ mock_event.assert_not_called()
+
diff --git a/deploy/env.native.example b/deploy/env.native.example
index 3fc283d..609fa94 100644
--- a/deploy/env.native.example
+++ b/deploy/env.native.example
@@ -53,6 +53,13 @@ SAC_NOTIFY_COOLDOWN_ENABLED=true
SAC_NOTIFY_EVENT_COOLDOWN_SEC=90
SAC_NOTIFY_PROBLEM_COOLDOWN_SEC=300
+# Суточные отчёты из SAC (F-NOT-05; systemd sac-daily-report.timer)
+SAC_DAILY_REPORT_ENABLED=true
+SAC_DAILY_REPORT_HOUR=9
+SAC_DAILY_REPORT_TIMEZONE=Europe/Moscow
+SAC_DAILY_REPORT_SKIP_IF_AGENT_SENT=true
+SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true
+
# Статус хоста по agent.heartbeat
SAC_HEARTBEAT_STALE_MINUTES=780
diff --git a/deploy/systemd/sac-daily-report.service b/deploy/systemd/sac-daily-report.service
new file mode 100644
index 0000000..135bc88
--- /dev/null
+++ b/deploy/systemd/sac-daily-report.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Security Alert Center daily report generation (F-NOT-05)
+After=network-online.target postgresql.service
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+User=sac
+Group=sac
+WorkingDirectory=/opt/security-alert-center/backend
+EnvironmentFile=/opt/security-alert-center/config/sac-api.env
+ExecStart=/opt/security-alert-center/backend/.venv/bin/python -m app.jobs.daily_report
+Nice=10
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/systemd/sac-daily-report.timer b/deploy/systemd/sac-daily-report.timer
new file mode 100644
index 0000000..0f655ab
--- /dev/null
+++ b/deploy/systemd/sac-daily-report.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Daily SAC report aggregation and notify
+
+[Timer]
+OnCalendar=*-*-* 09:15:00
+RandomizedDelaySec=300
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/docs/agent-integration.md b/docs/agent-integration.md
index 6f7e16f..3bbbe93 100644
--- a/docs/agent-integration.md
+++ b/docs/agent-integration.md
@@ -256,6 +256,7 @@ SAC применяет cooldown по `dedup_key` (события, по умол
- `Send-SacEvent` + замена вызовов `Send-TelegramMessage` для событий мониторинга.
- Heartbeat и daily report — отдельные типы в SAC.
+- При `UseSAC=exclusive` суточный отчёт может формироваться **на SAC** (`generated_by: sac` в `details`), если агент за день не прислал `report.daily.*` — см. `SAC_DAILY_REPORT_*`, job `python -m app.jobs.daily_report`, timer `sac-daily-report.timer`.
---
diff --git a/docs/operations-prod-status.md b/docs/operations-prod-status.md
index cd6b802..35b2893 100644
--- a/docs/operations-prod-status.md
+++ b/docs/operations-prod-status.md
@@ -52,9 +52,10 @@ sudo /opt/sac-deploy.sh
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` на пилотных хостах
-5. Пилот **UseSAC=exclusive** на Windows — ~16.06 (см. [todo-2026-05-29.md](todo-2026-05-29.md))
+3. **Timer** — `sac-daily-report.timer` + `SAC_DAILY_REPORT_*` (миграций нет; см. runbook § F-NOT-05)
+4. **Настройки UI** — `/settings`: Telegram `warning+`, кнопка «Проверить Telegram»
+5. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах
+6. Пилот **UseSAC=exclusive** на Windows — ~16.06 (см. [todo-2026-05-29.md](todo-2026-05-29.md))
---
diff --git a/docs/pilot-2.2-heartbeat-reports.md b/docs/pilot-2.2-heartbeat-reports.md
index 06e52a6..8371287 100644
--- a/docs/pilot-2.2-heartbeat-reports.md
+++ b/docs/pilot-2.2-heartbeat-reports.md
@@ -45,7 +45,35 @@ SAC_HEARTBEAT_STALE_MINUTES=780
3. **Хосты** → статус `online` после свежего heartbeat.
-4. Ежедневный отчёт: после `DAILY_REPORT_HOUR` — событие `report.daily.ssh`.
+4. Ежедневный отчёт: после `DAILY_REPORT_HOUR` — событие `report.daily.ssh` (от агента) **или** от SAC при `SAC_DAILY_REPORT_ENABLED=true` и отсутствии отчёта агента за сутки.
+
+---
+
+## SAC: отчёт без агента (exclusive / пропуск агента)
+
+В `config/sac-api.env`:
+
+```bash
+SAC_DAILY_REPORT_ENABLED=true
+SAC_DAILY_REPORT_HOUR=9
+SAC_DAILY_REPORT_TIMEZONE=Europe/Moscow
+SAC_DAILY_REPORT_SKIP_IF_AGENT_SENT=true
+SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true
+```
+
+- Job: `cd backend && .venv/bin/python -m app.jobs.daily_report` (ручной прогон: `--force`).
+- Timer: `deploy/systemd/sac-daily-report.service` + `.timer` (по умолчанию 09:15 UTC в unit; час проверяется по `SAC_DAILY_REPORT_TIMEZONE`).
+- Оповещение: `notify_daily_report` — **не** режется `NOTIFY_MIN_SEVERITY=warning` (severity отчёта `info`).
+- UI **Отчёты** и Telegram используют `details.report_html` / `report_body` как у агента.
+
+Установка timer (один раз на сервере):
+
+```bash
+sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.service /etc/systemd/system/
+sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.timer /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now sac-daily-report.timer
+```
---
diff --git a/docs/runbook-ops.md b/docs/runbook-ops.md
index 87301be..ece3701 100644
--- a/docs/runbook-ops.md
+++ b/docs/runbook-ops.md
@@ -61,6 +61,21 @@ sudo systemctl enable --now sac-retention.timer
systemctl list-timers sac-retention.timer
```
+## Суточные отчёты (F-NOT-05)
+
+```bash
+sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.service /etc/systemd/system/
+sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.timer /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now sac-daily-report.timer
+systemctl list-timers sac-daily-report.timer
+
+# Ручной прогон (игнор часа)
+sudo -u sac bash -lc 'cd /opt/security-alert-center/backend && .venv/bin/python -m app.jobs.daily_report --force'
+```
+
+Переменные: `SAC_DAILY_REPORT_*` в `config/sac-api.env` (см. `deploy/env.native.example`).
+
## Health и мониторинг
| Endpoint | Назначение |
diff --git a/docs/testing-e2e-checklist.md b/docs/testing-e2e-checklist.md
index 47533f8..61bd47f 100644
--- a/docs/testing-e2e-checklist.md
+++ b/docs/testing-e2e-checklist.md
@@ -11,7 +11,7 @@
- [ ] Неудачный SSH → `ssh.login.failed`
- [ ] `SERVER_DISPLAY_NAME` задан → в SAC **Хосты** отображается display name
- [ ] Heartbeat → `agent.heartbeat`, хост не `stale` в пределах `SAC_HEARTBEAT_STALE_MINUTES`
-- [ ] Daily report → `report.daily.ssh` (если включён)
+- [ ] Daily report → `report.daily.ssh` (агент или SAC: `python -m app.jobs.daily_report --force`, `details.generated_by`)
### Windows (RDP-login-monitor, `UseSAC=dual`)
diff --git a/docs/todo-2026-05-29.md b/docs/todo-2026-05-29.md
index 5c5e2f9..97192d0 100644
--- a/docs/todo-2026-05-29.md
+++ b/docs/todo-2026-05-29.md
@@ -54,7 +54,7 @@
|----|--------|
| `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 | ✅ `notification_cooldown`, env 90/300 с |
-| `notif-32` | F-NOT-05: daily report из SAC при exclusive (отдельный эпик) |
+| `notif-32` | F-NOT-05: daily report из SAC при exclusive (отдельный эпик) | ✅ `daily_report.py`, timer `sac-daily-report` |
---