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:
PTah
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.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,7 +66,10 @@ 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)
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: if problem is not None and problem_created:
notify_problem(problem, event, db=db) 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) 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_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
+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): 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"):
+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 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()
+7
View File
@@ -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
+16
View File
@@ -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
+10
View File
@@ -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
+1
View File
@@ -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. Обратная совместимость
+4 -3
View File
@@ -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))
--- ---
+29 -1
View File
@@ -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
```
--- ---
+15
View File
@@ -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 | Назначение |
+1 -1
View File
@@ -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`)
+1 -1
View File
@@ -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` |
--- ---