diff --git a/decnet/collector/worker.py b/decnet/collector/worker.py index 63c6018..2383a5b 100644 --- a/decnet/collector/worker.py +++ b/decnet/collector/worker.py @@ -114,7 +114,7 @@ _RFC5424_RE = re.compile( ) _SD_BLOCK_RE = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL) _PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') -_IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "remote_addr", "ip") +_IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "remote_addr", "target_ip", "ip") def parse_rfc5424(line: str) -> Optional[dict[str, Any]]: diff --git a/decnet/correlation/parser.py b/decnet/correlation/parser.py index b6b95ac..001019e 100644 --- a/decnet/correlation/parser.py +++ b/decnet/correlation/parser.py @@ -38,7 +38,7 @@ _SD_BLOCK_RE = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL) _PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') # Field names to probe for attacker IP, in priority order -_IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "remote_addr", "ip") +_IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "remote_addr", "target_ip", "ip") @dataclass diff --git a/decnet/prober/worker.py b/decnet/prober/worker.py index e5769f0..f9e0fce 100644 --- a/decnet/prober/worker.py +++ b/decnet/prober/worker.py @@ -43,8 +43,9 @@ DEFAULT_PROBE_PORTS: list[int] = [ # HASSHServer: common SSH server ports DEFAULT_SSH_PORTS: list[int] = [22, 2222, 22222, 2022] -# TCP/IP stack: probe on common service ports -DEFAULT_TCPFP_PORTS: list[int] = [80, 443] +# TCP/IP stack: probe on ports commonly open on attacker machines. +# Wide spread gives the best chance of a SYN-ACK for TTL/fingerprint extraction. +DEFAULT_TCPFP_PORTS: list[int] = [22, 80, 443, 8080, 8443, 445, 3389] # ─── RFC 5424 formatting (inline, mirrors templates/*/decnet_logging.py) ───── diff --git a/decnet/profiler/behavioral.py b/decnet/profiler/behavioral.py index db44648..7e440db 100644 --- a/decnet/profiler/behavioral.py +++ b/decnet/profiler/behavioral.py @@ -35,8 +35,18 @@ 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_SYN_EVENT: str = "tcp_syn_fingerprint" _SNIFFER_FLOW_EVENT: str = "tcp_flow_timing" +# Prober-emitted active-probe result (SYN-ACK fingerprint of attacker machine). +_PROBER_TCPFP_EVENT: str = "tcpfp_fingerprint" + +# Canonical initial TTL for each coarse OS bucket. Used to derive hop +# distance when only the observed TTL is available (prober path). +_INITIAL_TTL: dict[str, int] = { + "linux": 64, + "windows": 128, + "embedded": 255, +} # Events that signal "recon" phase (scans, probes, auth attempts). _RECON_EVENT_TYPES: frozenset[str] = frozenset({ @@ -461,6 +471,36 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]: except (TypeError, ValueError): pass + elif e.event_type == _PROBER_TCPFP_EVENT: + # Active-probe result: prober sent SYN to attacker, got SYN-ACK back. + # Field names differ from the passive sniffer (different emitter). + ttl_raw = e.fields.get("ttl") + if ttl_raw: + ttl_values.append(ttl_raw) + + # Derive hop distance from observed TTL vs canonical initial TTL. + os_hint = _os_from_ttl(ttl_raw) + if os_hint: + initial = _INITIAL_TTL.get(os_hint) + if initial: + try: + hop_val = initial - int(ttl_raw) + if hop_val > 0: + hops.append(hop_val) + except (TypeError, ValueError): + pass + + # Prober uses window_size/window_scale/options_order instead of + # the sniffer's window/wscale/options_sig. + tcp_fp = { + "window": _int_or_none(e.fields.get("window_size")), + "wscale": _int_or_none(e.fields.get("window_scale")), + "mss": _int_or_none(e.fields.get("mss")), + "options_sig": e.fields.get("options_order", ""), + "has_sack": e.fields.get("sack_ok") == "1", + "has_timestamps": e.fields.get("timestamp") == "1", + } + # Mode for the OS bucket — most frequently observed label. os_guess: str | None = None if os_guesses: diff --git a/tests/test_profiler_behavioral.py b/tests/test_profiler_behavioral.py index ecddd31..5599bd6 100644 --- a/tests/test_profiler_behavioral.py +++ b/tests/test_profiler_behavioral.py @@ -423,6 +423,45 @@ class TestSnifferRollup: r = sniffer_rollup(events) assert r["os_guess"] == "macos_ios" + def test_prober_tcpfp_os_from_ttl(self): + # Active-probe event: TTL=121 → windows OS guess. + events = [ + _mk(0, event_type="tcpfp_fingerprint", + fields={"ttl": "121", "window_size": "64240", "mss": "1460", + "window_scale": "8", "sack_ok": "1", "timestamp": "0", + "options_order": "M,N,W,N,N,S"}), + ] + r = sniffer_rollup(events) + assert r["os_guess"] == "windows" + + def test_prober_tcpfp_hop_distance_derived(self): + # TTL=121 with windows initial TTL=128 → hop_distance=7. + events = [ + _mk(0, event_type="tcpfp_fingerprint", + fields={"ttl": "121", "window_size": "64240", "mss": "1460", + "window_scale": "8", "sack_ok": "1", "timestamp": "0", + "options_order": "M,N,W,N,N,S"}), + ] + r = sniffer_rollup(events) + assert r["hop_distance"] == 7 + + def test_prober_tcpfp_tcp_fingerprint_fields(self): + # Prober field names (window_size, window_scale, etc.) are mapped correctly. + events = [ + _mk(0, event_type="tcpfp_fingerprint", + fields={"ttl": "60", "window_size": "29200", "mss": "1460", + "window_scale": "7", "sack_ok": "1", "timestamp": "1", + "options_order": "M,N,W,N,N,T,S,E"}), + ] + r = sniffer_rollup(events) + fp = r["tcp_fingerprint"] + assert fp["window"] == 29200 + assert fp["wscale"] == 7 + assert fp["mss"] == 1460 + assert fp["has_sack"] is True + assert fp["has_timestamps"] is True + assert fp["options_sig"] == "M,N,W,N,N,T,S,E" + # ─── build_behavior_record (composite) ──────────────────────────────────────