fix: audit round 3 - fail-closed leak, shared asyncio loop, GOST bundling, close-X UX, persona key lookup, +29 tests
Some checks failed
CI / Test Python 3.10 (push) Has been cancelled
CI / Test Python 3.11 (push) Has been cancelled
CI / Test Python 3.12 (push) Has been cancelled

This commit is contained in:
Dr Jones
2026-05-22 18:23:52 -07:00
parent ad56f75e8a
commit b852fd264f
15 changed files with 556 additions and 19 deletions

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)"

View File

@@ -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
View 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()

View 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
View 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()

View 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
View 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()