first commit
This commit is contained in:
313
proxy_chain_manager/leak_audit.py
Normal file
313
proxy_chain_manager/leak_audit.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Consolidated live leak audit.
|
||||
|
||||
Used by the Privacy tab "Run audit" button. Probes every surface that can
|
||||
deanonymize the host even when the chain is healthy:
|
||||
|
||||
• IP — exit IP through chain vs direct IP (subnet leak)
|
||||
• DNS — system resolvers, whether they're public
|
||||
• IPv6 binding — adapters with IPv6 active
|
||||
• WPAD/PAC — leftover AutoConfigURL or AutoDetect
|
||||
• Policy locks — Group Policy proxy keys that beat our settings
|
||||
• PerUser flag — Windows Server ProxySettingsPerUser
|
||||
• LAN broadcast — LLMNR / NetBIOS / mDNS status
|
||||
• VPN — adapter presence
|
||||
• WebRTC — Chrome/Edge policy presence
|
||||
• System proxy — what registry says we're set to
|
||||
• TLS exit — quick categorisation hint (datacenter / residential)
|
||||
|
||||
Each check has its own short timeout so a slow probe never blocks the rest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import winreg
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .dns_leak import get_system_dns_servers
|
||||
from .firewall import is_admin
|
||||
from .privacy_lan import lan_status
|
||||
from .sysproxy import detect_policy_overrides, is_system_proxy_set
|
||||
from .validator import check_chain_exit_ip, get_direct_ip
|
||||
from .vpn_detect import detect_vpn
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditFinding:
|
||||
name: str
|
||||
ok: bool
|
||||
value: str
|
||||
note: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditReport:
|
||||
findings: list[AuditFinding] = field(default_factory=list)
|
||||
overall_ok: bool = True
|
||||
|
||||
def add(self, name: str, ok: bool, value: str, note: str = "") -> None:
|
||||
self.findings.append(AuditFinding(name, ok, value, note))
|
||||
if not ok:
|
||||
self.overall_ok = False
|
||||
|
||||
|
||||
def _check_ipv6_active() -> tuple[bool, str]:
|
||||
"""True/'string' if any 'Up' adapter has IPv6 binding enabled.
|
||||
|
||||
PowerShell 3.0+ path; falls back to ipconfig parsing (locale-tolerant
|
||||
enough — looks for hex colons).
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||
"Get-NetAdapterBinding -ComponentID ms_tcpip6 | "
|
||||
"Where-Object { $_.Enabled } | "
|
||||
"Select-Object -ExpandProperty Name"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
names = [n.strip() for n in (r.stdout or "").splitlines() if n.strip()]
|
||||
if names:
|
||||
return True, ", ".join(names[:3]) + (f" +{len(names)-3}" if len(names) > 3 else "")
|
||||
# Empty stdout could mean PS3 cmdlet missing — try ipconfig fallback.
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError("PS3 net cmdlets unavailable")
|
||||
return False, "no adapters bound to IPv6"
|
||||
except Exception:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ipconfig"], capture_output=True, text=True, timeout=8,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
has_v6 = any(
|
||||
"IPv6" in ln and ":" in ln.split(":", 1)[1]
|
||||
for ln in (r.stdout or "").splitlines()
|
||||
)
|
||||
return has_v6, ("IPv6 address present" if has_v6 else "no IPv6 address")
|
||||
except Exception as e:
|
||||
return False, f"unknown ({e})"
|
||||
|
||||
|
||||
def _check_wpad() -> tuple[bool, str]:
|
||||
path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
|
||||
autoconf = ""
|
||||
autodet = 0
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_QUERY_VALUE
|
||||
) as key:
|
||||
try:
|
||||
autoconf = str(winreg.QueryValueEx(key, "AutoConfigURL")[0] or "")
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
autodet = int(winreg.QueryValueEx(key, "AutoDetect")[0])
|
||||
except OSError:
|
||||
autodet = 0
|
||||
except OSError:
|
||||
pass
|
||||
leaking = bool(autoconf) or autodet == 1
|
||||
if leaking:
|
||||
bits = []
|
||||
if autoconf:
|
||||
bits.append(f"AutoConfigURL={autoconf}")
|
||||
if autodet:
|
||||
bits.append(f"AutoDetect={autodet}")
|
||||
return False, ", ".join(bits)
|
||||
return True, "no WPAD/PAC override"
|
||||
|
||||
|
||||
def _check_per_user_flag() -> tuple[bool, str]:
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings",
|
||||
0, winreg.KEY_QUERY_VALUE,
|
||||
) as key:
|
||||
v = int(winreg.QueryValueEx(key, "ProxySettingsPerUser")[0])
|
||||
if v == 0:
|
||||
return False, "ProxySettingsPerUser=0 (HKCU IGNORED)"
|
||||
return True, "ProxySettingsPerUser=1"
|
||||
except OSError:
|
||||
return True, "unset (default → HKCU honored)"
|
||||
|
||||
|
||||
def _check_webrtc_policy() -> tuple[bool, str]:
|
||||
"""OK means the policy IS set (browsers won't leak non-proxied UDP)."""
|
||||
paths = (
|
||||
r"SOFTWARE\Policies\Google\Chrome",
|
||||
r"SOFTWARE\Policies\Microsoft\Edge",
|
||||
)
|
||||
_DWORD_DISABLE = 3 # disable_non_proxied_udp
|
||||
_STR_KEY = "WebRtcIPHandling"
|
||||
_STR_DISABLE = "disable_non_proxied_udp"
|
||||
found = []
|
||||
for p in paths:
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, p, 0, winreg.KEY_QUERY_VALUE
|
||||
) as key:
|
||||
ok = False
|
||||
try:
|
||||
v = int(winreg.QueryValueEx(key, "DefaultWebRtcIpHandlingPolicy")[0])
|
||||
if v == _DWORD_DISABLE:
|
||||
ok = True
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
v_str = str(winreg.QueryValueEx(key, _STR_KEY)[0]).strip().lower()
|
||||
if v_str == _STR_DISABLE:
|
||||
ok = True
|
||||
except OSError:
|
||||
pass
|
||||
if ok:
|
||||
found.append(p.split("\\")[-1])
|
||||
except OSError:
|
||||
continue
|
||||
if found:
|
||||
return True, "policy set: " + ", ".join(found)
|
||||
return False, "no WebRTC policy — Chrome/Edge may leak local IPs"
|
||||
|
||||
|
||||
def _categorize_exit(ip: str | None) -> str:
|
||||
"""Cheap categorization hint based on common RIR / ASN heuristics.
|
||||
|
||||
No external lookup — just shape-based hints. Real classification can be
|
||||
added later via offline GeoIP DBs.
|
||||
"""
|
||||
if not ip:
|
||||
return "unknown"
|
||||
try:
|
||||
a = int(ip.split(".")[0])
|
||||
except Exception:
|
||||
return "non-IPv4"
|
||||
if a in (10,) or ip.startswith(("192.168.", "172.16.", "172.17.", "172.18.",
|
||||
"172.19.", "172.2", "172.30.", "172.31.")):
|
||||
return "PRIVATE — chain broken"
|
||||
if a == 127:
|
||||
return "LOOPBACK — chain broken"
|
||||
return "public"
|
||||
|
||||
|
||||
async def run_audit(
|
||||
listen_proxy: str,
|
||||
ip_check_url: str,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> AuditReport:
|
||||
"""Run every check in parallel where safe; return structured report."""
|
||||
rep = AuditReport()
|
||||
chain_running = is_system_proxy_set()
|
||||
|
||||
# Network probes in parallel
|
||||
direct_task = asyncio.create_task(get_direct_ip(ip_check_url, timeout_seconds))
|
||||
chain_task = (
|
||||
asyncio.create_task(
|
||||
check_chain_exit_ip(listen_proxy, ip_check_url, timeout_seconds, chain_hops=1)
|
||||
)
|
||||
if chain_running else None
|
||||
)
|
||||
|
||||
direct_ip = await direct_task
|
||||
exit_ip = await chain_task if chain_task else None
|
||||
|
||||
if direct_ip:
|
||||
rep.add("Direct IP (no chain)", True, direct_ip, "")
|
||||
else:
|
||||
rep.add("Direct IP (no chain)", False, "unreachable", "no internet?")
|
||||
|
||||
if chain_running:
|
||||
if exit_ip:
|
||||
cat = _categorize_exit(exit_ip)
|
||||
same = bool(direct_ip and exit_ip == direct_ip)
|
||||
rep.add(
|
||||
"Chain exit IP",
|
||||
not same and cat == "public",
|
||||
f"{exit_ip} ({cat})",
|
||||
"matches direct IP — chain not forwarding" if same else "",
|
||||
)
|
||||
else:
|
||||
rep.add("Chain exit IP", False, "unreachable through chain", "")
|
||||
else:
|
||||
rep.add("Chain exit IP", True, "chain not running", "skipped")
|
||||
|
||||
# DNS resolvers
|
||||
dns_servers = get_system_dns_servers()
|
||||
private_prefixes = ("127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.",
|
||||
"172.19.", "172.2", "172.30.", "172.31.")
|
||||
public_dns = [d for d in dns_servers if not d.startswith(private_prefixes)]
|
||||
if not dns_servers:
|
||||
rep.add("DNS resolvers", True, "none reported (DHCP)", "")
|
||||
elif public_dns:
|
||||
rep.add(
|
||||
"DNS resolvers",
|
||||
False,
|
||||
", ".join(dns_servers),
|
||||
f"{len(public_dns)} public — DNS may bypass chain",
|
||||
)
|
||||
else:
|
||||
rep.add("DNS resolvers", True, ", ".join(dns_servers), "all private/local")
|
||||
|
||||
# IPv6 binding
|
||||
v6_on, v6_msg = _check_ipv6_active()
|
||||
rep.add("IPv6 binding", not v6_on, v6_msg, "IPv6 active = potential leak past v4 proxies" if v6_on else "")
|
||||
|
||||
# WPAD
|
||||
wpad_ok, wpad_msg = _check_wpad()
|
||||
rep.add("WPAD / PAC", wpad_ok, wpad_msg)
|
||||
|
||||
# Per-User flag
|
||||
pu_ok, pu_msg = _check_per_user_flag()
|
||||
rep.add("ProxySettingsPerUser", pu_ok, pu_msg,
|
||||
"needs Admin fix on this box" if not pu_ok else "")
|
||||
|
||||
# Policy locks
|
||||
pol = detect_policy_overrides()
|
||||
if pol:
|
||||
rep.add("Group Policy proxy locks", False, f"{len(pol)} entries",
|
||||
"policy keys override our proxy")
|
||||
else:
|
||||
rep.add("Group Policy proxy locks", True, "none")
|
||||
|
||||
# LAN-scope broadcasts
|
||||
lan = lan_status()
|
||||
lan_clean = "OFF" in lan["llmnr"] and "OFF" in lan["mdns"] and "NICs with NetBIOS disabled" in lan["netbios"]
|
||||
rep.add(
|
||||
"LAN broadcast (LLMNR/NetBIOS/mDNS)",
|
||||
lan_clean,
|
||||
f"LLMNR={lan['llmnr']} NetBIOS={lan['netbios']} mDNS={lan['mdns']}",
|
||||
"hostname leaks to local network" if not lan_clean else "",
|
||||
)
|
||||
|
||||
# VPN
|
||||
vpn = detect_vpn()
|
||||
rep.add(
|
||||
"VPN adapter",
|
||||
True,
|
||||
f"{vpn.label}" + (f" ({vpn.adapter})" if vpn.adapter else ""),
|
||||
"informational",
|
||||
)
|
||||
|
||||
# WebRTC policy
|
||||
rtc_ok, rtc_msg = _check_webrtc_policy()
|
||||
rep.add("Browser WebRTC policy", rtc_ok, rtc_msg)
|
||||
|
||||
# Admin status (a lot of fixes require it)
|
||||
rep.add(
|
||||
"Administrator privileges",
|
||||
is_admin(),
|
||||
"Yes" if is_admin() else "No",
|
||||
"MAC/IPv6/LAN/HKLM fixes need elevation" if not is_admin() else "",
|
||||
)
|
||||
|
||||
return rep
|
||||
|
||||
|
||||
def run_audit_sync(
|
||||
listen_proxy: str,
|
||||
ip_check_url: str,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> AuditReport:
|
||||
return asyncio.run(run_audit(listen_proxy, ip_check_url, timeout_seconds))
|
||||
Reference in New Issue
Block a user