Files

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,
),
)