merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/sniffer/__init__.py
Normal file
0
tests/sniffer/__init__.py
Normal file
160
tests/sniffer/test_sniffer_bus.py
Normal file
160
tests/sniffer/test_sniffer_bus.py
Normal 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"}
|
||||
1187
tests/sniffer/test_sniffer_ja3.py
Normal file
1187
tests/sniffer/test_sniffer_ja3.py
Normal file
File diff suppressed because it is too large
Load Diff
117
tests/sniffer/test_sniffer_p0f.py
Normal file
117
tests/sniffer/test_sniffer_p0f.py
Normal 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"
|
||||
108
tests/sniffer/test_sniffer_retransmit.py
Normal file
108
tests/sniffer/test_sniffer_retransmit.py
Normal 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
|
||||
66
tests/sniffer/test_sniffer_seq_class.py
Normal file
66
tests/sniffer/test_sniffer_seq_class.py
Normal 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"
|
||||
306
tests/sniffer/test_sniffer_tcp_fingerprint.py
Normal file
306
tests/sniffer/test_sniffer_tcp_fingerprint.py
Normal 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
|
||||
308
tests/sniffer/test_sniffer_worker.py
Normal file
308
tests/sniffer/test_sniffer_worker.py
Normal 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 == {}
|
||||
Reference in New Issue
Block a user