feat: proactive host_silence scan for stale agent.heartbeat (0.8.3)
Background scan every 5 min opens rule:host_silence and notifies without waiting for the next ingest event. CLI job + optional systemd timer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Периодическое сканирование хостов с устаревшим agent.heartbeat (proactive host_silence)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Host, Problem
|
||||
from app.services.problem_rules import RuleMatch, build_fingerprint, host_silence_match_for_host
|
||||
from app.services.problems import _correlation_cutoff
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostSilenceScanResult:
|
||||
host_id: int
|
||||
hostname: str
|
||||
problem: Problem
|
||||
created: bool
|
||||
|
||||
|
||||
def open_or_refresh_host_silence_problem(
|
||||
db: Session,
|
||||
host_id: int,
|
||||
match: RuleMatch,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[Problem, bool]:
|
||||
"""Открыть или обновить open Problem host_silence без ingest-события."""
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
fingerprint = build_fingerprint(
|
||||
host_id,
|
||||
match.correlation_type,
|
||||
match.rule_id,
|
||||
match.fingerprint_suffix,
|
||||
)
|
||||
cutoff = _correlation_cutoff(ref)
|
||||
|
||||
open_problem = db.scalar(
|
||||
select(Problem)
|
||||
.where(
|
||||
Problem.status == "open",
|
||||
Problem.fingerprint == fingerprint,
|
||||
Problem.host_id == host_id,
|
||||
Problem.last_seen_at >= cutoff,
|
||||
)
|
||||
.order_by(Problem.last_seen_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if open_problem:
|
||||
open_problem.summary = match.summary
|
||||
open_problem.title = match.title
|
||||
open_problem.last_seen_at = ref
|
||||
open_problem.updated_at = ref
|
||||
db.flush()
|
||||
return open_problem, False
|
||||
|
||||
problem = Problem(
|
||||
host_id=host_id,
|
||||
title=match.title,
|
||||
summary=match.summary,
|
||||
severity=match.severity,
|
||||
status="open",
|
||||
rule_id=match.rule_id,
|
||||
fingerprint=fingerprint,
|
||||
event_count=0,
|
||||
last_seen_at=ref,
|
||||
)
|
||||
db.add(problem)
|
||||
db.flush()
|
||||
return problem, True
|
||||
|
||||
|
||||
def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[HostSilenceScanResult]:
|
||||
"""Найти stale-хосты и создать/обновить Problems rule:host_silence."""
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
hosts = db.scalars(select(Host).order_by(Host.id)).all()
|
||||
results: list[HostSilenceScanResult] = []
|
||||
|
||||
for host in hosts:
|
||||
match = host_silence_match_for_host(db, host.id, hostname=host.hostname, now=ref)
|
||||
if match is None:
|
||||
continue
|
||||
problem, created = open_or_refresh_host_silence_problem(db, host.id, match, now=ref)
|
||||
results.append(
|
||||
HostSilenceScanResult(
|
||||
host_id=host.id,
|
||||
hostname=host.hostname,
|
||||
problem=problem,
|
||||
created=created,
|
||||
)
|
||||
)
|
||||
if created:
|
||||
logger.info(
|
||||
"host_silence scan: new problem host=%s id=%s problem_id=%s",
|
||||
host.hostname,
|
||||
host.id,
|
||||
problem.id,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def dispatch_host_silence_notifications(db: Session, results: list[HostSilenceScanResult]) -> int:
|
||||
"""Telegram/webhook/email только для вновь созданных Problems."""
|
||||
from app.services.notify_dispatch import notify_problem
|
||||
|
||||
sent = 0
|
||||
for item in results:
|
||||
if not item.created:
|
||||
continue
|
||||
notify_problem(item.problem, event=None, db=db)
|
||||
sent += 1
|
||||
return sent
|
||||
@@ -122,18 +122,26 @@ def evaluate_privilege_spike(db: Session, event: Event) -> RuleMatch | None:
|
||||
)
|
||||
|
||||
|
||||
def evaluate_host_silence(db: Session, event: Event) -> RuleMatch | None:
|
||||
if event.type == HEARTBEAT_TYPE:
|
||||
return None
|
||||
def host_silence_match_for_host(
|
||||
db: Session,
|
||||
host_id: int,
|
||||
*,
|
||||
hostname: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> RuleMatch | None:
|
||||
"""Сформировать RuleMatch host_silence, если последний heartbeat устарел."""
|
||||
settings = get_settings()
|
||||
hb = last_heartbeat_at(db, event.host_id)
|
||||
ref = now or _now()
|
||||
hb = last_heartbeat_at(db, host_id)
|
||||
if hb is None:
|
||||
return None
|
||||
if agent_status(hb, stale_minutes=settings.sac_heartbeat_stale_minutes, now=_now()) != "stale":
|
||||
if agent_status(hb, stale_minutes=settings.sac_heartbeat_stale_minutes, now=ref) != "stale":
|
||||
return None
|
||||
host = db.get(Host, event.host_id)
|
||||
hostname = host.hostname if host else f"host#{event.host_id}"
|
||||
age_min = int((_now() - hb.replace(tzinfo=timezone.utc if hb.tzinfo is None else hb.tzinfo)).total_seconds() // 60)
|
||||
if hostname is None:
|
||||
host = db.get(Host, host_id)
|
||||
hostname = host.hostname if host else f"host#{host_id}"
|
||||
hb_utc = hb.replace(tzinfo=timezone.utc) if hb.tzinfo is None else hb.astimezone(timezone.utc)
|
||||
age_min = int((ref - hb_utc).total_seconds() // 60)
|
||||
return RuleMatch(
|
||||
rule_id=RULE_HOST_SILENCE,
|
||||
correlation_type="host.silence",
|
||||
@@ -146,6 +154,14 @@ def evaluate_host_silence(db: Session, event: Event) -> RuleMatch | None:
|
||||
)
|
||||
|
||||
|
||||
def evaluate_host_silence(db: Session, event: Event) -> RuleMatch | None:
|
||||
if event.type == HEARTBEAT_TYPE:
|
||||
return None
|
||||
host = db.get(Host, event.host_id)
|
||||
hostname = host.hostname if host else None
|
||||
return host_silence_match_for_host(db, event.host_id, hostname=hostname)
|
||||
|
||||
|
||||
def evaluate_immediate_high(event: Event) -> RuleMatch | None:
|
||||
"""Одиночные high/critical и известные типы (ban, mass bruteforce)."""
|
||||
problem_types = frozenset({"ssh.ip.banned", "ssh.bruteforce.mass"})
|
||||
|
||||
@@ -543,6 +543,8 @@ def format_problem_telegram_html(problem: Problem, event: Event | None = None) -
|
||||
msg += f"\n<b>{html_escape(problem.title)}</b>\n{html_escape(problem.summary)}"
|
||||
if event is not None:
|
||||
msg += f"\n\n<i>Триггер:</i> {html_escape(event.type)} ({html_escape(event.severity)})"
|
||||
elif problem.rule_id == "rule:host_silence":
|
||||
msg += "\n\n<i>Триггер:</i> периодическая проверка SAC (host_silence scan)"
|
||||
if event.type in (
|
||||
"rdp.login.success",
|
||||
"rdp.login.failed",
|
||||
|
||||
Reference in New Issue
Block a user