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.
This commit is contained in:
Andrew Pimenov 2026-08-09 13:00:19 +03:00
parent 4ffc39906b
commit 4888862305
2 changed files with 35 additions and 3 deletions

View file

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

View file

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