Files
EventLens-Public/backend/app/correlate.py
T

363 lines
12 KiB
Python

import hashlib
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from .models import Category, IncidentCluster, NormalizedEvent
from .normalize import (
is_application_error,
is_dns_client,
is_event_log,
is_kernel_general,
is_kernel_power,
is_security_audit,
is_service_control_manager,
is_windows_error_reporting,
)
TitleBuilder = Callable[[list[NormalizedEvent]], str]
ReasonBuilder = Callable[[list[NormalizedEvent]], str]
@dataclass(frozen=True)
class Rule:
id: str
category: Category
severity: str
window_seconds: int
predicate: Callable[[NormalizedEvent], bool]
key: Callable[[NormalizedEvent], str | None]
title_for: TitleBuilder
reason_for: ReasonBuilder
def _field(event: NormalizedEvent, *names: str) -> str:
for name in names:
value = event.fields.get(name, "").strip()
if value:
return value
return ""
def _dimension(value: str) -> str | None:
normalized = value.strip()
if not normalized or normalized.casefold() in {
"unknown",
"unknown-account",
"unknown-host",
"unknown-process",
"unknown-service",
"n/a",
"-",
}:
return None
return normalized
def _host(event: NormalizedEvent) -> str | None:
return _dimension(event.computer)
def _process(event: NormalizedEvent) -> str | None:
return _dimension(_field(event, "process", "EventData.AppName", "EventData.Data[0]"))
def _service(event: NormalizedEvent) -> str | None:
return _dimension(_field(event, "service", "EventData.param1", "UserData.param1"))
def _account(event: NormalizedEvent) -> str | None:
return _dimension(_field(event, "EventData.TargetUserName", "TargetUserName"))
def _group_key(*dimensions: str | None) -> str | None:
if any(value is None for value in dimensions):
return None
return "|".join(value.casefold() for value in dimensions if value is not None)
def _crash(event: NormalizedEvent) -> bool:
return is_application_error(event.provider, event.channel, event.event_id) or is_windows_error_reporting(
event.provider, event.channel, event.event_id
)
def _service_failure(event: NormalizedEvent) -> bool:
return is_service_control_manager(event.provider, event.channel) and event.event_id in {7023, 7024, 7031, 7034}
def _dns_timeout(event: NormalizedEvent) -> bool:
return is_dns_client(event.provider, event.channel) and event.event_id == 1014
def _failed_logon(event: NormalizedEvent) -> bool:
return is_security_audit(event.provider, event.channel) and event.event_id == 4625
def _normal_startup(event: NormalizedEvent) -> bool:
return (is_kernel_general(event.provider, event.channel) and event.event_id == 12) or (
is_event_log(event.provider, event.channel) and event.event_id == 6005
)
def _normal_shutdown(event: NormalizedEvent) -> bool:
return (is_kernel_general(event.provider, event.channel) and event.event_id == 13) or (
is_event_log(event.provider, event.channel) and event.event_id == 6006
)
def _unexpected_shutdown(event: NormalizedEvent) -> bool:
return (is_kernel_power(event.provider, event.channel) and event.event_id == 41) or (
is_event_log(event.provider, event.channel) and event.event_id == 6008
)
def _crash_title(evidence: list[NormalizedEvent]) -> str:
ids = {event.event_id for event in evidence}
if {1000, 1001}.issubset(ids):
return "Application crash and reporting sequence"
if 1000 in ids:
return "Application crash recorded"
return "Crash report recorded"
def _crash_reason(evidence: list[NormalizedEvent]) -> str:
ids = {event.event_id for event in evidence}
if {1000, 1001}.issubset(ids):
return "Application Error and Windows Error Reporting records were temporally related; no causal claim is made."
return "A crash-related provider record was observed; a reporting sequence is not established by this evidence."
def _service_title(evidence: list[NormalizedEvent]) -> str:
return "Service failure window" if len(evidence) > 1 else "Service failure recorded"
def _service_reason(evidence: list[NormalizedEvent]) -> str:
service = _service(evidence[0])
if service is None or _host(evidence[0]) is None:
return "Service Control Manager reported a service failure; no service/host grouping was applied because a grouping dimension was unavailable."
return "Service Control Manager reported an abnormal service termination for the same known service and host."
def _dns_title(evidence: list[NormalizedEvent]) -> str:
return "DNS timeout activity" if len(evidence) > 1 else "DNS timeout recorded"
def _dns_reason(evidence: list[NormalizedEvent]) -> str:
if _host(evidence[0]) is None:
return "DNS Client reported a timeout; no host grouping was applied because the computer identity was unavailable."
return "DNS Client timeout events were observed in the same bounded window on the same known host."
def _auth_title(evidence: list[NormalizedEvent]) -> str:
if len(evidence) >= 3:
return "Failed sign-in burst"
if len(evidence) == 2:
return "Repeated failed sign-ins"
return "Failed sign-in recorded"
def _auth_reason(evidence: list[NormalizedEvent]) -> str:
if _group_key(_host(evidence[0]), _account(evidence[0])) is None:
return "Security Auditing reported a failed sign-in; no account/host grouping was applied because a grouping dimension was unavailable."
return "Security Auditing reported failed sign-ins for the same known account and host; this is evidence of activity, not proof of cause."
def _startup_title(evidence: list[NormalizedEvent]) -> str:
return "Normal startup sequence" if len(evidence) > 1 else "Normal startup evidence"
def _startup_reason(evidence: list[NormalizedEvent]) -> str:
return "Kernel-General/EventLog startup records were temporally related; no cause is inferred."
def _shutdown_title(evidence: list[NormalizedEvent]) -> str:
return "Normal shutdown evidence"
def _shutdown_reason(evidence: list[NormalizedEvent]) -> str:
return "Kernel-General/EventLog shutdown evidence was observed; no cause is inferred."
def _unexpected_title(evidence: list[NormalizedEvent]) -> str:
return "Unexpected shutdown/restart evidence"
def _unexpected_reason(evidence: list[NormalizedEvent]) -> str:
return "Kernel-Power/EventLog records indicate an unexpected shutdown or restart; the logs do not establish why it occurred."
RULES = (
Rule(
"windows.crash.application-report",
"crash",
"critical",
120,
_crash,
lambda event: _group_key(_host(event), _process(event)),
_crash_title,
_crash_reason,
),
Rule(
"windows.service.failure",
"service",
"warning",
120,
_service_failure,
lambda event: _group_key(_host(event), _service(event)),
_service_title,
_service_reason,
),
Rule(
"windows.network.dns-timeout",
"network",
"warning",
120,
_dns_timeout,
lambda event: _group_key(_host(event)),
_dns_title,
_dns_reason,
),
Rule(
"windows.auth.failed-logon",
"authentication",
"warning",
120,
_failed_logon,
lambda event: _group_key(_host(event), _account(event)),
_auth_title,
_auth_reason,
),
Rule(
"windows.system.startup",
"system",
"info",
300,
_normal_startup,
lambda event: _group_key(_host(event)),
_startup_title,
_startup_reason,
),
Rule(
"windows.system.shutdown.normal",
"system",
"info",
300,
_normal_shutdown,
lambda event: _group_key(_host(event)),
_shutdown_title,
_shutdown_reason,
),
Rule(
"windows.system.shutdown.unexpected",
"system",
"warning",
300,
_unexpected_shutdown,
lambda event: _group_key(_host(event)),
_unexpected_title,
_unexpected_reason,
),
)
def _event_sort_key(event: NormalizedEvent) -> tuple[bool, datetime, str]:
reliable_timestamp = event.timestamp is not None and event.timestamp_valid
return (
not reliable_timestamp,
event.timestamp if reliable_timestamp else datetime.max.replace(tzinfo=UTC),
event.id,
)
def _groups_for_rule(events: list[NormalizedEvent], rule: Rule) -> list[list[NormalizedEvent]]:
buckets: dict[str, list[NormalizedEvent]] = {}
isolated_number = 0
for event in events:
if not rule.predicate(event) or event.timestamp is None or not event.timestamp_valid:
continue
key = rule.key(event)
if key is None:
isolated_number += 1
key = f"__isolated__{isolated_number}:{event.id}"
buckets.setdefault(key, []).append(event)
groups: list[list[NormalizedEvent]] = []
window = timedelta(seconds=rule.window_seconds)
for key in sorted(buckets):
ordered = sorted(buckets[key], key=_event_sort_key)
current: list[NormalizedEvent] = []
for event in ordered:
if not current or event.timestamp - current[0].timestamp <= window:
current.append(event)
else:
groups.append(current)
current = [event]
if current:
groups.append(current)
return groups
def _incident_id(rule_id: str, evidence: list[NormalizedEvent]) -> str:
seed = f"{rule_id}|{'|'.join(event.id for event in evidence)}".encode()
return f"incident-{hashlib.sha256(seed).hexdigest()[:16]}"
def _build(rule: Rule, evidence: list[NormalizedEvent]) -> IncidentCluster:
signatures = {(event.provider, event.event_id, event.summary) for event in evidence}
reasons = [rule.reason_for(evidence)]
if len(evidence) > 1:
seconds = int((evidence[-1].timestamp - evidence[0].timestamp).total_seconds())
reasons.append(f"{len(evidence)} evidence events occurred within {seconds} second(s).")
if len(signatures) < len(evidence):
reasons.append(f"Repeated signals compress from {len(evidence)} records to {len(signatures)} distinct signature(s).")
return IncidentCluster(
id=_incident_id(rule.id, evidence),
rule_id=rule.id,
start=evidence[0].timestamp,
end=evidence[-1].timestamp,
title=rule.title_for(evidence),
category=rule.category,
severity=rule.severity,
reasons=reasons,
evidence_event_ids=[event.id for event in evidence],
original_count=len(evidence),
compressed_count=max(1, len(signatures)),
)
def correlate(events: list[NormalizedEvent]) -> list[IncidentCluster]:
ordered = sorted(events, key=_event_sort_key)
incidents: list[IncidentCluster] = []
consumed: set[str] = set()
for rule in RULES:
for group in _groups_for_rule(ordered, rule):
incidents.append(_build(rule, group))
consumed.update(event.id for event in group)
fallback_rule_id = "windows.generic.attention"
for event in ordered:
if event.id not in consumed and event.level.casefold() in {"critical", "error", "warning"}:
incidents.append(
IncidentCluster(
id=_incident_id(fallback_rule_id, [event]),
rule_id=fallback_rule_id,
start=event.timestamp if event.timestamp_valid else None,
end=event.timestamp if event.timestamp_valid else None,
title=event.summary or "Unclassified attention event",
category=event.category,
severity="warning",
reasons=["An error or warning was not matched by a more specific correlation rule."],
evidence_event_ids=[event.id],
original_count=1,
compressed_count=1,
)
)
return sorted(
incidents,
key=lambda incident: (
incident.start is None,
incident.start or datetime.max.replace(tzinfo=UTC),
incident.rule_id,
incident.id,
),
)