Files
DECNET/tests/sniffer/test_sniffer_retransmit.py
anti ea95a009df refactor(tests): move flat tests/*.py into per-subsystem subfolders
Groups every flat test_*.py under the module it exercises, matching the
existing tests/{profiler,sniffer,prober,collector,correlation,cli,web,
topology,swarm,bus,updater,api,docker,geoip,...} layout. New folders:
services/, fleet/, config/, logging/, db/ (+ db/mysql/), telemetry/,
mutator/, core/.

Path-dependent __file__ references bumped an extra .parent in three
files that moved one level deeper:
- tests/sniffer/test_sniffer_ja3.py   (template path)
- tests/services/test_ssh_capture_emit.py (template path)
- tests/cli/test_mode_gating.py  (REPO root)
- tests/web/test_env_lazy_jwt.py (repo var)

Also drops two SQLite runtime artifacts (test_decnet.db-{shm,wal}) that
were leaking into the repo from a previous test run.

Fixes two test_service_isolation cases that patched asyncio.sleep (no
longer on the profiler main-loop hot path — same pre-existing bug I
fixed earlier in test_attacker_worker.py) by patching asyncio.wait_for
and passing interval=0.
2026-04-23 21:34:25 -04:00

109 lines
3.7 KiB
Python

"""
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