diff --git a/decnet/profiler/behavioral.py b/decnet/profiler/behavioral.py index 37b59c9f..68f4c080 100644 --- a/decnet/profiler/behavioral.py +++ b/decnet/profiler/behavioral.py @@ -90,11 +90,13 @@ def build_behavior_record(events: list[LogEvent]) -> dict[str, Any]: _span.set_attribute("tools", ",".join(all_tools)) kex_list = rollup.get("kex_order_raw") or [] + ssh_banners = rollup.get("ssh_client_banners") or [] return { "os_guess": rollup["os_guess"], "hop_distance": rollup["hop_distance"], "tcp_fingerprint": json.dumps(rollup["tcp_fingerprint"]), "kex_order_raw": json.dumps(kex_list) if kex_list else None, + "ssh_client_banners": json.dumps(ssh_banners) if ssh_banners else None, "retransmit_count": rollup["retransmit_count"], "behavior_class": behavior, "beacon_interval_s": beacon_interval_s, diff --git a/decnet/profiler/fingerprint.py b/decnet/profiler/fingerprint.py index 155d70f4..970f6090 100644 --- a/decnet/profiler/fingerprint.py +++ b/decnet/profiler/fingerprint.py @@ -21,6 +21,8 @@ _SNIFFER_FLOW_EVENT: str = "tcp_flow_timing" _PROBER_TCPFP_EVENT: str = "tcpfp_fingerprint" # Prober-emitted HASSHServer fingerprint; carries the raw kex_algorithms string. _PROBER_HASSH_EVENT: str = "hassh_fingerprint" +# Sniffer-emitted SSH client identification string (RFC 4253 §4.2). +_SNIFFER_SSH_BANNER_EVENT: str = "ssh_client_banner" # Canonical initial TTL for each coarse OS bucket. Used to derive hop # distance when only the observed TTL is available (prober path). @@ -75,6 +77,8 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]: retransmits = 0 kex_order_raw: list[str] = [] _kex_seen: set[str] = set() + ssh_client_banners: list[str] = [] + _ssh_banner_seen: set[str] = set() for e in events: if e.event_type == _SNIFFER_SYN_EVENT: @@ -122,6 +126,15 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]: kex_order_raw.append(kex) _kex_seen.add(kex) + elif e.event_type == _SNIFFER_SSH_BANNER_EVENT: + # Sniffer-observed SSH identification string from attacker. + # Dedup: the same attacker will reuse the same client banner + # across flows/reconnects; record distinct values in order seen. + banner = e.fields.get("ssh_version") + if banner and banner not in _ssh_banner_seen: + ssh_client_banners.append(banner) + _ssh_banner_seen.add(banner) + 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). @@ -173,4 +186,5 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]: "tcp_fingerprint": tcp_fp or {}, "retransmit_count": retransmits, "kex_order_raw": kex_order_raw, + "ssh_client_banners": ssh_client_banners, } diff --git a/decnet/sniffer/fingerprint.py b/decnet/sniffer/fingerprint.py index 14a72477..5eb1e2ad 100644 --- a/decnet/sniffer/fingerprint.py +++ b/decnet/sniffer/fingerprint.py @@ -61,9 +61,35 @@ _BUS_TRAFFIC_EVENTS: frozenset[str] = frozenset({ "tls_session", "tcp_flow_timing", "tcp_syn_fingerprint", + "ssh_client_banner", }) +def _parse_ssh_banner(data: bytes) -> str | None: + """ + Return the attacker's SSH identification string (RFC 4253 §4.2) if + *data* begins with one, else None. + + A valid banner starts with ``SSH-`` and terminates at the first CR or LF + within the 255-byte RFC-mandated window. The returned string is decoded + as ASCII and stripped of the trailing CR/LF bytes. + """ + if not data.startswith(b"SSH-"): + return None + end = -1 + # RFC 4253: identification string (incl. CR LF) must not exceed 255 bytes. + for i, b in enumerate(data[:255]): + if b in (0x0D, 0x0A): # CR or LF + end = i + break + if end < 5: # "SSH-X" minimum + return None + try: + return data[:end].decode("ascii", errors="strict") + except UnicodeDecodeError: + return None + + # ─── TCP option extraction for passive fingerprinting ─────────────────────── def _extract_tcp_fingerprint(tcp_options: list) -> dict[str, Any]: @@ -1053,6 +1079,29 @@ class SnifferEngine: if not payload: return + # SSH client banner (RFC 4253 §4.2): attacker→decky TCP/22, first + # application-data segment of the flow. Emit once per flow. + if ( + dst_port == 22 + and dst_ip in self._ip_to_decky + and direction_forward + ): + flow = self._flows.get(flow_key) + if flow is not None and not flow.get("ssh_banner_seen"): + banner = _parse_ssh_banner(payload) + if banner is not None: + flow["ssh_banner_seen"] = True + target_node = self._ip_to_decky[dst_ip] + self._log( + target_node, + "ssh_client_banner", + src_ip=src_ip, + src_port=str(src_port), + dst_ip=dst_ip, + dst_port=str(dst_port), + ssh_version=banner, + ) + if payload[0] != _TLS_RECORD_HANDSHAKE: return diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py index b8e372bb..7b245c1b 100644 --- a/decnet/web/db/models.py +++ b/decnet/web/db/models.py @@ -182,6 +182,13 @@ class AttackerBehavior(SQLModel, table=True): default=None, sa_column=Column("kex_order_raw", Text, nullable=True), ) # JSON list[str] — kex_algorithms comma-separated strings + # Sniffer-observed SSH client identification strings (RFC 4253 §4.2), + # deduped in observation order. Captures the attacker's SSH client + # software (e.g. "SSH-2.0-OpenSSH_9.2p1", "SSH-2.0-libssh2_1.10.0"). + ssh_client_banners: Optional[str] = Field( + default=None, + sa_column=Column("ssh_client_banners", Text, nullable=True), + ) # JSON list[str] retransmit_count: int = Field(default=0) # Behavioral (derived by the profiler from log-event timing) behavior_class: Optional[str] = None # beaconing | interactive | scanning | brute_force | slow_scan | mixed | unknown diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index a66c5b45..fd11cb99 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -683,6 +683,16 @@ class SQLModelRepository(BaseRepository): d["kex_order_raw"] = [] elif raw_kex is None: d["kex_order_raw"] = [] + # Same list-or-None pattern for ssh_client_banners. + raw_banners = d.get("ssh_client_banners") + if isinstance(raw_banners, str): + try: + parsed_banners = json.loads(raw_banners) + d["ssh_client_banners"] = parsed_banners if isinstance(parsed_banners, list) else [parsed_banners] + except (json.JSONDecodeError, TypeError): + d["ssh_client_banners"] = [] + elif raw_banners is None: + d["ssh_client_banners"] = [] return d @staticmethod diff --git a/tests/test_profiler_behavioral.py b/tests/test_profiler_behavioral.py index 08f3cc87..981a63d8 100644 --- a/tests/test_profiler_behavioral.py +++ b/tests/test_profiler_behavioral.py @@ -486,6 +486,30 @@ class TestSnifferRollup: r = sniffer_rollup(events) assert r["kex_order_raw"] == [] + def test_ssh_client_banners_collected(self): + # Sniffer ssh_client_banner events accumulate the attacker's observed + # SSH identification strings, deduplicated in observation order. + ban_a = "SSH-2.0-OpenSSH_9.2p1 Debian-2" + ban_b = "SSH-2.0-libssh2_1.10.0" + events = [ + _mk(0, event_type="ssh_client_banner", + fields={"ssh_version": ban_a}), + _mk(1, event_type="ssh_client_banner", + fields={"ssh_version": ban_a}), # dup + _mk(2, event_type="ssh_client_banner", + fields={"ssh_version": ban_b}), + ] + r = sniffer_rollup(events) + assert r["ssh_client_banners"] == [ban_a, ban_b] + + def test_ssh_client_banners_empty_when_none(self): + events = [ + _mk(0, event_type="tcp_syn_fingerprint", + fields={"os_guess": "linux"}), + ] + r = sniffer_rollup(events) + assert r["ssh_client_banners"] == [] + # ─── build_behavior_record (composite) ────────────────────────────────────── @@ -565,6 +589,20 @@ class TestBuildBehaviorRecord: r = build_behavior_record(_regular_beacon(count=5, interval_s=60.0)) assert r["kex_order_raw"] is None + def test_ssh_client_banners_persisted_as_json(self): + banner = "SSH-2.0-OpenSSH_9.2p1" + events = [ + _mk(0, event_type="ssh_client_banner", + fields={"ssh_version": banner}), + ] + r = build_behavior_record(events) + assert isinstance(r["ssh_client_banners"], str) + assert json.loads(r["ssh_client_banners"]) == [banner] + + def test_ssh_client_banners_null_when_none(self): + r = build_behavior_record(_regular_beacon(count=5, interval_s=60.0)) + assert r["ssh_client_banners"] is None + def test_nmap_promoted_from_tcp_fingerprint(self): # p0f identifies nmap from TCP handshake → must appear in tool_guesses # even when no HTTP request events are present. diff --git a/tests/test_sniffer_worker.py b/tests/test_sniffer_worker.py index 4a815bbb..412ca0d6 100644 --- a/tests/test_sniffer_worker.py +++ b/tests/test_sniffer_worker.py @@ -22,6 +22,7 @@ from decnet.sniffer.fingerprint import ( _ja3s, _parse_client_hello, _parse_server_hello, + _parse_ssh_banner, _session_resumption_info, _tls_version_str, ) @@ -125,6 +126,33 @@ class TestTlsParsers: assert _parse_server_hello(b"garbage") is None +# ─── SSH banner parser tests ──────────────────────────────────────────────── + +class TestSshBannerParser: + def test_openssh_banner_crlf(self): + data = b"SSH-2.0-OpenSSH_9.2p1 Debian-2\r\nkex-init..." + assert _parse_ssh_banner(data) == "SSH-2.0-OpenSSH_9.2p1 Debian-2" + + def test_banner_lf_only(self): + data = b"SSH-2.0-libssh2_1.10.0\n" + assert _parse_ssh_banner(data) == "SSH-2.0-libssh2_1.10.0" + + def test_non_ssh_payload(self): + assert _parse_ssh_banner(b"GET / HTTP/1.1\r\n") is None + assert _parse_ssh_banner(b"") is None + assert _parse_ssh_banner(b"\x16\x03\x01\x00") is None + + def test_missing_terminator(self): + # No CR/LF within the 255-byte RFC window → not a complete banner yet. + assert _parse_ssh_banner(b"SSH-2.0-OpenSSH_9.2p1" + b" " * 300) is None + + def test_banner_too_short(self): + assert _parse_ssh_banner(b"SSH-\r\n") is None + + def test_non_ascii_rejected(self): + assert _parse_ssh_banner(b"SSH-2.0-\xff\xfe\r\n") is None + + # ─── Fingerprint computation tests ────────────────────────────────────────── class TestFingerprints: