merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

View File

@@ -0,0 +1,160 @@
"""Bus wiring for the fleet sniffer (DEBT-031, worker 1).
The sniff loop itself lives in a dedicated thread running scapy and
cannot be exercised cleanly under pytest (see the "no scapy in
TestClient lifespan tests" constraint — same hazard applies here).
These tests instead pin the two things that actually carry the
contract:
1. ``SnifferEngine`` invokes ``publish_fn`` on traffic-summary events
and skips intermediate parser artifacts.
2. The worker's thread-safe publisher marshals syncronous calls from
the sniff thread back onto the asyncio loop where the bus lives,
and routes them under the ``decky.{id}.traffic`` topic.
"""
from __future__ import annotations
import asyncio
import pytest
import pytest_asyncio
from decnet.bus import topics as _topics
from decnet.bus.fake import FakeBus
from decnet.sniffer.fingerprint import SnifferEngine
from decnet.sniffer.worker import _make_decky_traffic_publisher
@pytest_asyncio.fixture
async def bus() -> FakeBus:
b = FakeBus()
await b.connect()
yield b
await b.close()
# ─── Engine-level publish hook ───────────────────────────────────────────────
def test_engine_publishes_on_traffic_summary_events() -> None:
captured: list[tuple[str, str, dict]] = []
engine = SnifferEngine(
ip_to_decky={"10.0.0.5": "decky-a"},
write_fn=lambda _line: None,
publish_fn=lambda node, event, payload: captured.append((node, event, payload)),
)
engine._log(
"decky-a", "tcp_flow_timing",
src_ip="203.0.113.9", src_port="4444",
dst_ip="10.0.0.5", dst_port="22",
packets="17", bytes="2048", duration_s="5.1",
mean_iat_ms="300", min_iat_ms="1", max_iat_ms="1200",
retransmits="0",
)
assert captured == [(
"decky-a", "tcp_flow_timing",
{
"src_ip": "203.0.113.9", "src_port": "4444",
"dst_ip": "10.0.0.5", "dst_port": "22",
"packets": "17", "bytes": "2048", "duration_s": "5.1",
"mean_iat_ms": "300", "min_iat_ms": "1", "max_iat_ms": "1200",
"retransmits": "0",
},
)]
def test_engine_skips_intermediate_parser_artifacts() -> None:
captured: list[tuple[str, str, dict]] = []
engine = SnifferEngine(
ip_to_decky={"10.0.0.5": "decky-a"},
write_fn=lambda _line: None,
publish_fn=lambda node, event, payload: captured.append((node, event, payload)),
)
# tls_client_hello is parser intermediate — the completed tls_session
# handshake is what downstream consumers actually want.
engine._log("decky-a", "tls_client_hello", src_ip="1.2.3.4", ja3="abc", ja4="t13d0")
engine._log("decky-a", "tls_certificate", src_ip="1.2.3.4", subject_cn="foo", issuer="bar")
assert captured == []
def test_engine_no_publish_when_hook_absent() -> None:
# Engine without publish_fn is the pre-bus behavior; the syslog line
# is still written. No crash, no exceptions, no publish attempts.
calls: list[str] = []
engine = SnifferEngine(
ip_to_decky={"10.0.0.5": "decky-a"},
write_fn=lambda line: calls.append(line),
)
engine._log(
"decky-a", "tcp_flow_timing",
src_ip="1.2.3.4", src_port="4", dst_ip="10.0.0.5", dst_port="22",
packets="5", bytes="100", duration_s="2",
mean_iat_ms="0", min_iat_ms="0", max_iat_ms="0", retransmits="0",
)
assert len(calls) == 1
def test_engine_swallows_publish_fn_failures() -> None:
# A publish hook that blows up must never break the sniff thread.
def _boom(_node, _event, _payload):
raise RuntimeError("transport exploded")
engine = SnifferEngine(
ip_to_decky={"10.0.0.5": "decky-a"},
write_fn=lambda _line: None,
publish_fn=_boom,
)
# Must not raise.
engine._log(
"decky-a", "tcp_flow_timing",
src_ip="1.2.3.4", src_port="4", dst_ip="10.0.0.5", dst_port="22",
packets="5", bytes="100", duration_s="2",
mean_iat_ms="0", min_iat_ms="0", max_iat_ms="0", retransmits="0",
)
# ─── Thread-safe publisher (worker → bus) ────────────────────────────────────
@pytest.mark.asyncio
async def test_sniffer_worker_degrades_cleanly_when_bus_disabled(
monkeypatch: pytest.MonkeyPatch, tmp_path,
) -> None:
"""``DECNET_BUS_ENABLED=false`` is the non-negotiable escape hatch.
With the bus disabled, ``get_bus()`` returns a ``NullBus`` that
connects without error, and the worker proceeds in publish-off mode
without crashing. We don't exercise the scapy sniff loop (hangs
pytest teardown); we just assert the bus setup path is benign.
"""
from decnet.bus.factory import get_bus
monkeypatch.setenv("DECNET_BUS_ENABLED", "false")
bus = get_bus(client_name="sniffer")
await bus.connect()
# NullBus.publish is a no-op and must never raise.
await bus.publish("decky.x.traffic", {"probe": "ok"}, event_type="tcp_flow_timing")
await bus.close()
@pytest.mark.asyncio
async def test_thread_safe_publisher_routes_to_decky_traffic_topic(bus: FakeBus) -> None:
loop = asyncio.get_running_loop()
publish = _make_decky_traffic_publisher(bus, loop)
sub = bus.subscribe(f"{_topics.DECKY}.*.{_topics.DECKY_TRAFFIC}")
async with sub:
# Fire from the same thread for test determinism — the
# run_coroutine_threadsafe path works identically in-thread, and
# asserting topic/payload shape is the point.
publish("decky-a", "tcp_flow_timing", {"src_ip": "1.2.3.4"})
event = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
assert event.topic == "decky.decky-a.traffic"
assert event.type == "tcp_flow_timing"
assert event.payload == {"src_ip": "1.2.3.4"}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
"""
Unit tests for the passive p0f-lite OS fingerprint lookup.
Covers:
- initial_ttl() TTL → bucket rounding
- hop_distance() upper-bound clamping
- guess_os() signature matching for Linux, Windows, macOS, nmap,
embedded, and the unknown fallback
"""
from __future__ import annotations
from decnet.sniffer.p0f import guess_os, hop_distance, initial_ttl
# ─── initial_ttl ────────────────────────────────────────────────────────────
class TestInitialTtl:
def test_linux_bsd(self):
assert initial_ttl(64) == 64
assert initial_ttl(59) == 64
assert initial_ttl(33) == 64
def test_windows(self):
assert initial_ttl(128) == 128
assert initial_ttl(120) == 128
assert initial_ttl(65) == 128
def test_embedded(self):
assert initial_ttl(255) == 255
assert initial_ttl(254) == 255
assert initial_ttl(200) == 255
def test_very_short(self):
# anything <= 32 rounds to 32
assert initial_ttl(32) == 32
assert initial_ttl(1) == 32
def test_out_of_range(self):
# Packets with TTL > 255 (should never happen) still bucket.
assert initial_ttl(300) == 255
# ─── hop_distance ───────────────────────────────────────────────────────────
class TestHopDistance:
def test_zero_when_local(self):
assert hop_distance(64) == 0
assert hop_distance(128) == 0
assert hop_distance(255) == 0
def test_typical(self):
assert hop_distance(60) == 4 # 4 hops from Linux
assert hop_distance(120) == 8 # 8 hops from Windows
def test_negative_or_weird_still_bucketed(self):
# TTL=0 is anomalous but we still return a non-negative distance.
# TTL 0 bucket is 32 → distance = 32 - 0 = 32.
assert hop_distance(0) == 32
# ─── guess_os ───────────────────────────────────────────────────────────────
class TestGuessOs:
def test_linux_default(self):
# Modern Linux: TTL 64, window 29200+, WScale 7, full options
result = guess_os(
ttl=64, window=29200, mss=1460, wscale=7,
options_sig="M,S,T,N,W",
)
assert result == "linux"
def test_windows_default(self):
# Windows 10: TTL 128, window 64240, WScale 8, MSS 1460
result = guess_os(
ttl=128, window=64240, mss=1460, wscale=8,
options_sig="M,N,W,N,N,T,S",
)
assert result == "windows"
def test_macos_ios(self):
# macOS default: TTL 64, window 65535, WScale 6, ends with EOL
result = guess_os(
ttl=64, window=65535, mss=1460, wscale=6,
options_sig="M,N,W,N,N,T,S,E",
)
assert result == "macos_ios"
def test_nmap_sYn(self):
# nmap -sS uses tiny/distinctive windows like 1024 or 4096
result = guess_os(
ttl=64, window=1024, mss=1460, wscale=10,
options_sig="M,W,T,S,S",
)
assert result == "nmap"
def test_nmap_alt_window(self):
result = guess_os(
ttl=64, window=31337, mss=1460, wscale=10,
options_sig="M,W,T,S,S",
)
assert result == "nmap"
def test_embedded_ttl255(self):
# Any TTL bucket 255 → embedded
result = guess_os(
ttl=250, window=4128, mss=536, wscale=None,
options_sig="M",
)
assert result == "embedded"
def test_unknown(self):
# Bizarre combo nothing matches
result = guess_os(
ttl=50, window=100, mss=0, wscale=None, options_sig="",
)
assert result == "unknown"

