feat(sniffer): ISN sequence classifier (reuses seq_class helper)

Mirrors the IP-ID classifier for TCP ISN values: per-source-IP rolling
deque (maxlen=8) populated from each inbound SYN's tcp.seq, classified
on every emission. A 'random' verdict is the modern norm; 'incremental',
'zero', or 'constant' indicates legacy stacks or hand-rolled raw-socket
tooling — a strong fingerprint signal.

Active prober now also captures server_isn (single sample, not classified
in-flight; downstream consumers correlating multi-probe results can apply
seq_class.classify_sequence themselves).

Profiler rollup carries the latest non-'unknown' label into
attacker.tcp_fingerprint. Dedup key already covers isn_class from
the previous commit, so transitions emit cleanly.

UI surfaces ISN class as a colour-coded tag with a ⚠ glyph for
non-random verdicts, since they're the genuinely interesting case.
This commit is contained in:
2026-04-26 20:30:24 -04:00
parent 0e40cc8ae1
commit c595d039bd
7 changed files with 67 additions and 0 deletions

View File

@@ -188,6 +188,39 @@ class TestSynFingerprintEmission:
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."""