From ddfb232590ca9fd50e59ba2dedb6f9ba036544ae Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 15 Apr 2026 12:51:19 -0400 Subject: [PATCH] feat: add behavioral profiler for attacker pattern analysis - decnet/profiler/: analyze attacker behavior timings, command sequences, service probing patterns - Enables detection of coordinated attacks vs random scanning - Feeds into attacker scoring and risk assessment --- decnet/profiler/__init__.py | 5 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 330 bytes .../__pycache__/behavioral.cpython-314.pyc | Bin 0 -> 13679 bytes .../__pycache__/worker.cpython-314.pyc | Bin 0 -> 12297 bytes decnet/profiler/behavioral.py | 375 ++++++++++++++++++ decnet/profiler/worker.py | 213 ++++++++++ 6 files changed, 593 insertions(+) create mode 100644 decnet/profiler/__init__.py create mode 100644 decnet/profiler/__pycache__/__init__.cpython-314.pyc create mode 100644 decnet/profiler/__pycache__/behavioral.cpython-314.pyc create mode 100644 decnet/profiler/__pycache__/worker.cpython-314.pyc create mode 100644 decnet/profiler/behavioral.py create mode 100644 decnet/profiler/worker.py diff --git a/decnet/profiler/__init__.py b/decnet/profiler/__init__.py new file mode 100644 index 0000000..138ce0e --- /dev/null +++ b/decnet/profiler/__init__.py @@ -0,0 +1,5 @@ +"""DECNET profiler — standalone attacker profile builder worker.""" + +from decnet.profiler.worker import attacker_profile_worker + +__all__ = ["attacker_profile_worker"] diff --git a/decnet/profiler/__pycache__/__init__.cpython-314.pyc b/decnet/profiler/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0d8d6171c81de85dcfe69f0149d812c0f44544d GIT binary patch literal 330 zcmdPqxWjGl}hOeIY63_(nKj3vxL z%*qU!ET#59B`&Vcey$-31x5L3nK`LN3XdA5C={0@=A|U&TZlX-=wL5hu_LMj$R01`;2b85tRGGPpivuzJ9)a)C>=iM@y&C=LK$c3`3a literal 0 HcmV?d00001 diff --git a/decnet/profiler/__pycache__/behavioral.cpython-314.pyc b/decnet/profiler/__pycache__/behavioral.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..974c0717796ba91c74b6ea16211a4b85d506f0a3 GIT binary patch literal 13679 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 b?N2x2-NmQ7z^?OYKLgUrXXU{4=`j8m4A(T_ literal 0 HcmV?d00001 diff --git a/decnet/profiler/behavioral.py b/decnet/profiler/behavioral.py new file mode 100644 index 0000000..8875605 --- /dev/null +++ b/decnet/profiler/behavioral.py @@ -0,0 +1,375 @@ +""" +Behavioral and timing analysis for DECNET attacker profiles. + +Consumes the chronological `LogEvent` stream already built by +`decnet.correlation.engine.CorrelationEngine` and derives per-IP metrics: + + - Inter-event timing statistics (mean / median / stdev / min / max) + - Coefficient-of-variation (jitter metric) + - Beaconing vs. interactive vs. scanning classification + - Tool attribution against known C2 frameworks (Cobalt Strike, Sliver, + Havoc, Mythic) using default beacon/jitter profiles + - Recon → exfil phase sequencing (latency between the last recon event + and the first exfil-like event) + - OS / TCP fingerprint + retransmit rollup from sniffer-emitted events + +Pure-Python; no external dependencies. All functions are safe to call from +both sync and async contexts. +""" + +from __future__ import annotations + +import json +import statistics +from collections import Counter +from typing import Any + +from decnet.correlation.parser import LogEvent + +# ─── Event-type taxonomy ──────────────────────────────────────────────────── + +# Sniffer-emitted packet events that feed into fingerprint rollup. +_SNIFFER_SYN_EVENT: str = "tcp_syn_fingerprint" +_SNIFFER_FLOW_EVENT: str = "tcp_flow_timing" + +# Events that signal "recon" phase (scans, probes, auth attempts). +_RECON_EVENT_TYPES: frozenset[str] = frozenset({ + "scan", "connection", "banner", "probe", + "login_attempt", "auth", "auth_failure", +}) + +# Events that signal "exfil" / action-on-objective phase. +_EXFIL_EVENT_TYPES: frozenset[str] = frozenset({ + "download", "upload", "file_transfer", "data_exfil", + "command", "exec", "query", "shell_input", +}) + +# Fields carrying payload byte counts (for "large payload" detection). +_PAYLOAD_SIZE_FIELDS: tuple[str, ...] = ("bytes", "size", "content_length") + +# ─── C2 tool attribution signatures ───────────────────────────────────────── +# +# Each entry lists the default beacon cadence profile of a popular C2. +# A profile *matches* an attacker when: +# - mean inter-event time is within ±`interval_tolerance` seconds, AND +# - jitter (cv = stdev / mean) is within ±`jitter_tolerance` +# +# These defaults are documented in each framework's public user guides; +# real operators often tune them, so attribution is advisory, not definitive. + +_TOOL_SIGNATURES: tuple[dict[str, Any], ...] = ( + { + "name": "cobalt_strike", + "interval_s": 60.0, + "interval_tolerance_s": 8.0, + "jitter_cv": 0.20, + "jitter_tolerance": 0.05, + }, + { + "name": "sliver", + "interval_s": 60.0, + "interval_tolerance_s": 10.0, + "jitter_cv": 0.30, + "jitter_tolerance": 0.08, + }, + { + "name": "havoc", + "interval_s": 45.0, + "interval_tolerance_s": 8.0, + "jitter_cv": 0.10, + "jitter_tolerance": 0.03, + }, + { + "name": "mythic", + "interval_s": 30.0, + "interval_tolerance_s": 6.0, + "jitter_cv": 0.15, + "jitter_tolerance": 0.03, + }, +) + + +# ─── Timing stats ─────────────────────────────────────────────────────────── + +def timing_stats(events: list[LogEvent]) -> dict[str, Any]: + """ + Compute inter-arrival-time statistics across *events* (sorted by ts). + + Returns a dict with: + mean_iat_s, median_iat_s, stdev_iat_s, min_iat_s, max_iat_s, cv, + event_count, duration_s + + For n < 2 events the interval-based fields are None/0. + """ + if not events: + return { + "event_count": 0, + "duration_s": 0.0, + "mean_iat_s": None, + "median_iat_s": None, + "stdev_iat_s": None, + "min_iat_s": None, + "max_iat_s": None, + "cv": None, + } + + sorted_events = sorted(events, key=lambda e: e.timestamp) + duration_s = (sorted_events[-1].timestamp - sorted_events[0].timestamp).total_seconds() + + if len(sorted_events) < 2: + return { + "event_count": len(sorted_events), + "duration_s": round(duration_s, 3), + "mean_iat_s": None, + "median_iat_s": None, + "stdev_iat_s": None, + "min_iat_s": None, + "max_iat_s": None, + "cv": None, + } + + iats = [ + (sorted_events[i].timestamp - sorted_events[i - 1].timestamp).total_seconds() + for i in range(1, len(sorted_events)) + ] + # Exclude spuriously-negative (clock-skew) intervals. + iats = [v for v in iats if v >= 0] + if not iats: + return { + "event_count": len(sorted_events), + "duration_s": round(duration_s, 3), + "mean_iat_s": None, + "median_iat_s": None, + "stdev_iat_s": None, + "min_iat_s": None, + "max_iat_s": None, + "cv": None, + } + + mean = statistics.fmean(iats) + median = statistics.median(iats) + stdev = statistics.pstdev(iats) if len(iats) > 1 else 0.0 + cv = (stdev / mean) if mean > 0 else None + + return { + "event_count": len(sorted_events), + "duration_s": round(duration_s, 3), + "mean_iat_s": round(mean, 3), + "median_iat_s": round(median, 3), + "stdev_iat_s": round(stdev, 3), + "min_iat_s": round(min(iats), 3), + "max_iat_s": round(max(iats), 3), + "cv": round(cv, 4) if cv is not None else None, + } + + +# ─── Behavior classification ──────────────────────────────────────────────── + +def classify_behavior(stats: dict[str, Any], services_count: int) -> str: + """ + Coarse behavior bucket: beaconing | interactive | scanning | mixed | unknown + + Heuristics: + * `beaconing` — low CV (< 0.35) + mean IAT ≥ 5 s + ≥ 5 events + * `scanning` — ≥ 3 services touched in short bursts (mean IAT < 3 s) + * `interactive` — fast but irregular: mean IAT < 3 s AND CV ≥ 0.5, ≥ 10 events + * `mixed` — moderate count + moderate CV, neither cleanly beaconing nor interactive + * `unknown` — too few data points + """ + n = stats.get("event_count") or 0 + mean = stats.get("mean_iat_s") + cv = stats.get("cv") + + if n < 3 or mean is None: + return "unknown" + + # Scanning: many services, fast bursts, few events per service. + if services_count >= 3 and mean < 3.0 and n >= 5: + return "scanning" + + # Beaconing: regular cadence over many events. + if cv is not None and cv < 0.35 and mean >= 5.0 and n >= 5: + return "beaconing" + + # Interactive: short, irregular intervals. + if cv is not None and cv >= 0.5 and mean < 3.0 and n >= 10: + return "interactive" + + return "mixed" + + +# ─── C2 tool attribution ──────────────────────────────────────────────────── + +def guess_tool(mean_iat_s: float | None, cv: float | None) -> str | None: + """ + Match (mean_iat, cv) against known C2 default beacon profiles. + + Returns the tool name if a single signature matches; None otherwise. + Multiple matches also return None to avoid false attribution. + """ + if mean_iat_s is None or cv is None: + return None + + hits: list[str] = [] + for sig in _TOOL_SIGNATURES: + if abs(mean_iat_s - sig["interval_s"]) > sig["interval_tolerance_s"]: + continue + if abs(cv - sig["jitter_cv"]) > sig["jitter_tolerance"]: + continue + hits.append(sig["name"]) + + if len(hits) == 1: + return hits[0] + return None + + +# ─── Phase sequencing ─────────────────────────────────────────────────────── + +def phase_sequence(events: list[LogEvent]) -> dict[str, Any]: + """ + Derive recon→exfil phase transition info. + + Returns: + recon_end_ts : ISO timestamp of last recon-class event (or None) + exfil_start_ts : ISO timestamp of first exfil-class event (or None) + exfil_latency_s : seconds between them (None if not both present) + large_payload_count: count of events whose *fields* report a payload + ≥ 1 MiB (heuristic for bulk data transfer) + """ + recon_end = None + exfil_start = None + large_payload_count = 0 + + for e in sorted(events, key=lambda x: x.timestamp): + if e.event_type in _RECON_EVENT_TYPES: + recon_end = e.timestamp + elif e.event_type in _EXFIL_EVENT_TYPES and exfil_start is None: + exfil_start = e.timestamp + + for fname in _PAYLOAD_SIZE_FIELDS: + raw = e.fields.get(fname) + if raw is None: + continue + try: + if int(raw) >= 1_048_576: + large_payload_count += 1 + break + except (TypeError, ValueError): + continue + + latency: float | None = None + if recon_end is not None and exfil_start is not None and exfil_start >= recon_end: + latency = round((exfil_start - recon_end).total_seconds(), 3) + + return { + "recon_end_ts": recon_end.isoformat() if recon_end else None, + "exfil_start_ts": exfil_start.isoformat() if exfil_start else None, + "exfil_latency_s": latency, + "large_payload_count": large_payload_count, + } + + +# ─── Sniffer rollup (OS fingerprint + retransmits) ────────────────────────── + +def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]: + """ + Roll up sniffer-emitted `tcp_syn_fingerprint` and `tcp_flow_timing` + events into a per-attacker summary. + """ + os_guesses: list[str] = [] + hops: list[int] = [] + tcp_fp: dict[str, Any] | None = None + retransmits = 0 + + for e in events: + if e.event_type == _SNIFFER_SYN_EVENT: + og = e.fields.get("os_guess") + if og: + os_guesses.append(og) + try: + hops.append(int(e.fields.get("hop_distance", "0"))) + except (TypeError, ValueError): + pass + # Keep the latest fingerprint snapshot. + tcp_fp = { + "window": _int_or_none(e.fields.get("window")), + "wscale": _int_or_none(e.fields.get("wscale")), + "mss": _int_or_none(e.fields.get("mss")), + "options_sig": e.fields.get("options_sig", ""), + "has_sack": e.fields.get("has_sack") == "true", + "has_timestamps": e.fields.get("has_timestamps") == "true", + } + + elif e.event_type == _SNIFFER_FLOW_EVENT: + try: + retransmits += int(e.fields.get("retransmits", "0")) + except (TypeError, ValueError): + pass + + # Mode for the OS bucket — most frequently observed label. + os_guess: str | None = None + if os_guesses: + os_guess = Counter(os_guesses).most_common(1)[0][0] + + # Median hop distance (robust to the occasional weird TTL). + hop_distance: int | None = None + if hops: + hop_distance = int(statistics.median(hops)) + + return { + "os_guess": os_guess, + "hop_distance": hop_distance, + "tcp_fingerprint": tcp_fp or {}, + "retransmit_count": retransmits, + } + + +def _int_or_none(v: Any) -> int | None: + if v is None or v == "": + return None + try: + return int(v) + except (TypeError, ValueError): + return None + + +# ─── Composite: build the full AttackerBehavior record ────────────────────── + +def build_behavior_record(events: list[LogEvent]) -> dict[str, Any]: + """ + Build the dict to persist in the `attacker_behavior` table. + + Callers (profiler worker) pre-serialize JSON-typed fields; we do the + JSON encoding here to keep the repo layer schema-agnostic. + """ + # Timing stats are computed across *all* events (not filtered), because + # a C2 beacon often reuses the same "connection" event_type on each + # check-in. Filtering would throw that signal away. + stats = timing_stats(events) + services = {e.service for e in events} + behavior = classify_behavior(stats, len(services)) + tool = guess_tool(stats.get("mean_iat_s"), stats.get("cv")) + phase = phase_sequence(events) + rollup = sniffer_rollup(events) + + # Beacon-specific projection: only surface interval/jitter when we've + # classified the flow as beaconing (otherwise these numbers are noise). + beacon_interval_s: float | None = None + beacon_jitter_pct: float | None = None + if behavior == "beaconing": + beacon_interval_s = stats.get("mean_iat_s") + cv = stats.get("cv") + beacon_jitter_pct = round(cv * 100, 2) if cv is not None else None + + return { + "os_guess": rollup["os_guess"], + "hop_distance": rollup["hop_distance"], + "tcp_fingerprint": json.dumps(rollup["tcp_fingerprint"]), + "retransmit_count": rollup["retransmit_count"], + "behavior_class": behavior, + "beacon_interval_s": beacon_interval_s, + "beacon_jitter_pct": beacon_jitter_pct, + "tool_guess": tool, + "timing_stats": json.dumps(stats), + "phase_sequence": json.dumps(phase), + } diff --git a/decnet/profiler/worker.py b/decnet/profiler/worker.py new file mode 100644 index 0000000..ebd1ed0 --- /dev/null +++ b/decnet/profiler/worker.py @@ -0,0 +1,213 @@ +""" +Attacker profile builder — incremental background worker. + +Maintains a persistent CorrelationEngine and a log-ID cursor across cycles. +On cold start (first cycle or process restart), performs one full build from +all stored logs. Subsequent cycles fetch only new logs via the cursor, +ingest them into the existing engine, and rebuild profiles for affected IPs +only. + +Complexity per cycle: O(new_logs + affected_ips) instead of O(total_logs²). +""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from decnet.correlation.engine import CorrelationEngine +from decnet.correlation.parser import LogEvent +from decnet.logging import get_logger +from decnet.profiler.behavioral import build_behavior_record +from decnet.web.db.repository import BaseRepository + +logger = get_logger("attacker_worker") + +_BATCH_SIZE = 500 +_STATE_KEY = "attacker_worker_cursor" + +# Event types that indicate active command/query execution (not just connection/scan) +_COMMAND_EVENT_TYPES = frozenset({ + "command", "exec", "query", "input", "shell_input", + "execute", "run", "sql_query", "redis_command", +}) + +# Fields that carry the executed command/query text +_COMMAND_FIELDS = ("command", "query", "input", "line", "sql", "cmd") + + +@dataclass +class _WorkerState: + engine: CorrelationEngine = field(default_factory=CorrelationEngine) + last_log_id: int = 0 + initialized: bool = False + + +async def attacker_profile_worker(repo: BaseRepository, *, interval: int = 30) -> None: + """Periodically updates the Attacker table incrementally. Designed to run as an asyncio Task.""" + logger.info("attacker profile worker started interval=%ds", interval) + state = _WorkerState() + while True: + await asyncio.sleep(interval) + try: + await _incremental_update(repo, state) + except Exception as exc: + logger.error("attacker worker: update failed: %s", exc) + + +async def _incremental_update(repo: BaseRepository, state: _WorkerState) -> None: + if not state.initialized: + await _cold_start(repo, state) + return + + affected_ips: set[str] = set() + + while True: + batch = await repo.get_logs_after_id(state.last_log_id, limit=_BATCH_SIZE) + if not batch: + break + + for row in batch: + event = state.engine.ingest(row["raw_line"]) + if event and event.attacker_ip: + affected_ips.add(event.attacker_ip) + state.last_log_id = row["id"] + + if len(batch) < _BATCH_SIZE: + break + + if not affected_ips: + await repo.set_state(_STATE_KEY, {"last_log_id": state.last_log_id}) + return + + await _update_profiles(repo, state, affected_ips) + await repo.set_state(_STATE_KEY, {"last_log_id": state.last_log_id}) + + logger.info("attacker worker: updated %d profiles (incremental)", len(affected_ips)) + + +async def _cold_start(repo: BaseRepository, state: _WorkerState) -> None: + all_logs = await repo.get_all_logs_raw() + if not all_logs: + state.last_log_id = await repo.get_max_log_id() + state.initialized = True + await repo.set_state(_STATE_KEY, {"last_log_id": state.last_log_id}) + return + + for row in all_logs: + state.engine.ingest(row["raw_line"]) + state.last_log_id = max(state.last_log_id, row["id"]) + + all_ips = set(state.engine._events.keys()) + await _update_profiles(repo, state, all_ips) + await repo.set_state(_STATE_KEY, {"last_log_id": state.last_log_id}) + + state.initialized = True + logger.info("attacker worker: cold start rebuilt %d profiles", len(all_ips)) + + +async def _update_profiles( + repo: BaseRepository, + state: _WorkerState, + ips: set[str], +) -> None: + traversal_map = {t.attacker_ip: t for t in state.engine.traversals(min_deckies=2)} + bounties_map = await repo.get_bounties_for_ips(ips) + + for ip in ips: + events = state.engine._events.get(ip, []) + if not events: + continue + + traversal = traversal_map.get(ip) + bounties = bounties_map.get(ip, []) + commands = _extract_commands_from_events(events) + + record = _build_record(ip, events, traversal, bounties, commands) + attacker_uuid = await repo.upsert_attacker(record) + + # Behavioral / fingerprint rollup lives in a sibling table so failures + # here never block the core attacker profile upsert. + try: + behavior = build_behavior_record(events) + await repo.upsert_attacker_behavior(attacker_uuid, behavior) + except Exception as exc: + logger.error("attacker worker: behavior upsert failed for %s: %s", ip, exc) + + +def _build_record( + ip: str, + events: list[LogEvent], + traversal: Any, + bounties: list[dict[str, Any]], + commands: list[dict[str, Any]], +) -> dict[str, Any]: + services = sorted({e.service for e in events}) + deckies = ( + traversal.deckies + if traversal + else _first_contact_deckies(events) + ) + fingerprints = [b for b in bounties if b.get("bounty_type") == "fingerprint"] + credential_count = sum(1 for b in bounties if b.get("bounty_type") == "credential") + + return { + "ip": ip, + "first_seen": min(e.timestamp for e in events), + "last_seen": max(e.timestamp for e in events), + "event_count": len(events), + "service_count": len(services), + "decky_count": len({e.decky for e in events}), + "services": json.dumps(services), + "deckies": json.dumps(deckies), + "traversal_path": traversal.path if traversal else None, + "is_traversal": traversal is not None, + "bounty_count": len(bounties), + "credential_count": credential_count, + "fingerprints": json.dumps(fingerprints), + "commands": json.dumps(commands), + "updated_at": datetime.now(timezone.utc), + } + + +def _first_contact_deckies(events: list[LogEvent]) -> list[str]: + """Return unique deckies in first-contact order (for non-traversal attackers).""" + seen: list[str] = [] + for e in sorted(events, key=lambda x: x.timestamp): + if e.decky not in seen: + seen.append(e.decky) + return seen + + +def _extract_commands_from_events(events: list[LogEvent]) -> list[dict[str, Any]]: + """ + Extract executed commands from LogEvent objects. + + Works directly on LogEvent.fields (already a dict), so no JSON parsing needed. + """ + commands: list[dict[str, Any]] = [] + for event in events: + if event.event_type not in _COMMAND_EVENT_TYPES: + continue + + cmd_text: str | None = None + for key in _COMMAND_FIELDS: + val = event.fields.get(key) + if val: + cmd_text = str(val) + break + + if not cmd_text: + continue + + commands.append({ + "service": event.service, + "decky": event.decky, + "command": cmd_text, + "timestamp": event.timestamp.isoformat(), + }) + + return commands