363 lines
12 KiB
Python
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,
|
|
),
|
|
)
|