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.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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
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"):
|
||||
|
||||
@@ -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 "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_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
|
||||
|
||||
|
||||
@@ -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` для событий мониторинга.
|
||||
- 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`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Обратная совместимость
|
||||
|
||||
@@ -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))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 | Назначение |
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user