fix(prober): consolidate ip route get to single call + log bare excepts
_route_info() calls _ip_route_get once and returns (on_link, iface); worker._ipv6_leak_phase now calls it instead of the two separate helpers. Bare except clauses at _ip_route_get and response parse now log at debug.
This commit is contained in:
@@ -35,26 +35,37 @@ def _ip_route_get(attacker_v4: str) -> str:
|
|||||||
capture_output=True, text=True, timeout=2,
|
capture_output=True, text=True, timeout=2,
|
||||||
)
|
)
|
||||||
return out.stdout
|
return out.stdout
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
_log.debug("ipv6_leak: ip route get failed for %s: %s", attacker_v4, exc)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _route_info(attacker_v4: str) -> tuple[bool, str | None]:
|
||||||
|
"""Return (on_link, iface) with a single `ip route get` invocation.
|
||||||
|
|
||||||
|
on_link is True when there is no intermediate gateway ("via" absent).
|
||||||
|
iface is the local interface name, or None if not parseable.
|
||||||
|
"""
|
||||||
|
stdout = _ip_route_get(attacker_v4)
|
||||||
|
parts = stdout.split()
|
||||||
|
on_link = "via" not in parts
|
||||||
|
iface: str | None = None
|
||||||
|
if "dev" in parts:
|
||||||
|
idx = parts.index("dev")
|
||||||
|
iface = parts[idx + 1] if idx + 1 < len(parts) else None
|
||||||
|
return on_link, iface
|
||||||
|
|
||||||
|
|
||||||
def _resolve_iface_for_ip(attacker_v4: str) -> str | None:
|
def _resolve_iface_for_ip(attacker_v4: str) -> str | None:
|
||||||
"""Return the local interface name that would route to attacker_v4."""
|
"""Return the local interface name that would route to attacker_v4."""
|
||||||
stdout = _ip_route_get(attacker_v4)
|
_, iface = _route_info(attacker_v4)
|
||||||
parts = stdout.split()
|
return iface
|
||||||
if "dev" in parts:
|
|
||||||
idx = parts.index("dev")
|
|
||||||
return parts[idx + 1] if idx + 1 < len(parts) else None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_on_link(attacker_v4: str) -> bool:
|
def _is_on_link(attacker_v4: str) -> bool:
|
||||||
"""Return True only when the attacker is directly reachable on L2.
|
"""Return True only when the attacker is directly reachable on L2."""
|
||||||
|
on_link, _ = _route_info(attacker_v4)
|
||||||
Checks that `ip route get` shows no intermediate gateway (no "via").
|
return on_link
|
||||||
"""
|
|
||||||
return "via" not in _ip_route_get(attacker_v4)
|
|
||||||
|
|
||||||
|
|
||||||
def solicit_ipv6_leak(
|
def solicit_ipv6_leak(
|
||||||
@@ -100,7 +111,8 @@ def solicit_ipv6_leak(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
src_addr: str = resp[IPv6].src
|
src_addr: str = resp[IPv6].src
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
_log.debug("ipv6_leak: response parse failed on %s: %s", attacker_v4, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not _is_link_local(src_addr):
|
if not _is_link_local(src_addr):
|
||||||
|
|||||||
@@ -402,13 +402,12 @@ def _ipv6_leak_phase(
|
|||||||
return
|
return
|
||||||
done.add(0)
|
done.add(0)
|
||||||
|
|
||||||
from decnet.prober.ipv6_leak import _is_on_link, _resolve_iface_for_ip, solicit_ipv6_leak
|
from decnet.prober.ipv6_leak import _route_info, solicit_ipv6_leak
|
||||||
|
|
||||||
if not _is_on_link(ip):
|
on_link, iface = _route_info(ip)
|
||||||
|
if not on_link:
|
||||||
logger.debug("prober: ipv6_leak: %s is not on-link — skip active probe", ip)
|
logger.debug("prober: ipv6_leak: %s is not on-link — skip active probe", ip)
|
||||||
return
|
return
|
||||||
|
|
||||||
iface = _resolve_iface_for_ip(ip)
|
|
||||||
if iface is None:
|
if iface is None:
|
||||||
logger.debug("prober: ipv6_leak: cannot determine iface for %s", ip)
|
logger.debug("prober: ipv6_leak: cannot determine iface for %s", ip)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ no sniff threads. Validates:
|
|||||||
- Phase skips on second call (dedup via ip_probed sentinel).
|
- Phase skips on second call (dedup via ip_probed sentinel).
|
||||||
- Phase emits log + publish_fn when solicit_ipv6_leak returns evidence.
|
- Phase emits log + publish_fn when solicit_ipv6_leak returns evidence.
|
||||||
- Phase is silent when solicit_ipv6_leak returns None.
|
- Phase is silent when solicit_ipv6_leak returns None.
|
||||||
|
- _route_info calls _ip_route_get exactly once per invocation.
|
||||||
|
- _ip_route_get subprocess failure is logged at debug.
|
||||||
|
- solicit_ipv6_leak response-parse failure is logged at debug.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -46,8 +49,7 @@ _EVIDENCE = {
|
|||||||
def test_phase_skips_when_not_on_link() -> None:
|
def test_phase_skips_when_not_on_link() -> None:
|
||||||
published: list[Any] = []
|
published: list[Any] = []
|
||||||
with (
|
with (
|
||||||
patch("decnet.prober.ipv6_leak._is_on_link", return_value=False),
|
patch("decnet.prober.ipv6_leak._route_info", return_value=(False, "eth0")),
|
||||||
patch("decnet.prober.ipv6_leak._resolve_iface_for_ip", return_value="eth0"),
|
|
||||||
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE) as mock_sol,
|
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE) as mock_sol,
|
||||||
):
|
):
|
||||||
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
||||||
@@ -58,8 +60,7 @@ def test_phase_skips_when_not_on_link() -> None:
|
|||||||
def test_phase_skips_when_no_iface() -> None:
|
def test_phase_skips_when_no_iface() -> None:
|
||||||
published: list[Any] = []
|
published: list[Any] = []
|
||||||
with (
|
with (
|
||||||
patch("decnet.prober.ipv6_leak._is_on_link", return_value=True),
|
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, None)),
|
||||||
patch("decnet.prober.ipv6_leak._resolve_iface_for_ip", return_value=None),
|
|
||||||
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE) as mock_sol,
|
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE) as mock_sol,
|
||||||
):
|
):
|
||||||
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
||||||
@@ -70,8 +71,7 @@ def test_phase_skips_when_no_iface() -> None:
|
|||||||
def test_phase_emits_on_evidence() -> None:
|
def test_phase_emits_on_evidence() -> None:
|
||||||
published: list[Any] = []
|
published: list[Any] = []
|
||||||
with (
|
with (
|
||||||
patch("decnet.prober.ipv6_leak._is_on_link", return_value=True),
|
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")),
|
||||||
patch("decnet.prober.ipv6_leak._resolve_iface_for_ip", return_value="eth0"),
|
|
||||||
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE),
|
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE),
|
||||||
):
|
):
|
||||||
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
||||||
@@ -86,8 +86,7 @@ def test_phase_emits_on_evidence() -> None:
|
|||||||
def test_phase_silent_when_solicit_returns_none() -> None:
|
def test_phase_silent_when_solicit_returns_none() -> None:
|
||||||
published: list[Any] = []
|
published: list[Any] = []
|
||||||
with (
|
with (
|
||||||
patch("decnet.prober.ipv6_leak._is_on_link", return_value=True),
|
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")),
|
||||||
patch("decnet.prober.ipv6_leak._resolve_iface_for_ip", return_value="eth0"),
|
|
||||||
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=None),
|
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=None),
|
||||||
):
|
):
|
||||||
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
||||||
@@ -98,8 +97,7 @@ def test_phase_dedup_skips_on_second_call() -> None:
|
|||||||
published: list[Any] = []
|
published: list[Any] = []
|
||||||
ip_probed: dict = {}
|
ip_probed: dict = {}
|
||||||
with (
|
with (
|
||||||
patch("decnet.prober.ipv6_leak._is_on_link", return_value=True),
|
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")),
|
||||||
patch("decnet.prober.ipv6_leak._resolve_iface_for_ip", return_value="eth0"),
|
|
||||||
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE) as mock_sol,
|
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", return_value=_EVIDENCE) as mock_sol,
|
||||||
):
|
):
|
||||||
_phase(ip_probed=ip_probed, publish_fn=lambda k, p: published.append((k, p)))
|
_phase(ip_probed=ip_probed, publish_fn=lambda k, p: published.append((k, p)))
|
||||||
@@ -112,9 +110,57 @@ def test_phase_dedup_skips_on_second_call() -> None:
|
|||||||
def test_phase_handles_solicit_exception_silently() -> None:
|
def test_phase_handles_solicit_exception_silently() -> None:
|
||||||
published: list[Any] = []
|
published: list[Any] = []
|
||||||
with (
|
with (
|
||||||
patch("decnet.prober.ipv6_leak._is_on_link", return_value=True),
|
patch("decnet.prober.ipv6_leak._route_info", return_value=(True, "eth0")),
|
||||||
patch("decnet.prober.ipv6_leak._resolve_iface_for_ip", return_value="eth0"),
|
|
||||||
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", side_effect=RuntimeError("boom")),
|
patch("decnet.prober.ipv6_leak.solicit_ipv6_leak", side_effect=RuntimeError("boom")),
|
||||||
):
|
):
|
||||||
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
_phase(publish_fn=lambda k, p: published.append((k, p)))
|
||||||
assert published == []
|
assert published == []
|
||||||
|
|
||||||
|
|
||||||
|
# ─── _route_info / _ip_route_get unit tests ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_info_calls_ip_route_get_once() -> None:
|
||||||
|
"""_route_info must shell out exactly once regardless of parse path."""
|
||||||
|
from decnet.prober.ipv6_leak import _route_info
|
||||||
|
stdout = "10.0.0.9 dev eth0 src 10.0.0.1 uid 0\n cache"
|
||||||
|
with patch("decnet.prober.ipv6_leak._ip_route_get", return_value=stdout) as mock_rg:
|
||||||
|
on_link, iface = _route_info("10.0.0.9")
|
||||||
|
mock_rg.assert_called_once_with("10.0.0.9")
|
||||||
|
assert on_link is True
|
||||||
|
assert iface == "eth0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_info_detects_gateway() -> None:
|
||||||
|
from decnet.prober.ipv6_leak import _route_info
|
||||||
|
stdout = "10.0.0.9 via 192.168.1.1 dev eth0 src 192.168.1.50\n cache"
|
||||||
|
with patch("decnet.prober.ipv6_leak._ip_route_get", return_value=stdout):
|
||||||
|
on_link, iface = _route_info("10.0.0.9")
|
||||||
|
assert on_link is False
|
||||||
|
assert iface == "eth0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ip_route_get_logs_on_subprocess_failure() -> None:
|
||||||
|
from decnet.prober.ipv6_leak import _ip_route_get
|
||||||
|
with (
|
||||||
|
patch("decnet.prober.ipv6_leak.subprocess.run", side_effect=OSError("no ip")),
|
||||||
|
patch("decnet.prober.ipv6_leak._log") as mock_log,
|
||||||
|
):
|
||||||
|
result = _ip_route_get("10.0.0.9")
|
||||||
|
assert result == ""
|
||||||
|
mock_log.debug.assert_called_once()
|
||||||
|
assert "10.0.0.9" in mock_log.debug.call_args.args[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ip_route_get_returns_empty_string_on_failure() -> None:
|
||||||
|
"""subprocess failure returns "" and logs at debug — not a silent swallow."""
|
||||||
|
from decnet.prober.ipv6_leak import _ip_route_get
|
||||||
|
with (
|
||||||
|
patch("decnet.prober.ipv6_leak.subprocess.run", side_effect=OSError("no ip binary")),
|
||||||
|
patch("decnet.prober.ipv6_leak._log") as mock_log,
|
||||||
|
):
|
||||||
|
result = _ip_route_get("10.0.0.9")
|
||||||
|
assert result == ""
|
||||||
|
assert mock_log.debug.called
|
||||||
|
logged_msg = mock_log.debug.call_args.args
|
||||||
|
assert "10.0.0.9" in str(logged_msg)
|
||||||
|
|||||||
Reference in New Issue
Block a user