fix: wire prober tcpfp_fingerprint events into sniffer_rollup for OS/hop detection
The active prober emits tcpfp_fingerprint events with TTL, window, MSS etc. from the attacker's SYN-ACK. These were invisible to the behavioral profiler for two reasons: 1. target_ip (prober's field name for attacker IP) was not in _IP_FIELDS in collector/worker.py or correlation/parser.py, so the profiler re-parsed raw_lines and got attacker_ip=None, never attributing prober events to the attacker profile. 2. sniffer_rollup only handled tcp_syn_fingerprint (passive sniffer) and ignored tcpfp_fingerprint (active prober). Prober events use different field names: window_size/window_scale/sack_ok vs window/wscale/has_sack. Changes: - Add target_ip to _IP_FIELDS in collector and parser - Add _PROBER_TCPFP_EVENT and _INITIAL_TTL table to behavioral.py - sniffer_rollup now processes tcpfp_fingerprint: maps field names, derives OS from TTL via _os_from_ttl, computes hop_distance = initial_ttl - observed - Expand prober DEFAULT_TCPFP_PORTS to [22,80,443,8080,8443,445,3389] for better SYN-ACK coverage on attacker machines - Add 4 tests covering prober OS detection, hop distance, and field mapping
This commit is contained in:
@@ -114,7 +114,7 @@ _RFC5424_RE = re.compile(
|
|||||||
)
|
)
|
||||||
_SD_BLOCK_RE = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
_SD_BLOCK_RE = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||||
_PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
_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]]:
|
def parse_rfc5424(line: str) -> Optional[dict[str, Any]]:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ _SD_BLOCK_RE = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
|||||||
_PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
_PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||||
|
|
||||||
# Field names to probe for attacker IP, in priority order
|
# 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
|
@dataclass
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ DEFAULT_PROBE_PORTS: list[int] = [
|
|||||||
# HASSHServer: common SSH server ports
|
# HASSHServer: common SSH server ports
|
||||||
DEFAULT_SSH_PORTS: list[int] = [22, 2222, 22222, 2022]
|
DEFAULT_SSH_PORTS: list[int] = [22, 2222, 22222, 2022]
|
||||||
|
|
||||||
# TCP/IP stack: probe on common service ports
|
# TCP/IP stack: probe on ports commonly open on attacker machines.
|
||||||
DEFAULT_TCPFP_PORTS: list[int] = [80, 443]
|
# 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) ─────
|
# ─── RFC 5424 formatting (inline, mirrors templates/*/decnet_logging.py) ─────
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,18 @@ from decnet.correlation.parser import LogEvent
|
|||||||
# ─── Event-type taxonomy ────────────────────────────────────────────────────
|
# ─── Event-type taxonomy ────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Sniffer-emitted packet events that feed into fingerprint rollup.
|
# 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"
|
_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).
|
# Events that signal "recon" phase (scans, probes, auth attempts).
|
||||||
_RECON_EVENT_TYPES: frozenset[str] = frozenset({
|
_RECON_EVENT_TYPES: frozenset[str] = frozenset({
|
||||||
@@ -461,6 +471,36 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
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.
|
# Mode for the OS bucket — most frequently observed label.
|
||||||
os_guess: str | None = None
|
os_guess: str | None = None
|
||||||
if os_guesses:
|
if os_guesses:
|
||||||
|
|||||||
@@ -423,6 +423,45 @@ class TestSnifferRollup:
|
|||||||
r = sniffer_rollup(events)
|
r = sniffer_rollup(events)
|
||||||
assert r["os_guess"] == "macos_ios"
|
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) ──────────────────────────────────────
|
# ─── build_behavior_record (composite) ──────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user