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] = [] 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]): for index, hop in enumerate(trace.hops[:-1]):
following = trace.hops[index + 1] following = trace.hops[index + 1]
# A single lossy hop with clean hops after it is ICMP rate limiting on that if loss_persists_to_the_end and hop.loss_pct >= 20.0 and following.loss_pct >= 20.0:
# 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:
findings.append( findings.append(
Finding( Finding(
id="path.loss_jump", 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"] 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.config import VpnBands
from netsleuth.models import AdapterLeakResult, CfTrace, DnsLeak, IpGeo, LocalNet, Signal from netsleuth.models import AdapterLeakResult, CfTrace, DnsLeak, IpGeo, LocalNet, Signal
from netsleuth.interpret import SIGNAL_WEIGHTS, assess_vpn, gather_vpn_signals, score_vpn from netsleuth.interpret import SIGNAL_WEIGHTS, assess_vpn, gather_vpn_signals, score_vpn