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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:30:07 +10:00
parent 4167687dec
commit 415d863b3b
17 changed files with 734 additions and 8 deletions
+7 -2
View File
@@ -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)
+7
View File
@@ -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
+45
View File
@@ -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())
+328
View File
@@ -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 = '<div class="agent-report">' + escaped.replace("\n", "<br>\n") + "</div>"
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
+8
View File
@@ -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)
@@ -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"<b>📊 {html_escape(event.title)}</b>\n"
f'<div class="agent-report">{escaped.replace(chr(10), "<br>")}</div>'
)
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"):
+212
View File
@@ -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": "<b>📊 OK</b><br>line"},
payload={},
)
assert format_event_telegram_html(event) == "<b>📊 OK</b><br>line"
+25
View File
@@ -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()