View File

@@ -0,0 +1,108 @@
"""
Unit tests for TCP retransmit detection in the SnifferEngine flow aggregator.
A retransmit is defined as a *forward-direction* (attacker → decky) TCP
segment carrying payload whose sequence number has already been seen on
this flow. Empty SYN/ACKs that share seq legitimately are excluded.
"""
from __future__ import annotations
from scapy.layers.inet import IP, TCP
from decnet.sniffer.fingerprint import SnifferEngine
_DECKY_IP = "192.168.1.10"
_DECKY = "decky-01"
_ATTACKER_IP = "10.0.0.7"
def _mk_engine() -> tuple[SnifferEngine, list[str]]:
captured: list[str] = []
engine = SnifferEngine(
ip_to_decky={_DECKY_IP: _DECKY},
write_fn=captured.append,
dedup_ttl=0, # disable dedup for easier assertion
)
return engine, captured
def _data_pkt(seq: int, payload: bytes = b"data", sport: int = 55555):
return IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=sport, dport=22, flags="A", seq=seq, window=29200,
) / payload
def _rst(sport: int = 55555):
return IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=sport, flags="R",
)
def _extract_retransmits(lines: list[str]) -> int:
"""Pull `retransmits=` from the last tcp_flow_timing line."""
import re
for line in reversed(lines):
if "tcp_flow_timing" not in line:
continue
m = re.search(r'retransmits="(\d+)"', line)
if m:
return int(m.group(1))
return -1
class TestRetransmitDetection:
def test_no_retransmits_when_seqs_unique(self):
engine, captured = _mk_engine()
engine.on_packet(_data_pkt(seq=1000))
engine.on_packet(_data_pkt(seq=1004))
engine.on_packet(_data_pkt(seq=1008))
engine.on_packet(_rst())
assert _extract_retransmits(captured) == 0
def test_single_retransmit(self):
engine, captured = _mk_engine()
engine.on_packet(_data_pkt(seq=2000))
engine.on_packet(_data_pkt(seq=2004))
engine.on_packet(_data_pkt(seq=2000)) # retransmitted
engine.on_packet(_rst())
assert _extract_retransmits(captured) == 1
def test_multiple_retransmits(self):
engine, captured = _mk_engine()
engine.on_packet(_data_pkt(seq=3000))
engine.on_packet(_data_pkt(seq=3000))
engine.on_packet(_data_pkt(seq=3000))
engine.on_packet(_data_pkt(seq=3004))
engine.on_packet(_rst())
# Two retransmits (original + 2 dupes of seq=3000)
assert _extract_retransmits(captured) == 2
def test_reverse_direction_not_counted(self):
"""Packets from decky → attacker sharing seq should NOT count."""
engine, captured = _mk_engine()
# Forward data
engine.on_packet(_data_pkt(seq=4000))
engine.on_packet(_data_pkt(seq=4004))
engine.on_packet(_data_pkt(seq=4008))
# Reverse response (decky → attacker) with same seq as a forward
# packet — different flow direction, must not count as retransmit.
reverse = IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=55555, flags="A", seq=4000, window=29200,
) / b"resp"
engine.on_packet(reverse)
engine.on_packet(_rst())
assert _extract_retransmits(captured) == 0
def test_empty_segments_not_counted(self):
"""Pure ACKs (no payload) are not retransmits even if seqs repeat."""
engine, captured = _mk_engine()
# Three pure-ACKs with identical seq
for _ in range(3):
pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=55555, dport=22, flags="A", seq=5000, window=29200,
)
engine.on_packet(pkt)
engine.on_packet(_rst())
assert _extract_retransmits(captured) == 0

