diff --git a/docs/AUDIT_FINDINGS.md b/docs/AUDIT_FINDINGS.md index 92d3cdc..1e348fb 100644 --- a/docs/AUDIT_FINDINGS.md +++ b/docs/AUDIT_FINDINGS.md @@ -1,193 +1,202 @@ # Proxy God — Audit Findings -**Audit date:** 2026-05-21 -**Scope:** Production-readiness gaps, README cross-check, automated tests -**Tests run:** `python -m unittest discover -s tests -v` → **47 passed**, 0 failed (~25s) +**Generated:** 2026-05-22 +**Tests:** 47/47 pass · compileall clean +**Severity summary:** P0: 12 · P1: 28 · P2: 22 · **Total: 62** --- ## Executive Summary -| Category | P0 | P1 | P2 | Total | -|----------|----|----|-----|-------| -| **Production readiness gaps** (this pass) | 15 | 46 | 16 | **77** | -| Critical bugs (separate bug-hunt pass) | — | — | — | *pending / parallel* | +| Severity | Count | +|----------|------:| +| Critical (P0) | 12 | +| High (P1) | 28 | +| Medium (P2) | 22 | +| **Total** | **62** | -**Test status:** Core pure-logic modules are covered (config, leak_detect, validator timing, chain_map, browser persona). No automated tests touch `service.py`, `firewall.py`, `gost_util.py`, `sysproxy.py`, or `app.py`. - -**Top 5 P0 production blockers** - -1. **No CI/CD** — nothing runs tests or build on push/tag (`requirements.txt:1-4`, no `.github/workflows` / `.gitea/workflows`). -2. **No code signing** — release `.exe` is unsigned (`ProxyChainManager.spec:70-71`). -3. **Secrets at rest in plaintext** — proxy credentials and signup passwords in `%LOCALAPPDATA%\ProxyChainManager\` JSON (`config.py:364-368`, `signup_prep.py:295-296`). -4. **GOST binary integrity not verified** — runtime download with zip magic check only, no SHA256/signature pin (`gost_util.py:17-65`). -5. **Kill-switch recovery gap** — outbound BLOCK can persist if process dies mid-run; no standalone recovery tool documented (`firewall.py:142-156`, `service.py:136-143`). +**Already remediated (prior passes):** CI workflow, GOST SHA256 pin, kill-switch `emergency_disengage` + atexit, settings migration/backup, log rotation, MIT LICENSE, GOST SHA256 verified correct. --- -## Production Readiness Gaps +## Top P0 Issues -### Infrastructure - -| # | What's missing | Why it matters for prod | Suggested fix | Priority | -|---|----------------|-------------------------|---------------|----------| -| I-1 | **No CI/CD pipeline** (no GitHub/Gitea workflow YAML in repo) | Regressions ship silently; no reproducible build gate | Add workflow: `pip install -r requirements.txt`, `unittest discover`, optional `pyinstaller` on tag | **P0** | -| I-2 | **No automated test gate on PR/merge** | CONTRIBUTING.md says “run tests” but nothing enforces it (`CONTRIBUTING.md:9`) | Fail CI if `python -m unittest discover -s tests` fails | **P0** | -| I-3 | **No dependency lockfile** (`requirements.txt` uses `>=` only) | Non-reproducible builds across machines/time | Generate `requirements.lock` or pin exact versions in release branch | **P1** | -| I-4 | **PyInstaller not pinned** — installed ad hoc in `scripts/setup_and_build.ps1:39` | Release binaries differ per builder Python/PyInstaller version | Pin `pyinstaller==x.y.z` in dev requirements; record in release notes | **P1** | -| I-5 | **No release automation** — `docs/RELEASE_PROCESS.md` is manual checklist only | SHA256/commit attachment steps are skipped in practice | Script: build → hash → attach to Gitea release with commit SHA | **P1** | -| I-6 | **No pre-commit / secret scanning** | Risk of committing proxy creds or signup JSON from dev machines | `gitleaks` or similar in CI + pre-commit | **P1** | -| I-7 | **No documented required env vars** — `MAINTENANCE.md:22` asks for them but none exist | Operators cannot configure corporate proxy, custom data dir, etc. | Document optional `LOCALAPPDATA` override pattern or explicit env contract | **P2** | -| I-8 | **Stewardship checklists are templates only** — all items unchecked in `docs/SECURITY_REVIEW.md`, `docs/PROVENANCE_CHECKLIST.md` | Release gate in docs is never enforced | Complete checklists per release or automate in CI | **P1** | - -### Reliability - -| # | What's missing | Why it matters for prod | Suggested fix | Priority | -|---|----------------|-------------------------|---------------|----------| -| R-1 | **Kill-switch may remain engaged after abnormal exit** — `fw_disengage()` only on `stop()` / `_close()` (`service.py:136-143`, `app.py:2877-2886`) | Crashed/killed process leaves `netsh` outbound BLOCK → machine appears “offline” | `atexit` + signal handlers; ship `scripts/disengage_kill_switch.ps1`; detect orphan rules at startup | **P0** | -| R-2 | **GOST download without checksum/signature** (`gost_util.py:49-61`) | Supply-chain compromise or CDN swap replaces `gost.exe` | Pin release URL + publish SHA256; verify before extract; optional minisign | **P0** | -| R-3 | **No settings schema version / migration** — `load_settings()` merges unknown keys silently (`config.py:329-361`) | Upgrades break or drop fields; no upgrade path for operators | Add `settings_version`; migration functions per version | **P1** | -| R-4 | **Corrupt `settings.json` silently reverts to defaults** (`config.py:335-336`) | Operator loses pinned chain/credentials without warning | On parse failure: backup corrupt file, surface UI error, offer restore | **P1** | -| R-5 | **`update_settings()` while service thread running** — no lock around `_settings` (`service.py:112-114`, `349+`) | Race: health interval / pool refresh reads half-written settings | `threading.Lock` on settings read/write or restart service on save | **P1** | -| R-6 | **No offline / degraded mode** — empty pool sleeps 60s and retries (`service.py:402` area) | Air-gapped or CDN-down hosts spin with no cached pool | Persist last-good pool snapshot; UI banner “offline, using cache” | **P1** | -| R-7 | **ChainService thread is daemon** (`service.py:131-133`) | Process exit can abort mid-teardown (proxy/firewall left dirty) | Non-daemon thread + bounded shutdown protocol | **P1** | -| R-8 | **`stop()` join timeout 15s only** (`service.py:140-141`) | Hung GOST/async loop leaves firewall engaged | Force-kill GOST after timeout; always call `fw_disengage()` in `finally` | **P0** | -| R-9 | **Global `ThreadPoolExecutor`s never shut down** (`service.py:64`, `pool_ops.py:15`) | Interpreter shutdown warnings; resource leaks in long-running tray sessions | `shutdown(wait=False)` on app exit | **P2** | -| R-10 | **No GOST process watchdog** — if GOST dies, behavior depends on loop branch | Silent proxy outage until next health check | Detect `proc.poll()`; restart or rotate immediately | **P1** | -| R-11 | **Logon scheduled task runs LIMITED** (`windows_task.py:47-48`) | Auto-start cannot engage kill-switch / MAC / HKLM fixes that need Admin | Document “task ≠ full enforcement”; optional elevated task with warning | **P1** | -| R-12 | **No backup/restore for operator data** | Reinstall or profile wipe loses chains and signup vault | Export/import ZIP of `%LOCALAPPDATA%\ProxyChainManager\` | **P2** | -| R-13 | **No rollback to prior release** | Bad build bricks operators | Keep previous `ProxyChainManager.exe` + settings backup; versioned releases | **P1** | - -### Security - -| # | What's missing | Why it matters for prod | Suggested fix | Priority | -|---|----------------|-------------------------|---------------|----------| -| S-1 | **Plaintext `settings.json`** with embedded proxy auth (`config.py:364-368`, `asdict` stores URLs) | Disk theft = credential leak | DPAPI-encrypt sensitive fields or Windows credential locker | **P0** | -| S-2 | **Plaintext signup vault** — `signup_draft.json`, `signup_accounts.json` (`signup_prep.py:252-296`) | Passwords/emails readable by any local user/process | Encrypt at rest; redact in logs (partially done for proxy URLs only) | **P0** | -| S-3 | **No TLS/certificate pinning for feed URLs** (`fetcher.py:11-18`) | MITM on jsdivr/GitHub could inject malicious proxy list | Pin certs or use signed offline bundle for sources | **P2** | -| S-4 | **Broad Defender exclusion** on entire GOST folder (`gost_util.py:23-39`) | AV blind spot if folder is poisoned | Exclude only `gost.exe`; verify hash before exclusion | **P1** | -| S-5 | **`ip_check_url` and custom `sources` not URL-validated** (`config.py:338-340`) | Hand-edited JSON could point health checks at internal/metadata endpoints | Allowlist HTTPS hosts; block RFC1918/link-local in validator | **P1** | -| S-6 | **Kill-switch default ON** (`config.py:186`) without mandatory recovery docs in README | Bricked network for non-technical operators | README “emergency disengage” + startup orphan detection | **P0** | -| S-7 | **Always-elevated PyInstaller build** (`ProxyChainManager.spec:72` `uac_admin=True`) | Every launch prompts UAC; users may habit-click; not least-privilege | Split “Operator” vs “Enforcer” binaries or manifest optional elevation | **P1** | -| S-8 | **Signup autofill extension** writes config into Firefox profile (`signup_prep.py:334+`) | Local profile theft exposes generated credentials | Document threat model; optional OS-level profile encryption | **P1** | -| S-9 | **No rate limiting on outbound validation fan-out** (default concurrency 64, `config.py:179`) | Can trigger abuse flags on ipify/jsdelivr/proxy sources | Cap per-host concurrency; exponential backoff on 429 | **P1** | -| S-10 | **No secrets rotation story** | Long-lived proxy passwords in JSON never expire | Export reminder; credential field rotation UX | **P2** | - -### Observability - -| # | What's missing | Why it matters for prod | Suggested fix | Priority | -|---|----------------|-------------------------|---------------|----------| -| O-1 | **`proxy_chain_manager.log` has no rotation** (`app.py:91-98`) — unlike `gost.log` (`gost_util.py:98-105`) | Long sessions fill disk | `RotatingFileHandler` or size cap like GOST | **P1** | -| O-2 | **No crash reporting** (WER hook, Sentry, minidump) | Field failures are invisible to maintainer | Opt-in crash telemetry with redaction | **P1** | -| O-3 | **Unstructured text logs only** | Hard to query in SIEM | Optional JSON log format for file handler | **P2** | -| O-4 | **No metrics** (Prometheus/windows perf counters) | Cannot alert on pool size, rotation rate, leak count | Export counters via named pipe or local HTTP if needed | **P2** | -| O-5 | **No external health endpoint** | Expected for server apps; desktop app has tray only — gap if headless automation desired | Document N/A for GUI; optional `127.0.0.1:status` for scripting | **P2** | -| O-6 | **Broad `except Exception: pass` in Defender helper** (`gost_util.py:38-39`) | Silent failure to exclude → GOST quarantined with no UI clue | Log warning + surface in Live tab | **P1** | -| O-7 | **UI event queue unbounded** (`app.py:200`) | Log flood can grow memory | `queue.Queue(maxsize=N)` with drop-oldest policy | **P2** | - -### Distribution - -| # | What's missing | Why it matters for prod | Suggested fix | Priority | -|---|----------------|-------------------------|---------------|----------| -| D-1 | **No Authenticode signing** (`ProxyChainManager.spec:70-71` `codesign_identity=None`) | SmartScreen blocks; enterprise deployment blocked | Sign with cert; document thumbprint in release | **P0** | -| D-2 | **No SHA256 publish step in build script** — required by `docs/RELEASE_PROCESS.md:10` but `setup_and_build.ps1` only copies exe | Users cannot verify download integrity | Emit `ProxyChainManager.exe.sha256` in `dist/` | **P1** | -| D-3 | **UPX compression enabled** (`ProxyChainManager.spec:66-67`) | AV false positives on packed binaries | Disable UPX for release builds | **P1** | -| D-4 | **GOST not shipped in installer** — downloaded on first run (`gost_util.py:42-65`) | First-run requires internet; air-gap fails | Bundle pinned `gost.exe` in installer or optional offline pack | **P1** | -| D-5 | **No MSI/Inno installer** — only raw exe + Desktop copy (`setup_and_build.ps1:55-58`) | No uninstaller, no Start Menu integration, no upgrade path | Wrap in signed installer with upgrade code | **P2** | -| D-6 | **No in-app auto-update channel** | Security patches require manual git pull / rebuild | Check release API + signed delta or full exe | **P1** | -| D-7 | **Version `1.0.0` static** (`__init__.py:3`) — not embedded in Windows file properties | Support cannot map exe → commit | PyInstaller `version` resource from git tag | **P1** | -| D-8 | **No reproducible build manifest** (SBOM) | Supply-chain audits fail | `pip freeze` + GOST hash in release notes | **P1** | - -### Documentation - -| # | What's missing | Why it matters for prod | Suggested fix | Priority | -|---|----------------|-------------------------|---------------|----------| -| DOC-1 | **No LICENSE file** — `LICENSE_STATUS.md:3-9` says “all rights reserved” | Cannot legally redistribute or accept external contributions | Choose SPDX license; add `LICENSE` | **P0** | -| DOC-2 | **README omits production realities** — no mention of: unsigned exe, first-run GOST download, Defender exclusion, kill-switch recovery (`README.md:69-114`) | Operators misconfigure or panic when network dies | Add “Production deployment” section | **P1** | -| DOC-3 | **README test claim understates gaps** — “Covers config… smoke paths” (`README.md:124`) but no service/firewall/GUI tests | False confidence in release quality | Align README with actual test matrix | **P1** | -| DOC-4 | **`CHANGELOG.md` Unreleased empty** (`CHANGELOG.md:5-7`) | No user-visible record between tags | Require changelog entry in PR template | **P1** | -| DOC-5 | **No `THIRD_PARTY_NOTICES.md`** for GOST, CustomTkinter, Pystray, Proxifly data | License compliance for binary distribution | Generate notices from `pip licenses` + GOST MIT | **P1** | -| DOC-6 | **No operator runbook** (kill-switch stuck, proxy leak, pool empty) | MTTR high in field | `docs/RUNBOOK.md` with netsh recovery steps | **P0** | -| DOC-7 | **“Mission-grade” / “fail closed” marketing without SLOs** (`README.md:5-12`) | Enterprise buyers expect defined behavior | Add limitations: public proxy quality, VPN requirement, admin scope | **P2** | -| DOC-8 | **`docs/MAINTENANCE.md` rate-limit note** (`MAINTENANCE.md:23`) not reflected in product | Release gate incomplete | Implement S-9 or remove claim | **P1** | - -### Testing - -| # | What's missing | Why it matters for prod | Suggested fix | Priority | -|---|----------------|-------------------------|---------------|----------| -| T-1 | **No CI executing test suite** | 47 tests only run manually | Wire I-1 to `unittest discover` | **P0** | -| T-2 | **Zero tests for `service.py`, `firewall.py`, `gost_util.py`, `sysproxy.py`** | Core runtime unverified | Mock subprocess/netsh; test engage/disengage, settings races | **P0** | -| T-3 | **No tests for `load_settings` / `save_settings` round-trip** | Regression on migration/sanitize | Add `TestSettingsPersistence` with temp dir | **P1** | -| T-4 | **No frozen-bundle path tests** — `_MEIPASS` branches in `signup_prep.py:21-22`, `chain_map.py:50-51` | PyInstaller regressions break map/signup | Test with `sys.frozen` monkeypatch | **P1** | -| T-5 | **No GUI smoke tests** (`app.py` ~2900 lines) | UI regressions ship every release | Minimal `pytest-qt` or screenshot smoke on CI Windows runner | **P1** | -| T-6 | **No `compileall` / lint in pipeline** | Syntax errors in rarely-imported modules | Add `python -m compileall proxy_chain_manager` to CI | **P1** | -| T-7 | **`TestFetcher.test_fetch_smoke` network-dependent** (`tests/test_proxy_god.py`) | CI flakiness offline | Mark `@unittest.skipUnless` or mock httpx | **P2** | -| T-8 | **No coverage threshold** | Unknown untested lines | `coverage run` + fail under 60% on core modules | **P2** | - -### Compliance / Legal - -| # | What's missing | Why it matters for prod | Suggested fix | Priority | -|---|----------------|-------------------------|---------------|----------| -| C-1 | **No SPDX license on repository** (`LICENSE_STATUS.md`) | Blocks open-source and commercial redistribution | Legal review → add `LICENSE` | **P0** | -| C-2 | **GOST upstream license not bundled in release artifact** | Binary distribution obligation | Include go-gost LICENSE in installer | **P1** | -| C-3 | **Signup presets target third-party ToS** (`signup_prep.py:37-164`) | Automation/autofill may violate site policies | Disclaimer in UI; operator assumes liability | **P1** | -| C-4 | **Provenance checklist incomplete** (`docs/PROVENANCE_CHECKLIST.md` all `[ ]`) | `docs/ROADMAP.md:14` blocks “first verified release” | Complete before v1.0 tag | **P1** | -| C-5 | **No privacy notice for local JSON stores** | GDPR-style transparency if EU operators | Short privacy section: what is stored where | **P1** | - -### Operator Experience - -| # | What's missing | Why it matters for prod | Suggested fix | Priority | -|---|----------------|-------------------------|---------------|----------| -| OP-1 | **No emergency kill-switch disengage UX** — must know netsh or restart app elevated (`firewall.py:149-156`) | #1 support incident when chain crashes | Tray item “Disengage firewall”; standalone script | **P0** | -| OP-2 | **“Run as Admin” relaunch closes app** (`app.py:347-350`) | Loses unsaved UI state; confusing flow | Relaunch with `--elevate` handoff file | **P1** | -| OP-3 | **Kill-switch skipped silently when non-admin** (`service.py:328-330`) | User believes they are “fail closed” but are not | Prominent banner when not admin + kill-switch ON | **P1** | -| OP-4 | **No settings export/import** | Machine migration painful | JSON export with optional encryption | **P2** | -| OP-5 | **No first-run wizard** (GOST download, VPN check, admin recommendation) | High abandonment on first failure | Step-through: VPN → Admin → Test pool | **P1** | -| OP-6 | **Close button minimizes to tray only** (`app.py:2888`) — good for daemon, bad if user expects quit | Document; tray “Exit” must disengage FW | Already in tray; add tooltip on X | **P2** | -| OP-7 | **README says “Accept UAC” but spec forces `uac_admin`** (`README.md:131`, `ProxyChainManager.spec:72`) | Confusing for non-admin workflows | Document two launch modes | **P1** | -| OP-8 | **No visible “version / build commit” in UI** | Support tickets lack context | Show `__version__` + git SHA in About | **P1** | -| OP-9 | **Pool refresh hammers external CDNs** (default 1800s, 3 sources) | Operator IP banned from jsdivr | Backoff; custom mirror field | **P1** | -| OP-10 | **Ban tester / leak audit results not persisted** | Cannot trend ban rate over sessions | Optional CSV export | **P2** | +1. **Wrong Chrome/Edge WebRTC policy key/value** — `fingerprint.py:21,177` writes DWORD `2` which is `default_public_and_private_interfaces`, NOT `disable_non_proxied_udp` (DWORD `3`). Modern Chrome also requires REG_SZ `WebRtcIPHandling = disable_non_proxied_udp`. Audits treat `2` as success → false green. +2. **`BrowserSession.stop()` kills ALL Firefox on the machine** — `browser_launcher.py:297-305` runs `taskkill /F /IM firefox.exe` with no PID scope. Operator loses unrelated browser sessions. +3. **Credentials plaintext at rest** — `config.py:19-22`, `signup_prep.py:252-325` store proxy auth + signup passwords in JSON. `cryptography==48.0.0` is in requirements but unused. +4. **Preflight race condition** — `app.py:2001-2009` uses fixed `root.after(200, _final)` which can report "All passed" before async network checks finish. +5. **Preflight IP compare is exact-match only** — ignores the VPN-aware `/16` logic used in production leak detection (`leak_detect.py:24-38`). +6. **HTTPS CONNECT failure doesn't stop chain** — `service.py:573-580` logs warning only; chain stays "healthy" and system proxy remains set. +7. **Pinned manual chain never validates hops at runtime** — dead chain retries every 10s forever (`service.py:393-403`). +8. **`emergency_disengage()` runs on ALL exits** — even if kill-switch never engaged; can mask other apps' firewall state (`service.py:105`, `firewall.py:165-177`). +9. **Signup extension runs on all URLs** — `signup_extension/manifest.json:13-19` uses ``. +10. **Account auto-saved before signup completes** — password saved on browser launch before user registers (`app.py:2147-2174`). +11. **GOST exe not re-hashed on reuse** — zip is verified but extracted `gost.exe` on disk is not re-checked (`gost_util.py:61-64`). +12. **`asyncio.run()` from worker threads** — `app.py:1860-1863` nested event loop risk if caller context changes. --- -## README Claims vs Implementation +## 1. Critical Bugs -| README claim | Status | Evidence | -|--------------|--------|----------| -| “Self-healing… rotate on failure, repeat forever” | **Partial** | Rotation exists (`service.py` main loop); no GOST watchdog (R-10) | -| “Fail closed” kill-switch | **Partial** | Requires Admin (`service.py:328-330`); orphan rules possible (R-1) | -| “Python not required” for exe | **OK** | PyInstaller one-file build (`ProxyChainManager.spec`) | -| “Works with or without VPN” | **OK** | `leak_detect.py` VPN-aware modes; tested | -| “Validation / tests” coverage | **Overstated** | 47 unit tests; no service/firewall/GUI (T-2, DOC-3) | -| Default kill-switch ON | **OK** | `config.py:186` | -| VM-safe loopback `127.0.0.1` | **OK** | `config.py:22-24`, `normalize_listen_host` | -| Build via `setup_and_build.ps1` | **OK** | Script exists; no signing/checksum (D-1, D-2) | -| Tray red/yellow/green | **OK** | `tray.py`, `app.py` status handlers | -| QUIC disabled where needed | **Verify manually** | Browser/sysproxy paths — not covered by unit tests | +| ID | Finding | File:Line | +|----|---------|-----------| +| C-01 | WebRTC policy wrong key/type/value | `fingerprint.py:18-21,162-177`, `webrtc_check.py:37-96`, `leak_audit.py:138-157` | +| C-02 | Browser stop kills all `firefox.exe` globally | `browser_launcher.py:297-305` | +| C-03 | Plaintext `settings.json` / signup JSON credentials | `config.py:19-22`, `signup_prep.py:252-325` | +| C-04 | Preflight `root.after(200)` race — final verdict before checks complete | `app.py:2001-2009` | +| C-05 | Preflight IP compare exact-match only (ignores /16 VPN logic) | `app.py:1886-1893`, `leak_detect.py:24-38` | +| C-06 | HTTPS CONNECT failure keeps chain "healthy" | `service.py:573-580` | +| C-07 | Pinned chain never validates hops at runtime | `service.py:393-403` | +| C-08 | `emergency_disengage()` fires on ALL exits | `service.py:105`, `firewall.py:165-177` | +| C-09 | Signup extension `` permissions | `signup_extension/manifest.json:13-19` | +| C-10 | Account saved before signup completes | `app.py:2147-2174` | +| C-11 | GOST exe not re-hashed on disk reuse | `gost_util.py:61-64` | +| C-12 | `asyncio.run()` from worker threads | `app.py:1860-1863` | --- -## Test Run Notes (2026-05-21) +## 2. Edge Cases / Failure Modes -``` -Ran 47 tests in ~25s — OK -``` - -**Modules with tests:** `config`, `leak_detect`, `validator`, `fetcher` (network smoke), `leak_audit`, `privacy_lan`, `browser_identity`, `chain_map`, `exit_intel`. - -**Modules without tests:** `app`, `service`, `gost_util`, `firewall`, `sysproxy`, `tray`, `ban_tester`, `dns_leak`, `webrtc_check`, `fingerprint`, `artifact_wipe`, `windows_task`, `browser_launcher`, `signup_prep` (integration). +| ID | Finding | File:Line | +|----|---------|-----------| +| E-01 | `real_ip` captured once at start; VPN reconnect mid-session not reflected | `service.py:339-340,551-558` | +| E-02 | Sticky exit suppresses leak rotation but not manual rotate/GOST death | `service.py:607-619` | +| E-03 | Sticky + health leak: status forced "healthy" while leaking | `service.py:610-618` | +| E-04 | `is_chain_leak(real_ip=None)` returns False (no leak) — fail-open | `leak_detect.py:32-33` | +| E-05 | Same /16 ISP neighbors never flagged — some leak modes missed by design | `leak_detect.py:36-37` | +| E-06 | Empty pinned chain + `use_pinned_chain=True` falls through to pool silently | `service.py:393`, `config.py:201` | +| E-07 | `chain_length=1` + manual exit only — no mid-hop redundancy | `service.py:407-415` | +| E-08 | Obfuscation `random_mix` shuffles list but takes prefix slice — not random per hop | `service.py:641-644,717` | +| E-09 | Pool exhausted → reshuffle; blacklisted proxies stay excluded until refresh | `service.py:409-417,768-771` | +| E-10 | Fixed exit validation failure still used in chains | `service.py:822-839` | +| E-11 | `socket.setdefaulttimeout()` global mutation — thread race in DNS helpers | `dns_leak.py:140-151`, `ban_tester.py:149-150` | +| E-12 | DNS leak "proxy path" uses Google DoH through HTTP proxy — not equivalent to system DNS | `dns_leak.py:154-167` | +| E-13 | DNS `all_match` logic can false-flag or miss due to CDN/geo DNS answers | `dns_leak.py:221-252` | +| E-14 | Kill-switch allows outbound DNS globally — DNS exfil channel exists | `firewall.py:136-140` | +| E-15 | Kill-switch skipped when non-admin with only log line | `service.py:350-357` | +| E-16 | IPv6 disable skips virtual adapters by name heuristics only | `fingerprint.py:122-137` | +| E-17 | Hostname rename may require reboot; partial apply possible | `fingerprint.py:73-103` | +| E-18 | MAC spoof restore depends on in-memory originals — crash mid-session may leave spoofed MAC | `service.py:96-97` | +| E-19 | `BrowserSession.is_running()` treats any `firefox.exe` within 5 min as "ours" | `browser_launcher.py:153-157` | +| E-20 | Auto-relaunch can loop if profile locked/corrupt | `app.py:2866-2871` | +| E-21 | `task_exists()` logon task runs LIMITED — no admin kill-switch at boot | `windows_task.py:47-48` | +| E-22 | Group Policy proxy locks detected but never remediated | `sysproxy.py:298-317`, `service.py:586-594` | +| E-23 | `load_settings` auto-rewrites sanitized file — can surprise operators | `config.py:436-443` | +| E-24 | `_is_safe_https_url` allows `http://` sources — MITM on pool fetch | `config.py:266-271`, `fetcher.py:13-16` | +| E-25 | Ban tester DNS leak helper checks `x-real-ip` header (usually absent) — nonsense results | `ban_tester.py:169-219` | +| E-26 | Ban tester `_looks_banned_body` matches "captcha" on normal pages → false bans | `ban_tester.py:71-89,133-134` | +| E-27 | `run_ban_tests(categories=["All"])` duplicates sites | `ban_tester.py:237-243` | +| E-28 | Exit intel/geo fetch via third-party APIs through proxy — telemetry surface | `exit_intel.py`, `chain_map.py:152-179` | +| E-29 | World map geo resolution uses direct `socket.getaddrinfo` — DNS leak for hop lookup | `chain_map.py:86-112,160` | +| E-30 | GOST immediate exit blacklists hops but pinned/manual exit exempt | `service.py:515-522,557-562` | --- -## Recommended Fix Priority (production) +## 3. Missing Features / README Gaps -| Priority | Focus | -|----------|--------| -| **P0** | CI + tests (I-1, T-1), code signing (D-1), secrets at rest (S-1, S-2), GOST integrity (R-2), kill-switch recovery (R-1, R-8, OP-1, DOC-6), LICENSE (C-1, DOC-1) | -| **P1** | Lockfiles, migrations, runbooks, README honesty, integration tests, rate limits, log rotation, third-party notices | -| **P2** | Metrics, installer, backup/restore, coverage gates, TLS pinning | +| ID | Finding | Reference | +|----|---------|-----------| +| M-01 | README "self-healing forever" — pinned mode doesn't rotate pool | `README.md:9`, `service.py:393-403` | +| M-02 | README "fail closed" — non-admin + kill-switch skipped | `README.md:25`, `service.py:350-357` | +| M-03 | README 1–8 hops; UI/config says 2–8 in places | `README.md:48`, `config.py:167` | +| M-04 | No in-GUI emergency kill-switch disengage button | `firewall.py`, `app.py` | +| M-05 | `audit_device()` is OS-only but UI shows it under "Browser Fingerprint" | `fingerprint.py:299-383`, `app.py:2310-2313` | +| M-06 | Preflight card mentions "fingerprint" but no fingerprint check in `_PF_CHECKS` | `app.py:1775-1793` | +| M-07 | `browser_auto_relaunch` not tied to chain health flag | `app.py:2866-2871`, `config.py:205` | +| M-08 | No export/import encrypted settings/signup vault | — | +| M-09 | No persisted ban-test / audit history | `app.py` | +| M-10 | `prefer_elite` can empty pool with no GUI recovery wizard | `config.py:183`, `service.py:754-761` | +| M-11 | SOCKS5 remote DNS disabled in Firefox profile | `browser_profile.py:125` | +| M-12 | Signup presets have no ToS/disclaimer in UI | `signup_prep.py:37-160` | +| M-13 | No IPv6 chain path — IPv6 disable adapter-level only | `fingerprint.py:106-138` | +| M-14 | Tray red/yellow/green with no click-through to failed check detail | `tray.py`, `app.py:2841-2844` | +| M-15 | `CHANGELOG.md` claims DPAPI/Fernet — not implemented | `CHANGELOG.md:35-36` | --- -*Bug-hunt findings (critical bugs, edge cases, UI issues) may be appended by a separate audit pass under sections 2–8 above.* +## 4. Build / Packaging / Deployment + +| ID | Finding | Reference | +|----|---------|-----------| +| B-01 | No Authenticode signing | `ProxyChainManager.spec:70-71` | +| B-02 | UPX enabled — AV false positives risk | `ProxyChainManager.spec:66-67` | +| B-03 | `uac_admin=True` — every launch elevates | `ProxyChainManager.spec:72` | +| B-04 | PyInstaller not pinned in `requirements.txt` | `scripts/setup_and_build.ps1:39` | +| B-05 | Build script doesn't emit SHA256 sidecar | `scripts/setup_and_build.ps1:50-58` | +| B-06 | GOST not bundled — first run requires GitHub access | `gost_util.py:61-86` | +| B-07 | `__version__` static `1.0.0`, not tied to git tag | `__init__.py:3` | +| B-08 | CI does not run PyInstaller build | `.github/workflows/test.yml` | +| B-09 | No SBOM / `pip freeze` in release artifacts | — | +| B-10 | Defender exclusion adds whole `%LOCALAPPDATA%\ProxyChainManager` folder | `gost_util.py:29-45` | + +--- + +## 5. Security / Privacy + +| ID | Finding | Reference | +|----|---------|-----------| +| S-01 | Plaintext secrets at rest (proxy creds + signup passwords) | `config.py`, `signup_prep.py` | +| S-02 | WebRTC mis-hardening (wrong policy value) | `fingerprint.py` | +| S-03 | All httpx clients use `verify=False` — MITM risk | `validator.py:142`, `ban_tester.py:119`, `dns_leak.py:159` | +| S-04 | Signup passwords can appear in UI/log via clipboard actions | `app.py:2059-2062` | +| S-05 | Signup extension content script on all URLs | `signup_extension/manifest.json` | +| S-06 | Public proxy list sources — hostile infrastructure by design | `config.py:218-223` | +| S-07 | `cryptography` dependency unused — attack surface without benefit | `requirements.txt:5` | +| S-08 | Kill-switch + DNS allow rule = DNS bypass channel | `firewall.py:136-140` | +| S-09 | Forensic wipe deletes Recycle Bin without extra confirmation | `artifact_wipe.py:208-216` | +| S-10 | No secure deletion of signup JSON on uninstall | — | + +--- + +## 6. UI/UX Issues + +| ID | Finding | Reference | +|----|---------|-----------| +| U-01 | Window close minimizes to tray — chain/firewall active silently | `app.py:2908` | +| U-02 | "Run as Admin" relaunch closes app — unsaved UI state lost | `app.py:347-350` | +| U-03 | No version/build SHA in About UI | `__init__.py:3` | +| U-04 | Cookie/persona menus reverse-lookup by label — fragile if labels change | `app.py:1321-1325` | +| U-05 | Hardened toggles visible even when blend persona overrides them | `browser_profile.py:42-65` | +| U-06 | Preflight "fingerprint" in subtitle but not in checklist | `app.py:1775-1793` | +| U-07 | Ban tester has no progress bar for 30+ sites | `ban_tester.py:222-260` | +| U-08 | Saved accounts list shows email + password in plain text | `app.py:2046-2049` | +| U-09 | Chain map silently skips hops when geo lookup fails — no unknown marker | `chain_map.py:156-166,322-328` | +| U-10 | `verbose_logs_var` affects UI handler only; file log stays INFO | `app.py:707-713` | + +--- + +## 7. Test Coverage Gaps + +| ID | Finding | +|----|---------| +| T-01 | No tests for `service.py` (rotation, sticky, kill-switch) | +| T-02 | No tests for `firewall.py` / `emergency_disengage` | +| T-03 | No tests for `sysproxy.py` registry blob encoding | +| T-04 | No tests for GOST SHA256 mismatch path | +| T-05 | No tests for `dns_leak.run_dns_leak_test` verdict logic | +| T-06 | No tests for `webrtc_check` / `apply_webrtc_hardening` | +| T-07 | No tests for `BrowserSession` | +| T-08 | No tests for `signup_prep.install_signup_extension` | +| T-09 | No tests for `config.load_settings`/`migrate`/`sanitize` round-trip | +| T-10 | No tests for `ban_tester` heuristics | +| T-11 | No GUI/smoke tests for `app.py` (~2900 lines) | +| T-12 | `test_fetch_smoke` is network-dependent — can skip silently in CI | +| T-13 | No test that WebRTC policy value matches Chrome documentation | +| T-14 | No frozen-bundle (`_MEIPASS`) path tests in CI | + +--- + +## 8. Recommended Fix Priority + +**P0 — Fix immediately:** +C-01, C-02, C-03, C-04, C-05, C-06, S-01, S-02, S-03, B-01 + +**P1 — Next sprint:** +C-07, C-10, E-01–E-06, E-11, E-22, E-25–E-27, M-04–M-07, M-11, B-04–B-06, T-01–T-05, T-09, U-01–U-03 + +**P2 — Backlog:** +All remaining E/M/B/U/T items, installer signing, metrics, coverage gates + +--- + +*Proxy God Audit — 2026-05-22* diff --git a/proxy_chain_manager/app.py b/proxy_chain_manager/app.py index a60968e..fc6408e 100644 --- a/proxy_chain_manager/app.py +++ b/proxy_chain_manager/app.py @@ -1999,6 +1999,10 @@ def _main_inner() -> None: root.after(0, lambda: _pf_set("target", "fail", "chain not running")) # ── Final summary ───────────────────────────────────────────────── + # Schedule _final via root.after(0, ...) so it is enqueued AFTER + # all previously-scheduled root.after(0, ...) callbacks (which + # populate `warns`). A fixed 200 ms delay could fire before those + # callbacks execute on a slow main thread. def _final() -> None: pf_running[0] = False if warns: @@ -2006,7 +2010,7 @@ def _main_inner() -> None: else: pf_summary_var.set("✓ All pre-flight checks passed — good to go.") - root.after(200, _final) + root.after(0, _final) threading.Thread(target=work, daemon=True).start() diff --git a/proxy_chain_manager/ban_tester.py b/proxy_chain_manager/ban_tester.py index 206aa33..8d4f27a 100644 --- a/proxy_chain_manager/ban_tester.py +++ b/proxy_chain_manager/ban_tester.py @@ -145,6 +145,7 @@ def _test_one( def _dns_resolve_via_system(hostname: str, timeout: float = 5.0) -> str | None: """Resolve a hostname using system DNS (does not go through proxy — exposes leak).""" + _prev = socket.getdefaulttimeout() try: socket.setdefaulttimeout(timeout) info = socket.getaddrinfo(hostname, None) @@ -154,6 +155,8 @@ def _dns_resolve_via_system(hostname: str, timeout: float = 5.0) -> str | None: return addr except Exception: pass + finally: + socket.setdefaulttimeout(_prev) return None @@ -235,10 +238,13 @@ def run_ban_tests( if sites is not None: work = [(s, u, "") for s, u in sites] elif categories is not None: + seen_urls: set[str] = set() work = [] for cat in categories: for s, u in SITE_CATEGORIES.get(cat, ()): - work.append((s, u, cat)) + if u not in seen_urls: + seen_urls.add(u) + work.append((s, u, cat)) else: work = [(s, u, cat) for cat, pairs in SITE_CATEGORIES.items() if cat != "All" for s, u in pairs] diff --git a/proxy_chain_manager/browser_launcher.py b/proxy_chain_manager/browser_launcher.py index ef65322..363da2a 100644 --- a/proxy_chain_manager/browser_launcher.py +++ b/proxy_chain_manager/browser_launcher.py @@ -279,6 +279,9 @@ class BrowserSession: return True, f"Firefox started ({mode}, pid {pid})." def stop(self, dispose: bool = False) -> tuple[bool, str]: + # Capture our PID before clearing the handle + launched_pid: int | None = self._proc.pid if self._proc else None + # Terminate the Popen handle if still alive if self._proc and self._proc.poll() is None: try: @@ -292,17 +295,19 @@ class BrowserSession: finally: self._proc = None - # Also kill any remaining firefox.exe processes (handles the - # detached-child case where the parent already exited naturally) - try: - subprocess.run( - ["taskkill", "/F", "/IM", "firefox.exe"], - capture_output=True, - timeout=10, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000), - ) - except Exception: - pass + # Kill only the process tree we spawned (handles the detached-child + # case where the launcher process already exited naturally). + # /T kills the entire child tree; /PID scopes to our PID only. + if launched_pid is not None: + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(launched_pid)], + capture_output=True, + timeout=10, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000), + ) + except Exception: + pass self._proc = None if dispose and self._profile_path and self._profile_path.exists(): diff --git a/proxy_chain_manager/dns_leak.py b/proxy_chain_manager/dns_leak.py index 13c0b5a..801ca29 100644 --- a/proxy_chain_manager/dns_leak.py +++ b/proxy_chain_manager/dns_leak.py @@ -136,6 +136,7 @@ def flush_dns_cache() -> tuple[bool, str]: def _resolve_direct(hostname: str, timeout: float = 4.0) -> list[str]: """Resolve hostname using system DNS (direct, not through proxy).""" + _prev = socket.getdefaulttimeout() try: socket.setdefaulttimeout(timeout) infos = socket.getaddrinfo(hostname, None) @@ -148,7 +149,7 @@ def _resolve_direct(hostname: str, timeout: float = 4.0) -> list[str]: except Exception: return [] finally: - socket.setdefaulttimeout(None) + socket.setdefaulttimeout(_prev) def _resolve_via_proxy(hostname: str, proxy_url: str, timeout: float = 8.0) -> list[str]: diff --git a/proxy_chain_manager/fingerprint.py b/proxy_chain_manager/fingerprint.py index 85f9bc7..6451332 100644 --- a/proxy_chain_manager/fingerprint.py +++ b/proxy_chain_manager/fingerprint.py @@ -17,8 +17,12 @@ log = logging.getLogger(__name__) _WEBRTC_CHROME = r"SOFTWARE\Policies\Google\Chrome" _WEBRTC_EDGE = r"SOFTWARE\Policies\Microsoft\Edge" +# Legacy DWORD key — value 3 = disable_non_proxied_udp (value 2 was wrong: public+private only) _WEBRTC_VALUE = "DefaultWebRtcIpHandlingPolicy" -_WEBRTC_DISABLE = 2 # disable_non_proxied_udp +_WEBRTC_DISABLE = 3 # disable_non_proxied_udp (correct Chrome/Edge DWORD) +# Modern REG_SZ key required by Chrome 114+ Group Policy +_WEBRTC_VALUE_STR = "WebRtcIPHandling" +_WEBRTC_DISABLE_STR = "disable_non_proxied_udp" @dataclass @@ -160,7 +164,12 @@ def enable_ipv6_on_adapters(adapters: list[str]) -> list[str]: def apply_webrtc_hardening(enable: bool) -> tuple[bool, str]: - """Chrome/Edge: disable WebRTC non-proxied UDP (Admin, HKLM policies).""" + """Chrome/Edge: disable WebRTC non-proxied UDP (Admin, HKLM policies). + + Writes both the legacy DWORD key (DefaultWebRtcIpHandlingPolicy=3) and + the modern REG_SZ key (WebRtcIPHandling=disable_non_proxied_udp) so that + all Chrome/Edge versions are covered. + """ if not is_admin(): return False, "Administrator required for browser WebRTC policy." paths = [_WEBRTC_CHROME, _WEBRTC_EDGE] @@ -175,15 +184,17 @@ def apply_webrtc_hardening(enable: bool) -> tuple[bool, str]: continue with key: winreg.SetValueEx(key, _WEBRTC_VALUE, 0, winreg.REG_DWORD, _WEBRTC_DISABLE) + winreg.SetValueEx(key, _WEBRTC_VALUE_STR, 0, winreg.REG_SZ, _WEBRTC_DISABLE_STR) else: try: with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE ) as key: - try: - winreg.DeleteValue(key, _WEBRTC_VALUE) - except OSError: - pass + for val_name in (_WEBRTC_VALUE, _WEBRTC_VALUE_STR): + try: + winreg.DeleteValue(key, val_name) + except OSError: + pass except OSError: pass return True, ( @@ -340,16 +351,28 @@ def audit_device() -> FingerprintAudit: ] # Quick consistency checks — WebRTC IP-handling policy (Chrome / Edge) + # Accept either: DWORD DefaultWebRtcIpHandlingPolicy==3 + # or REG_SZ WebRtcIPHandling=="disable_non_proxied_udp" webrtc_ok = False for hive_root in (_WEBRTC_CHROME, _WEBRTC_EDGE): try: with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, hive_root, 0, winreg.KEY_QUERY_VALUE ) as k: - v = int(winreg.QueryValueEx(k, _WEBRTC_VALUE)[0]) - if v == _WEBRTC_DISABLE: - webrtc_ok = True - break + try: + v = int(winreg.QueryValueEx(k, _WEBRTC_VALUE)[0]) + if v == _WEBRTC_DISABLE: + webrtc_ok = True + break + except OSError: + pass + try: + v_str = str(winreg.QueryValueEx(k, _WEBRTC_VALUE_STR)[0]) + if v_str == _WEBRTC_DISABLE_STR: + webrtc_ok = True + break + except OSError: + pass except OSError: continue diff --git a/proxy_chain_manager/webrtc_check.py b/proxy_chain_manager/webrtc_check.py index 30dc6e2..fe799d9 100644 --- a/proxy_chain_manager/webrtc_check.py +++ b/proxy_chain_manager/webrtc_check.py @@ -40,8 +40,10 @@ _CHROMIUM_POLICY_PATHS = ( r"SOFTWARE\Policies\Microsoft\Edge", r"SOFTWARE\Policies\Chromium", ) -_WEBRTC_POLICY_VALUE = "DefaultWebRtcIpHandlingPolicy" -_WEBRTC_BLOCK_VALUE = 2 # "default_public_and_private_interfaces" +_WEBRTC_POLICY_VALUE = "DefaultWebRtcIpHandlingPolicy" +_WEBRTC_BLOCK_VALUE = 3 # disable_non_proxied_udp (was 2 = public+private only — wrong) +_WEBRTC_POLICY_STR_KEY = "WebRtcIPHandling" +_WEBRTC_BLOCK_STR_VALUE = "disable_non_proxied_udp" @dataclass @@ -71,21 +73,39 @@ class WebRtcCheckResult: # ───────────────────────────────────────────────────────────────────────────── def check_chromium_webrtc_policy() -> tuple[bool, str]: - """Return (policy_set, detail_string).""" + """Return (policy_set, detail_string). + + Accepts either the legacy DWORD DefaultWebRtcIpHandlingPolicy==3 + OR the modern REG_SZ WebRtcIPHandling=="disable_non_proxied_udp". + """ found: list[str] = [] missing: list[str] = [] for path in _CHROMIUM_POLICY_PATHS: try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_QUERY_VALUE) as k: + name = path.split("\\")[-1] + dword_ok = False + str_ok = False + # Check legacy DWORD try: val = int(winreg.QueryValueEx(k, _WEBRTC_POLICY_VALUE)[0]) - name = path.split("\\")[-1] - if val == _WEBRTC_BLOCK_VALUE: - found.append(name) - else: - missing.append(f"{name}={val} (need {_WEBRTC_BLOCK_VALUE})") + dword_ok = val == _WEBRTC_BLOCK_VALUE + if not dword_ok: + missing.append(f"{name} DWORD={val} (need {_WEBRTC_BLOCK_VALUE})") except OSError: - missing.append(path.split("\\")[-1] + " key missing") + pass + # Check modern REG_SZ + try: + val_str = str(winreg.QueryValueEx(k, _WEBRTC_POLICY_STR_KEY)[0]) + str_ok = val_str == _WEBRTC_BLOCK_STR_VALUE + if not str_ok: + missing.append(f"{name} REG_SZ={val_str!r} (need {_WEBRTC_BLOCK_STR_VALUE!r})") + except OSError: + pass + if dword_ok or str_ok: + found.append(name) + elif not dword_ok and not str_ok: + missing.append(f"{name}: no WebRTC policy keys present") except OSError: continue # key not present at all — browser not installed or not policy-managed