Initial curated EventLens source from 0d80d3b38dfb817285481665b9e71e4b8a0656a0
This commit is contained in:
commit
663ea8c829
85 files changed
+12395
No files matched your search
@@ -0,0 +1,362 @@
|
||||
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,
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user