from __future__ import annotations from collections import defaultdict from datetime import datetime from .detectors import event_detector_categories from .entities import event_entities from .logs import THREAT_ACTIONS from .models import LogEvent from .normalization import canonical_value DEFAULT_SEQUENCE_PATTERNS = { "dns_network_auth_sequence": ("dns_query", "network_connection", "auth_failure"), } def _timestamp(event: LogEvent, fallback: int) -> int: value = event.fields.get("eventtime", event.fields.get("timestamp", "")) if value: try: parsed = float(value) while parsed > 10_000_000_000: parsed /= 1_000 return int(parsed) except ValueError: try: return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()) except ValueError: pass date = event.fields.get("date") clock = event.fields.get("time") if date and clock: try: return int(datetime.fromisoformat(f"{date}T{clock}").timestamp()) except ValueError: pass return fallback def _is_network_event(event: LogEvent) -> bool: if event.dst_ip: return True if canonical_value(event.fields, "dstport") or canonical_value(event.fields, "service"): return True return event.action in {"accept", "pass", "allowed", "allow", "close", "client-rst", "server-rst"} | THREAT_ACTIONS def _sample(event: LogEvent, value: str) -> dict[str, str]: return { "timestamp": event.fields.get("eventtime", event.fields.get("timestamp", "")), "stream": event.fields.get("fgai_stream", event.fields.get("fgai_stream_id", "")), "source": event.src_ip or event.fields.get("source", ""), "destination": event.dst_ip or canonical_value(event.fields, "context"), "action": event.action, "severity": event.severity, "service": canonical_value(event.fields, "service"), "value": value, "message": canonical_value(event.fields, "context")[:240], } def detect_sequences(events: list[LogEvent], *, window_seconds: int = 900, limit: int = 50, patterns: dict[str, tuple[str, ...]] | None = None) -> dict[str, list[dict[str, object]]]: """Detect ordered multi-event behavior for any entity shared across streams.""" patterns = patterns or DEFAULT_SEQUENCE_PATTERNS by_entity: dict[str, list[tuple[int, LogEvent, set[str]]]] = defaultdict(list) for index, event in enumerate(events): categories = set(event_detector_categories(event)) if _is_network_event(event): categories.add("network_connection") if not categories: continue timestamp = _timestamp(event, index) for identity in event_entities(event): by_entity[identity["entity"]].append((timestamp, event, categories)) findings: dict[str, list[dict[str, object]]] = defaultdict(list) for entity, items in by_entity.items(): ordered = sorted(items, key=lambda item: item[0]) for detector, sequence in patterns.items(): match: list[tuple[int, LogEvent, str]] = [] start_at = 0 for category in sequence: found = next( ( (timestamp, event, category) for timestamp, event, categories in ordered if timestamp >= start_at and (not match or timestamp <= match[0][0] + window_seconds) and category in categories ), None, ) if not found: match = [] break match.append(found) start_at = found[0] if len(match) != len(sequence): continue duration = max(0, match[-1][0] - match[0][0]) streams = sorted({event.fields.get("fgai_stream", "") for _, event, _ in match} - {""}) score = 28 if len(streams) >= 2 else 20 findings[entity].append({ "detector": detector, "field": "sequence", "stream_id": "multi_stream", "stream_name": "Multi-stream", "stream_title": "Multi-stream", "score": score, "base_score": score, "weight": 1.0, "confidence": "medium" if len(streams) >= 2 else "low", "baseline_samples": 0, "baseline_scope": f"{window_seconds}s sequence window", "reason": f"ordered {' -> '.join(sequence)} sequence observed in {duration}s", "current": len(sequence), "baseline": 0, "value": detector, "sample_values": streams, "sample_events": [_sample(event, category) for _, event, category in match], }) if sum(len(values) for values in findings.values()) >= limit: return findings break return findings