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

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