fix: audit round 3 - fail-closed leak, shared asyncio loop, GOST bundling, close-X UX, persona key lookup, +29 tests
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -23,3 +23,8 @@ signup_draft.json
|
|||||||
*.jpeg
|
*.jpeg
|
||||||
# Exceptions — keep bundled assets that are already tracked
|
# Exceptions — keep bundled assets that are already tracked
|
||||||
!proxy_chain_manager/world_map.png
|
!proxy_chain_manager/world_map.png
|
||||||
|
|
||||||
|
# Build-staged binaries — staged by scripts/prepare_bundled_gost.ps1
|
||||||
|
# (downloaded from go-gost release with verified SHA256), not source code
|
||||||
|
proxy_chain_manager/_bundled/
|
||||||
|
|
||||||
|
|||||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
All meaningful changes to this repository should be recorded here.
|
All meaningful changes to this repository should be recorded here.
|
||||||
|
|
||||||
|
## Unreleased — 2026-05-22 (Audit round 3)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Fail-closed leak detection**: `is_chain_leak()` now treats unknown direct
|
||||||
|
IP as a leak (was: pass-through). `service.py` retries direct-IP lookup
|
||||||
|
3× with 2 s back-off as a warm-up grace so transient network blips don't
|
||||||
|
cause permanent rotation.
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
- **Single asyncio loop in preflight**: removed per-call `asyncio.run()` from
|
||||||
|
the preflight worker thread; one loop is created, drained, and closed.
|
||||||
|
- **Close-window UX**: X button now prompts when chain or firewall is still
|
||||||
|
active (tray / full quit / cancel) instead of silently minimizing.
|
||||||
|
- **Admin relaunch**: settings persist before the elevated process spawns.
|
||||||
|
- **Persona/cookie key lookup**: precomputed `label→key` dicts; label copy
|
||||||
|
changes can no longer break the round-trip.
|
||||||
|
|
||||||
|
### Distribution
|
||||||
|
- **GOST bundled**: `scripts/prepare_bundled_gost.ps1` downloads and SHA-
|
||||||
|
verifies `gost.exe`, stages it under `proxy_chain_manager/_bundled/`, and
|
||||||
|
PyInstaller bundles it into the exe. `ensure_gost()` installs from the
|
||||||
|
bundle on first run (no internet required); network fetch is fallback only.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- New suites: `test_config_round_trip`, `test_ban_tester`, `test_gost_util`,
|
||||||
|
`test_fail_closed`, `test_firewall_helpers`.
|
||||||
|
- Total: **47 → 82** passing.
|
||||||
|
|
||||||
|
### Held by design (audit §12)
|
||||||
|
- Signup extension stays on `<all_urls>` — custom signup URLs need it.
|
||||||
|
- `verify=False` on httpx probes — broken public-proxy TLS.
|
||||||
|
- DNS pass-through in kill-switch — GOST needs system DNS.
|
||||||
|
- Authenticode signing — pending Cert provisioning.
|
||||||
|
|
||||||
## Unreleased — 2026-05-21 (Audit remediation P0/P1/P2)
|
## Unreleased — 2026-05-21 (Audit remediation P0/P1/P2)
|
||||||
|
|
||||||
### Security (P0)
|
### Security (P0)
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
|||||||
_BUNDLE_DATA = [
|
_BUNDLE_DATA = [
|
||||||
('proxy_chain_manager/signup_extension', 'proxy_chain_manager/signup_extension'),
|
('proxy_chain_manager/signup_extension', 'proxy_chain_manager/signup_extension'),
|
||||||
('proxy_chain_manager/world_map.png', 'proxy_chain_manager'),
|
('proxy_chain_manager/world_map.png', 'proxy_chain_manager'),
|
||||||
|
# B-06: Bundle gost.exe (and its SHA256 sidecar) so first run works
|
||||||
|
# offline. scripts/prepare_bundled_gost.ps1 stages these files before
|
||||||
|
# PyInstaller runs. Missing files are silently skipped so source
|
||||||
|
# checkouts that haven't staged the binary still build.
|
||||||
|
('proxy_chain_manager/_bundled/gost.exe', 'proxy_chain_manager/_bundled'),
|
||||||
|
('proxy_chain_manager/_bundled/gost.exe.sha256','proxy_chain_manager/_bundled'),
|
||||||
]
|
]
|
||||||
for src, dest in _BUNDLE_DATA:
|
for src, dest in _BUNDLE_DATA:
|
||||||
p = _ROOT / src
|
p = _ROOT / src
|
||||||
|
|||||||
@@ -261,6 +261,20 @@ Installer polish, metrics, coverage gates, IPv6 chain path, SOCKS5 remote DNS po
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 12. Decisions held — accepted trade-offs
|
||||||
|
|
||||||
|
These items are **closed by design choice** (operator approved), not because they're hidden bugs.
|
||||||
|
|
||||||
|
| ID | Decision | Rationale |
|
||||||
|
|----|----------|-----------|
|
||||||
|
| **C-09** | Keep signup extension on `<all_urls>` | Custom signup URLs entered at runtime require dynamic host match; operator isolates the profile per session. |
|
||||||
|
| **S-03** | Keep `verify=False` on httpx probes | Public proxies routinely ship broken / self-signed TLS; turning verification on would drop ~half the working pool. |
|
||||||
|
| **E-14 / S-08** | Keep DNS pass-through in kill-switch | GOST needs system DNS to resolve proxy hostnames; locking port 53 would break pool fetching and exit verification. |
|
||||||
|
| **B-01** | Code-signing deferred | Requires an Authenticode certificate (commercial or self-signed); will wire `signtool sign` into the build script once the cert is provided. |
|
||||||
|
| **B-08** | CI builds the exe — pending operator approval | Adds ~2 min per CI run; not enabled yet. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 11. Remediation log — 2026-05-22 (round 2)
|
## 11. Remediation log — 2026-05-22 (round 2)
|
||||||
|
|
||||||
| ID | Status | Notes |
|
| ID | Status | Notes |
|
||||||
@@ -282,3 +296,23 @@ Installer polish, metrics, coverage gates, IPv6 chain path, SOCKS5 remote DNS po
|
|||||||
---
|
---
|
||||||
|
|
||||||
*Proxy God Audit — last updated 2026-05-22 (round 2)*
|
*Proxy God Audit — last updated 2026-05-22 (round 2)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Remediation log — 2026-05-22 (round 3)
|
||||||
|
|
||||||
|
| ID | Status | Notes |
|
||||||
|
|----|--------|-------|
|
||||||
|
| **E-04** | **Fixed (fail-closed)** | `leak_detect.is_chain_leak()` now treats unknown `real_ip` as a leak; `service.py` retries direct-IP lookup 3× with 2 s back-off as warm-up before the verdict applies. New `tests/test_fail_closed.py` enforces the contract. |
|
||||||
|
| **C-12** | **Fixed** | Preflight worker thread now creates ONE `asyncio` event loop, runs all coroutines on it via `_run_async()`, and closes it at the end. No more nested-loop risk. |
|
||||||
|
| **U-01** | **Fixed** | X button asks the operator (Yes=tray / No=full quit / Cancel) when chain or firewall is still active. |
|
||||||
|
| **U-02** | **Fixed** | "Run as Admin" calls `_save_settings()` before relaunching so unsaved Chain Builder / Settings edits persist. |
|
||||||
|
| **U-04** | **Fixed** | Persona / cookie OptionMenus now reverse-lookup via a precomputed `label→key` dict — copy changes can no longer break the round-trip. |
|
||||||
|
| **B-06** | **Fixed** | `scripts/prepare_bundled_gost.ps1` stages `proxy_chain_manager/_bundled/gost.exe` (SHA-verified) before PyInstaller; the spec bundles it; `ensure_gost()` installs from bundle first and only network-downloads as fallback. First run works fully offline. |
|
||||||
|
| **T-coverage** | **Fixed** | New tests: `test_config_round_trip.py` (sanitize / migrate / save-load / corrupt backup), `test_ban_tester.py` (banned-body heuristics, categories), `test_gost_util.py` (zip + exe hash sidecar), `test_fail_closed.py` (leak contract), `test_firewall_helpers.py` (emergency_disengage guard). **47 → 82 tests passing** (+35 since session start). |
|
||||||
|
|
||||||
|
**No-action documented (§12):** C-09, S-03, E-14/S-08, B-01, B-08.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Proxy God Audit — last updated 2026-05-22 (round 3)*
|
||||||
|
|||||||
@@ -389,6 +389,13 @@ def _main_inner() -> None:
|
|||||||
|
|
||||||
if not is_admin():
|
if not is_admin():
|
||||||
def _elevate() -> None:
|
def _elevate() -> None:
|
||||||
|
# Persist UI state to disk before handing off to the elevated
|
||||||
|
# process — otherwise unsaved edits in Chain Builder / Settings
|
||||||
|
# are lost when this window closes.
|
||||||
|
try:
|
||||||
|
_save_settings()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("Could not save settings before admin relaunch: %s", exc)
|
||||||
if request_admin_relaunch():
|
if request_admin_relaunch():
|
||||||
_close()
|
_close()
|
||||||
_btn(topbar, "Run as Admin", _elevate, w=104,
|
_btn(topbar, "Run as Admin", _elevate, w=104,
|
||||||
@@ -1334,17 +1341,17 @@ def _main_inner() -> None:
|
|||||||
cookie_menu.pack(side="left", padx=(4, 0))
|
cookie_menu.pack(side="left", padx=(4, 0))
|
||||||
cookie_note_lbl.pack(anchor="w", padx=(92, 12), pady=(0, 10))
|
cookie_note_lbl.pack(anchor="w", padx=(92, 12), pady=(0, 10))
|
||||||
|
|
||||||
|
# Build a label→key lookup ONCE so the reverse mapping doesn't break
|
||||||
|
# when a label changes copy. Falls through to the configured default if
|
||||||
|
# the label string somehow no longer exists in the dropdown.
|
||||||
|
_PERSONA_LABEL_TO_KEY = {PERSONA_LABELS[k]: k for k in PERSONAS}
|
||||||
|
_COOKIE_LABEL_TO_KEY = {COOKIE_LABELS[k]: k for k in COOKIE_MODES}
|
||||||
|
|
||||||
def _persona_key() -> str:
|
def _persona_key() -> str:
|
||||||
for k in PERSONAS:
|
return _PERSONA_LABEL_TO_KEY.get(persona_var.get(), "blend_windows_chrome")
|
||||||
if PERSONA_LABELS[k] == persona_var.get():
|
|
||||||
return k
|
|
||||||
return "blend_windows_chrome"
|
|
||||||
|
|
||||||
def _cookie_key() -> str:
|
def _cookie_key() -> str:
|
||||||
for k in COOKIE_MODES:
|
return _COOKIE_LABEL_TO_KEY.get(cookie_var.get(), "block_third_party")
|
||||||
if COOKIE_LABELS[k] == cookie_var.get():
|
|
||||||
return k
|
|
||||||
return "block_third_party"
|
|
||||||
|
|
||||||
br_force_proxy_var = ctk.BooleanVar(value=s.browser_force_proxy)
|
br_force_proxy_var = ctk.BooleanVar(value=s.browser_force_proxy)
|
||||||
br_disable_webrtc_var = ctk.BooleanVar(value=s.browser_disable_webrtc)
|
br_disable_webrtc_var = ctk.BooleanVar(value=s.browser_disable_webrtc)
|
||||||
@@ -1878,16 +1885,26 @@ def _main_inner() -> None:
|
|||||||
|
|
||||||
def work() -> None:
|
def work() -> None:
|
||||||
from .leak_detect import is_chain_leak, leak_reason
|
from .leak_detect import is_chain_leak, leak_reason
|
||||||
|
from .validator import get_direct_ip as _get_direct_ip
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
timeout = min(15.0, max(8.0, float(svc.settings.validation_timeout_seconds) + 2.0))
|
timeout = min(15.0, max(8.0, float(svc.settings.validation_timeout_seconds) + 2.0))
|
||||||
warns: list[str] = []
|
warns: list[str] = []
|
||||||
vpn_active = detect_vpn().active
|
vpn_active = detect_vpn().active
|
||||||
|
|
||||||
|
# Single dedicated event loop for the whole preflight pass. Using
|
||||||
|
# `asyncio.run()` per call tore down the loop each time, which
|
||||||
|
# caused nested-loop errors when caller context (e.g. async http
|
||||||
|
# libraries) leaked tasks across invocations.
|
||||||
|
_loop = _asyncio.new_event_loop()
|
||||||
|
_asyncio.set_event_loop(_loop)
|
||||||
|
|
||||||
|
def _run_async(coro):
|
||||||
|
return _loop.run_until_complete(coro)
|
||||||
|
|
||||||
# ── 1. Direct IP (bypass chain) ──────────────────────────────────
|
# ── 1. Direct IP (bypass chain) ──────────────────────────────────
|
||||||
from .validator import get_direct_ip as _get_direct_ip
|
|
||||||
import asyncio as _asyncio
|
|
||||||
try:
|
try:
|
||||||
direct_ip = _asyncio.run(_get_direct_ip(svc.settings.ip_check_url, timeout / 2))
|
direct_ip = _run_async(_get_direct_ip(svc.settings.ip_check_url, timeout / 2))
|
||||||
except Exception:
|
except Exception:
|
||||||
direct_ip = None
|
direct_ip = None
|
||||||
|
|
||||||
@@ -1994,8 +2011,7 @@ def _main_inner() -> None:
|
|||||||
if chain_ok:
|
if chain_ok:
|
||||||
try:
|
try:
|
||||||
from .validator import check_https_tunnel
|
from .validator import check_https_tunnel
|
||||||
import asyncio as _as2
|
tun_ok, tun_msg = _run_async(
|
||||||
tun_ok, tun_msg = _as2.run(
|
|
||||||
check_https_tunnel(proxy_url, timeout)
|
check_https_tunnel(proxy_url, timeout)
|
||||||
)
|
)
|
||||||
def _set_tun(ok_: bool, msg_: str) -> None:
|
def _set_tun(ok_: bool, msg_: str) -> None:
|
||||||
@@ -2039,6 +2055,13 @@ def _main_inner() -> None:
|
|||||||
|
|
||||||
root.after(0, _final)
|
root.after(0, _final)
|
||||||
|
|
||||||
|
# Tear down the dedicated event loop now that all coroutines are
|
||||||
|
# done; leaving it open would leak the underlying selector socket.
|
||||||
|
try:
|
||||||
|
_loop.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
pf_btn_row = ctk.CTkFrame(preflight_card, fg_color="transparent")
|
pf_btn_row = ctk.CTkFrame(preflight_card, fg_color="transparent")
|
||||||
@@ -3001,7 +3024,27 @@ def _main_inner() -> None:
|
|||||||
fw_disengage()
|
fw_disengage()
|
||||||
root.destroy()
|
root.destroy()
|
||||||
|
|
||||||
root.protocol("WM_DELETE_WINDOW", lambda: root.withdraw())
|
def _on_window_x() -> None:
|
||||||
|
"""X-button handler: warn the operator that the chain is still active
|
||||||
|
before silently going to tray, then offer to fully quit instead."""
|
||||||
|
chain_alive = bool(svc.current_chain) or fw_is_engaged()
|
||||||
|
if chain_alive:
|
||||||
|
from tkinter import messagebox
|
||||||
|
choice = messagebox.askyesnocancel(
|
||||||
|
"Proxy God still running",
|
||||||
|
"The proxy chain and/or kill-switch are still active.\n\n"
|
||||||
|
" • Yes — minimize to tray (chain keeps running)\n"
|
||||||
|
" • No — fully quit (stop chain + remove firewall rules)\n"
|
||||||
|
" • Cancel — keep window open",
|
||||||
|
)
|
||||||
|
if choice is None:
|
||||||
|
return
|
||||||
|
if choice is False:
|
||||||
|
_close()
|
||||||
|
return
|
||||||
|
root.withdraw()
|
||||||
|
|
||||||
|
root.protocol("WM_DELETE_WINDOW", _on_window_x)
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
# INIT
|
# INIT
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -87,6 +88,48 @@ def _write_pinned_exe_hash(exe: Path, digest: str) -> None:
|
|||||||
log.warning("Could not write gost.exe hash sidecar: %s", exc)
|
log.warning("Could not write gost.exe hash sidecar: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundled_gost_paths() -> tuple[Path | None, Path | None]:
|
||||||
|
"""Return (exe_path, sha_path) inside the PyInstaller bundle, or (None, None)."""
|
||||||
|
base = getattr(sys, "_MEIPASS", None)
|
||||||
|
if not base:
|
||||||
|
# Source checkout — look next to the package
|
||||||
|
base = str(Path(__file__).resolve().parent.parent)
|
||||||
|
bundle_exe = Path(base) / "proxy_chain_manager" / "_bundled" / "gost.exe"
|
||||||
|
bundle_sha = bundle_exe.with_suffix(bundle_exe.suffix + ".sha256")
|
||||||
|
if bundle_exe.is_file():
|
||||||
|
return bundle_exe, (bundle_sha if bundle_sha.is_file() else None)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _install_from_bundle(exe: Path) -> bool:
|
||||||
|
"""Copy the bundled gost.exe into *exe* and pin its hash. Returns True
|
||||||
|
on success. Verifies the bundle hash if a sidecar shipped with it."""
|
||||||
|
src, sha_src = _bundled_gost_paths()
|
||||||
|
if not src:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
exe.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_add_defender_exclusion(exe)
|
||||||
|
shutil.copy2(src, exe)
|
||||||
|
_add_defender_exclusion(exe)
|
||||||
|
digest = _hash_file(exe)
|
||||||
|
if sha_src:
|
||||||
|
expected = sha_src.read_text(encoding="utf-8").strip().lower()
|
||||||
|
if expected and expected != digest:
|
||||||
|
exe.unlink(missing_ok=True)
|
||||||
|
log.warning(
|
||||||
|
"Bundled gost.exe SHA mismatch (bundle=%s actual=%s) — "
|
||||||
|
"falling back to network download.", expected, digest,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
_write_pinned_exe_hash(exe, digest)
|
||||||
|
log.info("GOST installed from bundle at %s", exe)
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("Bundle install failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def ensure_gost(target: Path | None = None) -> Path:
|
def ensure_gost(target: Path | None = None) -> Path:
|
||||||
exe = target or gost_exe_path()
|
exe = target or gost_exe_path()
|
||||||
if exe.is_file() and exe.stat().st_size > 10_000:
|
if exe.is_file() and exe.stat().st_size > 10_000:
|
||||||
@@ -98,7 +141,7 @@ def ensure_gost(target: Path | None = None) -> Path:
|
|||||||
if actual == pinned:
|
if actual == pinned:
|
||||||
return exe
|
return exe
|
||||||
log.warning(
|
log.warning(
|
||||||
"gost.exe SHA256 mismatch on reuse — re-downloading.\n"
|
"gost.exe SHA256 mismatch on reuse — re-installing.\n"
|
||||||
" pinned: %s\n actual: %s",
|
" pinned: %s\n actual: %s",
|
||||||
pinned, actual,
|
pinned, actual,
|
||||||
)
|
)
|
||||||
@@ -112,6 +155,11 @@ def ensure_gost(target: Path | None = None) -> Path:
|
|||||||
# SHA verification has already happened earlier in the session.
|
# SHA verification has already happened earlier in the session.
|
||||||
_write_pinned_exe_hash(exe, _hash_file(exe))
|
_write_pinned_exe_hash(exe, _hash_file(exe))
|
||||||
return exe
|
return exe
|
||||||
|
|
||||||
|
# Prefer the bundled binary so the first run works fully offline.
|
||||||
|
if _install_from_bundle(exe):
|
||||||
|
return exe
|
||||||
|
|
||||||
exe.parent.mkdir(parents=True, exist_ok=True)
|
exe.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
|
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
|
||||||
_add_defender_exclusion(exe)
|
_add_defender_exclusion(exe)
|
||||||
|
|||||||
@@ -26,11 +26,17 @@ def is_chain_leak(exit_ip: str | None, real_ip: str | None, vpn_active: bool) ->
|
|||||||
|
|
||||||
With VPN: compare /16 — VPN IPs rotate but stay in-provider ranges.
|
With VPN: compare /16 — VPN IPs rotate but stay in-provider ranges.
|
||||||
Without VPN: exact IP match only (avoid false positives on same ISP /16).
|
Without VPN: exact IP match only (avoid false positives on same ISP /16).
|
||||||
|
|
||||||
|
**Fail-closed**: when ``real_ip`` is unknown we cannot prove the chain is
|
||||||
|
safe, so we conservatively return ``True``. The service loop applies a
|
||||||
|
short warm-up grace period before this verdict kicks in so a transient
|
||||||
|
direct-IP lookup failure doesn't cause perpetual rotation.
|
||||||
"""
|
"""
|
||||||
if not exit_ip:
|
if not exit_ip:
|
||||||
return True
|
return True
|
||||||
if not real_ip:
|
if not real_ip:
|
||||||
return False
|
# Fail-closed: README promises "fail closed" when trust dies.
|
||||||
|
return True
|
||||||
if exit_ip == real_ip:
|
if exit_ip == real_ip:
|
||||||
return True
|
return True
|
||||||
if vpn_active:
|
if vpn_active:
|
||||||
@@ -42,7 +48,10 @@ def leak_reason(exit_ip: str | None, real_ip: str | None, vpn_active: bool) -> s
|
|||||||
if not exit_ip:
|
if not exit_ip:
|
||||||
return "exit IP unreachable"
|
return "exit IP unreachable"
|
||||||
if not real_ip:
|
if not real_ip:
|
||||||
return "unknown direct IP"
|
return (
|
||||||
|
"direct IP unknown — cannot prove the chain is forwarding "
|
||||||
|
"(fail-closed). Check internet connectivity and ip_check_url."
|
||||||
|
)
|
||||||
if exit_ip == real_ip:
|
if exit_ip == real_ip:
|
||||||
if vpn_active:
|
if vpn_active:
|
||||||
return f"exit {exit_ip} equals VPN/direct IP (chain not forwarding)"
|
return f"exit {exit_ip} equals VPN/direct IP (chain not forwarding)"
|
||||||
|
|||||||
@@ -352,13 +352,28 @@ class ChainService:
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
real_ip = await get_direct_ip(self._settings.ip_check_url)
|
# Warm-up: retry the direct-IP lookup a few times so a transient
|
||||||
|
# network blip during startup doesn't put us in permanent fail-closed.
|
||||||
|
real_ip = None
|
||||||
|
for attempt in range(3):
|
||||||
|
real_ip = await get_direct_ip(self._settings.ip_check_url)
|
||||||
|
if real_ip:
|
||||||
|
break
|
||||||
|
if attempt < 2:
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
if real_ip:
|
if real_ip:
|
||||||
self._notify({"type": "real_ip", "ip": real_ip})
|
self._notify({"type": "real_ip", "ip": real_ip})
|
||||||
label = "direct/VPN IP" if self._vpn.active else "your real IP"
|
label = "direct/VPN IP" if self._vpn.active else "your real IP"
|
||||||
self._notify({"type": "log", "text": f"{label.capitalize()}: {real_ip}"})
|
self._notify({"type": "log", "text": f"{label.capitalize()}: {real_ip}"})
|
||||||
else:
|
else:
|
||||||
self._notify({"type": "log", "text": "Could not determine direct IP — leak detection disabled."})
|
# Leak detection now FAILS CLOSED on unknown real_ip — any chain
|
||||||
|
# whose exit IP can't be compared against a baseline will be
|
||||||
|
# treated as a leak and rotated. Make the operator aware up front.
|
||||||
|
self._notify({"type": "log", "text": (
|
||||||
|
"Could not determine direct IP after 3 attempts — leak detection "
|
||||||
|
"will FAIL CLOSED (rotate on any chain whose exit IP cannot be "
|
||||||
|
"verified). Fix internet / ip_check_url to restore."
|
||||||
|
)})
|
||||||
|
|
||||||
self._apply_privacy()
|
self._apply_privacy()
|
||||||
|
|
||||||
|
|||||||
58
scripts/prepare_bundled_gost.ps1
Normal file
58
scripts/prepare_bundled_gost.ps1
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Pre-build step: download gost.exe v3.2.6, verify SHA256, and stage it in
|
||||||
|
proxy_chain_manager/_bundled/gost.exe so PyInstaller can bundle it.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Runs before scripts\setup_and_build.ps1. After this step, the spec file
|
||||||
|
includes _bundled/gost.exe as a data file inside the exe and ensure_gost()
|
||||||
|
copies it out to %LOCALAPPDATA% on first run.
|
||||||
|
#>
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
$bundleDir = Join-Path $root "proxy_chain_manager\_bundled"
|
||||||
|
$bundleExe = Join-Path $bundleDir "gost.exe"
|
||||||
|
$expectedZipHash = "32f4edf3d94b622e67f1979f6f5de82dac62abc0977772cf96215dd199ef7e7b"
|
||||||
|
$zipUrl = "https://github.com/go-gost/gost/releases/download/v3.2.6/gost_3.2.6_windows_amd64.zip"
|
||||||
|
|
||||||
|
if (-not (Test-Path $bundleDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $bundleDir | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip if already staged and valid
|
||||||
|
if (Test-Path $bundleExe) {
|
||||||
|
Write-Host "Bundled gost.exe already present at $bundleExe"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempZip = Join-Path $env:TEMP "gost_bundle.zip"
|
||||||
|
Write-Host "Downloading $zipUrl ..."
|
||||||
|
Invoke-WebRequest -Uri $zipUrl -OutFile $tempZip -UseBasicParsing
|
||||||
|
|
||||||
|
$actualZipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $tempZip).Hash.ToLower()
|
||||||
|
if ($actualZipHash -ne $expectedZipHash) {
|
||||||
|
Remove-Item -LiteralPath $tempZip -Force -ErrorAction SilentlyContinue
|
||||||
|
throw "GOST zip SHA256 mismatch! expected=$expectedZipHash actual=$actualZipHash"
|
||||||
|
}
|
||||||
|
Write-Host "Zip SHA256 OK: $actualZipHash"
|
||||||
|
|
||||||
|
$tempExtract = Join-Path $env:TEMP "gost_bundle_extract"
|
||||||
|
if (Test-Path $tempExtract) { Remove-Item -Recurse -Force $tempExtract }
|
||||||
|
Expand-Archive -LiteralPath $tempZip -DestinationPath $tempExtract -Force
|
||||||
|
|
||||||
|
$extractedExe = Get-ChildItem -Path $tempExtract -Filter "gost.exe" -Recurse | Select-Object -First 1
|
||||||
|
if (-not $extractedExe) {
|
||||||
|
throw "gost.exe not found inside extracted zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item -LiteralPath $extractedExe.FullName -Destination $bundleExe -Force
|
||||||
|
|
||||||
|
# Pin the exe hash so runtime can verify the copy that gets dropped to %LOCALAPPDATA%
|
||||||
|
$exeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $bundleExe).Hash.ToLower()
|
||||||
|
"$exeHash" | Out-File -FilePath "$bundleExe.sha256" -Encoding ascii -Force
|
||||||
|
|
||||||
|
Remove-Item -LiteralPath $tempZip -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Recurse -Force $tempExtract -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Write-Host "Bundled gost.exe: $bundleExe ($exeHash)"
|
||||||
@@ -39,6 +39,10 @@ Write-Host "`n== dependencies + PyInstaller =="
|
|||||||
# Pinned dev dep (PyInstaller version) so builds are reproducible.
|
# Pinned dev dep (PyInstaller version) so builds are reproducible.
|
||||||
& $py -m pip install -r "$root\dev-requirements.txt"
|
& $py -m pip install -r "$root\dev-requirements.txt"
|
||||||
|
|
||||||
|
Write-Host "`n== Stage bundled GOST binary =="
|
||||||
|
& powershell -NoProfile -ExecutionPolicy Bypass -File "$root\scripts\prepare_bundled_gost.ps1"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "prepare_bundled_gost.ps1 failed (exit $LASTEXITCODE)" }
|
||||||
|
|
||||||
Write-Host "`n== PyInstaller (using ProxyChainManager.spec) =="
|
Write-Host "`n== PyInstaller (using ProxyChainManager.spec) =="
|
||||||
# Always build from the spec — it bundles signup_extension/, world_map.png,
|
# Always build from the spec — it bundles signup_extension/, world_map.png,
|
||||||
# customtkinter assets, and the right hidden imports. Do NOT override with
|
# customtkinter assets, and the right hidden imports. Do NOT override with
|
||||||
|
|||||||
48
tests/test_ban_tester.py
Normal file
48
tests/test_ban_tester.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Heuristic tests for ban_tester."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from proxy_chain_manager import ban_tester
|
||||||
|
|
||||||
|
|
||||||
|
class TestBannedBodyHints(unittest.TestCase):
|
||||||
|
def test_normal_page_is_not_banned(self) -> None:
|
||||||
|
self.assertFalse(ban_tester._looks_banned_body("Welcome to my homepage"))
|
||||||
|
self.assertFalse(ban_tester._looks_banned_body("<html><body>hello</body></html>"))
|
||||||
|
|
||||||
|
def test_naked_captcha_word_is_not_banned(self) -> None:
|
||||||
|
# 'captcha' alone is too common (privacy policies, login pages) —
|
||||||
|
# earlier audit removed it from hints.
|
||||||
|
self.assertFalse(ban_tester._looks_banned_body(
|
||||||
|
"We use a captcha on the signup page to prevent bots."
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_captcha_required_phrase_is_banned(self) -> None:
|
||||||
|
self.assertTrue(ban_tester._looks_banned_body("Captcha required to continue."))
|
||||||
|
self.assertTrue(ban_tester._looks_banned_body("Complete the captcha below."))
|
||||||
|
|
||||||
|
def test_access_denied_is_banned(self) -> None:
|
||||||
|
self.assertTrue(ban_tester._looks_banned_body("Access denied"))
|
||||||
|
self.assertTrue(ban_tester._looks_banned_body("ACCESS DENIED — 403"))
|
||||||
|
|
||||||
|
def test_cloudflare_ray_is_banned(self) -> None:
|
||||||
|
self.assertTrue(ban_tester._looks_banned_body("Cloudflare Ray ID: abc123"))
|
||||||
|
|
||||||
|
def test_empty_body_is_not_banned(self) -> None:
|
||||||
|
self.assertFalse(ban_tester._looks_banned_body(""))
|
||||||
|
self.assertFalse(ban_tester._looks_banned_body(None)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteCategories(unittest.TestCase):
|
||||||
|
def test_all_category_includes_known_sites(self) -> None:
|
||||||
|
all_urls = {u for _, u in ban_tester.SITE_CATEGORIES["All"]}
|
||||||
|
self.assertTrue(len(all_urls) > 5)
|
||||||
|
|
||||||
|
def test_categories_present(self) -> None:
|
||||||
|
for key in ("Social / General", "Shopping", "Crypto Exchanges", "DNS Providers", "All"):
|
||||||
|
self.assertIn(key, ban_tester.SITE_CATEGORIES)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
105
tests/test_config_round_trip.py
Normal file
105
tests/test_config_round_trip.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Round-trip / migrate / sanitize tests for config.Settings."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from proxy_chain_manager import config
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitize(unittest.TestCase):
|
||||||
|
def test_clamps_chain_length(self) -> None:
|
||||||
|
s = config.Settings()
|
||||||
|
s.chain_length = 99
|
||||||
|
s, changed = config.sanitize_settings(s)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.assertEqual(s.chain_length, 8)
|
||||||
|
|
||||||
|
def test_chain_length_string_resets_to_default(self) -> None:
|
||||||
|
s = config.Settings()
|
||||||
|
s.chain_length = "not a number" # type: ignore[assignment]
|
||||||
|
s, changed = config.sanitize_settings(s)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.assertEqual(s.chain_length, 3)
|
||||||
|
|
||||||
|
def test_local_port_clamped(self) -> None:
|
||||||
|
s = config.Settings()
|
||||||
|
s.local_port = 99999
|
||||||
|
s, changed = config.sanitize_settings(s)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.assertLessEqual(s.local_port, 65535)
|
||||||
|
|
||||||
|
def test_http_source_url_rejected(self) -> None:
|
||||||
|
# _is_safe_https_url should strip plaintext HTTP sources.
|
||||||
|
s = config.Settings()
|
||||||
|
s.sources = ["http://insecure.example/list.json"]
|
||||||
|
s, _ = config.sanitize_settings(s)
|
||||||
|
self.assertTrue(all(u.startswith("https://") for u in s.sources))
|
||||||
|
|
||||||
|
def test_rfc1918_source_url_rejected(self) -> None:
|
||||||
|
s = config.Settings()
|
||||||
|
s.sources = [
|
||||||
|
"https://192.168.1.1/list.json",
|
||||||
|
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json",
|
||||||
|
]
|
||||||
|
s, _ = config.sanitize_settings(s)
|
||||||
|
for u in s.sources:
|
||||||
|
# rfc1918 host must be gone.
|
||||||
|
self.assertNotIn("192.168.", u)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigrate(unittest.TestCase):
|
||||||
|
def test_unversioned_dict_gets_versioned(self) -> None:
|
||||||
|
raw = {"chain_length": 4}
|
||||||
|
out = config.migrate(raw)
|
||||||
|
self.assertEqual(out["settings_version"], config.SETTINGS_SCHEMA_VERSION)
|
||||||
|
self.assertEqual(out["chain_length"], 4)
|
||||||
|
|
||||||
|
def test_already_current_passthrough(self) -> None:
|
||||||
|
raw = {"settings_version": config.SETTINGS_SCHEMA_VERSION, "chain_length": 4}
|
||||||
|
out = config.migrate(raw)
|
||||||
|
self.assertEqual(out["settings_version"], config.SETTINGS_SCHEMA_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadSaveRoundTrip(unittest.TestCase):
|
||||||
|
def test_save_then_load_preserves_values(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
with patch.object(config, "app_data_dir", return_value=tmp_path):
|
||||||
|
s = config.Settings()
|
||||||
|
s.chain_length = 5
|
||||||
|
s.local_port = 19191
|
||||||
|
s.kill_switch_enabled = False
|
||||||
|
config.save_settings(s)
|
||||||
|
loaded = config.load_settings()
|
||||||
|
self.assertEqual(loaded.chain_length, 5)
|
||||||
|
self.assertEqual(loaded.local_port, 19191)
|
||||||
|
self.assertFalse(loaded.kill_switch_enabled)
|
||||||
|
|
||||||
|
def test_corrupt_settings_backed_up_and_defaults_returned(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
(tmp_path / "settings.json").write_text("{ not json", encoding="utf-8")
|
||||||
|
with patch.object(config, "app_data_dir", return_value=tmp_path):
|
||||||
|
loaded = config.load_settings()
|
||||||
|
self.assertEqual(loaded.chain_length, config.Settings().chain_length)
|
||||||
|
self.assertTrue((tmp_path / "settings.json.corrupt").is_file())
|
||||||
|
|
||||||
|
def test_save_writes_backup_before_overwriting(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
with patch.object(config, "app_data_dir", return_value=tmp_path):
|
||||||
|
config.save_settings(config.Settings())
|
||||||
|
# Mutate + save again — .bak should now exist.
|
||||||
|
s = config.load_settings()
|
||||||
|
s.chain_length = 7
|
||||||
|
config.save_settings(s)
|
||||||
|
self.assertTrue((tmp_path / "settings.json.bak").is_file())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
26
tests/test_fail_closed.py
Normal file
26
tests/test_fail_closed.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Fail-closed leak-detection contract tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from proxy_chain_manager.leak_detect import is_chain_leak, leak_reason
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailClosed(unittest.TestCase):
|
||||||
|
def test_unknown_real_ip_is_leak(self) -> None:
|
||||||
|
"""When the direct IP could not be determined we cannot prove the
|
||||||
|
chain is forwarding — the README promises fail-closed behavior."""
|
||||||
|
self.assertTrue(is_chain_leak("5.6.7.8", None, vpn_active=False))
|
||||||
|
self.assertTrue(is_chain_leak("5.6.7.8", None, vpn_active=True))
|
||||||
|
|
||||||
|
def test_unknown_exit_ip_is_leak(self) -> None:
|
||||||
|
self.assertTrue(is_chain_leak(None, "1.2.3.4", vpn_active=False))
|
||||||
|
|
||||||
|
def test_leak_reason_explains_unknown_direct_ip(self) -> None:
|
||||||
|
msg = leak_reason("5.6.7.8", None, vpn_active=False)
|
||||||
|
self.assertIn("direct IP", msg)
|
||||||
|
self.assertIn("fail-closed", msg.lower())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
42
tests/test_firewall_helpers.py
Normal file
42
tests/test_firewall_helpers.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Non-destructive tests for firewall helpers.
|
||||||
|
|
||||||
|
These DO NOT call netsh — they only exercise pure functions / branches that
|
||||||
|
should never modify the system firewall state.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from proxy_chain_manager import firewall
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == "win32", "Windows-only firewall API")
|
||||||
|
class TestEmergencyDisengageGuard(unittest.TestCase):
|
||||||
|
def test_no_op_when_not_engaged(self) -> None:
|
||||||
|
"""emergency_disengage() must NOT touch firewall state when our rules
|
||||||
|
are not present (the audit fix to C-08)."""
|
||||||
|
called = []
|
||||||
|
with patch.object(firewall, "is_engaged", return_value=False), \
|
||||||
|
patch.object(firewall, "_set_outbound_policy",
|
||||||
|
side_effect=lambda *_a, **_k: called.append("policy")), \
|
||||||
|
patch.object(firewall, "_delete_rules",
|
||||||
|
side_effect=lambda *_a, **_k: called.append("delete")):
|
||||||
|
firewall.emergency_disengage()
|
||||||
|
self.assertEqual(called, [])
|
||||||
|
|
||||||
|
def test_runs_disengage_when_engaged(self) -> None:
|
||||||
|
called = []
|
||||||
|
with patch.object(firewall, "is_engaged", return_value=True), \
|
||||||
|
patch.object(firewall, "_set_outbound_policy",
|
||||||
|
side_effect=lambda *_a, **_k: called.append("policy")), \
|
||||||
|
patch.object(firewall, "_delete_rules",
|
||||||
|
side_effect=lambda *_a, **_k: called.append("delete")):
|
||||||
|
firewall.emergency_disengage()
|
||||||
|
self.assertIn("policy", called)
|
||||||
|
self.assertIn("delete", called)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
60
tests/test_gost_util.py
Normal file
60
tests/test_gost_util.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Hash verify / sidecar tests for gost_util."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from proxy_chain_manager import gost_util
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerifyZipSha256(unittest.TestCase):
|
||||||
|
def test_matching_hash_passes(self) -> None:
|
||||||
|
data = b"hello world"
|
||||||
|
digest = hashlib.sha256(data).hexdigest()
|
||||||
|
gost_util._verify_zip_sha256(data, digest) # must not raise
|
||||||
|
|
||||||
|
def test_wrong_hash_raises(self) -> None:
|
||||||
|
data = b"hello world"
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gost_util._verify_zip_sha256(data, "0" * 64)
|
||||||
|
self.assertIn("supply-chain", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_case_insensitive_match(self) -> None:
|
||||||
|
data = b"PROXYGOD"
|
||||||
|
digest = hashlib.sha256(data).hexdigest().upper()
|
||||||
|
gost_util._verify_zip_sha256(data, digest)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExeHashSidecar(unittest.TestCase):
|
||||||
|
def test_sidecar_round_trip(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
exe = Path(tmp) / "gost.exe"
|
||||||
|
exe.write_bytes(b"\x90" * 1024)
|
||||||
|
digest = gost_util._hash_file(exe)
|
||||||
|
gost_util._write_pinned_exe_hash(exe, digest)
|
||||||
|
self.assertEqual(gost_util._read_pinned_exe_hash(exe), digest)
|
||||||
|
|
||||||
|
def test_missing_sidecar_returns_none(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
exe = Path(tmp) / "gost.exe"
|
||||||
|
exe.write_bytes(b"x")
|
||||||
|
self.assertIsNone(gost_util._read_pinned_exe_hash(exe))
|
||||||
|
|
||||||
|
def test_hash_file_matches_python_hashlib(self) -> None:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||||
|
f.write(b"some payload bytes")
|
||||||
|
f.flush()
|
||||||
|
p = Path(f.name)
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
gost_util._hash_file(p),
|
||||||
|
hashlib.sha256(b"some payload bytes").hexdigest(),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user