View File

@@ -0,0 +1,66 @@
"""
Unit tests for decnet.sniffer.seq_class.classify_sequence.
Verifies the four classification branches plus the "unknown" fallback
when fewer than the minimum number of samples is supplied.
"""
from __future__ import annotations
from decnet.sniffer.seq_class import classify_sequence
class TestUnknown:
def test_empty(self):
assert classify_sequence([]) == "unknown"
def test_below_min_samples(self):
# _MIN_SAMPLES is 4 — three samples should not commit.
assert classify_sequence([10, 20, 30]) == "unknown"
class TestZero:
def test_all_zero(self):
assert classify_sequence([0, 0, 0, 0, 0]) == "zero"
def test_zero_long(self):
assert classify_sequence([0] * 8) == "zero"
class TestConstant:
def test_all_same_nonzero(self):
assert classify_sequence([42, 42, 42, 42]) == "constant"
def test_mixed_breaks_constant(self):
assert classify_sequence([42, 42, 43, 42]) != "constant"
class TestIncremental:
def test_strict_increment_one(self):
assert classify_sequence([100, 101, 102, 103, 104]) == "incremental"
def test_increment_with_small_jumps(self):
# Some kernels skip a few IDs but stay monotonic.
assert classify_sequence([1000, 1003, 1010, 1012, 1015]) == "incremental"
def test_decreasing_is_not_incremental(self):
# Reverse-monotonic could happen on wrap; we treat it as random
# (callers care about a counter-like signal, not "any monotonic").
assert classify_sequence([500, 400, 300, 200]) != "incremental"
def test_huge_jump_breaks_incremental(self):
# 0x1000 = 4096 is the cutoff; 0x2000 between samples is "random".
result = classify_sequence([0, 0x2000, 0x4000, 0x6000])
assert result == "random"
class TestRandom:
def test_high_variance(self):
samples = [12345, 0xABCD, 0x1234, 0xFFFF, 0x00FF, 0x7F7F]
assert classify_sequence(samples) == "random"
def test_repeated_value_with_one_outlier(self):
# Not constant (one outlier), not monotonic, not high-variance —
# still classified as random per the fallthrough rule.
result = classify_sequence([42, 42, 42, 99])
assert result == "random"

