From 094e0577fb4a9ccf97dffd7c9a22ecb3ba6d75b9 Mon Sep 17 00:00:00 2001 From: Indiana Holmes Date: Sat, 16 May 2026 23:07:18 -0700 Subject: [PATCH] Spoof-when-possible browser personas, Tier-2 telemetry kill + artifact wipe, UI polish Browser: identity persona dropdown (blend Windows-Chrome / Firefox / mac-Safari / hardened / custom) + cookie policy dropdown. Blend personas relax RFP/FPI/strict-TP so the machine looks normal while still hiding behind the chain. Persona TZ applied via env var; UA / language / locale / screen via user.js. WebRTC still forced off (it leaks real IP). Privacy: Telemetry kill toggle (DiagTrack, Activity History, ad ID, Cortana, scheduled tasks) with full snapshot/restore. One-button forensic artifact wipe (TEMP, Recent, Jump Lists, Prefetch, MRU, clipboard). UI: neon palette, glow card borders, gradient header banner. Co-authored-by: Cursor --- proxy_chain_manager/app.py | 218 ++++++++++++++++-- proxy_chain_manager/artifact_wipe.py | 218 ++++++++++++++++++ proxy_chain_manager/browser_identity.py | 141 ++++++++++++ proxy_chain_manager/browser_launcher.py | 10 +- proxy_chain_manager/browser_profile.py | 113 ++++++++- proxy_chain_manager/config.py | 3 + proxy_chain_manager/service.py | 20 ++ proxy_chain_manager/telemetry_kill.py | 292 ++++++++++++++++++++++++ tests/test_proxy_god.py | 59 +++++ 9 files changed, 1050 insertions(+), 24 deletions(-) create mode 100644 proxy_chain_manager/artifact_wipe.py create mode 100644 proxy_chain_manager/browser_identity.py create mode 100644 proxy_chain_manager/telemetry_kill.py diff --git a/proxy_chain_manager/app.py b/proxy_chain_manager/app.py index 37e4a29..86b050c 100644 --- a/proxy_chain_manager/app.py +++ b/proxy_chain_manager/app.py @@ -35,6 +35,13 @@ from .firewall import ( is_engaged as fw_is_engaged, request_admin_relaunch, ) +from .artifact_wipe import wipe_artifacts +from .browser_identity import ( + COOKIE_LABELS, + COOKIE_MODES, + PERSONA_LABELS, + PERSONAS, +) from .dns_leak import check_dns_leak_hint, flush_dns_cache from .leak_audit import AuditReport, run_audit_sync from .browser_launcher import ( @@ -80,22 +87,26 @@ class UILogHandler(logging.Handler): # ── palette ────────────────────────────────────────────────────────────────── -BG = "#0d0d1a" -CARD = "#12122a" -PANEL = "#1a1a3e" -ACCENT = "#1e3a8a" -ACCENT2 = "#2563eb" -GREEN = "#00e676" -RED = "#ff1744" -YELLOW = "#ffab00" -ORANGE = "#ff6d00" -PURPLE = "#bb86fc" -BLUE = "#448aff" -CYAN = "#00bcd4" -DIM = "#4a5568" -TEXT = "#e2e8f0" -TEXT2 = "#94a3b8" -FONT = "Segoe UI" +# Neon "blueprint terminal" theme — same overall vibe, deeper black, sharper +# cyan/magenta accent, glow borders rendered via 1px border_color on cards. +BG = "#070713" +CARD = "#0f0f24" +PANEL = "#171738" +ACCENT = "#3052ff" # primary action / outlines +ACCENT2 = "#00e5ff" # secondary highlight / glow +GREEN = "#00ff9c" +RED = "#ff2a6d" +YELLOW = "#ffd000" +ORANGE = "#ff8a00" +PURPLE = "#c77dff" +BLUE = "#5b8bff" +CYAN = "#00f5ff" +MAGENTA = "#ff00d6" +DIM = "#2a2f4a" +TEXT = "#eaf2ff" +TEXT2 = "#7a8aab" +GLOW = "#0a87ff" +FONT = "Segoe UI" def _ts() -> str: @@ -108,10 +119,56 @@ def main() -> None: root = ctk.CTk() root.title("Proxy God v2") - root.geometry("1080x740") + root.geometry("1080x760") root.minsize(900, 620) root.configure(fg_color=BG) + # ── 3D-ish neon header banner (drawn on a Canvas for a true gradient) ──── + def _draw_header_banner(parent: Any) -> Any: + import tkinter as tk + canvas = tk.Canvas( + parent, height=44, highlightthickness=0, bd=0, bg=BG, + ) + canvas.pack(fill="x", side="top") + + def _hex(rgb: tuple[int, int, int]) -> str: + return "#" + "".join(f"{v:02x}" for v in rgb) + + def _lerp(a: tuple[int, int, int], b: tuple[int, int, int], t: float) -> tuple[int, int, int]: + return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) # type: ignore[return-value] + + def _repaint(_event: Any = None) -> None: + canvas.delete("grad") + w = canvas.winfo_width() or 1080 + h = 44 + left = (8, 32, 64) # near-black blue + mid = (48, 82, 255) # ACCENT + right = (0, 229, 255) # ACCENT2 cyan + for x in range(0, w, 2): + t = x / max(1, w - 1) + c = _lerp(left, mid, t * 2) if t < 0.5 else _lerp(mid, right, (t - 0.5) * 2) + canvas.create_line(x, 0, x, h, fill=_hex(c), tags="grad") + # 1px neon underline + canvas.create_line(0, h - 1, w, h - 1, fill=CYAN, width=1, tags="grad") + # Title text + small subtitle + canvas.create_text( + 18, h // 2 - 2, anchor="w", + text="P R O X Y G O D", + fill="#ffffff", font=(FONT, 16, "bold"), + tags="grad", + ) + canvas.create_text( + 190, h // 2 - 1, anchor="w", + text="// multi-hop · anti-fingerprint · paranoid mode", + fill="#bfeaff", font=(FONT, 9, "italic"), + tags="grad", + ) + + canvas.bind("", _repaint) + return canvas + + _draw_header_banner(root) + ui_q: queue.Queue[dict[str, Any]] = queue.Queue() svc = ChainService(notify=lambda m: ui_q.put(m)) s = load_settings() @@ -847,7 +904,10 @@ def main() -> None: ctk.CTkLabel( br_scroll, text=subtitle, font=(FONT, 10), text_color=TEXT2, wraplength=900, justify="left" ).pack(anchor="w", padx=8, pady=(0, 4)) - f = ctk.CTkFrame(br_scroll, fg_color=CARD, corner_radius=8) + f = ctk.CTkFrame( + br_scroll, fg_color=CARD, corner_radius=10, + border_width=1, border_color=GLOW, + ) f.pack(fill="x", padx=8, pady=(4, 8)) return f @@ -895,6 +955,78 @@ def main() -> None: "Browser controls", "These settings are applied each time you launch from this tab.", ) + persona_card = _section_inline( + br_scroll, + "Identity persona (spoof when possible, block when not)", + "Blend-in personas mimic the most common Windows + Chrome setup so the " + "browser looks normal. Hardened mode is the classic 'block everything' " + "profile but stands out as anti-fingerprint.", + ) if "_section_inline" in dir() else None + if persona_card is None: + persona_card = ctk.CTkFrame( + br_scroll, fg_color=CARD, corner_radius=10, + border_width=1, border_color=GLOW, + ) + persona_card.pack(fill="x", padx=8, pady=(2, 8)) + ctk.CTkLabel( + persona_card, + text="Identity persona (spoof when possible, block when not)", + font=(FONT, 12, "bold"), + text_color=ORANGE, + ).pack(anchor="w", padx=12, pady=(10, 0)) + ctk.CTkLabel( + persona_card, + text=( + "Blend-in personas mimic the most common Windows + Chrome setup " + "so the browser looks normal. Hardened = full anti-fingerprint." + ), + font=(FONT, 9), text_color=TEXT2, justify="left", wraplength=820, + ).pack(anchor="w", padx=12, pady=(0, 6)) + + persona_row = ctk.CTkFrame(persona_card, fg_color="transparent") + persona_row.pack(fill="x", padx=12, pady=(0, 6)) + ctk.CTkLabel(persona_row, text="Persona:", font=(FONT, 11), text_color=TEXT, + width=80, anchor="w").pack(side="left") + persona_var = ctk.StringVar( + value=PERSONA_LABELS.get(s.browser_persona, PERSONA_LABELS["blend_windows_chrome"]) + ) + persona_menu = ctk.CTkOptionMenu( + persona_row, + values=[PERSONA_LABELS[k] for k in PERSONAS], + variable=persona_var, + fg_color=BG, button_color=ACCENT2, button_hover_color=ACCENT, + text_color=TEXT, font=(FONT, 11), width=500, + ) + persona_menu.pack(side="left", padx=(4, 0)) + + cookie_row = ctk.CTkFrame(persona_card, fg_color="transparent") + cookie_row.pack(fill="x", padx=12, pady=(0, 10)) + ctk.CTkLabel(cookie_row, text="Cookies:", font=(FONT, 11), text_color=TEXT, + width=80, anchor="w").pack(side="left") + cookie_var = ctk.StringVar( + value=COOKIE_LABELS.get(s.browser_cookie_mode, COOKIE_LABELS["block_third_party"]) + ) + cookie_menu = ctk.CTkOptionMenu( + cookie_row, + values=[COOKIE_LABELS[k] for k in COOKIE_MODES], + variable=cookie_var, + fg_color=BG, button_color=ACCENT2, button_hover_color=ACCENT, + text_color=TEXT, font=(FONT, 11), width=500, + ) + cookie_menu.pack(side="left", padx=(4, 0)) + + def _persona_key() -> str: + for k in PERSONAS: + if PERSONA_LABELS[k] == persona_var.get(): + return k + return "blend_windows_chrome" + + def _cookie_key() -> str: + for k in COOKIE_MODES: + if COOKIE_LABELS[k] == cookie_var.get(): + return k + return "block_third_party" + br_force_proxy_var = ctk.BooleanVar(value=s.browser_force_proxy) br_disable_webrtc_var = ctk.BooleanVar(value=s.browser_disable_webrtc) br_rfp_var = ctk.BooleanVar(value=s.browser_resist_fingerprinting) @@ -984,6 +1116,8 @@ def main() -> None: first_party_isolation=bool(br_fpi_var.get()), strict_tracking_protection=bool(br_strict_tp_var.get()), timezone_utc=bool(br_tz_utc_var.get()), + persona=_persona_key(), + cookie_mode=_cookie_key(), ) def _refresh_browser_status() -> None: @@ -1043,7 +1177,10 @@ def main() -> None: ctk.CTkLabel(priv_scroll, text=subtitle, font=(FONT, 10), text_color=TEXT2, wraplength=900, justify="left").pack( anchor="w", padx=8, pady=(0, 4)) - f = ctk.CTkFrame(priv_scroll, fg_color=CARD, corner_radius=8) + f = ctk.CTkFrame( + priv_scroll, fg_color=CARD, corner_radius=10, + border_width=1, border_color=GLOW, + ) f.pack(fill="x", padx=8, pady=(4, 8)) return f @@ -1090,6 +1227,7 @@ def main() -> None: ipv6_var = ctk.BooleanVar(value=s.disable_ipv6_while_active) webrtc_var = ctk.BooleanVar(value=s.harden_webrtc_enabled) lan_var = ctk.BooleanVar(value=s.lan_lockdown_enabled) + telemetry_var = ctk.BooleanVar(value=s.telemetry_kill_enabled) def _priv_toggle(parent: Any, text: str, var: ctk.BooleanVar, tip: str = "") -> ctk.CTkCheckBox: cb = ctk.CTkCheckBox( @@ -1132,6 +1270,13 @@ def main() -> None: "Stops your machine from advertising its hostname on the local network " "(admin required, reversible on stop).", ) + _priv_toggle( + toggles_card, + "Telemetry kill: DiagTrack, Activity History, Cortana web, ad ID", + telemetry_var, + "Stops Windows telemetry pipelines while chain is up. Reversible on stop " + "(snapshot of original state taken).", + ) fp_card = _priv_section( "Device fingerprint", @@ -1177,6 +1322,35 @@ def main() -> None: _btn(dns_btn_row, "Flush DNS now", _manual_dns_flush, w=110, h=28, fg_color=DIM).pack(side="left") + wipe_card = _priv_section( + "Forensic artifact wipe", + "One-button purge of common Windows breadcrumb trails. Irreversible.", + ) + wipe_result_lbl = ctk.CTkLabel( + wipe_card, text="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", + ) + wipe_result_lbl.pack(anchor="w", padx=12, pady=8) + + def _do_wipe() -> None: + wipe_result_lbl.configure(text="Wiping…", text_color=YELLOW) + + def work() -> None: + try: + rep = wipe_artifacts(include_prefetch=True) + except Exception as e: + root.after(0, lambda: wipe_result_lbl.configure( + text=f"Wipe failed: {e}", text_color=RED)) + return + root.after(0, lambda: wipe_result_lbl.configure( + text=rep.summary(), text_color=GREEN if not rep.errors else YELLOW)) + + threading.Thread(target=work, daemon=True).start() + + _btn(wipe_card, "Wipe now", _do_wipe, w=110, h=28, + fg_color=RED, hover_color="#8a1d1d").pack(anchor="w", padx=12, pady=(0, 10)) + audit_card = _priv_section( "Leak audit (mission-critical)", "Probes every leak surface: IP, DNS, IPv6, WPAD, Group Policy, " @@ -1310,6 +1484,9 @@ def main() -> None: ipv6_var.set(False) webrtc_var.set(True) lan_var.set(False) + telemetry_var.set(False) + persona_var.set(PERSONA_LABELS["blend_windows_chrome"]) + cookie_var.set(COOKIE_LABELS["block_third_party"]) br_force_proxy_var.set(True) br_disable_webrtc_var.set(True) br_rfp_var.set(True) @@ -1381,6 +1558,7 @@ def main() -> None: disable_ipv6_while_active=bool(ipv6_var.get()), harden_webrtc_enabled=bool(webrtc_var.get()), lan_lockdown_enabled=bool(lan_var.get()), + telemetry_kill_enabled=bool(telemetry_var.get()), firefox_path=firefox_path_var.get().strip(), firefox_profile_dir=firefox_profile_var.get().strip(), browser_clear_on_close=bool(br_clear_var.get()), @@ -1395,6 +1573,8 @@ def main() -> None: browser_first_party_isolation=bool(br_fpi_var.get()), browser_strict_tracking_protection=bool(br_strict_tp_var.get()), browser_timezone_utc=bool(br_tz_utc_var.get()), + browser_persona=_persona_key(), + browser_cookie_mode=_cookie_key(), ) if not (1 <= ns.local_port <= 65535): raise ValueError("Port must be 1-65535") diff --git a/proxy_chain_manager/artifact_wipe.py b/proxy_chain_manager/artifact_wipe.py new file mode 100644 index 0000000..f9f7347 --- /dev/null +++ b/proxy_chain_manager/artifact_wipe.py @@ -0,0 +1,218 @@ +"""One-button forensic artifact wipe. + +Surfaces purged: + + • %TEMP% — per-user temp + • %SystemRoot%\\Prefetch\\*.pf (Admin only) — file-launch history + • %APPDATA%\\Microsoft\\Windows\\Recent\\* — recent files + • %APPDATA%\\Microsoft\\Windows\\Recent\\AutomaticDestinations\\* — Jump Lists + • %APPDATA%\\Microsoft\\Windows\\Recent\\CustomDestinations\\* — Jump Lists + • HKCU\\…\\Explorer\\RunMRU — Win+R history + • HKCU\\…\\Explorer\\TypedPaths — Explorer typed paths + • HKCU\\…\\Explorer\\WordWheelQuery — Start / Explorer search history + • HKCU\\…\\Explorer\\RecentDocs — recent docs MRU + • Clipboard — current contents + +Counts are reported but specific filenames are never logged (this is the +opposite of what we want to leak). All deletes use ``ignore_errors=True`` +because files in use by other apps are expected. +""" +from __future__ import annotations + +import ctypes +import logging +import os +import shutil +import subprocess +import winreg +from dataclasses import dataclass, field +from pathlib import Path + +from .firewall import is_admin + +log = logging.getLogger(__name__) + + +_REG_MRU_KEYS = ( + r"Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU", + r"Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths", + r"Software\Microsoft\Windows\CurrentVersion\Explorer\WordWheelQuery", + r"Software\Microsoft\Windows\CurrentVersion\Explorer\RecentDocs", +) + + +@dataclass +class WipeReport: + files_deleted: int = 0 + bytes_freed: int = 0 + folders_skipped: int = 0 + registry_keys_cleared: int = 0 + clipboard_cleared: bool = False + errors: list[str] = field(default_factory=list) + + def summary(self) -> str: + mb = self.bytes_freed / (1024 * 1024) + return ( + f"{self.files_deleted} files / {mb:.1f} MB freed, " + f"{self.registry_keys_cleared} MRU entries cleared, " + f"clipboard={'yes' if self.clipboard_cleared else 'no'}, " + f"errors={len(self.errors)}" + ) + + +def _walk_size(path: Path) -> int: + total = 0 + for p in path.rglob("*"): + try: + if p.is_file(): + total += p.stat().st_size + except OSError: + pass + return total + + +def _purge_dir(path: Path, rep: WipeReport, keep_root: bool = True) -> None: + """Delete contents of `path`. Preserves the directory itself when keep_root.""" + if not path.exists(): + return + try: + size_before = _walk_size(path) + except Exception: + size_before = 0 + + count = 0 + for child in path.iterdir(): + try: + if child.is_dir(): + shutil.rmtree(child, ignore_errors=True) + else: + child.unlink(missing_ok=True) + count += 1 + except OSError: + rep.folders_skipped += 1 + + if not keep_root: + try: + path.rmdir() + except OSError: + pass + + rep.files_deleted += count + try: + size_after = _walk_size(path) + except Exception: + size_after = 0 + rep.bytes_freed += max(0, size_before - size_after) + + +def _clear_mru_key(path: str, rep: WipeReport) -> None: + """Remove every value under a Run/Typed/Search MRU key.""" + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_ALL_ACCESS + ) as key: + count = 0 + try: + while True: + name, _, _ = winreg.EnumValue(key, 0) + try: + winreg.DeleteValue(key, name) + count += 1 + except OSError: + break + except OSError: + pass + # Recurse into subkeys (e.g. RecentDocs/.png) + sub_count = 0 + try: + while True: + sub_count += 1 + sub_name = winreg.EnumKey(key, 0) + try: + winreg.DeleteKey(key, sub_name) + except OSError: + break + if sub_count > 200: # safety cap + break + except OSError: + pass + if count or sub_count: + rep.registry_keys_cleared += 1 + except OSError: + pass + + +def _clear_clipboard(rep: WipeReport) -> None: + try: + user32 = ctypes.windll.user32 # type: ignore[attr-defined] + if user32.OpenClipboard(None): + try: + user32.EmptyClipboard() + rep.clipboard_cleared = True + finally: + user32.CloseClipboard() + except Exception as e: + rep.errors.append(f"clipboard: {e}") + + +def wipe_artifacts(include_prefetch: bool = True) -> WipeReport: + rep = WipeReport() + 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 + sysroot = Path(os.environ.get("SystemRoot", r"C:\Windows")) + + # %TEMP% + if temp and temp.exists(): + _purge_dir(temp, rep, keep_root=True) + + # Prefetch (Admin) + if include_prefetch and is_admin(): + pf = sysroot / "Prefetch" + if pf.exists(): + count = 0 + for f in pf.glob("*.pf"): + try: + sz = f.stat().st_size + f.unlink(missing_ok=True) + count += 1 + rep.bytes_freed += sz + except OSError as e: + rep.errors.append(f"prefetch: {e}") + rep.files_deleted += count + + # Recent / Jump Lists + if appdata: + recent = appdata / "Microsoft" / "Windows" / "Recent" + if recent.exists(): + _purge_dir(recent / "AutomaticDestinations", rep, keep_root=True) + _purge_dir(recent / "CustomDestinations", rep, keep_root=True) + _purge_dir(recent, rep, keep_root=True) + + # MRU registry keys + for path in _REG_MRU_KEYS: + _clear_mru_key(path, rep) + + # Clipboard + _clear_clipboard(rep) + + # Trigger Explorer's "Clear recent items" via Shell API (covers Win10/11 + # Quick Access pinned ↔ recent lists not covered by raw file delete). + try: + shell32 = ctypes.windll.shell32 # type: ignore[attr-defined] + # SHCNE_ASSOCCHANGED tells Explorer to refresh its caches. + shell32.SHChangeNotify(0x08000000, 0x0000, None, None) + except Exception: + pass + + # Best-effort Defender quick-scan history flush — non-fatal. + try: + subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", + "Clear-RecycleBin -Force -ErrorAction SilentlyContinue"], + capture_output=True, text=True, timeout=20, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except Exception: + pass + + return rep diff --git a/proxy_chain_manager/browser_identity.py b/proxy_chain_manager/browser_identity.py new file mode 100644 index 0000000..816d32b --- /dev/null +++ b/proxy_chain_manager/browser_identity.py @@ -0,0 +1,141 @@ +"""Browser identity personas + cookie policies. + +The "lock everything down" approach (Firefox RFP + WebRTC off + cookies blocked) +keeps you safe from fingerprinting but makes the machine *look weird* — sites +flag you as suspicious and ban / captcha you. Worse: very few users use that +config, so RFP-on is itself a fingerprint. + +Two strategies are exposed: + + • **Blend-in personas** — mimic the most common Windows-Chrome-US-en setup. + JS / cookies / WebGL all work; everything looks normal; only WebRTC is + blocked (because that leaks the real IP behind the proxy). The proxy + chain itself does the actual hiding. + + • **Hardened** — full RFP / blocked WebRTC / strict tracking / FPI. Stand-out + but maximum block. Same behavior as the original profile. + +Cookie modes are independent of persona and let the user pick per-session +behavior from a dropdown. +""" +from __future__ import annotations + +from dataclasses import dataclass + +# Order matters: dropdown order in the UI. +PERSONAS: list[str] = [ + "blend_windows_chrome", + "blend_windows_firefox", + "blend_mac_safari", + "hardened", + "custom", +] + +PERSONA_LABELS: dict[str, str] = { + "blend_windows_chrome": "Blend in — Windows 10 + Chrome (US-en, most common)", + "blend_windows_firefox": "Blend in — Windows 10 + Firefox (US-en)", + "blend_mac_safari": "Blend in — macOS + Safari (US-en)", + "hardened": "Hardened — block + RFP (stands out; max anti-fingerprint)", + "custom": "Custom — use individual hardening toggles", +} + +COOKIE_MODES: list[str] = [ + "accept_all", + "block_third_party", + "block_third_party_trackers", + "session_only", + "block_all", +] + +COOKIE_LABELS: dict[str, str] = { + "accept_all": "Accept all cookies (most sites work)", + "block_third_party": "Block 3rd-party cookies (recommended)", + "block_third_party_trackers": "Block 3rd-party trackers only (Firefox default-strict)", + "session_only": "Session only — clear on close", + "block_all": "Block ALL cookies (breaks logins)", +} + +# Firefox network.cookie.cookieBehavior values +_COOKIE_BEHAVIOR = { + "accept_all": 0, + "block_third_party": 1, + "block_third_party_trackers": 4, + "session_only": 0, + "block_all": 2, +} + + +@dataclass(frozen=True) +class Persona: + """A user-agent / locale / timezone / screen tuple that mimics a real, + common configuration. All fields are deliberately popular values so the + persona blends in. + """ + key: str + user_agent: str + accept_language: str + timezone: str # IANA tz; used only when persona drives env TZ + screen_w: int + screen_h: int + platform: str # navigator.platform + locale: str # general.useragent.locale equivalent + + +PERSONA_DATA: dict[str, Persona] = { + "blend_windows_chrome": Persona( + key="blend_windows_chrome", + # Stable Chrome on Win10 x64 — by far the most common UA on the web. + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + ), + accept_language="en-US,en;q=0.9", + timezone="America/New_York", + screen_w=1920, + screen_h=1080, + platform="Win32", + locale="en-US", + ), + "blend_windows_firefox": Persona( + key="blend_windows_firefox", + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) " + "Gecko/20100101 Firefox/128.0" + ), + accept_language="en-US,en;q=0.5", + timezone="America/New_York", + screen_w=1920, + screen_h=1080, + platform="Win32", + locale="en-US", + ), + "blend_mac_safari": Persona( + key="blend_mac_safari", + user_agent=( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15" + ), + accept_language="en-US,en;q=0.9", + timezone="America/Los_Angeles", + screen_w=1680, + screen_h=1050, + platform="MacIntel", + locale="en-US", + ), +} + + +def cookie_behavior_value(mode: str) -> int: + return _COOKIE_BEHAVIOR.get(mode, 1) + + +def cookie_session_only(mode: str) -> bool: + return mode == "session_only" + + +def get_persona(key: str) -> Persona | None: + return PERSONA_DATA.get(key) + + +def is_blend_persona(key: str) -> bool: + return key.startswith("blend_") diff --git a/proxy_chain_manager/browser_launcher.py b/proxy_chain_manager/browser_launcher.py index 844ccd8..a0b54f0 100644 --- a/proxy_chain_manager/browser_launcher.py +++ b/proxy_chain_manager/browser_launcher.py @@ -1,13 +1,14 @@ from __future__ import annotations import logging +import os import shutil import subprocess import time from dataclasses import dataclass from pathlib import Path -from .browser_profile import FirefoxHardening, ensure_firefox_profile +from .browser_profile import FirefoxHardening, ensure_firefox_profile, persona_env from .paths import app_data_dir log = logging.getLogger(__name__) @@ -29,6 +30,8 @@ class BrowserConfig: first_party_isolation: bool = True strict_tracking_protection: bool = True timezone_utc: bool = True + persona: str = "blend_windows_chrome" + cookie_mode: str = "block_third_party" def default_firefox_path() -> str: @@ -80,6 +83,8 @@ class BrowserSession: strict_tracking_protection=cfg.strict_tracking_protection, clear_on_shutdown=cfg.clear_on_close, timezone_utc=cfg.timezone_utc, + persona_key=cfg.persona, + cookie_mode=cfg.cookie_mode, ) if cfg.lock_managed_profile: ensure_firefox_profile(profile_dir, proxy_host, int(proxy_port), hard) @@ -89,6 +94,8 @@ class BrowserSession: args = [str(exe), "-no-remote", "-profile", str(profile_dir)] else: args = [str(exe)] + env = os.environ.copy() + env.update(persona_env(hard)) try: self._proc = subprocess.Popen( args, @@ -96,6 +103,7 @@ class BrowserSession: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True, + env=env, ) # Smoke-check: catch immediate startup crashes and report clearly. time.sleep(0.4) diff --git a/proxy_chain_manager/browser_profile.py b/proxy_chain_manager/browser_profile.py index 9089870..8ea96fb 100644 --- a/proxy_chain_manager/browser_profile.py +++ b/proxy_chain_manager/browser_profile.py @@ -1,13 +1,24 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +from .browser_identity import ( + Persona, + cookie_behavior_value, + cookie_session_only, + get_persona, + is_blend_persona, +) + @dataclass class FirefoxHardening: + # Proxy & network always-on force_proxy: bool = True - disable_webrtc: bool = True + disable_webrtc: bool = True # always strongly recommended; leaks real IP + + # Hardened block mode — overrides persona spoofing resist_fingerprinting: bool = True disable_telemetry: bool = True first_party_isolation: bool = True @@ -15,6 +26,46 @@ class FirefoxHardening: clear_on_shutdown: bool = True timezone_utc: bool = True + # Persona / cookie selectors (added in "spoof when possible" mode) + persona_key: str = "custom" # see browser_identity.PERSONAS + cookie_mode: str = "block_third_party" # see browser_identity.COOKIE_MODES + + # Optional Firefox-managed user-agent override even outside of a persona + user_agent_override: str = "" + + # Filled in by ``apply_persona`` when persona_key starts with "blend_". + persona: Persona | None = field(default=None, repr=False, compare=False) + + +def apply_persona(hard: FirefoxHardening) -> FirefoxHardening: + """Mutate hardening flags consistent with the persona choice. + + Blend-in personas relax the "stand out" toggles (RFP, FPI, strict TP) but + keep the IP-leakers off (WebRTC, geolocation, etc.). Cookie mode is + honored. The result is a profile that looks like a normal browser. + """ + if hard.persona_key == "hardened": + hard.resist_fingerprinting = True + hard.first_party_isolation = True + hard.strict_tracking_protection = True + hard.timezone_utc = True + hard.persona = None + return hard + + if is_blend_persona(hard.persona_key): + hard.resist_fingerprinting = False # RFP itself is a fingerprint + hard.first_party_isolation = False # breaks logins on many sites + hard.strict_tracking_protection = False # Firefox default ETP is enough + hard.timezone_utc = False + hard.persona = get_persona(hard.persona_key) + if hard.persona and not hard.user_agent_override: + hard.user_agent_override = hard.persona.user_agent + return hard + + # custom — honor the individual flags as-is. + hard.persona = None + return hard + def _bool(v: bool) -> str: return "true" if v else "false" @@ -25,8 +76,10 @@ def build_user_js( proxy_port: int, hard: FirefoxHardening, ) -> str: + hard = apply_persona(hard) lines: list[str] = [ "// Managed by Proxy God. Changes are overwritten on next launch.", + # Always-on baseline (no UX cost) 'user_pref("app.normandy.enabled", false);', 'user_pref("app.shield.optoutstudies.enabled", false);', 'user_pref("browser.newtabpage.activity-stream.feeds.telemetry", false);', @@ -45,14 +98,14 @@ def build_user_js( 'user_pref("network.trr.mode", 5);', 'user_pref("network.captive-portal-service.enabled", false);', 'user_pref("geo.enabled", false);', - 'user_pref("media.peerconnection.enabled", false);', 'user_pref("media.navigator.enabled", false);', 'user_pref("dom.battery.enabled", false);', 'user_pref("dom.gamepad.enabled", false);', 'user_pref("dom.netinfo.enabled", false);', - 'user_pref("webgl.disabled", false);', + # WebGL allowed by default (blocking it makes you stand out massively) 'user_pref("webgl.enable-debug-renderer-info", false);', ] + if hard.force_proxy: lines.extend( [ @@ -71,7 +124,9 @@ def build_user_js( 'user_pref("network.proxy.no_proxies_on", "");', ] ) + if hard.disable_webrtc: + # Off regardless of persona — WebRTC leaks real IP through STUN. lines.extend( [ 'user_pref("media.peerconnection.enabled", false);', @@ -79,6 +134,7 @@ def build_user_js( 'user_pref("media.peerconnection.ice.no_host", true);', ] ) + if hard.resist_fingerprinting: lines.extend( [ @@ -88,10 +144,13 @@ def build_user_js( 'user_pref("privacy.window.maxInnerHeight", 900);', ] ) + if hard.first_party_isolation: lines.append('user_pref("privacy.firstparty.isolate", true);') + if hard.disable_telemetry: lines.append('user_pref("browser.send_pings", false);') + if hard.strict_tracking_protection: lines.extend( [ @@ -99,6 +158,38 @@ def build_user_js( 'user_pref("privacy.trackingprotection.pbmode.enabled", true);', ] ) + + # Persona spoofing (UA, accept-language, screen, platform) + if hard.persona is not None: + p = hard.persona + lines.extend( + [ + f'user_pref("general.useragent.override", "{p.user_agent}");', + f'user_pref("intl.accept_languages", "{p.accept_language}");', + f'user_pref("general.useragent.locale", "{p.locale}");', + 'user_pref("javascript.use_us_english_locale", true);', + f'user_pref("privacy.window.maxInnerWidth", {int(p.screen_w)});', + f'user_pref("privacy.window.maxInnerHeight", {int(p.screen_h)});', + # Hint timezone — Firefox honors TZ env var at launch (set by + # the launcher) but this pref nudges some site detection too. + f'user_pref("intl.locale.requested", "{p.locale}");', + ] + ) + elif hard.user_agent_override: + lines.append( + f'user_pref("general.useragent.override", "{hard.user_agent_override}");' + ) + + # Cookie policy (persona-independent) + behavior = cookie_behavior_value(hard.cookie_mode) + lines.extend( + [ + f'user_pref("network.cookie.cookieBehavior", {behavior});', + # 0 = expire normally, 2 = current session only + f'user_pref("network.cookie.lifetimePolicy", {2 if cookie_session_only(hard.cookie_mode) else 0});', + ] + ) + if hard.clear_on_shutdown: lines.extend( [ @@ -111,12 +202,26 @@ def build_user_js( 'user_pref("privacy.clearOnShutdown.sessions", true);', ] ) + if hard.timezone_utc: lines.append('user_pref("privacy.resistFingerprinting.reduceTimerPrecision", true);') + lines.append("") return "\n".join(lines) +def persona_env(hard: FirefoxHardening) -> dict[str, str]: + """Environment overrides applied when launching Firefox under a persona. + + Only the timezone needs the OS-level env var (TZ) — Firefox reads it + even when running under Windows. + """ + hard = apply_persona(hard) + if hard.persona is None: + return {} + return {"TZ": hard.persona.timezone} + + def ensure_firefox_profile( profile_dir: Path, proxy_host: str, diff --git a/proxy_chain_manager/config.py b/proxy_chain_manager/config.py index 74b3517..94a63e0 100644 --- a/proxy_chain_manager/config.py +++ b/proxy_chain_manager/config.py @@ -194,6 +194,7 @@ class Settings: disable_ipv6_while_active: bool = False # disable IPv6 bindings while chain runs (Admin) harden_webrtc_enabled: bool = False # Chrome/Edge WebRTC policy (Admin) lan_lockdown_enabled: bool = False # kill LLMNR/NetBIOS/mDNS while chain runs + telemetry_kill_enabled: bool = False # disable DiagTrack + Activity History (Admin) # ── hardened browser ──────────────────────────────────────────────────── firefox_path: str = "" @@ -210,6 +211,8 @@ class Settings: browser_first_party_isolation: bool = True browser_strict_tracking_protection: bool = True browser_timezone_utc: bool = True + browser_persona: str = "blend_windows_chrome" + browser_cookie_mode: str = "block_third_party" # ── sources ─────────────────────────────────────────────────────────── sources: list[str] = field( diff --git a/proxy_chain_manager/service.py b/proxy_chain_manager/service.py index 07f58b0..ce21810 100644 --- a/proxy_chain_manager/service.py +++ b/proxy_chain_manager/service.py @@ -35,6 +35,11 @@ from .privacy_lan import ( engage_lan_lockdown, restore_lan, ) +from .telemetry_kill import ( + TelemetrySnapshot, + engage_telemetry_kill, + restore_telemetry, +) from .sysproxy import ( clear_system_proxy, detect_policy_overrides, @@ -82,6 +87,7 @@ class ChainService: self._ipv6_adapters: list[str] = [] self._webrtc_was_applied: bool = False self._lan_snap: LanSnapshot | None = None + self._telemetry_snap: TelemetrySnapshot | None = None def _manual_exit_url(self) -> str | None: u = normalize_proxy_url(self._settings.manual_exit_proxy) @@ -181,6 +187,16 @@ class ChainService: elif s.lan_lockdown_enabled: self._notify({"type": "log", "text": "LAN lockdown enabled but not Admin — skipped."}) + if s.telemetry_kill_enabled and is_admin(): + tsnap, tlogs = engage_telemetry_kill() + self._telemetry_snap = tsnap + for ln in tlogs[:8]: + self._notify({"type": "log", "text": f"Telemetry: {ln}"}) + if len(tlogs) > 8: + self._notify({"type": "log", "text": f"Telemetry: … +{len(tlogs) - 8} more changes."}) + elif s.telemetry_kill_enabled: + self._notify({"type": "log", "text": "Telemetry kill enabled but not Admin — skipped."}) + def _restore_privacy(self) -> None: if self._mac_originals: for ln in restore_macs(self._mac_originals): @@ -202,6 +218,10 @@ class ChainService: for ln in restore_lan(self._lan_snap): self._notify({"type": "log", "text": f"LAN: {ln}"}) self._lan_snap = None + if self._telemetry_snap is not None and is_admin(): + for ln in restore_telemetry(self._telemetry_snap)[:8]: + self._notify({"type": "log", "text": f"Telemetry: {ln}"}) + self._telemetry_snap = None def _run_thread(self) -> None: try: diff --git a/proxy_chain_manager/telemetry_kill.py b/proxy_chain_manager/telemetry_kill.py new file mode 100644 index 0000000..6ebe836 --- /dev/null +++ b/proxy_chain_manager/telemetry_kill.py @@ -0,0 +1,292 @@ +"""Reversibly disable Windows telemetry / data collection. + +Targets, in order of leak severity: + + • DiagTrack service — primary telemetry pipeline ("Connected User + Experiences and Telemetry"). Phones home every + few minutes. + • dmwappushservice — WAP Push routing (DSMS), historically tied to + DiagTrack. Disabled when present. + • AllowTelemetry (HKLM) — DataCollection policy. 0 = lowest tier. + • Activity History — EnableActivityFeed / Upload / PublishUserActivities. + • Advertising ID — HKCU\\…\\AdvertisingInfo\\Enabled. + • Cortana Bing search — HKCU\\…\\Search\\BingSearchEnabled. + • Scheduled tasks — Compatibility Appraiser + CEIP Consolidator + + UsbCeip. + +Every change is snapshotted before mutation; ``restore_telemetry`` rolls back. +""" +from __future__ import annotations + +import json +import logging +import subprocess +import winreg +from dataclasses import dataclass, field + +from .firewall import is_admin + +log = logging.getLogger(__name__) + +_SERVICES = ("DiagTrack", "dmwappushservice") + +_SCHED_TASKS = ( + r"\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser", + r"\Microsoft\Windows\Customer Experience Improvement Program\Consolidator", + r"\Microsoft\Windows\Customer Experience Improvement Program\UsbCeip", +) + +# (hive, path, value_name, dword_when_killed) +_REG_KILLS = ( + (winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Policies\Microsoft\Windows\DataCollection", + "AllowTelemetry", 0), + (winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Policies\Microsoft\Windows\System", + "EnableActivityFeed", 0), + (winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Policies\Microsoft\Windows\System", + "PublishUserActivities", 0), + (winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Policies\Microsoft\Windows\System", + "UploadUserActivities", 0), + (winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Policies\Microsoft\Windows\CloudContent", + "DisableWindowsConsumerFeatures", 1), + (winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\AdvertisingInfo", + "Enabled", 0), + (winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Search", + "BingSearchEnabled", 0), + (winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Search", + "CortanaConsent", 0), +) + + +@dataclass +class TelemetrySnapshot: + service_state: dict[str, str] = field(default_factory=dict) # name -> 'auto'|'manual'|'disabled'|'absent' + sched_tasks_state: dict[str, bool] = field(default_factory=dict) # task -> was_enabled + reg_values: list[dict] = field(default_factory=list) + + +def _run(args: list[str], timeout: float = 12.0) -> tuple[int, str, str]: + try: + r = subprocess.run( + args, capture_output=True, text=True, timeout=timeout, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + return r.returncode, r.stdout or "", r.stderr or "" + except Exception as e: + return 1, "", str(e) + + +# ── service helpers ────────────────────────────────────────────────────────── + +def _service_start_type(name: str) -> str: + code, out, _ = _run(["sc", "qc", name]) + if code != 0: + return "absent" + for ln in out.splitlines(): + s = ln.strip().lower() + if "start_type" in s: + if "2" in s or "auto" in s: + return "auto" + if "3" in s or "demand" in s or "manual" in s: + return "manual" + if "4" in s or "disabled" in s: + return "disabled" + return "absent" + + +def _set_service_disabled(name: str) -> bool: + _run(["sc", "stop", name], timeout=15) + code, _, _ = _run(["sc", "config", name, "start=", "disabled"]) + return code == 0 + + +def _set_service_start(name: str, want: str) -> bool: + start_value = {"auto": "auto", "manual": "demand", "disabled": "disabled"}.get(want) + if not start_value: + return False + _run(["sc", "stop", name], timeout=10) + code, _, _ = _run(["sc", "config", name, "start=", start_value]) + if want == "auto": + _run(["sc", "start", name], timeout=15) + return code == 0 + + +# ── scheduled-task helpers ────────────────────────────────────────────────── + +def _task_is_enabled(name: str) -> bool | None: + code, out, _ = _run(["schtasks", "/Query", "/TN", name]) + if code != 0: + return None + for ln in out.splitlines(): + s = ln.strip().lower() + if s.startswith("status:") or "ready" in s or "disabled" in s: + if "disabled" in s: + return False + if "ready" in s or "running" in s: + return True + return True # assume ready when query succeeded + + +def _set_task_state(name: str, enable: bool) -> bool: + code, _, _ = _run([ + "schtasks", "/Change", "/TN", name, + "/Enable" if enable else "/Disable", + ]) + return code == 0 + + +# ── registry helpers ──────────────────────────────────────────────────────── + +def _read_reg_dword(hive: int, path: str, name: str) -> tuple[bool, int | None]: + """Returns (present, value).""" + try: + with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key: + v, _ = winreg.QueryValueEx(key, name) + return True, int(v) + except OSError: + return False, None + + +def _write_reg_dword(hive: int, path: str, name: str, value: int) -> bool: + try: + with winreg.CreateKeyEx(hive, path, 0, winreg.KEY_SET_VALUE) as key: + winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, value) + return True + except OSError: + return False + + +def _delete_reg_value(hive: int, path: str, name: str) -> None: + try: + with winreg.OpenKey(hive, path, 0, winreg.KEY_SET_VALUE) as key: + winreg.DeleteValue(key, name) + except OSError: + pass + + +# ── public API ────────────────────────────────────────────────────────────── + +def telemetry_status() -> dict[str, str]: + """Read-only snapshot for the audit panel.""" + out: dict[str, str] = {} + for svc in _SERVICES: + out[f"svc.{svc}"] = _service_start_type(svc) + pres, val = _read_reg_dword( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Policies\Microsoft\Windows\DataCollection", + "AllowTelemetry", + ) + out["AllowTelemetry"] = "0 (killed)" if pres and val == 0 else ( + f"{val}" if pres else "unset" + ) + pres, val = _read_reg_dword( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\AdvertisingInfo", + "Enabled", + ) + out["AdvertisingID"] = "off" if pres and val == 0 else ( + "on" if pres and val == 1 else "unset" + ) + return out + + +def engage_telemetry_kill() -> tuple[TelemetrySnapshot | None, list[str]]: + logs: list[str] = [] + if not is_admin(): + return None, ["Telemetry kill skipped — needs Administrator."] + snap = TelemetrySnapshot() + + for svc in _SERVICES: + st = _service_start_type(svc) + snap.service_state[svc] = st + if st == "absent": + continue + if _set_service_disabled(svc): + logs.append(f"Service '{svc}' stopped + disabled (was {st}).") + else: + logs.append(f"Service '{svc}' could not be disabled.") + + for task in _SCHED_TASKS: + was = _task_is_enabled(task) + if was is None: + continue + snap.sched_tasks_state[task] = was + if was: + if _set_task_state(task, enable=False): + logs.append(f"Scheduled task disabled: {task}") + + for hive, path, name, kill_val in _REG_KILLS: + present, prev = _read_reg_dword(hive, path, name) + snap.reg_values.append({ + "hive": "HKLM" if hive == winreg.HKEY_LOCAL_MACHINE else "HKCU", + "path": path, + "name": name, + "present": present, + "prev": prev, + }) + if _write_reg_dword(hive, path, name, kill_val): + logs.append(f"reg: {('HKLM' if hive == winreg.HKEY_LOCAL_MACHINE else 'HKCU')}\\{path}\\{name} → {kill_val}") + else: + logs.append(f"reg write failed: {path}\\{name}") + + return snap, logs + + +def restore_telemetry(snap: TelemetrySnapshot | None) -> list[str]: + if snap is None or not is_admin(): + return [] + logs: list[str] = [] + + for svc, prev in snap.service_state.items(): + if prev == "absent": + continue + if _set_service_start(svc, prev): + logs.append(f"Service '{svc}' restored → {prev}.") + + for task, was in snap.sched_tasks_state.items(): + if was: + _set_task_state(task, enable=True) + logs.append(f"Scheduled task re-enabled: {task}") + + for entry in snap.reg_values: + hive = winreg.HKEY_LOCAL_MACHINE if entry["hive"] == "HKLM" else winreg.HKEY_CURRENT_USER + path = entry["path"] + name = entry["name"] + if entry["present"]: + _write_reg_dword(hive, path, name, int(entry["prev"])) + logs.append(f"reg restore: {entry['hive']}\\{path}\\{name} ← {entry['prev']}") + else: + _delete_reg_value(hive, path, name) + logs.append(f"reg removed (was absent): {entry['hive']}\\{path}\\{name}") + return logs + + +def snapshot_to_json(snap: TelemetrySnapshot | None) -> str: + if snap is None: + return "" + return json.dumps({ + "services": snap.service_state, + "tasks": snap.sched_tasks_state, + "reg": snap.reg_values, + }) + + +def snapshot_from_json(raw: str) -> TelemetrySnapshot | None: + if not raw: + return None + try: + d = json.loads(raw) + return TelemetrySnapshot( + service_state=dict(d.get("services") or {}), + sched_tasks_state={k: bool(v) for k, v in (d.get("tasks") or {}).items()}, + reg_values=list(d.get("reg") or []), + ) + except Exception: + return None diff --git a/tests/test_proxy_god.py b/tests/test_proxy_god.py index 34c9f79..0571d3c 100644 --- a/tests/test_proxy_god.py +++ b/tests/test_proxy_god.py @@ -217,6 +217,65 @@ class TestValidator(unittest.TestCase): self.assertIsNone(asyncio.run(run())) +class TestBrowserPersona(unittest.TestCase): + def test_blend_persona_relaxes_block_flags(self) -> None: + from proxy_chain_manager.browser_profile import ( + FirefoxHardening, apply_persona, build_user_js, + ) + h = FirefoxHardening( + persona_key="blend_windows_chrome", + resist_fingerprinting=True, + first_party_isolation=True, + strict_tracking_protection=True, + timezone_utc=True, + ) + applied = apply_persona(h) + self.assertFalse(applied.resist_fingerprinting) + self.assertFalse(applied.first_party_isolation) + self.assertFalse(applied.strict_tracking_protection) + self.assertFalse(applied.timezone_utc) + self.assertIsNotNone(applied.persona) + # And the user.js carries a spoofed UA when blend is selected. + js = build_user_js("127.0.0.1", 18888, h) + self.assertIn("general.useragent.override", js) + self.assertIn("Chrome/124", js) + + def test_hardened_persona_keeps_block_flags(self) -> None: + from proxy_chain_manager.browser_profile import FirefoxHardening, apply_persona + h = FirefoxHardening( + persona_key="hardened", + resist_fingerprinting=False, # should be forced True + first_party_isolation=False, + ) + applied = apply_persona(h) + self.assertTrue(applied.resist_fingerprinting) + self.assertTrue(applied.first_party_isolation) + self.assertTrue(applied.strict_tracking_protection) + self.assertIsNone(applied.persona) + + def test_cookie_behavior_mapping(self) -> None: + from proxy_chain_manager.browser_identity import ( + cookie_behavior_value, cookie_session_only, + ) + self.assertEqual(cookie_behavior_value("accept_all"), 0) + self.assertEqual(cookie_behavior_value("block_third_party"), 1) + self.assertEqual(cookie_behavior_value("block_third_party_trackers"), 4) + self.assertEqual(cookie_behavior_value("session_only"), 0) + self.assertEqual(cookie_behavior_value("block_all"), 2) + self.assertTrue(cookie_session_only("session_only")) + self.assertFalse(cookie_session_only("accept_all")) + + def test_persona_env_returns_tz_for_blend(self) -> None: + from proxy_chain_manager.browser_profile import FirefoxHardening, persona_env + env = persona_env(FirefoxHardening(persona_key="blend_windows_chrome")) + self.assertIn("TZ", env) + self.assertTrue(env["TZ"]) + + def test_persona_env_empty_for_hardened(self) -> None: + from proxy_chain_manager.browser_profile import FirefoxHardening, persona_env + self.assertEqual(persona_env(FirefoxHardening(persona_key="hardened")), {}) + + class TestPrivacyLan(unittest.TestCase): def test_snapshot_round_trip(self) -> None: from proxy_chain_manager.privacy_lan import (