From 488886230576605ad374640080baf7b4b46ff4fa Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 9 Aug 2026 13:00:19 +0300 Subject: [PATCH] fix false-positive sustained-loss verdict on long runs of silent hops path_findings() flagged ICMP rate limiting as real loss whenever two consecutive traceroute hops both went silent, but real transit paths routinely have long runs of silent hops (10 in a row, confirmed on a live --full run) while the destination itself answers cleanly. Only flag loss that persists all the way to the last hop in the trace; a run that clears before the end is now ignored regardless of how many hops it spans. --- src/netsleuth/interpret.py | 8 +++++--- tests/test_interpret.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/netsleuth/interpret.py b/src/netsleuth/interpret.py index a4d1ac4..ee47ed9 100644 --- a/src/netsleuth/interpret.py +++ b/src/netsleuth/interpret.py @@ -131,11 +131,13 @@ def path_findings(trace: TraceResult) -> list[Finding]: ) ] findings: list[Finding] = [] + # A run of lossy hops that clears before the last hop we have data for is ICMP + # rate limiting on those routers, not a real problem, no matter how many hops + # in a row stayed silent; only loss that persists all the way to the end is real. + loss_persists_to_the_end = trace.hops[-1].loss_pct >= 20.0 for index, hop in enumerate(trace.hops[:-1]): following = trace.hops[index + 1] - # A single lossy hop with clean hops after it is ICMP rate limiting on that - # router, not a real problem; loss that persists to the next hop is real. - if hop.loss_pct >= 20.0 and following.loss_pct >= 20.0: + if loss_persists_to_the_end and hop.loss_pct >= 20.0 and following.loss_pct >= 20.0: findings.append( Finding( id="path.loss_jump", diff --git a/tests/test_interpret.py b/tests/test_interpret.py index bd4fe73..51a7980 100644 --- a/tests/test_interpret.py +++ b/tests/test_interpret.py @@ -156,6 +156,36 @@ def test_path_findings_report_an_incomplete_trace(): assert [f.id for f in path_findings(trace)] == ["path.incomplete"] +def test_path_findings_ignore_a_long_run_of_silent_hops_that_recovers_at_the_destination(): + # Real transit routers commonly rate-limit ICMP for many consecutive hops in a + # row (not just one) while the actual destination still answers cleanly. + trace = TraceResult( + target="8.8.8.8", + resolved_ip="8.8.8.8", + backend="icmp_win", + hops=[TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0)] + + [TraceHop(ttl=n, ip=None, loss_pct=100.0, avg_ms=None) for n in range(2, 11)] + + [TraceHop(ttl=11, ip="8.8.8.8", loss_pct=0.0, avg_ms=7.0)], + completed=True, + ) + assert [f.id for f in path_findings(trace)] == [] + + +def test_path_findings_flag_loss_that_persists_all_the_way_to_the_last_hop(): + trace = TraceResult( + target="8.8.8.8", + resolved_ip="8.8.8.8", + backend="icmp_win", + hops=[TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0)] + + [TraceHop(ttl=n, ip=None, loss_pct=100.0, avg_ms=None) for n in range(2, 11)], + completed=False, + ) + findings = path_findings(trace) + loss = [f for f in findings if f.id == "path.loss_jump"] + assert len(loss) == 1 + assert "hop 2" in loss[0].detail + + from netsleuth.config import VpnBands from netsleuth.models import AdapterLeakResult, CfTrace, DnsLeak, IpGeo, LocalNet, Signal from netsleuth.interpret import SIGNAL_WEIGHTS, assess_vpn, gather_vpn_signals, score_vpn