View File

@@ -0,0 +1,306 @@
"""
Integration tests for TCP-level passive fingerprinting in the SnifferEngine.
Covers end-to-end flow from a scapy packet through `on_packet()` to:
- tcp_syn_fingerprint event emission (OS guess, options, hop distance)
- tcp_flow_timing event emission (packet count, duration, retransmits)
- dedup behavior (one event per unique fingerprint per window)
- flow flush on FIN/RST
"""
from __future__ import annotations
from scapy.layers.inet import IP, TCP
from decnet.sniffer.fingerprint import SnifferEngine
# ─── Helpers ────────────────────────────────────────────────────────────────
_DECKY_IP = "192.168.1.10"
_DECKY = "decky-01"
_ATTACKER_IP = "10.0.0.7"
def _make_engine() -> tuple[SnifferEngine, list[str]]:
"""Return (engine, captured_syslog_lines)."""
captured: list[str] = []
engine = SnifferEngine(
ip_to_decky={_DECKY_IP: _DECKY},
write_fn=captured.append,
dedup_ttl=300.0,
)
return engine, captured
def _linux_syn(src_port: int = 45000, dst_port: int = 22, seq: int = 1000):
"""Build a synthetic SYN that should fingerprint as Linux."""
return IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=src_port,
dport=dst_port,
flags="S",
seq=seq,
window=29200,
options=[
("MSS", 1460),
("SAckOK", b""),
("Timestamp", (123, 0)),
("NOP", None),
("WScale", 7),
],
)
def _windows_syn(src_port: int = 45001):
"""Build a synthetic SYN that should fingerprint as Windows."""
return IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=128) / TCP(
sport=src_port,
dport=3389,
flags="S",
window=64240,
options=[
("MSS", 1460),
("NOP", None),
("WScale", 8),
("NOP", None),
("NOP", None),
("SAckOK", b""),
],
)
def _fields_from_line(line: str) -> dict[str, str]:
"""Parse the SD-params section of an RFC 5424 syslog line into a dict."""
import re
m = re.search(r"\[relay@55555 (.*?)\]", line)
if not m:
return {}
body = m.group(1)
out: dict[str, str] = {}
for k, v in re.findall(r'(\w+)="((?:[^"\\]|\\.)*)"', body):
out[k] = v
return out
def _msgid(line: str) -> str:
"""Extract MSGID from RFC 5424 line."""
parts = line.split(" ", 6)
return parts[5] if len(parts) > 5 else ""
# ─── tcp_syn_fingerprint emission ──────────────────────────────────────────
class TestSynFingerprintEmission:
def test_linux_syn_emits_fingerprint(self):
engine, captured = _make_engine()
engine.on_packet(_linux_syn())
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert len(fp_lines) == 1
f = _fields_from_line(fp_lines[0])
assert f["src_ip"] == _ATTACKER_IP
assert f["dst_ip"] == _DECKY_IP
assert f["os_guess"] == "linux"
assert f["ttl"] == "64"
assert f["initial_ttl"] == "64"
assert f["hop_distance"] == "0"
assert f["window"] == "29200"
assert f["wscale"] == "7"
assert f["mss"] == "1460"
assert f["has_sack"] == "true"
assert f["has_timestamps"] == "true"
def test_windows_syn_emits_windows_guess(self):
engine, captured = _make_engine()
engine.on_packet(_windows_syn())
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert len(fp_lines) == 1
f = _fields_from_line(fp_lines[0])
assert f["os_guess"] == "windows"
assert f["ttl"] == "128"
assert f["initial_ttl"] == "128"
def test_hop_distance_inferred_from_ttl(self):
engine, captured = _make_engine()
pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=58) / TCP(
sport=40000, dport=22, flags="S", window=29200,
options=[("MSS", 1460), ("SAckOK", b""), ("Timestamp", (0, 0)),
("NOP", None), ("WScale", 7)],
)
engine.on_packet(pkt)
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
f = _fields_from_line(fp_lines[0])
assert f["initial_ttl"] == "64"
assert f["hop_distance"] == "6"
def test_dedup_suppresses_repeated_fingerprints(self):
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=40001))
engine.on_packet(_linux_syn(src_port=40002))
engine.on_packet(_linux_syn(src_port=40003))
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert len(fp_lines) == 1 # same OS + options_sig deduped
def test_different_os_not_deduped(self):
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=40001))
engine.on_packet(_windows_syn(src_port=40002))
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert len(fp_lines) == 2
def test_tos_dscp_ecn_emitted(self):
engine, captured = _make_engine()
# ToS 0x2A → DSCP 10 (AF11), ECN 2 (ECT(0))
pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64, tos=0x2A) / TCP(
sport=45100, dport=22, flags="S", window=29200,
options=[("MSS", 1460), ("SAckOK", b""), ("Timestamp", (0, 0)),
("NOP", None), ("WScale", 7)],
)
engine.on_packet(pkt)
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert len(fp_lines) == 1
f = _fields_from_line(fp_lines[0])
assert f["tos"] == "42"
assert f["dscp"] == "10"
assert f["ecn"] == "2"
def test_ipid_classified_after_enough_samples(self):
"""Eight SYNs from one source with monotonic IP-IDs should yield
ipid_class=incremental on the final emission. Each transition of
ipid_class is part of the dedup key, so we expect exactly one
emission per distinct class as samples accumulate."""
engine, captured = _make_engine()
for i in range(8):
pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64, id=1000 + i) / TCP(
sport=46000 + i, dport=22, flags="S", seq=10_000 + i,
window=29200,
options=[("MSS", 1460), ("SAckOK", b""), ("Timestamp", (0, 0)),
("NOP", None), ("WScale", 7)],
)
engine.on_packet(pkt)
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
# First emission has only 1 sample → ipid_class=unknown.
# Once samples reach _MIN_SAMPLES (4) classification flips →
# second emission has ipid_class=incremental.
assert len(fp_lines) == 2
first = _fields_from_line(fp_lines[0])
last = _fields_from_line(fp_lines[1])
assert first["ipid_class"] == "unknown"
assert last["ipid_class"] == "incremental"
assert int(last["ipid_samples"]) >= 4
def test_isn_classified_random_with_high_variance_seqs(self):
"""SYNs with widely varying ISNs should classify as random."""
engine, captured = _make_engine()
# Spread ISNs across the 32-bit space; randomised initial sequence.
seqs = [0x12345678, 0xABCDEF01, 0x0F0F0F0F, 0xDEADBEEF,
0x11223344, 0x99887766, 0x44556677, 0xCAFEBABE]
for i, seq in enumerate(seqs):
pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64, id=2000 + i) / TCP(
sport=47000 + i, dport=22, flags="S", seq=seq, window=29200,
options=[("MSS", 1460), ("SAckOK", b""), ("Timestamp", (0, 0)),
("NOP", None), ("WScale", 7)],
)
engine.on_packet(pkt)
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
last = _fields_from_line(fp_lines[-1])
assert last["isn_class"] == "random"
assert int(last["isn_samples"]) >= 4
def test_isn_classified_incremental_with_monotonic_seqs(self):
"""SYNs whose ISNs march upward in small steps should classify
as incremental — a strong fingerprint signal vs. modern stacks."""
engine, captured = _make_engine()
for i in range(8):
pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64, id=3000 + i) / TCP(
sport=48000 + i, dport=22, flags="S", seq=10_000 + i, window=29200,
options=[("MSS", 1460), ("SAckOK", b""), ("Timestamp", (0, 0)),
("NOP", None), ("WScale", 7)],
)
engine.on_packet(pkt)
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
last = _fields_from_line(fp_lines[-1])
assert last["isn_class"] == "incremental"
def test_decky_source_does_not_emit(self):
"""Packets originating from a decky (outbound reply) should NOT
be classified as an attacker fingerprint."""
engine, captured = _make_engine()
pkt = IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=40000, flags="S", window=29200,
options=[("MSS", 1460)],
)
engine.on_packet(pkt)
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
assert fp_lines == []
# ─── tcp_flow_timing emission ───────────────────────────────────────────────
class TestFlowTiming:
def test_flow_flushed_on_fin_if_non_trivial(self):
"""A session with ≥4 packets triggers a tcp_flow_timing event on FIN."""
engine, captured = _make_engine()
# SYN + 3 data ACKs + FIN = 5 packets → passes the trivial-flow filter
pkts = [_linux_syn(src_port=50000, seq=100)]
for i, seq in enumerate((101, 200, 300)):
pkts.append(
IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=50000, dport=22, flags="A", seq=seq, window=29200,
) / b"hello-data-here"
)
pkts.append(
IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=50000, dport=22, flags="FA", seq=400, window=29200,
)
)
for p in pkts:
engine.on_packet(p)
flow_lines = [ln for ln in captured if _msgid(ln) == "tcp_flow_timing"]
assert len(flow_lines) == 1
f = _fields_from_line(flow_lines[0])
assert f["src_ip"] == _ATTACKER_IP
assert f["dst_ip"] == _DECKY_IP
assert int(f["packets"]) == 5
assert int(f["retransmits"]) == 0
def test_trivial_flow_dropped(self):
"""A 2-packet scan probe (SYN + RST) must NOT emit a timing event."""
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=50001, seq=200))
engine.on_packet(
IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=50001, flags="R", window=0,
)
)
flow_lines = [ln for ln in captured if _msgid(ln) == "tcp_flow_timing"]
assert flow_lines == [] # trivial: packets<4, no retransmits, dur<1s
def test_retransmit_forces_emission_on_short_flow(self):
"""Even a 3-packet flow must emit if it contains a retransmit."""
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=50002, seq=300))
# Repeat a forward data seq → retransmit
for _ in range(2):
engine.on_packet(
IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64) / TCP(
sport=50002, dport=22, flags="A", seq=301, window=29200,
) / b"payload"
)
engine.on_packet(
IP(src=_DECKY_IP, dst=_ATTACKER_IP, ttl=64) / TCP(
sport=22, dport=50002, flags="R", window=0,
)
)
flow_lines = [ln for ln in captured if _msgid(ln) == "tcp_flow_timing"]
assert len(flow_lines) == 1
f = _fields_from_line(flow_lines[0])
assert int(f["retransmits"]) == 1
def test_flush_all_flows_helper_drops_trivial(self):
"""flush_all_flows still filters trivial flows."""
engine, captured = _make_engine()
engine.on_packet(_linux_syn(src_port=50003, seq=400))
engine.flush_all_flows()
flow_lines = [ln for ln in captured if _msgid(ln) == "tcp_flow_timing"]
assert flow_lines == [] # single packet = trivial

