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