Extends tracing to every remaining module: all 23 API route handlers, correlation engine, sniffer (fingerprint/p0f/syslog), prober (jarm/hassh/tcpfp), profiler behavioral analysis, logging subsystem, engine, and mutator. Bridges the ingester→SSE trace gap by persisting trace_id/span_id columns on the logs table and creating OTEL span links in the SSE endpoint. Adds log-trace correlation via _TraceContextFilter injecting otel_trace_id into Python LogRecords. Includes development/docs/TRACING.md with full span reference (76 spans), pipeline propagation architecture, quick start guide, and troubleshooting.
253 lines
7.7 KiB
Python
253 lines
7.7 KiB
Python
"""
|
||
HASSHServer — SSH server fingerprinting via KEX_INIT algorithm ordering.
|
||
|
||
Connects to an SSH server, completes the version exchange, captures the
|
||
server's SSH_MSG_KEXINIT message, and hashes the server-to-client algorithm
|
||
fields (kex, encryption, MAC, compression) into a 32-character MD5 digest.
|
||
|
||
This is the *server* variant of HASSH (HASSHServer). It fingerprints what
|
||
the server *offers*, which identifies the SSH implementation (OpenSSH,
|
||
Paramiko, libssh, Cobalt Strike SSH, etc.).
|
||
|
||
Stdlib only (socket, struct, hashlib) plus decnet.telemetry for tracing (zero-cost when disabled).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import socket
|
||
import struct
|
||
from typing import Any
|
||
|
||
from decnet.telemetry import traced as _traced
|
||
|
||
# SSH protocol constants
|
||
_SSH_MSG_KEXINIT = 20
|
||
_KEX_INIT_COOKIE_LEN = 16
|
||
_KEX_INIT_NAME_LISTS = 10 # 10 name-list fields in KEX_INIT
|
||
|
||
# Blend in as a normal OpenSSH client
|
||
_CLIENT_BANNER = b"SSH-2.0-OpenSSH_9.6\r\n"
|
||
|
||
# Max bytes to read for server banner
|
||
_MAX_BANNER_LEN = 256
|
||
|
||
# Max bytes for a single SSH packet (KEX_INIT is typically < 2KB)
|
||
_MAX_PACKET_LEN = 35000
|
||
|
||
|
||
# ─── SSH connection + KEX_INIT capture ──────────────────────────────────────
|
||
|
||
@_traced("prober.hassh_ssh_connect")
|
||
def _ssh_connect(
|
||
host: str,
|
||
port: int,
|
||
timeout: float,
|
||
) -> tuple[str, bytes] | None:
|
||
"""
|
||
TCP connect, exchange version strings, read server's KEX_INIT.
|
||
|
||
Returns (server_banner, kex_init_payload) or None on failure.
|
||
The kex_init_payload starts at the SSH_MSG_KEXINIT type byte.
|
||
"""
|
||
sock = None
|
||
try:
|
||
sock = socket.create_connection((host, port), timeout=timeout)
|
||
sock.settimeout(timeout)
|
||
|
||
# 1. Read server banner (line ending \r\n or \n)
|
||
banner = _read_banner(sock)
|
||
if banner is None or not banner.startswith("SSH-"):
|
||
return None
|
||
|
||
# 2. Send our client version string
|
||
sock.sendall(_CLIENT_BANNER)
|
||
|
||
# 3. Read the server's first binary packet (should be KEX_INIT)
|
||
payload = _read_ssh_packet(sock)
|
||
if payload is None or len(payload) < 1:
|
||
return None
|
||
|
||
if payload[0] != _SSH_MSG_KEXINIT:
|
||
return None
|
||
|
||
return (banner, payload)
|
||
|
||
except (OSError, socket.timeout, TimeoutError, ConnectionError):
|
||
return None
|
||
finally:
|
||
if sock is not None:
|
||
try:
|
||
sock.close()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _read_banner(sock: socket.socket) -> str | None:
|
||
"""Read the SSH version banner line from the socket."""
|
||
buf = b""
|
||
while len(buf) < _MAX_BANNER_LEN:
|
||
try:
|
||
byte = sock.recv(1)
|
||
except (OSError, socket.timeout, TimeoutError):
|
||
return None
|
||
if not byte:
|
||
return None
|
||
buf += byte
|
||
if buf.endswith(b"\n"):
|
||
break
|
||
|
||
try:
|
||
return buf.decode("utf-8", errors="replace").rstrip("\r\n")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _read_ssh_packet(sock: socket.socket) -> bytes | None:
|
||
"""
|
||
Read a single SSH binary packet and return its payload.
|
||
|
||
SSH binary packet format:
|
||
uint32 packet_length (not including itself or MAC)
|
||
byte padding_length
|
||
byte[] payload (packet_length - padding_length - 1)
|
||
byte[] padding
|
||
"""
|
||
header = _recv_exact(sock, 4)
|
||
if header is None:
|
||
return None
|
||
|
||
packet_length = struct.unpack("!I", header)[0]
|
||
if packet_length < 2 or packet_length > _MAX_PACKET_LEN:
|
||
return None
|
||
|
||
rest = _recv_exact(sock, packet_length)
|
||
if rest is None:
|
||
return None
|
||
|
||
padding_length = rest[0]
|
||
payload_length = packet_length - padding_length - 1
|
||
if payload_length < 1 or payload_length > len(rest) - 1:
|
||
return None
|
||
|
||
return rest[1 : 1 + payload_length]
|
||
|
||
|
||
def _recv_exact(sock: socket.socket, n: int) -> bytes | None:
|
||
"""Read exactly n bytes from socket, or None on failure."""
|
||
buf = b""
|
||
while len(buf) < n:
|
||
try:
|
||
chunk = sock.recv(n - len(buf))
|
||
except (OSError, socket.timeout, TimeoutError):
|
||
return None
|
||
if not chunk:
|
||
return None
|
||
buf += chunk
|
||
return buf
|
||
|
||
|
||
# ─── KEX_INIT parsing ──────────────────────────────────────────────────────
|
||
|
||
def _parse_kex_init(payload: bytes) -> dict[str, str] | None:
|
||
"""
|
||
Parse SSH_MSG_KEXINIT payload and extract the 10 name-list fields.
|
||
|
||
Payload layout:
|
||
byte SSH_MSG_KEXINIT (20)
|
||
byte[16] cookie
|
||
10 × name-list:
|
||
uint32 length
|
||
byte[] utf-8 string (comma-separated algorithm names)
|
||
bool first_kex_packet_follows
|
||
uint32 reserved
|
||
|
||
Returns dict with keys: kex_algorithms, server_host_key_algorithms,
|
||
encryption_client_to_server, encryption_server_to_client,
|
||
mac_client_to_server, mac_server_to_client,
|
||
compression_client_to_server, compression_server_to_client,
|
||
languages_client_to_server, languages_server_to_client.
|
||
"""
|
||
if len(payload) < 1 + _KEX_INIT_COOKIE_LEN + 4:
|
||
return None
|
||
|
||
offset = 1 + _KEX_INIT_COOKIE_LEN # skip type byte + cookie
|
||
|
||
field_names = [
|
||
"kex_algorithms",
|
||
"server_host_key_algorithms",
|
||
"encryption_client_to_server",
|
||
"encryption_server_to_client",
|
||
"mac_client_to_server",
|
||
"mac_server_to_client",
|
||
"compression_client_to_server",
|
||
"compression_server_to_client",
|
||
"languages_client_to_server",
|
||
"languages_server_to_client",
|
||
]
|
||
|
||
fields: dict[str, str] = {}
|
||
for name in field_names:
|
||
if offset + 4 > len(payload):
|
||
return None
|
||
length = struct.unpack("!I", payload[offset : offset + 4])[0]
|
||
offset += 4
|
||
if offset + length > len(payload):
|
||
return None
|
||
fields[name] = payload[offset : offset + length].decode(
|
||
"utf-8", errors="replace"
|
||
)
|
||
offset += length
|
||
|
||
return fields
|
||
|
||
|
||
# ─── HASSH computation ──────────────────────────────────────────────────────
|
||
|
||
def _compute_hassh(kex: str, enc: str, mac: str, comp: str) -> str:
|
||
"""
|
||
Compute HASSHServer hash: MD5 of "kex;enc_s2c;mac_s2c;comp_s2c".
|
||
|
||
Returns 32-character lowercase hex digest.
|
||
"""
|
||
raw = f"{kex};{enc};{mac};{comp}"
|
||
return hashlib.md5(raw.encode("utf-8")).hexdigest() # nosec B324
|
||
|
||
|
||
# ─── Public API ─────────────────────────────────────────────────────────────
|
||
|
||
@_traced("prober.hassh_server")
|
||
def hassh_server(
|
||
host: str,
|
||
port: int,
|
||
timeout: float = 5.0,
|
||
) -> dict[str, Any] | None:
|
||
"""
|
||
Connect to an SSH server and compute its HASSHServer fingerprint.
|
||
|
||
Returns a dict with the hash, banner, and raw algorithm fields,
|
||
or None if the host is not running an SSH server on the given port.
|
||
"""
|
||
result = _ssh_connect(host, port, timeout)
|
||
if result is None:
|
||
return None
|
||
|
||
banner, payload = result
|
||
fields = _parse_kex_init(payload)
|
||
if fields is None:
|
||
return None
|
||
|
||
kex = fields["kex_algorithms"]
|
||
enc = fields["encryption_server_to_client"]
|
||
mac = fields["mac_server_to_client"]
|
||
comp = fields["compression_server_to_client"]
|
||
|
||
return {
|
||
"hassh_server": _compute_hassh(kex, enc, mac, comp),
|
||
"banner": banner,
|
||
"kex_algorithms": kex,
|
||
"encryption_s2c": enc,
|
||
"mac_s2c": mac,
|
||
"compression_s2c": comp,
|
||
}
|