harden macOS networking port

This commit is contained in:
drjones
2026-05-23 22:09:43 -07:00
parent 1f5e63ca1f
commit 35899ba1d1
24 changed files with 581 additions and 742 deletions

View File

@@ -35,7 +35,19 @@ The first run creates `.venv`, installs dependencies, downloads the correct macO
- macOS app-data paths under `~/Library/Application Support/ProxyChainManager`. - macOS app-data paths under `~/Library/Application Support/ProxyChainManager`.
- macOS GOST download/extract for `darwin_arm64` or `darwin_amd64`. - macOS GOST download/extract for `darwin_arm64` or `darwin_amd64`.
- macOS DNS cache flush and system proxy apply/clear through `networksetup`. - macOS DNS cache flush and system proxy apply/clear through `networksetup`.
- macOS launch-at-login through a per-user LaunchAgent.
- macOS hostname spoof and IPv6-off controls with standard administrator prompts.
## Platform Notes ## Platform Notes
The upstream project is Windows-first. Windows-only controls such as netsh firewall kill-switch, registry WebRTC policy, Task Scheduler boot task, telemetry kill, and LAN lockdown are kept in the UI but degrade safely on macOS. The core proxy chain engine, validation, map/intel, logs, browser profile generation, ban tests, signup prep, and settings are runnable on macOS. The upstream project is Windows-first. Features that do not have a clean, safe macOS equivalent were removed from the Mac UI instead of being left as fake toggles: netsh firewall kill-switch, registry Chrome/Edge WebRTC policy, MAC spoofing, Windows telemetry kill, and Windows LAN broadcast lockdown.
macOS networking is handled through:
- GOST bound to loopback at `127.0.0.1`.
- System HTTP/HTTPS proxy apply/clear via `networksetup`.
- Administrator prompt fallback when macOS requires permission for network changes.
- DNS cache flush with `dscacheutil` and `mDNSResponder`.
- Hardened Firefox profile for WebRTC and fingerprint controls.
The global Windows fail-closed firewall was not ported because a safe one-click macOS PF equivalent would require managing privileged packet-filter state and can leave the machine offline if interrupted. The Mac build favors deterministic system-proxy cleanup and verified chain health over unsafe PF mutation.

View File

@@ -1,318 +0,0 @@
# Proxy God — Audit Findings
**Generated:** 2026-05-22
**Tests:** 47/47 pass · compileall clean
**Severity summary:** P0: 12 · P1: 28 · P2: 22 · **Total: 62**
---
## Executive Summary
| Severity | Count |
|----------|------:|
| Critical (P0) | 12 |
| High (P1) | 28 |
| Medium (P2) | 22 |
| **Total** | **62** |
**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.
---
## Top P0 Issues
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 `<all_urls>`.
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.
---
## 1. Critical Bugs
| 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 `<all_urls>` 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` |
---
## 2. Edge Cases / Failure Modes
| 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` |
---
## 3. Missing Features / README Gaps
| 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 18 hops; UI/config says 28 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` |
---
## 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-01E-06, E-11, E-22, E-25E-27, M-04M-07, M-11, B-04B-06, T-01T-05, T-09, U-01U-03
**P2 — Backlog:**
All remaining E/M/B/U/T items, installer signing, metrics, coverage gates
---
## 9. Remediation log (2026-05-22 passes)
| ID | Status | Notes |
|----|--------|-------|
| C-01 | **Fixed** | `fingerprint.py`, `webrtc_check.py` — DWORD `3` + `WebRtcIPHandling` REG_SZ |
| C-02 | **Fixed** | `browser_launcher.py` — scoped `taskkill /PID` |
| C-04 | **Fixed** | `app.py``_final` scheduled after all `after(0)` callbacks |
| C-05 | **Fixed** | `app.py` preflight uses `is_chain_leak()` + VPN-aware `/16` |
| C-06 | **Fixed** | `service.py` — HTTPS CONNECT failure rotates chain (not healthy) |
| C-08 | **Fixed** | `firewall.py``emergency_disengage()` no-op unless `is_engaged()` |
| C-10 | **Fixed** | `app.py` — auto-save on launch is `pending` with empty password |
| E-06 | **Fixed** | `service.py` — log when pinned chain enabled but empty |
| E-11 | **Fixed** | `dns_leak.py`, `ban_tester.py` — restore socket timeout in `finally` |
| E-24 | **Fixed** | `config.py` — pool sources must be `https://` only |
| E-26 | **Fixed** | `ban_tester.py` — removed bare `"captcha"` hint; stricter phrases |
| E-27 | **Fixed** | `ban_tester.py` — dedupe by URL |
| M-06 / U-06 | **Fixed** | Preflight subtitle no longer claims “fingerprint” check |
| M-15 | **Fixed** | `CHANGELOG.md` — Fernet/DPAPI not implemented (clarified) |
| B-02 | **Fixed** | `ProxyChainManager.spec``upx=False` |
| leak_audit | **Fixed** | `_check_webrtc_policy()` accepts DWORD `3` or REG_SZ |
| (prior) | **Fixed** | CI, GOST zip SHA256, settings migration/backup, LICENSE, log rotation |
---
## 10. Still needs to be fixed
Items below were **not** changed because they need design decisions, external tooling (certs, infra), or behavior that is not safe to guess.
### Critical / security (P0)
| ID | Why left open | Suggested direction |
|----|---------------|---------------------|
| **C-09** | Narrowing signup extension off `<all_urls>` breaks **custom signup URLs** the operator types in | Dynamic host permissions API or per-session host approval prompt |
| **C-12** | `asyncio.run()` in worker threads (`app.py` preflight) needs a dedicated event-loop policy refactor | Move all preflight network ops to a single asyncio runner thread |
| **S-03** | `verify=False` on httpx is **intentional** for the broken-cert reality of public proxies | Per-setting toggle with security warning, documented trade-off |
| **B-01** | Authenticode signing needs an actual code-signing certificate + CI secrets | Sign `dist\ProxyChainManager.exe` in release pipeline once cert is provisioned |
### High (P1) — design / scope debt
| ID | Why left open |
|----|---------------|
| **E-01** | Refresh `real_ip` when VPN state changes mid-session — needs interval policy |
| **E-02E-05** | Sticky exit / leak semantics need a product rule (rotate vs warn) |
| **E-07E-10** | Pool/pinned/exit edge cases need operator UX |
| **E-12E-13** | DNS leak methodology (DoH vs system DNS) needs spec |
| **E-14 / S-08** | Kill-switch DNS allow rule is required for GOST hostname resolution |
| **E-16E-18** | OS adapter / MAC spoof crash recovery |
| **E-21** | Boot task LIMITED vs admin kill-switch |
| **E-22** | Group Policy proxy-lock remediation (destructive — needs explicit operator consent) |
| **E-23** | Auto-rewrite of sanitized settings — needs UI toggle |
| **E-28E-30** | Third-party intel APIs / map geo / GOST blacklist rules |
| **M-01M-03, M-05, M-07M-14** | README alignment, encrypted vault export, ban history, IPv6 chain path, tray click-through |
| **U-01, U-02, U-04, U-05, U-07U-10** | Tray/minimize UX, admin-relaunch save state, cookie menu keys, password masking |
| **T-01T-14** | Larger test-coverage program |
| **B-03, B-06B-10** | UAC manifest tradeoff, GOST bundling, SBOM, Defender scope |
### Medium (P2)
Installer polish, metrics, coverage gates, IPv6 chain path, SOCKS5 remote DNS policy (`browser_profile.py:125` — likely intentional for Firefox+GOST).
---
## 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)
| ID | Status | Notes |
|----|--------|-------|
| **C-03 / S-01** | **Fixed** | `secrets_store.py` (Windows DPAPI via ctypes — no new deps). Signup passwords + draft passwords now encrypted on disk; legacy plaintext migrates automatically on first save |
| **C-07** | **Fixed** | New `validate_pinned_on_start` setting + `_probe_tcp` helper in `service.py`; dead pinned hops logged but used |
| **C-11** | **Fixed** | `gost_util.py` writes `gost.exe.sha256` sidecar on extract and re-verifies on every reuse; mismatch triggers re-download |
| **E-15** | **Fixed** | `app.py` `_start()` confirms with operator before starting chain without admin when kill-switch is enabled |
| **E-19** | **Fixed** | `browser_launcher.py` `is_running()` walks the spawned PID's descendants via WMIC instead of pattern-matching any `firefox.exe` |
| **E-20** | **Fixed** | `max_browser_relaunches` setting + counter in `app.py`; auto-relaunch self-disables after N failures |
| **E-25** | **Fixed** | Dead `ban_tester.check_dns_leak()` removed (use `dns_leak.run_dns_leak_test`) |
| **M-04** | **Fixed** | Settings tab now has “⚠ Emergency disengage firewall now” button with confirm |
| **U-03** | **Fixed** | Window title now reads `Proxy God v{__version__}` |
| **B-04** | **Fixed** | New `dev-requirements.txt` pins `pyinstaller==6.10.0`; build script installs it |
| **B-05** | **Fixed** | Build script emits `ProxyChainManager.exe.sha256` next to the exe and copies it to Desktop |
**Test suite:** 47 → **53 passing** (added 6 round-trip tests for `secrets_store`).
---
*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

@@ -0,0 +1,44 @@
# Proxy God Mac Operator Notes
## Launch
Use `make run`, `run_mac.command`, or `open "Proxy God.app"`.
The launcher chooses a Python runtime with Tk support. If Homebrew Python lacks
`_tkinter`, it will use a Python.org or system Tk-capable runtime instead.
## Networking Permissions
Proxy God applies and clears macOS HTTP/HTTPS proxy settings with
`networksetup`. If macOS requires elevated permission, the app uses the standard
administrator prompt through `osascript`.
## Removed Windows-Only Controls
The Mac build intentionally does not expose:
- Windows `netsh` firewall kill-switch
- Windows registry Chrome/Edge WebRTC policy
- Windows telemetry kill
- Windows LAN broadcast lockdown
- Windows MAC spoofing
The equivalent Mac posture is loopback-only GOST, system proxy enforcement,
DNS flush, hardened Firefox profile controls, chain exit verification, and
health-triggered rotation.
## Cleanup
On stop or quit, the app terminates GOST and clears macOS system proxy settings.
If a manual cleanup is ever needed:
```sh
networksetup -setwebproxystate "Wi-Fi" off
networksetup -setsecurewebproxystate "Wi-Fi" off
```
Repeat for any active network service shown by:
```sh
networksetup -listallnetworkservices
```

