From 415d863b3bc23b6a254a9b92543f6367e915ab82 Mon Sep 17 00:00:00 2001 From: PTah Date: Fri, 29 May 2026 16:30:07 +1000 Subject: [PATCH] feat: SAC daily reports from DB aggregation (notif-32) - Aggregate report.daily.ssh/rdp from 24h ingest; job and systemd timer - notify_daily_report bypasses NOTIFY_MIN_SEVERITY; ingest routes agent reports - HTML templates, env SAC_DAILY_REPORT_*, docs and tests (56 cases) Co-authored-by: Cursor --- backend/app/api/v1/events.py | 9 +- backend/app/config.py | 7 + backend/app/jobs/daily_report.py | 45 +++ backend/app/services/daily_report.py | 328 +++++++++++++++++++++ backend/app/services/notify_dispatch.py | 8 + backend/app/services/telegram_templates.py | 18 ++ backend/tests/test_daily_report.py | 212 +++++++++++++ backend/tests/test_ingest.py | 25 ++ deploy/env.native.example | 7 + deploy/systemd/sac-daily-report.service | 16 + deploy/systemd/sac-daily-report.timer | 10 + docs/agent-integration.md | 1 + docs/operations-prod-status.md | 7 +- docs/pilot-2.2-heartbeat-reports.md | 30 +- docs/runbook-ops.md | 15 + docs/testing-e2e-checklist.md | 2 +- docs/todo-2026-05-29.md | 2 +- 17 files changed, 734 insertions(+), 8 deletions(-) create mode 100644 backend/app/jobs/daily_report.py create mode 100644 backend/app/services/daily_report.py create mode 100644 backend/tests/test_daily_report.py create mode 100644 deploy/systemd/sac-daily-report.service create mode 100644 deploy/systemd/sac-daily-report.timer 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` | ---