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:
@@ -17,7 +17,9 @@ from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
|||||||
from app.services.ingest import ingest_event
|
from app.services.ingest import ingest_event
|
||||||
from app.services.problems import maybe_create_problem
|
from app.services.problems import maybe_create_problem
|
||||||
from app.services.schema_validate import validate_event_payload
|
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"])
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
logger = logging.getLogger("sac.ingest")
|
logger = logging.getLogger("sac.ingest")
|
||||||
@@ -64,6 +66,9 @@ def post_event(
|
|||||||
problem_created = False
|
problem_created = False
|
||||||
if created:
|
if created:
|
||||||
problem, problem_created = maybe_create_problem(db, event)
|
problem, problem_created = maybe_create_problem(db, event)
|
||||||
|
if event.type in DAILY_REPORT_EVENT_TYPES:
|
||||||
|
notify_daily_report(event, db=db)
|
||||||
|
else:
|
||||||
notify_event(event, db=db)
|
notify_event(event, db=db)
|
||||||
if problem is not None and problem_created:
|
if problem is not None and problem_created:
|
||||||
notify_problem(problem, event, db=db)
|
notify_problem(problem, event, db=db)
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class Settings(BaseSettings):
|
|||||||
sac_notify_event_cooldown_sec: int = 90
|
sac_notify_event_cooldown_sec: int = 90
|
||||||
sac_notify_problem_cooldown_sec: int = 300
|
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
|
sac_heartbeat_stale_minutes: int = 780
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -47,3 +47,11 @@ def notify_problem(problem: Problem, event: Event | None = None, *, db: Session
|
|||||||
if not should_notify_problem(problem, db):
|
if not should_notify_problem(problem, db):
|
||||||
return
|
return
|
||||||
_dispatch_problem_channels(problem, event, db=db, policy=policy)
|
_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()
|
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:
|
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"):
|
if event.type in ("rdp.login.success", "rdp.login.failed"):
|
||||||
return format_rdp_login_html(event)
|
return format_rdp_login_html(event)
|
||||||
if event.type in ("ssh.login.success", "ssh.login.failed"):
|
if event.type in ("ssh.login.success", "ssh.login.failed"):
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -108,3 +108,28 @@ def test_ingest_invalid_payload_422(client, auth_headers):
|
|||||||
)
|
)
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
assert "schema_errors" in r.json()["detail"]
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ SAC_NOTIFY_COOLDOWN_ENABLED=true
|
|||||||
SAC_NOTIFY_EVENT_COOLDOWN_SEC=90
|
SAC_NOTIFY_EVENT_COOLDOWN_SEC=90
|
||||||
SAC_NOTIFY_PROBLEM_COOLDOWN_SEC=300
|
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
|
# Статус хоста по agent.heartbeat
|
||||||
SAC_HEARTBEAT_STALE_MINUTES=780
|
SAC_HEARTBEAT_STALE_MINUTES=780
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -256,6 +256,7 @@ SAC применяет cooldown по `dedup_key` (события, по умол
|
|||||||
- `Send-SacEvent` + замена вызовов `Send-TelegramMessage` для событий мониторинга.
|
- `Send-SacEvent` + замена вызовов `Send-TelegramMessage` для событий мониторинга.
|
||||||
- Heartbeat и daily report — отдельные типы в SAC.
|
- 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`.
|
- При `UseSAC=exclusive` суточный отчёт может формироваться **на SAC** (`generated_by: sac` в `details`), если агент за день не прислал `report.daily.*` — см. `SAC_DAILY_REPORT_*`, job `python -m app.jobs.daily_report`, timer `sac-daily-report.timer`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Обратная совместимость
|
## 6. Обратная совместимость
|
||||||
|
|||||||
@@ -52,9 +52,10 @@ sudo /opt/sac-deploy.sh
|
|||||||
|
|
||||||
1. **Деплой SAC v0.5.0** — `sudo /opt/sac-deploy.sh` (миграции **`004`–`008`**: каналы, правило, cooldown)
|
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
|
2. **notif-02** — прогон [testing-e2e-checklist.md](testing-e2e-checklist.md) § «SAC Telegram» на prod
|
||||||
3. **Настройки UI** — `/settings`: Telegram `warning+`, кнопка «Проверить Telegram»
|
3. **Timer** — `sac-daily-report.timer` + `SAC_DAILY_REPORT_*` (миграций нет; см. runbook § F-NOT-05)
|
||||||
4. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах
|
4. **Настройки UI** — `/settings`: Telegram `warning+`, кнопка «Проверить Telegram»
|
||||||
5. Пилот **UseSAC=exclusive** на Windows — ~16.06 (см. [todo-2026-05-29.md](todo-2026-05-29.md))
|
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))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,35 @@ SAC_HEARTBEAT_STALE_MINUTES=780
|
|||||||
|
|
||||||
3. **Хосты** → статус `online` после свежего heartbeat.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,21 @@ sudo systemctl enable --now sac-retention.timer
|
|||||||
systemctl list-timers 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 и мониторинг
|
## Health и мониторинг
|
||||||
|
|
||||||
| Endpoint | Назначение |
|
| Endpoint | Назначение |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- [ ] Неудачный SSH → `ssh.login.failed`
|
- [ ] Неудачный SSH → `ssh.login.failed`
|
||||||
- [ ] `SERVER_DISPLAY_NAME` задан → в SAC **Хосты** отображается display name
|
- [ ] `SERVER_DISPLAY_NAME` задан → в SAC **Хосты** отображается display name
|
||||||
- [ ] Heartbeat → `agent.heartbeat`, хост не `stale` в пределах `SAC_HEARTBEAT_STALE_MINUTES`
|
- [ ] 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`)
|
### Windows (RDP-login-monitor, `UseSAC=dual`)
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
|----|--------|
|
|----|--------|
|
||||||
| `notif-30` | Шаблон TG ближе к агенту (HTML, поля user/ip/logon_type из `details`) | ✅ `telegram_templates.py`, `parse_mode=HTML` |
|
| `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-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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user