View File

@@ -1,23 +0,0 @@
# Maintenance
<!-- stewardship-standard: maintenance-v1 -->
## Stewardship Rules
- Keep generated files, build outputs, copied SDKs, and raw firmware binaries out of Git unless they are the source of truth.
- Keep credentials, tokens, dumps, private messages, session stores, and local machine paths out of commits.
- Prefer small commits with clear intent and a matching issue or release note.
- Preserve upstream attribution when code is copied, forked, or adapted.
## Routine Checks
- README still describes what the project does.
- Setup instructions still work.
- Security policy is accurate for the current risk level.
- Changelog records user-visible changes.
- License status is explicit.
## Automation Gate
- Confirm no tokens, session cookies, personal data, or exported credentials are committed.
- Document required environment variables with safe example values only.
- Add rate-limit and account-safety notes before any release.

View File

@@ -1,128 +0,0 @@
# Proxy God — Operator Runbook
**Version:** 1.0
**Last updated:** 2026-05-21
---
## Emergency: Firewall Kill-Switch Stuck (Network Appears Offline)
If the application crashes or is force-killed while the kill-switch is engaged,
outbound traffic will remain blocked. Follow these steps to restore connectivity.
### Option 1 — Run the standalone disengage script (fastest)
Open an **elevated** (Run as Administrator) PowerShell terminal and run:
```powershell
# Remove all PCM_ firewall rules
netsh advfirewall firewall delete rule name=all
# Restore default outbound policy
netsh advfirewall set allprofiles firewallpolicy blockinbound,allowoutbound
```
### Option 2 — Restart the application elevated
1. Right-click `ProxyChainManager.exe`**Run as administrator**.
2. The application detects orphan firewall rules on startup and removes them.
3. Click **Stop** if the service does not start, to force disengage.
### Option 3 — Restart Windows Firewall service
```powershell
Restart-Service -Name MpsSvc -Force
```
This resets all runtime firewall state (not persistent rules). You may still
need to remove the `PCM_*` rules afterward with Option 1.
### How the application handles this automatically
`firewall.py` exposes `emergency_disengage()`, which is registered via
`atexit` and `signal.SIGTERM`/`SIGINT` in `service.py`. A clean exit or
SIGTERM will call `emergency_disengage()` automatically, removing all
`PCM_*` rules and restoring `allowoutbound` before the process terminates.
Only a hard kill (`SIGKILL`, power loss, BSOD) can bypass this handler —
in those cases use Option 1 or Option 2 above.
### Verify rules are gone
```powershell
netsh advfirewall firewall show rule name=all dir=out | Select-String "PCM_"
# Should return no output when clean
```
---
## Emergency: Proxy Leak Detected
If the Live tab reports a leak (exit IP matches your real IP or VPN IP):
1. Click **Rotate Now** to pick fresh proxies.
2. If leaks persist, click **Stop**, then re-enable the kill-switch and click **Start**.
3. Check the pool size — if < 5 proxies, force a full pool refresh by stopping
and restarting the service.
4. Enable VPN before starting if leak detection mode is "strict (exact IP)" —
a VPN provides a larger subnet mask for the leak check.
---
## Empty Pool / CDN Down
If every pool refresh returns 0 proxies:
1. Check internet connectivity (browser → any site through system proxy OFF).
2. Verify the proxy source URLs in **Settings** are reachable.
3. The default sources use `cdn.jsdelivr.net` — if blocked on your network,
replace with a mirror or local JSON file (use `file:///path/to/proxies.json`
format).
4. Temporarily lower `Max candidates` to speed up validation if the CDN is
slow.
---
## GOST Binary Missing or Quarantined
Symptoms: "GOST setup failed" or "GOST appears quarantined" in the Live log.
1. Check Windows Defender: **Windows Security → Protection history** — look for
a quarantine event on `gost.exe`.
2. Restore and exclude `gost.exe` from Defender, or re-download by deleting the
GOST folder:
```powershell
Remove-Item "$env:LOCALAPPDATA\ProxyChainManager\gost" -Recurse -Force
```
3. Restart the application — it will re-download and re-verify GOST via SHA256.
---
## Settings Corrupted / Reset to Defaults
A corrupt `settings.json` is automatically backed up to `settings.json.corrupt`
and the application reverts to defaults.
To restore:
```powershell
$dir = "$env:LOCALAPPDATA\ProxyChainManager"
Copy-Item "$dir\settings.json.corrupt" "$dir\settings.json" -Force
```
A rolling backup is also kept at `settings.json.bak` (last successful save).
---
## Log Files
| File | Location | Purpose |
|------|----------|---------|
| `proxy_chain_manager.log` | `%LOCALAPPDATA%\ProxyChainManager\` | Main app log (5 MB × 3 backups) |
| `gost.log` | `%LOCALAPPDATA%\ProxyChainManager\` | GOST process output (512 KB rolling) |
To open the log directory:
```powershell
explorer "$env:LOCALAPPDATA\ProxyChainManager"
```

View File

@@ -1,14 +0,0 @@
# Project Handoff
<!-- stewardship-standard: project-handoff-v1 -->
## What This Repo Needs From A Maintainer
- A one-paragraph project summary in README.md.
- Confirmed setup instructions.
- Confirmed license status.
- Confirmed provenance for imported code and binaries.
- A known-good verification command, test, build, flash, or demo path.
## Current Stewardship State
This repo has baseline governance files, wiki pages, issue templates, labels, milestones, and a readiness issue. The next maintainer should replace generic stewardship notes with project-specific facts.

View File

@@ -1,12 +0,0 @@
# Provenance Checklist
<!-- stewardship-standard: provenance-checklist-v1 -->
Use this before claiming ownership or publishing artifacts.
- [ ] Identify original upstream source, if any.
- [ ] Record fork URL, commit, tag, or archive source.
- [ ] Preserve third-party notices and license files.
- [ ] Separate local patches from imported code where practical.
- [ ] Record binary build inputs, toolchain versions, and source commit.
- [ ] Publish checksums for release assets.
- [ ] Mark unknown-origin content as blocked until resolved.

View File

@@ -1,92 +0,0 @@
# Release Process
Production Windows builds use **PyInstaller** with a gated pipeline in `scripts/release_build.ps1`.
## Quick commands
| Goal | Command |
|------|---------|
| **Production release** | `build_release.bat` or `powershell -File .\scripts\release_build.ps1` |
| **Dev build (Desktop copy)** | `build_exe.bat` or `powershell -File .\scripts\setup_and_build.ps1` |
| **Skip tests (dev only)** | `powershell -File .\scripts\setup_and_build.ps1 -SkipTests` |
## Production pipeline (`release_build.ps1`)
1. Resolve Python 3.10+
2. Install `requirements.txt` + `dev-requirements.txt` (PyInstaller **6.10.0** pinned)
3. **Test gate**: `compileall` + `unittest discover` (skip with `-SkipTests` — not for prod)
4. Download & SHA-verify bundled `gost.exe``proxy_chain_manager/_bundled/`
5. Generate Windows **VERSIONINFO**`build/version_info.txt`
6. **PyInstaller** one-file build via `ProxyChainManager.spec`
7. Optional **Authenticode** sign (see below)
8. SHA256 sidecar for the exe
9. **SBOM** (`SBOM.json` + `requirements-frozen.txt`)
10. **RELEASE_MANIFEST.json** (version, commit, sha256, build time)
11. Zip → `releases/ProxyGod-v{version}-windows-amd64.zip`
### Output layout
```
releases/
v1.0.0/
ProxyChainManager.exe
ProxyChainManager.exe.sha256
RELEASE_MANIFEST.json
SBOM.json
requirements-frozen.txt
LICENSE
OPERATOR_RUNBOOK.md
ProxyGod-v1.0.0-windows-amd64.zip
dist/
ProxyChainManager.exe # same binary (developer convenience)
ProxyChainManager.exe.sha256
```
## Version numbering
Resolved in order:
1. `-Version` parameter to `release_build.ps1`
2. Exact git tag on current commit (`git describe --tags --exact-match`)
3. `git describe --tags --always --dirty`
4. `proxy_chain_manager.__version__`
Tag releases with `v1.2.3` — CI **release.yml** runs automatically on `v*` tags.
## Authenticode signing (optional)
Set before building:
```powershell
$env:SIGN_CERT_PATH = "C:\certs\proxygod.pfx"
$env:SIGN_CERT_PASSWORD = "your-password" # optional if pfx has no password
powershell -File .\scripts\release_build.ps1
```
Requires **Windows SDK** (`signtool.exe` on PATH). Without a cert, the build completes unsigned (SmartScreen may warn on first run).
## CI / GitHub Releases
- **Every push/PR**: `.github/workflows/test.yml` — unit tests only
- **Tag `v*` or manual dispatch**: `.github/workflows/release.yml` — full release build + artifact upload + GitHub Release assets
```bash
git tag v1.0.0
git push origin v1.0.0
```
## Before tagging (checklist)
- [ ] `python -m unittest discover -s tests -v` passes locally
- [ ] CHANGELOG.md updated
- [ ] No secrets in `settings.json` / signup JSON committed
- [ ] `proxy_chain_manager/_bundled/gost.exe` will be fetched at build time (or pre-staged)
- [ ] Verify SHA256 after build: `Get-FileHash releases\v*\ProxyChainManager.exe -Algorithm SHA256`
## Verify a release artifact
```powershell
Get-FileHash -Algorithm SHA256 releases\v1.0.0\ProxyChainManager.exe
Get-Content releases\v1.0.0\ProxyChainManager.exe.sha256
Get-Content releases\v1.0.0\RELEASE_MANIFEST.json | ConvertFrom-Json
```

View File

@@ -1,20 +0,0 @@
# Roadmap
<!-- stewardship-standard: roadmap-v1 -->
## Now
- Confirm the project purpose in the README.
- Confirm build, run, or flash instructions on a clean machine.
- Classify license status and upstream provenance.
- Close the stewardship readiness checklist issue.
## Next
- Add project-specific tests or verification steps.
- Publish the first verified release only after provenance and security review.
- Replace placeholder wiki notes with project-specific architecture or hardware details.
## Later
- Add examples, screenshots, wiring diagrams, or demo media where useful.
- Decide whether duplicate or experimental branches should be archived.

View File

@@ -1,14 +0,0 @@
# Security Review
<!-- stewardship-standard: security-review-v1 -->
## Required Checks
- [ ] No credentials, tokens, cookies, API keys, private keys, or session files.
- [ ] No private user data, dumps, card data, logs, or captures that should not be stored.
- [ ] No copied dependency trees where package managers or SDK installers should be used instead.
- [ ] No unexplained binaries in source history.
- [ ] Risky behavior is documented and scoped to authorized lab use.
## Release Gate
A release is blocked until the checklist is complete or a maintainer explicitly records why the item does not apply.

View File

@@ -1,3 +1,3 @@
"""Rotating proxy chain manager with a GOST backend.""" """Rotating proxy chain manager with a GOST backend."""
__version__ = "2.0.0-mac" __version__ = "2.0.1-mac"

View File

@@ -138,6 +138,7 @@ TEXT = "#eaf2ff"
TEXT2 = "#7a8aab" TEXT2 = "#7a8aab"
GLOW = "#0a87ff" GLOW = "#0a87ff"
FONT = "Segoe UI" FONT = "Segoe UI"
IS_MAC = sys.platform == "darwin"
def _ts() -> str: def _ts() -> str:
@@ -314,7 +315,7 @@ def _main_inner() -> None:
from .firewall import is_admin as _is_admin from .firewall import is_admin as _is_admin
except Exception: except Exception:
_is_admin = lambda: True # noqa: E731 _is_admin = lambda: True # noqa: E731
if svc.settings.kill_switch_enabled and not _is_admin(): if (not IS_MAC) and svc.settings.kill_switch_enabled and not _is_admin():
from tkinter import messagebox from tkinter import messagebox
if messagebox.askyesno( if messagebox.askyesno(
"Kill-switch requires Admin", "Kill-switch requires Admin",
@@ -340,26 +341,26 @@ def _main_inner() -> None:
# Boot buttons # Boot buttons
ctk.CTkLabel(topbar, text="", text_color=DIM).pack(side="left", padx=4) ctk.CTkLabel(topbar, text="", text_color=DIM).pack(side="left", padx=4)
boot_lbl = ctk.CTkLabel(topbar, text="Boot:?", font=(FONT, 10), text_color=TEXT2) boot_lbl = ctk.CTkLabel(topbar, text=("Login:?" if IS_MAC else "Boot:?"), font=(FONT, 10), text_color=TEXT2)
boot_lbl.pack(side="left", padx=2) boot_lbl.pack(side="left", padx=2)
def _refresh_boot() -> None: def _refresh_boot() -> None:
on = task_exists() on = task_exists()
boot_lbl.configure(text="Boot:ON" if on else "Boot:OFF", boot_lbl.configure(text=("Login:ON" if on else "Login:OFF") if IS_MAC else ("Boot:ON" if on else "Boot:OFF"),
text_color=GREEN if on else DIM) text_color=GREEN if on else DIM)
def _inst_boot() -> None: def _inst_boot() -> None:
ok, msg = install_logon_task() ok, msg = install_logon_task()
_log("Boot task installed." if ok else f"Boot install: {msg}") _log(("Login item installed." if ok else f"Login item install: {msg}") if IS_MAC else ("Boot task installed." if ok else f"Boot install: {msg}"))
_refresh_boot() _refresh_boot()
def _rm_boot() -> None: def _rm_boot() -> None:
ok, msg = uninstall_logon_task() ok, msg = uninstall_logon_task()
_log("Boot task removed." if ok else f"Boot remove: {msg}") _log(("Login item removed." if ok else f"Login item remove: {msg}") if IS_MAC else ("Boot task removed." if ok else f"Boot remove: {msg}"))
_refresh_boot() _refresh_boot()
_btn(topbar, "Boot+", _inst_boot, w=54).pack(side="left", padx=2) _btn(topbar, "Login+" if IS_MAC else "Boot+", _inst_boot, w=64 if IS_MAC else 54).pack(side="left", padx=2)
_btn(topbar, "Boot", _rm_boot, w=54).pack(side="left", padx=2) _btn(topbar, "Login" if IS_MAC else "Boot", _rm_boot, w=64 if IS_MAC else 54).pack(side="left", padx=2)
# Right side indicators # Right side indicators
rotation_lbl = ctk.CTkLabel(topbar, text="rot: 0", font=(FONT, 10), text_color=TEXT2) rotation_lbl = ctk.CTkLabel(topbar, text="rot: 0", font=(FONT, 10), text_color=TEXT2)
@@ -373,7 +374,7 @@ def _main_inner() -> None:
proxy_lbl.pack(side="right", padx=(4, 4)) proxy_lbl.pack(side="right", padx=(4, 4))
ctk.CTkLabel(topbar, text="proxy:", font=(FONT, 10), text_color=TEXT2).pack(side="right") ctk.CTkLabel(topbar, text="proxy:", font=(FONT, 10), text_color=TEXT2).pack(side="right")
fw_lbl = ctk.CTkLabel(topbar, text="FW:—", font=(FONT, 10), text_color=DIM) fw_lbl = ctk.CTkLabel(topbar, text="KS:N/A" if IS_MAC else "FW:—", font=(FONT, 10), text_color=DIM)
fw_lbl.pack(side="right", padx=6) fw_lbl.pack(side="right", padx=6)
sys_lbl = ctk.CTkLabel(topbar, text="SYS:—", font=(FONT, 10), text_color=DIM) sys_lbl = ctk.CTkLabel(topbar, text="SYS:—", font=(FONT, 10), text_color=DIM)
@@ -381,13 +382,13 @@ def _main_inner() -> None:
admin_badge = ctk.CTkLabel( admin_badge = ctk.CTkLabel(
topbar, topbar,
text="⚡ ADMIN" if is_admin() else "👤 USER", text="macOS" if IS_MAC else ("⚡ ADMIN" if is_admin() else "👤 USER"),
font=(FONT, 10, "bold"), font=(FONT, 10, "bold"),
text_color=GREEN if is_admin() else YELLOW, text_color=GREEN if (IS_MAC or is_admin()) else YELLOW,
) )
admin_badge.pack(side="right", padx=4) admin_badge.pack(side="right", padx=4)
if not is_admin(): if (not IS_MAC) and not is_admin():
def _elevate() -> None: def _elevate() -> None:
# Persist UI state to disk before handing off to the elevated # Persist UI state to disk before handing off to the elevated
# process — otherwise unsaved edits in Chain Builder / Settings # process — otherwise unsaved edits in Chain Builder / Settings
@@ -2340,16 +2341,18 @@ def _main_inner() -> None:
toggles_card = _priv_section( toggles_card = _priv_section(
"Protections while chain is running", "Protections while chain is running",
"Applied on Start, restored on Stop. macOS prompts for approval when network identity changes need it."
if IS_MAC else
"Applied on Start, restored on Stop. Admin required for MAC, hostname, IPv6, and WebRTC.", "Applied on Start, restored on Stop. Admin required for MAC, hostname, IPv6, and WebRTC.",
) )
mac_var = ctk.BooleanVar(value=s.mac_spoof_enabled) mac_var = ctk.BooleanVar(value=False if IS_MAC else s.mac_spoof_enabled)
mac_rotate_var = ctk.BooleanVar(value=s.mac_rotate_on_chain_rotate) mac_rotate_var = ctk.BooleanVar(value=False if IS_MAC else s.mac_rotate_on_chain_rotate)
host_var = ctk.BooleanVar(value=s.spoof_hostname_enabled) host_var = ctk.BooleanVar(value=s.spoof_hostname_enabled)
dns_flush_var = ctk.BooleanVar(value=s.flush_dns_on_rotate) dns_flush_var = ctk.BooleanVar(value=s.flush_dns_on_rotate)
ipv6_var = ctk.BooleanVar(value=s.disable_ipv6_while_active) ipv6_var = ctk.BooleanVar(value=s.disable_ipv6_while_active)
webrtc_var = ctk.BooleanVar(value=s.harden_webrtc_enabled) webrtc_var = ctk.BooleanVar(value=False if IS_MAC else s.harden_webrtc_enabled)
lan_var = ctk.BooleanVar(value=s.lan_lockdown_enabled) lan_var = ctk.BooleanVar(value=False if IS_MAC else s.lan_lockdown_enabled)
telemetry_var = ctk.BooleanVar(value=s.telemetry_kill_enabled) telemetry_var = ctk.BooleanVar(value=False if IS_MAC else s.telemetry_kill_enabled)
def _priv_toggle(parent: Any, text: str, var: ctk.BooleanVar, tip: str = "") -> ctk.CTkCheckBox: def _priv_toggle(parent: Any, text: str, var: ctk.BooleanVar, tip: str = "") -> ctk.CTkCheckBox:
cb = ctk.CTkCheckBox( cb = ctk.CTkCheckBox(
@@ -2359,6 +2362,7 @@ def _main_inner() -> None:
cb.pack(anchor="w", padx=12, pady=6) cb.pack(anchor="w", padx=12, pady=6)
return cb return cb
if not IS_MAC:
_priv_toggle( _priv_toggle(
toggles_card, "Randomize MAC addresses on physical adapters", mac_var, toggles_card, "Randomize MAC addresses on physical adapters", mac_var,
"Changes NIC MAC values while chain runs (admin required).", "Changes NIC MAC values while chain runs (admin required).",
@@ -2368,8 +2372,9 @@ def _main_inner() -> None:
"Mutates MAC every time the chain rotates — prevents long-session correlation.", "Mutates MAC every time the chain rotates — prevents long-session correlation.",
) )
_priv_toggle( _priv_toggle(
toggles_card, "Spoof computer / NetBIOS hostname", host_var, toggles_card, "Spoof computer hostname" if IS_MAC else "Spoof computer / NetBIOS hostname", host_var,
"Temporarily renames machine identity while active (admin required).", "Temporarily renames macOS ComputerName, HostName, and LocalHostName while active."
if IS_MAC else "Temporarily renames machine identity while active (admin required).",
) )
_priv_toggle( _priv_toggle(
toggles_card, "Flush DNS cache when starting or rotating chains", dns_flush_var, toggles_card, "Flush DNS cache when starting or rotating chains", dns_flush_var,
@@ -2377,8 +2382,19 @@ def _main_inner() -> None:
) )
_priv_toggle( _priv_toggle(
toggles_card, "Disable IPv6 on active adapters", ipv6_var, toggles_card, "Disable IPv6 on active adapters", ipv6_var,
"Turns off IPv6 bindings while active to reduce IPv6 leak paths.", "Turns off IPv6 with networksetup while active, then restores Automatic/Link-local state on Stop."
if IS_MAC else "Turns off IPv6 bindings while active to reduce IPv6 leak paths.",
) )
if IS_MAC:
ctk.CTkLabel(
toggles_card,
text="Removed on macOS: global firewall kill-switch, MAC spoof, Chrome/Edge registry WebRTC policy, LAN lockdown, and Windows telemetry kill. Browser WebRTC hardening is handled by the Firefox profile in the Browser tab.",
font=(FONT, 10),
text_color=TEXT2,
wraplength=860,
justify="left",
).pack(anchor="w", padx=12, pady=(8, 10))
else:
_priv_toggle( _priv_toggle(
toggles_card, toggles_card,
"Harden WebRTC in Chrome / Edge (block non-proxied UDP)", "Harden WebRTC in Chrome / Edge (block non-proxied UDP)",
@@ -2492,7 +2508,8 @@ def _main_inner() -> None:
# ── WebRTC leak test ───────────────────────────────────────────────────── # ── WebRTC leak test ─────────────────────────────────────────────────────
rtc_card = _priv_section( rtc_card = _priv_section(
"WebRTC leak test", "WebRTC leak test",
"Scans Chrome/Edge policy, Firefox profile prefs, and live STUN server reachability.", "Scans Firefox profile prefs and live STUN reachability."
if IS_MAC else "Scans Chrome/Edge policy, Firefox profile prefs, and live STUN server reachability.",
) )
rtc_box = ctk.CTkTextbox( rtc_box = ctk.CTkTextbox(
rtc_card, height=110, font=("Consolas", 10), rtc_card, height=110, font=("Consolas", 10),
@@ -2536,12 +2553,16 @@ def _main_inner() -> None:
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left") fg_color=ACCENT2, hover_color=ACCENT).pack(side="left")
wipe_card = _priv_section( wipe_card = _priv_section(
"Forensic artifact wipe", "Local artifact cleanup" if IS_MAC else "Forensic artifact wipe",
"One-button purge of common Windows breadcrumb trails. Irreversible.", "Purges app temp/log traces and clipboard on macOS. Irreversible."
if IS_MAC else "One-button purge of common Windows breadcrumb trails. Irreversible.",
) )
wipe_result_lbl = ctk.CTkLabel( wipe_result_lbl = ctk.CTkLabel(
wipe_card, text="Click 'Wipe now' to purge %TEMP%, Recent, Jump Lists, " wipe_card,
"Prefetch (Admin), MRU lists, and the clipboard.", text=("Click 'Wipe now' to purge app temp/log traces and the clipboard."
if IS_MAC else
"Click 'Wipe now' to purge %TEMP%, Recent, Jump Lists, "
"Prefetch (Admin), MRU lists, and the clipboard."),
font=(FONT, 10), text_color=TEXT2, wraplength=880, justify="left", font=(FONT, 10), text_color=TEXT2, wraplength=880, justify="left",
) )
wipe_result_lbl.pack(anchor="w", padx=12, pady=8) wipe_result_lbl.pack(anchor="w", padx=12, pady=8)
@@ -2566,6 +2587,8 @@ def _main_inner() -> None:
audit_card = _priv_section( audit_card = _priv_section(
"Leak audit (mission-critical)", "Leak audit (mission-critical)",
"Probes macOS-relevant surfaces: IP, DNS, IPv6, system proxy, VPN, browser WebRTC profile, and chain exit."
if IS_MAC else
"Probes every leak surface: IP, DNS, IPv6, WPAD, Group Policy, " "Probes every leak surface: IP, DNS, IPv6, WPAD, Group Policy, "
"ProxySettingsPerUser, LLMNR / NetBIOS / mDNS, VPN, WebRTC policy.", "ProxySettingsPerUser, LLMNR / NetBIOS / mDNS, VPN, WebRTC policy.",
) )
@@ -2674,9 +2697,19 @@ def _main_inner() -> None:
_fld(val_sec, "Per-proxy timeout (sec)", "timeout", str(s.validation_timeout_seconds)) _fld(val_sec, "Per-proxy timeout (sec)", "timeout", str(s.validation_timeout_seconds))
sec_sec = _section("Security") sec_sec = _section("Security")
ks_var = ctk.BooleanVar(value=False if IS_MAC else s.kill_switch_enabled)
if IS_MAC:
ctk.CTkLabel(
sec_sec,
text="macOS mode uses loopback GOST, system proxy enforcement, hardened Firefox profiles, DNS flushing, and exit verification. The Windows netsh kill-switch is removed because there is no clean equivalent that can be safely toggled from a user app without risking a stuck network state.",
font=(FONT, 10),
text_color=TEXT2,
wraplength=880,
justify="left",
).pack(anchor="w", padx=12, pady=8)
else:
ks_row = ctk.CTkFrame(sec_sec, fg_color="transparent") ks_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
ks_row.pack(fill="x", padx=12, pady=8) ks_row.pack(fill="x", padx=12, pady=8)
ks_var = ctk.BooleanVar(value=s.kill_switch_enabled)
ctk.CTkCheckBox( ctk.CTkCheckBox(
ks_row, ks_row,
text="Firewall kill-switch (block ALL traffic if chain is down — requires Admin)", text="Firewall kill-switch (block ALL traffic if chain is down — requires Admin)",
@@ -2703,6 +2736,7 @@ def _main_inner() -> None:
messagebox.showinfo("Kill-switch", "Firewall rules removed.") messagebox.showinfo("Kill-switch", "Firewall rules removed.")
_log("Emergency disengage: firewall rules removed by operator.") _log("Emergency disengage: firewall rules removed by operator.")
if not IS_MAC:
ks_btn_row = ctk.CTkFrame(sec_sec, fg_color="transparent") ks_btn_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
ks_btn_row.pack(fill="x", padx=12, pady=(0, 8)) ks_btn_row.pack(fill="x", padx=12, pady=(0, 8))
_btn( _btn(
@@ -2717,13 +2751,13 @@ def _main_inner() -> None:
use_manual_var.set(True) use_manual_var.set(True)
mode_var.set("auto") mode_var.set("auto")
elite_var.set(False) elite_var.set(False)
ks_var.set(True) ks_var.set(False if IS_MAC else True)
dns_flush_var.set(True) dns_flush_var.set(True)
mac_var.set(False) mac_var.set(False)
mac_rotate_var.set(False) mac_rotate_var.set(False)
host_var.set(False) host_var.set(False)
ipv6_var.set(False) ipv6_var.set(False)
webrtc_var.set(True) webrtc_var.set(False if IS_MAC else True)
lan_var.set(False) lan_var.set(False)
telemetry_var.set(False) telemetry_var.set(False)
persona_var.set(PERSONA_LABELS["blend_windows_chrome"]) persona_var.set(PERSONA_LABELS["blend_windows_chrome"])
@@ -2788,18 +2822,18 @@ def _main_inner() -> None:
max_candidates=max(10, int(entries["maxc"].get().strip())), max_candidates=max(10, int(entries["maxc"].get().strip())),
validation_timeout_seconds=min(120.0, max(2.0, float(entries["timeout"].get().strip()))), validation_timeout_seconds=min(120.0, max(2.0, float(entries["timeout"].get().strip()))),
prefer_elite=bool(elite_var.get()), prefer_elite=bool(elite_var.get()),
kill_switch_enabled=bool(ks_var.get()), kill_switch_enabled=False if IS_MAC else bool(ks_var.get()),
proxy_bypass=entries["bypass"].get().strip() or Settings().proxy_bypass, proxy_bypass=entries["bypass"].get().strip() or Settings().proxy_bypass,
sources=src_list or Settings().sources, sources=src_list or Settings().sources,
ip_check_url=entries["check_url"].get().strip() or Settings().ip_check_url, ip_check_url=entries["check_url"].get().strip() or Settings().ip_check_url,
mac_spoof_enabled=bool(mac_var.get()), mac_spoof_enabled=False if IS_MAC else bool(mac_var.get()),
mac_rotate_on_chain_rotate=bool(mac_rotate_var.get()), mac_rotate_on_chain_rotate=False if IS_MAC else bool(mac_rotate_var.get()),
spoof_hostname_enabled=bool(host_var.get()), spoof_hostname_enabled=bool(host_var.get()),
flush_dns_on_rotate=bool(dns_flush_var.get()), flush_dns_on_rotate=bool(dns_flush_var.get()),
disable_ipv6_while_active=bool(ipv6_var.get()), disable_ipv6_while_active=bool(ipv6_var.get()),
harden_webrtc_enabled=bool(webrtc_var.get()), harden_webrtc_enabled=False if IS_MAC else bool(webrtc_var.get()),
lan_lockdown_enabled=bool(lan_var.get()), lan_lockdown_enabled=False if IS_MAC else bool(lan_var.get()),
telemetry_kill_enabled=bool(telemetry_var.get()), telemetry_kill_enabled=False if IS_MAC else bool(telemetry_var.get()),
firefox_path=firefox_path_var.get().strip(), firefox_path=firefox_path_var.get().strip(),
firefox_profile_dir=firefox_profile_var.get().strip(), firefox_profile_dir=firefox_profile_var.get().strip(),
browser_clear_on_close=bool(br_clear_var.get()), browser_clear_on_close=bool(br_clear_var.get()),
@@ -2836,6 +2870,9 @@ def _main_inner() -> None:
text_color=GREEN if on else DIM) text_color=GREEN if on else DIM)
def _refresh_fw(engaged: bool | None = None) -> None: def _refresh_fw(engaged: bool | None = None) -> None:
if IS_MAC:
fw_lbl.configure(text="KS:N/A", text_color=DIM)
return
if engaged is None: if engaged is None:
engaged = fw_is_engaged() engaged = fw_is_engaged()
fw_lbl.configure(text="FW:ON" if engaged else "FW:OFF", fw_lbl.configure(text="FW:ON" if engaged else "FW:OFF",
@@ -3019,7 +3056,10 @@ def _main_inner() -> None:
browser.stop(dispose=bool(br_disposable_var.get())) browser.stop(dispose=bool(br_disposable_var.get()))
tray.stop() tray.stop()
svc.stop() svc.stop()
try:
clear_system_proxy() clear_system_proxy()
except Exception as exc: # noqa: BLE001
_log(f"System proxy clear failed on quit: {exc}")
if is_admin() and fw_is_engaged(): if is_admin() and fw_is_engaged():
fw_disengage() fw_disengage()
root.destroy() root.destroy()
@@ -3030,12 +3070,20 @@ def _main_inner() -> None:
chain_alive = bool(svc.current_chain) or fw_is_engaged() chain_alive = bool(svc.current_chain) or fw_is_engaged()
if chain_alive: if chain_alive:
from tkinter import messagebox from tkinter import messagebox
choice = messagebox.askyesnocancel( body = (
"Proxy God still running", "The proxy chain is still active.\n\n"
" • Yes — hide window (chain keeps running)\n"
" • No — fully quit (stop chain + clear system proxy)\n"
" • Cancel — keep window open"
if IS_MAC else
"The proxy chain and/or kill-switch are still active.\n\n" "The proxy chain and/or kill-switch are still active.\n\n"
" • Yes — minimize to tray (chain keeps running)\n" " • Yes — minimize to tray (chain keeps running)\n"
" • No — fully quit (stop chain + remove firewall rules)\n" " • No — fully quit (stop chain + remove firewall rules)\n"
" • Cancel — keep window open", " • Cancel — keep window open"
)
choice = messagebox.askyesnocancel(
"Proxy God still running",
body,
) )
if choice is None: if choice is None:
return return
@@ -3057,13 +3105,13 @@ def _main_inner() -> None:
_refresh_fingerprint() _refresh_fingerprint()
_log("" * 60) _log("" * 60)
_log("Proxy God v2 — ready.") _log("Proxy God v2 — ready.")
_log(f"Admin: {'YES — kill-switch + privacy hardening available' if is_admin() else 'NO — run as Admin for full privacy tools'}") _log("macOS networking: system proxy uses networksetup and will prompt for approval if required." if IS_MAC else f"Admin: {'YES — kill-switch + privacy hardening available' if is_admin() else 'NO — run as Admin for full privacy tools'}")
_log(f"Log file: {LOG_PATH}") _log(f"Log file: {LOG_PATH}")
_log("Press START to fetch, validate and chain proxies.") _log("Press START to fetch, validate and chain proxies.")
_log("Chain Builder → build your chain, Test entire chain, then Start.") _log("Chain Builder → build your chain, Test entire chain, then Start.")
_log("Browser tab → launch hardened Firefox profile that follows your chain.") _log("Browser tab → launch hardened Firefox profile that follows your chain.")
_log("Signup Prep → open signup pages with autofill (Google / Proton / HydraProxy / custom).") _log("Signup Prep → open signup pages with autofill (Google / Proton / HydraProxy / custom).")
_log("Privacy tab → MAC, hostname, IPv6, WebRTC, fingerprint audit, DNS checks.") _log("Privacy tab → hostname, IPv6, DNS/WebRTC checks, fingerprint audit." if IS_MAC else "Privacy tab → MAC, hostname, IPv6, WebRTC, fingerprint audit, DNS checks.")
_log("Works with or without VPN — leak detection adapts automatically.") _log("Works with or without VPN — leak detection adapts automatically.")
_log("" * 60) _log("" * 60)
_pump() _pump()

View File

@@ -24,6 +24,7 @@ import logging
import os import os
import shutil import shutil
import subprocess import subprocess
import sys
import winreg import winreg
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -143,6 +144,13 @@ def _clear_mru_key(path: str, rep: WipeReport) -> None:
def _clear_clipboard(rep: WipeReport) -> None: def _clear_clipboard(rep: WipeReport) -> None:
if sys.platform == "darwin":
try:
subprocess.run(["pbcopy"], input="", text=True, timeout=5)
rep.clipboard_cleared = True
except Exception as e:
rep.errors.append(f"clipboard: {e}")
return
try: try:
user32 = ctypes.windll.user32 # type: ignore[attr-defined] user32 = ctypes.windll.user32 # type: ignore[attr-defined]
if user32.OpenClipboard(None): if user32.OpenClipboard(None):
@@ -157,6 +165,27 @@ def _clear_clipboard(rep: WipeReport) -> None:
def wipe_artifacts(include_prefetch: bool = True) -> WipeReport: def wipe_artifacts(include_prefetch: bool = True) -> WipeReport:
rep = WipeReport() rep = WipeReport()
if sys.platform == "darwin":
import tempfile
temp = Path(tempfile.gettempdir())
_purge_dir(temp, rep, keep_root=True)
for path in (
Path.home() / "Library" / "Caches" / "ProxyChainManager",
Path.home() / "Library" / "Logs" / "ProxyGod.out.log",
Path.home() / "Library" / "Logs" / "ProxyGod.err.log",
):
try:
if path.is_dir():
_purge_dir(path, rep, keep_root=False)
elif path.is_file():
sz = path.stat().st_size
path.unlink(missing_ok=True)
rep.files_deleted += 1
rep.bytes_freed += sz
except OSError as exc:
rep.errors.append(f"{path.name}: {exc}")
_clear_clipboard(rep)
return rep
appdata = Path(os.environ.get("APPDATA", "")) if os.environ.get("APPDATA") else None appdata = Path(os.environ.get("APPDATA", "")) if os.environ.get("APPDATA") else None
temp = Path(os.environ.get("TEMP", "")) if os.environ.get("TEMP") else None temp = Path(os.environ.get("TEMP", "")) if os.environ.get("TEMP") else None
sysroot = Path(os.environ.get("SystemRoot", r"C:\Windows")) sysroot = Path(os.environ.get("SystemRoot", r"C:\Windows"))

View File

@@ -303,6 +303,19 @@ def migrate(raw: dict) -> dict:
def sanitize_settings(s: Settings) -> tuple[Settings, bool]: def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
"""Clamp invalid values from hand-edited JSON. Returns (settings, changed).""" """Clamp invalid values from hand-edited JSON. Returns (settings, changed)."""
changed = False changed = False
if sys.platform != "win32":
unsupported = (
"kill_switch_enabled",
"mac_spoof_enabled",
"mac_rotate_on_chain_rotate",
"harden_webrtc_enabled",
"lan_lockdown_enabled",
"telemetry_kill_enabled",
)
for name in unsupported:
if bool(getattr(s, name, False)):
setattr(s, name, False)
changed = True
try: try:
cl = int(s.chain_length) cl = int(s.chain_length)
if not (1 <= cl <= 8): if not (1 <= cl <= 8):

View File

@@ -7,6 +7,7 @@ import random
import re import re
import string import string
import subprocess import subprocess
import sys
import winreg import winreg
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -14,6 +15,9 @@ from .firewall import is_admin
from .mac_spoof import list_nics from .mac_spoof import list_nics
from .win_compat import probe as _win_probe from .win_compat import probe as _win_probe
if sys.platform == "darwin":
from .macos_privileged import run_shell, run_shell_as_admin, shell_quote
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_WEBRTC_CHROME = r"SOFTWARE\Policies\Google\Chrome" _WEBRTC_CHROME = r"SOFTWARE\Policies\Google\Chrome"
@@ -26,6 +30,43 @@ _WEBRTC_VALUE_STR = "WebRtcIPHandling"
_WEBRTC_DISABLE_STR = "disable_non_proxied_udp" _WEBRTC_DISABLE_STR = "disable_non_proxied_udp"
def _mac_network_services() -> list[str]:
if sys.platform != "darwin":
return []
try:
r = subprocess.run(
["networksetup", "-listallnetworkservices"],
capture_output=True,
text=True,
timeout=10,
)
except Exception:
return []
services: list[str] = []
for line in (r.stdout or "").splitlines():
name = line.strip()
if not name or name.startswith("An asterisk"):
continue
services.append(name.lstrip("*").strip())
return services
def _mac_ipv6_state(service: str) -> str:
try:
r = subprocess.run(
["networksetup", "-getinfo", service],
capture_output=True,
text=True,
timeout=8,
)
for line in (r.stdout or "").splitlines():
if line.strip().startswith("IPv6:"):
return line.split(":", 1)[1].strip() or "Automatic"
except Exception:
pass
return "Automatic"
@dataclass @dataclass
class FingerprintAudit: class FingerprintAudit:
lines: list[str] = field(default_factory=list) lines: list[str] = field(default_factory=list)
@@ -51,6 +92,12 @@ def _run_ps(script: str, timeout: float = 15.0) -> str:
def get_computer_name() -> str: def get_computer_name() -> str:
if sys.platform == "darwin":
try:
r = subprocess.run(["scutil", "--get", "ComputerName"], capture_output=True, text=True, timeout=5)
return (r.stdout or "").strip() or platform.node() or ""
except Exception:
return platform.node() or ""
try: try:
import os import os
return os.environ.get("COMPUTERNAME", "") or platform.node() or "" return os.environ.get("COMPUTERNAME", "") or platform.node() or ""
@@ -77,6 +124,19 @@ def random_hostname(prefix: str = "PC") -> str:
def set_computer_name(name: str) -> tuple[bool, str]: def set_computer_name(name: str) -> tuple[bool, str]:
"""Set NetBIOS / computer name (Admin). Reboot may be required for all apps.""" """Set NetBIOS / computer name (Admin). Reboot may be required for all apps."""
if sys.platform == "darwin":
clean = re.sub(r"[^A-Za-z0-9\-]", "", name)[:63]
local = clean[:63].strip("-") or random_hostname("MAC")
script = "\n".join([
f"scutil --set ComputerName {shell_quote(clean)}",
f"scutil --set HostName {shell_quote(clean)}",
f"scutil --set LocalHostName {shell_quote(local)}",
"dscacheutil -flushcache || true",
])
code, _, err = run_shell_as_admin(script)
if code == 0:
return True, f"macOS host identity set to {clean}."
return False, f"macOS hostname change failed: {err.strip() or 'permission denied'}"
if not is_admin(): if not is_admin():
return False, "Administrator required to change computer name." return False, "Administrator required to change computer name."
name = re.sub(r"[^A-Za-z0-9\-]", "", name)[:15] name = re.sub(r"[^A-Za-z0-9\-]", "", name)[:15]
@@ -110,6 +170,24 @@ def set_computer_name(name: str) -> tuple[bool, str]:
def disable_ipv6_on_adapters() -> tuple[list[str], list[str]]: def disable_ipv6_on_adapters() -> tuple[list[str], list[str]]:
"""Disable IPv6 binding on up physical adapters. Returns (adapter names, log lines).""" """Disable IPv6 binding on up physical adapters. Returns (adapter names, log lines)."""
if sys.platform == "darwin":
services = _mac_network_services()
if not services:
return [], ["IPv6 disable skipped: no macOS network services found."]
states: list[str] = []
commands = ["set -e"]
for service in services:
state = _mac_ipv6_state(service)
if state.lower() == "off":
continue
states.append(f"{service}|{state}")
commands.append(f"networksetup -setv6off {shell_quote(service)}")
if not states:
return [], ["IPv6 already off on all detected macOS network services."]
code, _, err = run_shell_as_admin("\n".join(commands))
if code != 0:
return [], [f"IPv6 disable failed: {err.strip() or 'permission denied'}"]
return states, [f"IPv6 disabled on {len(states)} macOS network service(s)."]
if not is_admin(): if not is_admin():
return [], ["IPv6 disable skipped (not Admin)."] return [], ["IPv6 disable skipped (not Admin)."]
if not _win_probe().has_net_cmdlets: if not _win_probe().has_net_cmdlets:
@@ -144,6 +222,23 @@ def disable_ipv6_on_adapters() -> tuple[list[str], list[str]]:
def enable_ipv6_on_adapters(adapters: list[str]) -> list[str]: def enable_ipv6_on_adapters(adapters: list[str]) -> list[str]:
if sys.platform == "darwin":
if not adapters:
return []
commands = ["set -e"]
for item in adapters:
service, _, state = item.partition("|")
state_l = (state or "Automatic").lower()
if "link-local" in state_l:
commands.append(f"networksetup -setv6linklocal {shell_quote(service)}")
elif "manual" in state_l:
commands.append(f"networksetup -setv6automatic {shell_quote(service)}")
else:
commands.append(f"networksetup -setv6automatic {shell_quote(service)}")
code, _, err = run_shell_as_admin("\n".join(commands))
if code != 0:
return [f"IPv6 restore failed: {err.strip() or 'permission denied'}"]
return [f"IPv6 restored on {len(adapters)} macOS network service(s)."]
if not is_admin() or not adapters: if not is_admin() or not adapters:
return [] return []
if not _win_probe().has_net_cmdlets: if not _win_probe().has_net_cmdlets:
@@ -171,6 +266,8 @@ def apply_webrtc_hardening(enable: bool) -> tuple[bool, str]:
the modern REG_SZ key (WebRtcIPHandling=disable_non_proxied_udp) so that the modern REG_SZ key (WebRtcIPHandling=disable_non_proxied_udp) so that
all Chrome/Edge versions are covered. all Chrome/Edge versions are covered.
""" """
if sys.platform == "darwin":
return False, "Chrome/Edge WebRTC policy is not applied on macOS; use the hardened Firefox profile controls."
if not is_admin(): if not is_admin():
return False, "Administrator required for browser WebRTC policy." return False, "Administrator required for browser WebRTC policy."
paths = [_WEBRTC_CHROME, _WEBRTC_EDGE] paths = [_WEBRTC_CHROME, _WEBRTC_EDGE]

View File

@@ -22,6 +22,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import subprocess import subprocess
import sys
import winreg import winreg
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -60,6 +61,22 @@ def _check_ipv6_active() -> tuple[bool, str]:
PowerShell 3.0+ path; falls back to ipconfig parsing (locale-tolerant PowerShell 3.0+ path; falls back to ipconfig parsing (locale-tolerant
enough — looks for hex colons). enough — looks for hex colons).
""" """
if sys.platform == "darwin":
try:
r = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=8)
active: list[str] = []
current = ""
for line in (r.stdout or "").splitlines():
if line and not line.startswith("\t") and ":" in line:
current = line.split(":", 1)[0]
elif "\tinet6 " in line and current and not line.strip().startswith("inet6 ::1"):
active.append(current)
uniq = sorted(set(active))
if uniq:
return True, ", ".join(uniq[:3]) + (f" +{len(uniq)-3}" if len(uniq) > 3 else "")
return False, "no non-loopback IPv6 addresses"
except Exception as e:
return False, f"unknown ({e})"
try: try:
r = subprocess.run( r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ["powershell", "-NoProfile", "-NonInteractive", "-Command",
@@ -92,6 +109,8 @@ def _check_ipv6_active() -> tuple[bool, str]:
def _check_wpad() -> tuple[bool, str]: def _check_wpad() -> tuple[bool, str]:
if sys.platform != "win32":
return True, "not applicable on macOS"
path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings" path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
autoconf = "" autoconf = ""
autodet = 0 autodet = 0
@@ -121,6 +140,8 @@ def _check_wpad() -> tuple[bool, str]:
def _check_per_user_flag() -> tuple[bool, str]: def _check_per_user_flag() -> tuple[bool, str]:
if sys.platform != "win32":
return True, "not applicable on macOS"
try: try:
with winreg.OpenKey( with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_LOCAL_MACHINE,
@@ -137,6 +158,8 @@ def _check_per_user_flag() -> tuple[bool, str]:
def _check_webrtc_policy() -> tuple[bool, str]: def _check_webrtc_policy() -> tuple[bool, str]:
"""OK means the policy IS set (browsers won't leak non-proxied UDP).""" """OK means the policy IS set (browsers won't leak non-proxied UDP)."""
if sys.platform != "win32":
return True, "managed by hardened Firefox profile settings"
paths = ( paths = (
r"SOFTWARE\Policies\Google\Chrome", r"SOFTWARE\Policies\Google\Chrome",
r"SOFTWARE\Policies\Microsoft\Edge", r"SOFTWARE\Policies\Microsoft\Edge",
@@ -273,7 +296,10 @@ async def run_audit(
# LAN-scope broadcasts # LAN-scope broadcasts
lan = lan_status() lan = lan_status()
lan_clean = "OFF" in lan["llmnr"] and "OFF" in lan["mdns"] and "NICs with NetBIOS disabled" in lan["netbios"] lan_clean = (
sys.platform != "win32"
or ("OFF" in lan["llmnr"] and "OFF" in lan["mdns"] and "NICs with NetBIOS disabled" in lan["netbios"])
)
rep.add( rep.add(
"LAN broadcast (LLMNR/NetBIOS/mDNS)", "LAN broadcast (LLMNR/NetBIOS/mDNS)",
lan_clean, lan_clean,
@@ -292,14 +318,14 @@ async def run_audit(
# WebRTC policy # WebRTC policy
rtc_ok, rtc_msg = _check_webrtc_policy() rtc_ok, rtc_msg = _check_webrtc_policy()
rep.add("Browser WebRTC policy", rtc_ok, rtc_msg) rep.add("Browser WebRTC protection", rtc_ok, rtc_msg)
# Admin status (a lot of fixes require it) # Admin status (a lot of fixes require it)
rep.add( rep.add(
"Administrator privileges", "Privilege model",
is_admin(), True if sys.platform == "darwin" else is_admin(),
"Yes" if is_admin() else "No", "macOS prompts when network changes need approval" if sys.platform == "darwin" else ("Yes" if is_admin() else "No"),
"MAC/IPv6/LAN/HKLM fixes need elevation" if not is_admin() else "", "" if sys.platform == "darwin" or is_admin() else "MAC/IPv6/LAN/HKLM fixes need elevation",
) )
return rep return rep

View File

@@ -5,6 +5,7 @@ import logging
import random import random
import re import re
import subprocess import subprocess
import sys
from dataclasses import dataclass from dataclasses import dataclass
from .firewall import is_admin from .firewall import is_admin
@@ -41,6 +42,26 @@ def list_nics() -> list[NicMac]:
Falls back to ``wmic nic`` for hosts without PowerShell 3.0 (Server 2008 R2). Falls back to ``wmic nic`` for hosts without PowerShell 3.0 (Server 2008 R2).
""" """
if sys.platform == "darwin":
code, out, _ = _run(["ifconfig"], timeout=10)
if code != 0:
return []
nics: list[NicMac] = []
current = ""
is_up = False
mac = ""
for line in out.splitlines():
if line and not line.startswith("\t") and ":" in line:
if current and is_up and mac:
nics.append(NicMac(name=current, mac=mac, description=current))
current = line.split(":", 1)[0].strip()
is_up = "UP" in line
mac = ""
elif "\tether " in line:
mac = line.strip().split()[1].replace("-", ":")
if current and is_up and mac:
nics.append(NicMac(name=current, mac=mac, description=current))
return nics
compat = _win_probe() compat = _win_probe()
if not compat.has_net_cmdlets: if not compat.has_net_cmdlets:
return _list_nics_wmic() return _list_nics_wmic()

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
import shlex
import json
import subprocess
def shell_quote(value: str) -> str:
return shlex.quote(str(value))
def run_shell(script: str, timeout: float = 120.0) -> tuple[int, str, str]:
try:
r = subprocess.run(
["/bin/bash", "-lc", script],
capture_output=True,
text=True,
timeout=timeout,
)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as exc: # noqa: BLE001
return 1, "", str(exc)
def run_shell_as_admin(script: str, timeout: float = 180.0) -> tuple[int, str, str]:
"""Run a shell script through macOS' standard admin prompt.
This avoids silent failures for commands like ``networksetup`` and
``scutil --set`` when the user is not currently root.
"""
apple_script = (
"do shell script "
+ json.dumps(script)
+ " with administrator privileges"
)
try:
r = subprocess.run(
["osascript", "-e", apple_script],
capture_output=True,
text=True,
timeout=timeout,
)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as exc: # noqa: BLE001
return 1, "", str(exc)

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import json import json
import logging import logging
import subprocess import subprocess
import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
import winreg import winreg
@@ -164,6 +165,12 @@ def _restart_dnscache() -> None:
def lan_status() -> dict[str, str]: def lan_status() -> dict[str, str]:
"""Human-readable current state. Returned even when not Admin.""" """Human-readable current state. Returned even when not Admin."""
if sys.platform == "darwin":
return {
"netbios": "not applicable on macOS",
"llmnr": "not applicable on macOS",
"mdns": "managed by mDNSResponder",
}
nb = _list_netbios_via_wmic() nb = _list_netbios_via_wmic()
if nb: if nb:
disabled = sum(1 for v in nb.values() if v == 2) disabled = sum(1 for v in nb.values() if v == 2)
@@ -193,6 +200,8 @@ def engage_lan_lockdown() -> tuple[LanSnapshot | None, list[str]]:
snapshot is None when the operation was refused (not Admin, etc.). snapshot is None when the operation was refused (not Admin, etc.).
""" """
logs: list[str] = [] logs: list[str] = []
if sys.platform == "darwin":
return None, ["LAN lockdown is not applied on macOS; mDNSResponder is a core system service."]
if not is_admin(): if not is_admin():
return None, ["LAN privacy lockdown skipped — needs Administrator."] return None, ["LAN privacy lockdown skipped — needs Administrator."]
@@ -227,6 +236,8 @@ def engage_lan_lockdown() -> tuple[LanSnapshot | None, list[str]]:
def restore_lan(snap: LanSnapshot | None) -> list[str]: def restore_lan(snap: LanSnapshot | None) -> list[str]:
"""Roll back to the snapshot captured by engage_lan_lockdown.""" """Roll back to the snapshot captured by engage_lan_lockdown."""
if sys.platform == "darwin":
return []
if snap is None or not is_admin(): if snap is None or not is_admin():
return [] return []
logs: list[str] = [] logs: list[str] = []

View File

@@ -5,6 +5,7 @@ import asyncio
import logging import logging
import random import random
import signal import signal
import sys
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
@@ -204,8 +205,11 @@ class ChainService:
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
def _teardown_network(self) -> None: def _teardown_network(self) -> None:
try:
clear_system_proxy() clear_system_proxy()
self._notify({"type": "log", "text": "System proxy cleared."}) self._notify({"type": "log", "text": "System proxy cleared."})
except Exception as exc: # noqa: BLE001
self._notify({"type": "log", "text": f"System proxy clear failed: {exc}"})
self._restore_privacy() self._restore_privacy()
with self._settings_lock: with self._settings_lock:
ks_enabled = self._settings.kill_switch_enabled ks_enabled = self._settings.kill_switch_enabled
@@ -216,14 +220,15 @@ class ChainService:
def _apply_privacy(self) -> None: def _apply_privacy(self) -> None:
s = self._settings s = self._settings
can_prompt_admin = sys.platform == "darwin"
if s.mac_spoof_enabled and is_admin(): if s.mac_spoof_enabled and is_admin():
self._mac_originals, logs = spoof_all_physical(self._mac_originals or None) self._mac_originals, logs = spoof_all_physical(self._mac_originals or None)
for ln in logs: for ln in logs:
self._notify({"type": "log", "text": f"MAC: {ln}"}) self._notify({"type": "log", "text": f"MAC: {ln}"})
elif s.mac_spoof_enabled: elif s.mac_spoof_enabled:
self._notify({"type": "log", "text": "MAC spoof enabled but not Admin — skipped."}) self._notify({"type": "log", "text": "MAC spoof unavailable on macOS — skipped." if can_prompt_admin else "MAC spoof enabled but not Admin — skipped."})
if s.spoof_hostname_enabled and is_admin(): if s.spoof_hostname_enabled and (is_admin() or can_prompt_admin):
self._hostname_original = get_computer_name() self._hostname_original = get_computer_name()
new_name = random_hostname() new_name = random_hostname()
ok, msg = set_computer_name(new_name) ok, msg = set_computer_name(new_name)
@@ -231,7 +236,7 @@ class ChainService:
elif s.spoof_hostname_enabled: elif s.spoof_hostname_enabled:
self._notify({"type": "log", "text": "Hostname spoof enabled but not Admin — skipped."}) self._notify({"type": "log", "text": "Hostname spoof enabled but not Admin — skipped."})
if s.disable_ipv6_while_active and is_admin(): if s.disable_ipv6_while_active and (is_admin() or can_prompt_admin):
self._ipv6_adapters, logs = disable_ipv6_on_adapters() self._ipv6_adapters, logs = disable_ipv6_on_adapters()
for ln in logs: for ln in logs:
self._notify({"type": "log", "text": ln}) self._notify({"type": "log", "text": ln})
@@ -243,7 +248,7 @@ class ChainService:
self._webrtc_was_applied = ok self._webrtc_was_applied = ok
self._notify({"type": "log", "text": msg}) self._notify({"type": "log", "text": msg})
elif s.harden_webrtc_enabled: elif s.harden_webrtc_enabled:
self._notify({"type": "log", "text": "WebRTC hardening enabled but not Admin — skipped."}) self._notify({"type": "log", "text": "Chrome/Edge WebRTC policy is Windows-only; use Browser tab Firefox WebRTC hardening." if can_prompt_admin else "WebRTC hardening enabled but not Admin — skipped."})
if s.lan_lockdown_enabled and is_admin(): if s.lan_lockdown_enabled and is_admin():
snap, logs = engage_lan_lockdown() snap, logs = engage_lan_lockdown()
@@ -251,7 +256,7 @@ class ChainService:
for ln in logs: for ln in logs:
self._notify({"type": "log", "text": f"LAN: {ln}"}) self._notify({"type": "log", "text": f"LAN: {ln}"})
elif s.lan_lockdown_enabled: elif s.lan_lockdown_enabled:
self._notify({"type": "log", "text": "LAN lockdown enabled but not Admin — skipped."}) self._notify({"type": "log", "text": "LAN lockdown is Windows-only and disabled on macOS." if can_prompt_admin else "LAN lockdown enabled but not Admin — skipped."})
if s.telemetry_kill_enabled and is_admin(): if s.telemetry_kill_enabled and is_admin():
tsnap, tlogs = engage_telemetry_kill() tsnap, tlogs = engage_telemetry_kill()
@@ -261,14 +266,14 @@ class ChainService:
if len(tlogs) > 8: if len(tlogs) > 8:
self._notify({"type": "log", "text": f"Telemetry: … +{len(tlogs) - 8} more changes."}) self._notify({"type": "log", "text": f"Telemetry: … +{len(tlogs) - 8} more changes."})
elif s.telemetry_kill_enabled: elif s.telemetry_kill_enabled:
self._notify({"type": "log", "text": "Telemetry kill enabled but not Admin — skipped."}) self._notify({"type": "log", "text": "Telemetry kill is Windows-only and disabled on macOS." if can_prompt_admin else "Telemetry kill enabled but not Admin — skipped."})
def _restore_privacy(self) -> None: def _restore_privacy(self) -> None:
if self._mac_originals: if self._mac_originals:
for ln in restore_macs(self._mac_originals): for ln in restore_macs(self._mac_originals):
self._notify({"type": "log", "text": f"MAC restore: {ln}"}) self._notify({"type": "log", "text": f"MAC restore: {ln}"})
self._mac_originals.clear() self._mac_originals.clear()
if self._hostname_original and is_admin(): if self._hostname_original and (is_admin() or sys.platform == "darwin"):
ok, msg = set_computer_name(self._hostname_original) ok, msg = set_computer_name(self._hostname_original)
self._notify({"type": "log", "text": f"Hostname restore: {msg}"}) self._notify({"type": "log", "text": f"Hostname restore: {msg}"})
self._hostname_original = None self._hostname_original = None
@@ -657,11 +662,17 @@ class ChainService:
for p in pol_before[:3]: for p in pol_before[:3]:
self._notify({"type": "log", "text": f" ! {p}"}) self._notify({"type": "log", "text": f" ! {p}"})
try:
set_system_proxy( set_system_proxy(
self._settings.local_host, self._settings.local_host,
self._settings.local_port, self._settings.local_port,
self._settings.proxy_bypass, self._settings.proxy_bypass,
) )
except Exception as exc: # noqa: BLE001
self._notify({"type": "log", "text": f"System proxy apply failed: {exc}"})
terminate_process(self._proc)
self._proc = None
return False
applied = is_system_proxy_set() applied = is_system_proxy_set()
self._notify({ self._notify({
"type": "log", "type": "log",

View File

@@ -27,6 +27,9 @@ import winreg
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
if sys.platform == "darwin":
from .macos_privileged import run_shell, run_shell_as_admin, shell_quote
_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings" _KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
_CONN_KEY_PATH = ( _CONN_KEY_PATH = (
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Connections" r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"
@@ -73,11 +76,22 @@ def _mac_network_services() -> list[str]:
return services return services
def _mac_run_networksetup(args: list[str]) -> None: def _mac_run_networksetup(args: list[str]) -> bool:
try: try:
subprocess.run(["networksetup", *args], capture_output=True, text=True, timeout=20) r = subprocess.run(["networksetup", *args], capture_output=True, text=True, timeout=20)
if r.returncode != 0:
log.debug("networksetup failed (%s): %s", args, (r.stderr or r.stdout or "").strip())
return r.returncode == 0
except Exception as exc: except Exception as exc:
log.debug("networksetup failed (%s): %s", args, exc) log.debug("networksetup failed (%s): %s", args, exc)
return False
def _mac_networksetup_script(commands: list[list[str]]) -> str:
lines = ["set -e"]
for args in commands:
lines.append("networksetup " + " ".join(shell_quote(a) for a in args))
return "\n".join(lines)
def _mac_set_system_proxy(host: str, port: int, bypass: str) -> None: def _mac_set_system_proxy(host: str, port: int, bypass: str) -> None:
@@ -86,21 +100,44 @@ def _mac_set_system_proxy(host: str, port: int, bypass: str) -> None:
for h in bypass.replace(";", ",").split(",") for h in bypass.replace(";", ",").split(",")
if h.strip() and h.strip() != "<local>" if h.strip() and h.strip() != "<local>"
] ]
for service in _mac_network_services(): services = _mac_network_services()
_mac_run_networksetup(["-setwebproxy", service, host, str(port)]) commands: list[list[str]] = []
_mac_run_networksetup(["-setsecurewebproxy", service, host, str(port)]) for service in services:
_mac_run_networksetup(["-setwebproxystate", service, "on"]) commands.extend([
_mac_run_networksetup(["-setsecurewebproxystate", service, "on"]) ["-setwebproxy", service, host, str(port)],
["-setsecurewebproxy", service, host, str(port)],
["-setwebproxystate", service, "on"],
["-setsecurewebproxystate", service, "on"],
])
if bypass_hosts: if bypass_hosts:
_mac_run_networksetup(["-setproxybypassdomains", service, *bypass_hosts]) commands.append(["-setproxybypassdomains", service, *bypass_hosts])
log.info("macOS system proxy set to %s:%s for %d service(s)", host, port, len(_mac_network_services())) script = _mac_networksetup_script(commands)
code, _, err = run_shell(script)
if code != 0:
log.info("networksetup needs administrator approval; requesting macOS credentials.")
code, _, err = run_shell_as_admin(script)
if code != 0:
raise RuntimeError(f"macOS system proxy apply failed: {err.strip() or 'networksetup failed'}")
log.info("macOS system proxy set to %s:%s for %d service(s)", host, port, len(services))
def _mac_clear_system_proxy() -> None: def _mac_clear_system_proxy() -> None:
for service in _mac_network_services(): services = _mac_network_services()
_mac_run_networksetup(["-setwebproxystate", service, "off"]) commands = []
_mac_run_networksetup(["-setsecurewebproxystate", service, "off"]) for service in services:
log.info("macOS system proxy cleared for %d service(s)", len(_mac_network_services())) commands.extend([
["-setwebproxystate", service, "off"],
["-setsecurewebproxystate", service, "off"],
])
if not commands:
return
script = _mac_networksetup_script(commands)
code, _, err = run_shell(script)
if code != 0:
code, _, err = run_shell_as_admin(script)
if code != 0:
raise RuntimeError(f"macOS system proxy clear failed: {err.strip() or 'networksetup failed'}")
log.info("macOS system proxy cleared for %d service(s)", len(services))
def _mac_system_proxy_set() -> bool: def _mac_system_proxy_set() -> bool:

View File

@@ -21,6 +21,7 @@ from __future__ import annotations
import json import json
import logging import logging
import subprocess import subprocess
import sys
import winreg import winreg
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -175,6 +176,8 @@ def _delete_reg_value(hive: int, path: str, name: str) -> None:
def telemetry_status() -> dict[str, str]: def telemetry_status() -> dict[str, str]:
"""Read-only snapshot for the audit panel.""" """Read-only snapshot for the audit panel."""
if sys.platform == "darwin":
return {"macOS": "not applicable"}
out: dict[str, str] = {} out: dict[str, str] = {}
for svc in _SERVICES: for svc in _SERVICES:
out[f"svc.{svc}"] = _service_start_type(svc) out[f"svc.{svc}"] = _service_start_type(svc)
@@ -199,6 +202,8 @@ def telemetry_status() -> dict[str, str]:
def engage_telemetry_kill() -> tuple[TelemetrySnapshot | None, list[str]]: def engage_telemetry_kill() -> tuple[TelemetrySnapshot | None, list[str]]:
logs: list[str] = [] logs: list[str] = []
if sys.platform == "darwin":
return None, ["Telemetry kill is Windows-only; no macOS telemetry mutation applied."]
if not is_admin(): if not is_admin():
return None, ["Telemetry kill skipped — needs Administrator."] return None, ["Telemetry kill skipped — needs Administrator."]
snap = TelemetrySnapshot() snap = TelemetrySnapshot()
@@ -240,6 +245,8 @@ def engage_telemetry_kill() -> tuple[TelemetrySnapshot | None, list[str]]:
def restore_telemetry(snap: TelemetrySnapshot | None) -> list[str]: def restore_telemetry(snap: TelemetrySnapshot | None) -> list[str]:
if sys.platform == "darwin":
return []
if snap is None or not is_admin(): if snap is None or not is_admin():
return [] return []
logs: list[str] = [] logs: list[str] = []

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
import logging import logging
import socket import socket
import subprocess import subprocess
import sys
import winreg import winreg
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -78,6 +79,8 @@ def check_chromium_webrtc_policy() -> tuple[bool, str]:
Accepts either the legacy DWORD DefaultWebRtcIpHandlingPolicy==3 Accepts either the legacy DWORD DefaultWebRtcIpHandlingPolicy==3
OR the modern REG_SZ WebRtcIPHandling=="disable_non_proxied_udp". OR the modern REG_SZ WebRtcIPHandling=="disable_non_proxied_udp".
""" """
if sys.platform != "win32":
return True, "Chrome/Edge registry policy not applicable on macOS"
found: list[str] = [] found: list[str] = []
missing: list[str] = [] missing: list[str] = []
for path in _CHROMIUM_POLICY_PATHS: for path in _CHROMIUM_POLICY_PATHS:
@@ -207,6 +210,7 @@ def run_webrtc_check(profile_dir: Path | None = None) -> WebRtcCheckResult:
policy_ok, policy_detail = check_chromium_webrtc_policy() policy_ok, policy_detail = check_chromium_webrtc_policy()
res.chrome_edge_policy_set = policy_ok res.chrome_edge_policy_set = policy_ok
res.chrome_edge_policy_detail = policy_detail res.chrome_edge_policy_detail = policy_detail
if sys.platform == "win32":
res.add("Chrome/Edge WebRTC policy (HKLM)", policy_ok, policy_detail) res.add("Chrome/Edge WebRTC policy (HKLM)", policy_ok, policy_detail)
# 2. Firefox profile prefs # 2. Firefox profile prefs
@@ -223,8 +227,14 @@ def run_webrtc_check(profile_dir: Path | None = None) -> WebRtcCheckResult:
stun_reachable, stun_detail = check_stun_reachability() stun_reachable, stun_detail = check_stun_reachability()
res.stun_reachable = stun_reachable res.stun_reachable = stun_reachable
res.stun_detail = stun_detail res.stun_detail = stun_detail
# STUN reachable = risk; NOT reachable = good (firewall is blocking it) # STUN reachable is only a leak risk when the active browser profile has
res.add("STUN UDP reachability", not stun_reachable, stun_detail) # not disabled WebRTC. On macOS the clean port is Firefox-profile based,
# not OS registry based.
stun_ok = (not stun_reachable) or (sys.platform != "win32" and res.firefox_prefs_ok)
detail = stun_detail
if stun_reachable and stun_ok:
detail += " — browser profile disables WebRTC, so UDP reachability is informational"
res.add("STUN UDP reachability", stun_ok, detail)
# Overall verdict # Overall verdict
res.any_leak_risk = bool(res.issues) res.any_leak_risk = bool(res.issues)

View File

@@ -3,10 +3,53 @@ from __future__ import annotations
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import plistlib
from .paths import app_data_dir from .paths import app_data_dir
TASK_NAME = "ProxyChainManagerAutoStart" TASK_NAME = "ProxyChainManagerAutoStart"
LAUNCH_AGENT_ID = "local.proxy-god.mac"
def _launch_agent_path() -> Path:
p = Path.home() / "Library" / "LaunchAgents"
p.mkdir(parents=True, exist_ok=True)
return p / f"{LAUNCH_AGENT_ID}.plist"
def _mac_program_arguments() -> list[str]:
root = Path(__file__).resolve().parent.parent
return [str(root / "scripts" / "run_proxy_god.sh")]
def _install_launch_agent() -> tuple[bool, str]:
plist_path = _launch_agent_path()
data = {
"Label": LAUNCH_AGENT_ID,
"ProgramArguments": _mac_program_arguments(),
"RunAtLoad": True,
"WorkingDirectory": str(Path(__file__).resolve().parent.parent),
"StandardOutPath": str(Path.home() / "Library" / "Logs" / "ProxyGod.out.log"),
"StandardErrorPath": str(Path.home() / "Library" / "Logs" / "ProxyGod.err.log"),
}
try:
plist_path.write_bytes(plistlib.dumps(data))
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True, timeout=10)
r = subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True, text=True, timeout=10)
out = (r.stdout or "") + (r.stderr or "")
return r.returncode == 0, out.strip() or "LaunchAgent installed"
except Exception as e:
return False, str(e)
def _uninstall_launch_agent() -> tuple[bool, str]:
plist_path = _launch_agent_path()
try:
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True, timeout=10)
plist_path.unlink(missing_ok=True)
return True, "LaunchAgent removed"
except Exception as e:
return False, str(e)
def _write_launcher_cmd() -> Path: def _write_launcher_cmd() -> Path:
@@ -30,6 +73,8 @@ def _write_launcher_cmd() -> Path:
def install_logon_task() -> tuple[bool, str]: def install_logon_task() -> tuple[bool, str]:
if sys.platform == "darwin":
return _install_launch_agent()
launcher = _write_launcher_cmd() launcher = _write_launcher_cmd()
tr = str(launcher) tr = str(launcher)
try: try:
@@ -58,6 +103,8 @@ def install_logon_task() -> tuple[bool, str]:
def uninstall_logon_task() -> tuple[bool, str]: def uninstall_logon_task() -> tuple[bool, str]:
if sys.platform == "darwin":
return _uninstall_launch_agent()
try: try:
r = subprocess.run( r = subprocess.run(
["schtasks", "/Delete", "/F", "/TN", TASK_NAME], ["schtasks", "/Delete", "/F", "/TN", TASK_NAME],
@@ -72,6 +119,8 @@ def uninstall_logon_task() -> tuple[bool, str]:
def task_exists() -> bool: def task_exists() -> bool:
if sys.platform == "darwin":
return _launch_agent_path().is_file()
try: try:
r = subprocess.run( r = subprocess.run(
["schtasks", "/Query", "/TN", TASK_NAME], ["schtasks", "/Query", "/TN", TASK_NAME],