import hashlib import json from datetime import UTC, datetime from typing import Any from .models import Category, NormalizedEvent, TimestampSource AUTH_EVENT_IDS = {4624, 4625, 4634, 4647, 4648, 4672, 4768, 4769, 4771, 4776} LEVELS = {0: "information", 1: "critical", 2: "error", 3: "warning", 4: "information", 5: "verbose"} MAX_EVENT_ID = 4_294_967_295 MAX_RECORD_ID = 9_223_372_036_854_775_807 MAX_FIELDS = 200 MAX_FIELD_KEY_CHARS = 256 MAX_FIELD_VALUE_CHARS = 1000 MAX_NESTING_DEPTH = 16 MAX_PROVIDER_CHARS = 256 MAX_CHANNEL_CHARS = 256 MAX_COMPUTER_CHARS = 256 MAX_SUMMARY_CHARS = 512 def _bounded_text(value: Any, limit: int, fallback: str = "") -> str: if value is None: return fallback return str(value)[:limit] def _attributes(value: Any) -> dict[str, Any]: return value.get("#attributes", {}) if isinstance(value, dict) else {} def _scalar(value: Any) -> Any: if isinstance(value, dict): for key in ("#text", "Value", "value"): if key in value: return value[key] return value def _integer(value: Any, minimum: int = 0, maximum: int = MAX_RECORD_ID) -> int | None: value = _scalar(value) if isinstance(value, bool): return None try: parsed = int(value) except (TypeError, ValueError, OverflowError): return None return parsed if minimum <= parsed <= maximum else None def _coerce_timestamp(value: Any) -> datetime | None: if isinstance(value, datetime): return value if value.tzinfo else value.replace(tzinfo=UTC) if value is None: return None text = str(value).strip() if not text: return None if text.endswith("Z"): text = f"{text[:-1]}+00:00" try: parsed = datetime.fromisoformat(text) except (TypeError, ValueError): return None return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) def _timestamp(system: dict[str, Any], fallback: Any) -> tuple[datetime | None, TimestampSource]: event_raw = _attributes(system.get("TimeCreated")).get("SystemTime") if event_raw is not None: parsed = _coerce_timestamp(event_raw) if parsed is not None: return parsed, "event" record_parsed = _coerce_timestamp(fallback) return (record_parsed, "record" if record_parsed is not None else "invalid") if fallback is not None: record_parsed = _coerce_timestamp(fallback) return (record_parsed, "record" if record_parsed is not None else "invalid") return None, "missing" def _provider(system: dict[str, Any]) -> str: value = system.get("Provider") if isinstance(value, dict): return _bounded_text(_attributes(value).get("Name") or value.get("Name"), MAX_PROVIDER_CHARS, "Unknown provider") return _bounded_text(value, MAX_PROVIDER_CHARS, "Unknown provider") def _store_field(output: dict[str, str], key: str, value: str, limit: int) -> None: bounded_key = _bounded_text(key or "value", MAX_FIELD_KEY_CHARS) if bounded_key not in output: output[bounded_key] = value return suffix = 2 while len(output) < limit: suffix_key = _bounded_text(f"{bounded_key}#{suffix}", MAX_FIELD_KEY_CHARS) if suffix_key not in output: output[suffix_key] = value return suffix += 1 def _flatten( prefix: str, value: Any, output: dict[str, str], limit: int = MAX_FIELDS, depth: int = 0, ) -> None: if len(output) >= limit: return if depth > MAX_NESTING_DEPTH: _store_field(output, prefix or "value", "[nested value truncated]", limit) return if isinstance(value, dict): for key, child in value.items(): if key == "#attributes": continue next_prefix = f"{prefix}.{key}" if prefix else str(key) _flatten(next_prefix, child, output, limit, depth + 1) if len(output) >= limit: return return if isinstance(value, list): for index, child in enumerate(value): _flatten(f"{prefix}[{index}]", child, output, limit, depth + 1) if len(output) >= limit: return return try: rendered = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value except (TypeError, ValueError): rendered = "[unrenderable value]" _store_field(output, prefix or "value", rendered[:MAX_FIELD_VALUE_CHARS], limit) def _channel_matches(channel: str, *accepted: str) -> bool: return channel.strip().casefold() in {value.casefold() for value in accepted} def is_security_audit(provider: str, channel: str) -> bool: return provider.strip().casefold() == "microsoft-windows-security-auditing" and _channel_matches(channel, "Security") def is_service_control_manager(provider: str, channel: str) -> bool: return provider.strip().casefold() == "service control manager" and _channel_matches(channel, "System") def is_dns_client(provider: str, channel: str) -> bool: return provider.strip().casefold() == "microsoft-windows-dns-client" and _channel_matches(channel, "System") def is_application_error(provider: str, channel: str, event_id: int | None) -> bool: return provider.casefold().strip() == "application error" and event_id == 1000 and _channel_matches(channel, "Application") def is_windows_error_reporting(provider: str, channel: str, event_id: int | None) -> bool: return provider.casefold().strip() == "windows error reporting" and event_id == 1001 and _channel_matches(channel, "Application") def is_event_log(provider: str, channel: str) -> bool: return provider.casefold().strip() in {"eventlog", "microsoft-windows-eventlog"} and _channel_matches(channel, "System") def is_kernel_general(provider: str, channel: str) -> bool: return provider.strip().casefold() == "microsoft-windows-kernel-general" and _channel_matches(channel, "System") def is_kernel_power(provider: str, channel: str) -> bool: return provider.strip().casefold() == "microsoft-windows-kernel-power" and _channel_matches(channel, "System") def classify(provider: str, event_id: int | None, channel: str) -> Category: provider_lower = provider.casefold() channel_lower = channel.casefold() if is_service_control_manager(provider, channel): return "service" if is_dns_client(provider, channel) or any( token in provider_lower for token in ("networkprofile", "tcpip", "dhcp", "nlasvc") ): return "network" if is_security_audit(provider, channel) and event_id in AUTH_EVENT_IDS: return "authentication" if is_application_error(provider, channel, event_id) or is_windows_error_reporting(provider, channel, event_id): return "crash" if ( (is_kernel_general(provider, channel) and event_id in {12, 13}) or (is_kernel_power(provider, channel) and event_id == 41) or (is_event_log(provider, channel) and event_id in {6005, 6006, 6008}) ): return "system" if "application" in channel_lower: return "application" return "other" def summarize(provider: str, event_id: int | None, category: Category, fields: dict[str, str]) -> str: if category == "service" and event_id in {7023, 7024, 7031, 7034}: service = fields.get("EventData.param1") or fields.get("UserData.param1") or fields.get("service") return f"{service} terminated unexpectedly" if service else "Service failure recorded" if category == "crash" and event_id == 1000: process = fields.get("EventData.AppName") or fields.get("EventData.Data[0]") or fields.get("process") return f"{process} crashed" if process else "Application crash recorded" if category == "authentication" and event_id == 4625: account = fields.get("EventData.TargetUserName") or fields.get("TargetUserName") return f"Failed sign-in for {account}" if account else "Failed sign-in recorded" return f"{provider} event {event_id}" if event_id is not None else f"{provider} event" def normalize_record( payload: dict[str, Any], record_id: Any = None, record_timestamp: Any = None, record_ordinal: int | None = None, ) -> NormalizedEvent: if not isinstance(payload, dict): raise TypeError("Record does not contain an Event object") event = payload.get("Event", payload) if not isinstance(event, dict): raise TypeError("Record does not contain an Event object") system = event.get("System") if isinstance(event.get("System"), dict) else {} event_id = _integer(system.get("EventID"), maximum=MAX_EVENT_ID) provider = _provider(system) channel = _bounded_text(_scalar(system.get("Channel")), MAX_CHANNEL_CHARS) computer = _bounded_text(_scalar(system.get("Computer")), MAX_COMPUTER_CHARS) level_value = _integer(system.get("Level"), maximum=5) level = LEVELS.get(level_value, str(_scalar(system.get("Level")) or "information").lower()) fields: dict[str, str] = {} if "EventData" in event: _flatten("EventData", event.get("EventData"), fields) if "UserData" in event: _flatten("UserData", event.get("UserData"), fields) category = classify(provider, event_id, channel) normalized_record_id = _integer(system.get("EventRecordID")) if normalized_record_id is None: normalized_record_id = _integer(record_id) if normalized_record_id is not None: stable_id = str(normalized_record_id) else: canonical_event = json.dumps(event, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str) ordinal = "" if record_ordinal is None else str(record_ordinal) seed = f"{provider}|{event_id}|{record_timestamp}|{ordinal}|{canonical_event}".encode( "utf-8", errors="replace" ) stable_id = f"event-{hashlib.sha256(seed).hexdigest()[:16]}" timestamp, timestamp_source = _timestamp(system, record_timestamp) summary = summarize(provider, event_id, category, fields)[:MAX_SUMMARY_CHARS] return NormalizedEvent( id=stable_id, timestamp=timestamp, timestamp_source=timestamp_source, timestamp_valid=timestamp is not None, provider=provider, event_id=event_id, level=level[:32], channel=channel, computer=computer, category=category, summary=summary, fields=fields, )