View File

@@ -0,0 +1,308 @@
"""
Tests for the fleet-wide sniffer worker and fingerprinting engine.
Tests the IP-to-decky mapping, packet callback routing, syslog output
format, dedup logic, and worker fault isolation.
"""
import struct
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from decnet.sniffer.fingerprint import (
SnifferEngine,
_ja3,
_ja4,
_ja4_alpn_tag,
_ja4_version,
_ja4s,
_ja3s,
_parse_client_hello,
_parse_server_hello,
_parse_ssh_banner,
_session_resumption_info,
_tls_version_str,
)
from decnet.sniffer.syslog import syslog_line, write_event
from decnet.sniffer.worker import _load_ip_to_decky
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _build_tls_client_hello(
tls_version: int = 0x0303,
cipher_suites: list[int] | None = None,
sni: str = "example.com",
) -> bytes:
"""Build a minimal TLS ClientHello payload for testing."""
if cipher_suites is None:
cipher_suites = [0x1301, 0x1302, 0x1303]
body = b""
body += struct.pack("!H", tls_version) # ClientHello version
body += b"\x00" * 32 # Random
body += b"\x00" # Session ID length = 0
# Cipher suites
cs_data = b"".join(struct.pack("!H", cs) for cs in cipher_suites)
body += struct.pack("!H", len(cs_data)) + cs_data
# Compression methods
body += b"\x01\x00" # 1 method, null
# Extensions
ext_data = b""
if sni:
sni_bytes = sni.encode("ascii")
sni_ext = struct.pack("!HBH", len(sni_bytes) + 3, 0, len(sni_bytes)) + sni_bytes
ext_data += struct.pack("!HH", 0x0000, len(sni_ext)) + sni_ext
body += struct.pack("!H", len(ext_data)) + ext_data
# Handshake header
hs = struct.pack("!B", 0x01) + struct.pack("!I", len(body))[1:] # type + 3-byte length
hs_with_body = hs + body
# TLS record header
record = struct.pack("!BHH", 0x16, 0x0301, len(hs_with_body)) + hs_with_body
return record
def _build_tls_server_hello(
tls_version: int = 0x0303,
cipher_suite: int = 0x1301,
) -> bytes:
"""Build a minimal TLS ServerHello payload for testing."""
body = b""
body += struct.pack("!H", tls_version)
body += b"\x00" * 32 # Random
body += b"\x00" # Session ID length = 0
body += struct.pack("!H", cipher_suite)
body += b"\x00" # Compression method
# No extensions
body += struct.pack("!H", 0)
hs = struct.pack("!B", 0x02) + struct.pack("!I", len(body))[1:]
hs_with_body = hs + body
record = struct.pack("!BHH", 0x16, 0x0301, len(hs_with_body)) + hs_with_body
return record
# ─── TLS parser tests ───────────────────────────────────────────────────────
class TestTlsParsers:
def test_parse_client_hello_valid(self):
data = _build_tls_client_hello()
result = _parse_client_hello(data)
assert result is not None
assert result["tls_version"] == 0x0303
assert result["cipher_suites"] == [0x1301, 0x1302, 0x1303]
assert result["sni"] == "example.com"
def test_parse_client_hello_no_sni(self):
data = _build_tls_client_hello(sni="")
result = _parse_client_hello(data)
assert result is not None
assert result["sni"] == ""
def test_parse_client_hello_invalid_data(self):
assert _parse_client_hello(b"\x00\x01") is None
assert _parse_client_hello(b"") is None
assert _parse_client_hello(b"\x16\x03\x01\x00\x00") is None
def test_parse_server_hello_valid(self):
data = _build_tls_server_hello()
result = _parse_server_hello(data)
assert result is not None
assert result["tls_version"] == 0x0303
assert result["cipher_suite"] == 0x1301
def test_parse_server_hello_invalid(self):
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:
def test_ja3_deterministic(self):
data = _build_tls_client_hello()
ch = _parse_client_hello(data)
ja3_str1, hash1 = _ja3(ch)
ja3_str2, hash2 = _ja3(ch)
assert hash1 == hash2
assert len(hash1) == 32 # MD5 hex
def test_ja4_format(self):
data = _build_tls_client_hello()
ch = _parse_client_hello(data)
ja4 = _ja4(ch)
parts = ja4.split("_")
assert len(parts) == 3
assert parts[0].startswith("t") # TCP
def test_ja3s_deterministic(self):
data = _build_tls_server_hello()
sh = _parse_server_hello(data)
_, hash1 = _ja3s(sh)
_, hash2 = _ja3s(sh)
assert hash1 == hash2
def test_ja4s_format(self):
data = _build_tls_server_hello()
sh = _parse_server_hello(data)
ja4s = _ja4s(sh)
parts = ja4s.split("_")
assert len(parts) == 2
assert parts[0].startswith("t")
def test_tls_version_str(self):
assert _tls_version_str(0x0303) == "TLS 1.2"
assert _tls_version_str(0x0304) == "TLS 1.3"
assert "0x" in _tls_version_str(0x9999)
def test_ja4_version_with_supported_versions(self):
ch = {"tls_version": 0x0303, "supported_versions": [0x0303, 0x0304]}
assert _ja4_version(ch) == "13"
def test_ja4_alpn_tag(self):
assert _ja4_alpn_tag([]) == "00"
assert _ja4_alpn_tag(["h2"]) == "h2"
assert _ja4_alpn_tag(["http/1.1"]) == "h1"
def test_session_resumption_info(self):
ch = {"has_session_ticket_data": True, "has_pre_shared_key": False,
"has_early_data": False, "session_id": b""}
info = _session_resumption_info(ch)
assert info["resumption_attempted"] is True
assert "session_ticket" in info["mechanisms"]
# ─── Syslog format tests ────────────────────────────────────────────────────
class TestSyslog:
def test_syslog_line_format(self):
line = syslog_line("sniffer", "decky-01", "tls_client_hello", src_ip="10.0.0.1")
assert "<134>" in line # PRI for local0 + INFO
assert "decky-01" in line
assert "sniffer" in line
assert "tls_client_hello" in line
assert 'src_ip="10.0.0.1"' in line
def test_write_event_creates_files(self, tmp_path):
log_path = tmp_path / "test.log"
json_path = tmp_path / "test.json"
line = syslog_line("sniffer", "decky-01", "tls_client_hello", src_ip="10.0.0.1")
write_event(line, log_path, json_path)
assert log_path.exists()
assert json_path.exists()
assert "decky-01" in log_path.read_text()
# ─── SnifferEngine tests ────────────────────────────────────────────────────
class TestSnifferEngine:
def test_resolve_decky_by_dst(self):
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=lambda _: None,
)
assert engine._resolve_decky("10.0.0.1", "192.168.1.10") == "decky-01"
def test_resolve_decky_by_src(self):
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=lambda _: None,
)
assert engine._resolve_decky("192.168.1.10", "10.0.0.1") == "decky-01"
def test_resolve_decky_unknown(self):
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=lambda _: None,
)
assert engine._resolve_decky("10.0.0.1", "10.0.0.2") is None
def test_update_ip_map(self):
engine = SnifferEngine(
ip_to_decky={"192.168.1.10": "decky-01"},
write_fn=lambda _: None,
)
engine.update_ip_map({"192.168.1.20": "decky-02"})
assert engine._resolve_decky("10.0.0.1", "192.168.1.20") == "decky-02"
assert engine._resolve_decky("10.0.0.1", "192.168.1.10") is None
def test_dedup_suppresses_identical_events(self):
written: list[str] = []
engine = SnifferEngine(
ip_to_decky={},
write_fn=written.append,
dedup_ttl=300.0,
)
fields = {"src_ip": "10.0.0.1", "ja3": "abc", "ja4": "def"}
engine._log("decky-01", "tls_client_hello", **fields)
engine._log("decky-01", "tls_client_hello", **fields)
assert len(written) == 1
def test_dedup_allows_different_fingerprints(self):
written: list[str] = []
engine = SnifferEngine(
ip_to_decky={},
write_fn=written.append,
dedup_ttl=300.0,
)
engine._log("decky-01", "tls_client_hello", src_ip="10.0.0.1", ja3="abc", ja4="def")
engine._log("decky-01", "tls_client_hello", src_ip="10.0.0.1", ja3="xyz", ja4="uvw")
assert len(written) == 2
def test_dedup_disabled_when_ttl_zero(self):
written: list[str] = []
engine = SnifferEngine(
ip_to_decky={},
write_fn=written.append,
dedup_ttl=0,
)
fields = {"src_ip": "10.0.0.1", "ja3": "abc", "ja4": "def"}
engine._log("decky-01", "tls_client_hello", **fields)
engine._log("decky-01", "tls_client_hello", **fields)
assert len(written) == 2
# ─── Worker IP map loading ──────────────────────────────────────────────────
class TestWorkerIpMap:
def test_load_ip_to_decky_no_state(self):
with patch("decnet.config.load_state", return_value=None):
result = _load_ip_to_decky()
assert result == {}