//! Stealth anti-detection module. //! //! Injects browser-level patches to evade bot detection (creepjs, sannysoft, //! Cloudflare Turnstile, etc.) by normalizing fingerprint signals that betray //! headless or automated Chrome instances. use serde_json::json; use super::cdp::client::CdpClient; /// Full stealth JS payload compiled at build time (for --launch mode). const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js"); /// Minimal stealth script for CDP-attach mode (connecting to user's real Chrome). /// Only removes navigator.webdriver — the browser's own fingerprint is already real. /// Minimal stealth script for CDP-attach mode. /// Emulation.setAutomationOverride handles navigator.webdriver at the native /// level, so no JS patching is needed in CdpAttach mode. An empty script /// avoids creating any detectable lie-props artifacts. const MINIMAL_STEALTH_SCRIPT: &str = ""; /// Chrome launch arguments that reduce automation fingerprint surface. pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[ "--disable-blink-features=AutomationControlled", "--use-gl=angle", "--use-angle=default", ]; /// Connection mode determines which stealth patches to apply. #[derive(Clone, Copy, PartialEq)] pub enum StealthMode { /// Connected to user's real Chrome — minimal patches only (webdriver removal). /// The browser already has a real fingerprint; heavy patches would create detectable lies. CdpAttach, /// Launched a new Chrome instance — apply full stealth patches. FullLaunch, } /// Build the stealth JS payload for the given mode and locale. pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String { if mode == StealthMode::CdpAttach { return MINIMAL_STEALTH_SCRIPT.to_string(); } // Full launch mode: inject all patches let locale = locale.unwrap_or("en-US"); let base_lang = locale.split('-').next().unwrap_or(locale); let languages: Vec<&str> = if base_lang == locale { vec![locale] } else { vec![locale, base_lang] }; let config_line = format!( r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#, locale, serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()), hide_canvas_enabled(), canvas_noise_seed(), ); // NB: this prefix MUST match the first line of stealth_scripts.js verbatim, // otherwise the fallback below prepends a SECOND `const __abStealth` // declaration and the whole script dies with a redeclaration SyntaxError. if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix( r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };"#, ) { format!("{}{}", config_line, rest) } else { format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW) } } /// Whether canvas/audio fingerprint noise is opted into (FullLaunch only). /// OFF by default: injecting noise is a deliberate "lie" that can itself be a /// tell, so it's reserved for users who explicitly want it via /// `AGENT_BROWSER_HIDE_CANVAS=1`. fn hide_canvas_enabled() -> bool { std::env::var("AGENT_BROWSER_HIDE_CANVAS") .ok() .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false) } /// A per-process seed so canvas/audio noise is STABLE within a session (a real /// device returns the same hash on repeated reads) but differs from the /// headless-stable default. 0 is avoided so the JS can treat it as "unset". fn canvas_noise_seed() -> u32 { use std::sync::OnceLock; static SEED: OnceLock = OnceLock::new(); *SEED.get_or_init(|| { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.subsec_nanos()) .unwrap_or(0x9e3779b9); // mix the bits a little, then force non-zero let mixed = nanos ^ nanos.rotate_left(13).wrapping_mul(2654435761); mixed | 1 }) } /// Apply stealth patches to a browser session. /// /// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`. /// In `FullLaunch` mode (new Chrome): injects all 32 patches + UA override. pub async fn apply_stealth( client: &CdpClient, session_id: &str, mode: StealthMode, locale: Option<&str>, ) -> Result<(), String> { // First: disable the automation flag at the CDP protocol level. // This tells Chrome to natively set navigator.webdriver = false, // which is undetectable by lie-detection systems like CreepJS. // Falls back gracefully on older Chrome versions that don't support this. let _ = client .send_command( "Emulation.setAutomationOverride", Some(json!({ "enabled": false })), Some(session_id), ) .await; let script = build_stealth_script(mode, locale); // Inject stealth scripts to run before page JS client .send_command( "Page.addScriptToEvaluateOnNewDocument", Some(json!({ "source": script })), Some(session_id), ) .await?; // In full launch mode, also override UA to remove HeadlessChrome marker if mode == StealthMode::FullLaunch { let ua = get_browser_user_agent(client, session_id).await; if let Some(ua) = ua { let cleaned = ua.replace("HeadlessChrome", "Chrome"); if cleaned != ua { client .send_command( "Emulation.setUserAgentOverride", Some(json!({ "userAgent": cleaned, "acceptLanguage": locale.unwrap_or("en-US"), "platform": platform_string(), "userAgentMetadata": build_ua_metadata(&cleaned, locale), })), Some(session_id), ) .await?; } } // Align the timezone for fresh launches when explicitly requested. // Headless/launched Chrome often reports UTC (or the host's zone), which // can contradict a proxy's geolocation or a spoofed locale. // `Emulation.setTimezoneOverride` is a NATIVE override — Intl.DateTimeFormat // and Date both follow it with no detectable JS lie. Opt-in only: // AGENT_BROWSER_TIMEZONE= -> use that zone (e.g. align to proxy) // AGENT_BROWSER_TIMEZONE=auto -> derive a default from the locale // (unset) -> leave the real timezone untouched if let Some(tz) = resolve_timezone(locale) { let _ = client .send_command( "Emulation.setTimezoneOverride", Some(json!({ "timezoneId": tz })), Some(session_id), ) .await; } } Ok(()) } /// Resolve the timezone to emulate for a fresh-launch session, if any. /// Controlled by `AGENT_BROWSER_TIMEZONE`: an explicit IANA id, or `auto` to /// derive a sensible default from the locale. Returns `None` (leave the real /// timezone) when unset, empty, or when `auto` can't map the locale. fn resolve_timezone(locale: Option<&str>) -> Option { let raw = std::env::var("AGENT_BROWSER_TIMEZONE").ok()?; let raw = raw.trim(); if raw.is_empty() { return None; } if raw.eq_ignore_ascii_case("auto") { return locale .and_then(locale_default_timezone) .map(str::to_string); } Some(raw.to_string()) } /// Best-effort IANA timezone for a locale. Used only for /// `AGENT_BROWSER_TIMEZONE=auto`; unknown locales return `None` so the real /// timezone is left untouched rather than guessing a wrong one. fn locale_default_timezone(locale: &str) -> Option<&'static str> { let tz = match locale.to_ascii_lowercase().as_str() { "en-us" => "America/New_York", "en-ca" => "America/Toronto", "en-gb" => "Europe/London", "en-au" => "Australia/Sydney", "ja" | "ja-jp" => "Asia/Tokyo", "ko" | "ko-kr" => "Asia/Seoul", "zh-cn" | "zh-hans" | "zh-hans-cn" => "Asia/Shanghai", "zh-tw" | "zh-hant" | "zh-hant-tw" => "Asia/Taipei", "zh-hk" => "Asia/Hong_Kong", "de" | "de-de" => "Europe/Berlin", "fr" | "fr-fr" => "Europe/Paris", "es" | "es-es" => "Europe/Madrid", "it" | "it-it" => "Europe/Rome", "nl" | "nl-nl" => "Europe/Amsterdam", "pt-br" => "America/Sao_Paulo", "pt" | "pt-pt" => "Europe/Lisbon", "ru" | "ru-ru" => "Europe/Moscow", _ => return None, }; Some(tz) } /// Get the browser's User-Agent string via CDP. async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option { let result = client .send_command( "Runtime.evaluate", Some(json!({ "expression": "navigator.userAgent", "returnByValue": true })), Some(session_id), ) .await .ok()?; result .get("result") .and_then(|r| r.get("value")) .and_then(|v| v.as_str()) .map(String::from) } /// Also run stealth script on the current page (for already-loaded pages after CDP attach). pub async fn apply_stealth_to_current_page( client: &CdpClient, session_id: &str, mode: StealthMode, locale: Option<&str>, ) -> Result<(), String> { let script = build_stealth_script(mode, locale); client .send_command( "Runtime.evaluate", Some(json!({ "expression": script, "returnByValue": true, })), Some(session_id), ) .await?; Ok(()) } /// Strip sourceURL comments from CDP expressions to avoid leaking /// automation-framework identifiers in stack traces. pub fn strip_source_url_labels(input: &str) -> String { // Remove //# sourceURL=... and //@ sourceURL=... let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap(); let output = re_line.replace_all(input, ""); // Remove /*# sourceURL=...*/ block comments let re_block = regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap(); re_block.replace_all(&output, "").to_string() } fn platform_string() -> &'static str { if cfg!(target_os = "macos") { "macOS" } else if cfg!(target_os = "windows") { "Win32" } else { "Linux" } } fn platform_hint() -> &'static str { if cfg!(target_os = "macos") { "macOS" } else if cfg!(target_os = "windows") { "Windows" } else { "Linux" } } fn platform_version_hint() -> &'static str { if cfg!(target_os = "macos") { "14.0.0" } else if cfg!(target_os = "windows") { "10.0.0" } else { "6.5.0" } } fn build_ua_metadata(ua: &str, locale: Option<&str>) -> serde_json::Value { // Extract Chrome version from UA string let chrome_version = ua .split("Chrome/") .nth(1) .and_then(|s| s.split_whitespace().next()) .unwrap_or("130.0.0.0"); let major = chrome_version.split('.').next().unwrap_or("130"); let _lang = locale.unwrap_or("en-US"); json!({ "brands": [ { "brand": "Chromium", "version": major }, { "brand": "Google Chrome", "version": major }, { "brand": "Not?A_Brand", "version": "99" }, ], "fullVersionList": [ { "brand": "Chromium", "version": chrome_version }, { "brand": "Google Chrome", "version": chrome_version }, { "brand": "Not?A_Brand", "version": "99.0.0.0" }, ], "fullVersion": chrome_version, "platform": platform_hint(), "platformVersion": platform_version_hint(), "architecture": if cfg!(target_arch = "aarch64") { "arm" } else { "x86" }, "model": "", "mobile": false, "bitness": "64", "wow64": false, }) } #[cfg(test)] mod timezone_tests { use super::{locale_default_timezone, resolve_timezone}; #[test] fn maps_common_locales_case_insensitively() { assert_eq!(locale_default_timezone("en-US"), Some("America/New_York")); assert_eq!(locale_default_timezone("ja-JP"), Some("Asia/Tokyo")); assert_eq!(locale_default_timezone("zh-CN"), Some("Asia/Shanghai")); assert_eq!(locale_default_timezone("ZH-TW"), Some("Asia/Taipei")); assert_eq!(locale_default_timezone("ja"), Some("Asia/Tokyo")); } #[test] fn unknown_locale_returns_none() { assert_eq!(locale_default_timezone("xx-YY"), None); assert_eq!(locale_default_timezone(""), None); } #[test] fn resolve_timezone_honors_env() { // Serialized via a single test to avoid cross-test env races on this key. std::env::remove_var("AGENT_BROWSER_TIMEZONE"); assert_eq!(resolve_timezone(Some("en-US")), None); std::env::set_var("AGENT_BROWSER_TIMEZONE", "Europe/Berlin"); assert_eq!(resolve_timezone(None), Some("Europe/Berlin".to_string())); std::env::set_var("AGENT_BROWSER_TIMEZONE", " "); assert_eq!(resolve_timezone(Some("en-US")), None); std::env::set_var("AGENT_BROWSER_TIMEZONE", "auto"); assert_eq!(resolve_timezone(Some("ja-JP")), Some("Asia/Tokyo".to_string())); assert_eq!(resolve_timezone(Some("xx-YY")), None); assert_eq!(resolve_timezone(None), None); std::env::remove_var("AGENT_BROWSER_TIMEZONE"); } }