feat(stealth): inject anti-detection patches in native Rust architecture

- Created cli/src/native/stealth.rs with stealth JS injection via CDP
- Extracted 32 patch IIFEs from TS stealth.ts into stealth_scripts.js
- Injected via Page.addScriptToEvaluateOnNewDocument on every launch/connect
- Added stealth Chrome args (disable AutomationControlled, use ANGLE GL)
- Auto-detects and cleans HeadlessChrome from User-Agent string
- Overrides navigator.userAgentData high-entropy hints
- Stealth enabled by default, disable with AGENT_BROWSER_STEALTH=0

Track 2 of native-stealth migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leeguooooo
2026-05-08 23:47:50 +09:00
co-authored by Claude Opus 4.6
parent 6addc80aa1
commit 77616a209c
7 changed files with 1515 additions and 0 deletions
+7
View File
@@ -58,6 +58,7 @@ dependencies = [
"hmac",
"image",
"libc",
"regex-lite",
"reqwest",
"rust-embed",
"serde",
@@ -1691,6 +1692,12 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "reqwest"
version = "0.12.28"
+1
View File
@@ -17,6 +17,7 @@ path = "src/main.rs"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
regex-lite = "0.1"
dirs = "5.0"
base64 = "0.22"
getrandom = "0.2"
+35
View File
@@ -33,6 +33,7 @@ use super::recording::{self, RecordingState};
use super::screenshot::{self, ScreenshotOptions};
use super::snapshot::{self, SnapshotOptions};
use super::state;
use super::stealth;
use super::storage;
use super::stream::{self, StreamServer};
use super::tracing::{self as native_tracing, TracingState};
@@ -1550,6 +1551,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
apply_launch_init_scripts(state).await;
try_auto_restore_state(state).await;
try_load_storage_state(state, &storage_state_path).await;
apply_stealth_to_browser(state).await;
return Ok(());
}
@@ -1563,6 +1565,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
apply_launch_init_scripts(state).await;
try_auto_restore_state(state).await;
try_load_storage_state(state, &storage_state_path).await;
apply_stealth_to_browser(state).await;
return Ok(());
}
@@ -1633,6 +1636,8 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
apply_launch_init_scripts(state).await;
try_auto_restore_state(state).await;
try_load_storage_state(state, &storage_state_path).await;
// Apply stealth anti-detection patches after browser is ready
apply_stealth_to_browser(state).await;
Ok(())
}
@@ -1683,6 +1688,36 @@ async fn apply_launch_init_scripts(state: &DaemonState) {
}
}
/// Inject stealth scripts into the active browser session.
/// Called after every successful launch / CDP connect / auto-connect.
async fn apply_stealth_to_browser(state: &DaemonState) {
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
return; // Explicitly disabled
}
let Some(ref mgr) = state.browser else {
return;
};
let Ok(session_id) = mgr.active_session_id() else {
return;
};
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
if let Err(e) = stealth::apply_stealth(
&mgr.client,
session_id,
locale.as_deref(),
)
.await
{
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
}
// Also inject into the current page (already loaded before our init script)
if let Err(e) =
stealth::apply_stealth_to_current_page(&mgr.client, session_id, locale.as_deref()).await
{
eprintln!("[stealth] Failed to patch current page: {}", e);
}
}
fn launch_options_from_env() -> LaunchOptions {
let headed = env::var("AGENT_BROWSER_HEADED")
.map(|v| v == "1" || v == "true")
+4
View File
@@ -147,6 +147,10 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
"--remote-debugging-port=0".to_string(),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
// Stealth: reduce automation fingerprint surface
"--disable-blink-features=AutomationControlled".to_string(),
"--use-gl=angle".to_string(),
"--use-angle=default".to_string(),
"--disable-background-networking".to_string(),
"--disable-backgrounding-occluded-windows".to_string(),
"--disable-component-update".to_string(),
+2
View File
@@ -35,6 +35,8 @@ pub mod snapshot;
#[allow(dead_code)]
pub mod state;
#[allow(dead_code)]
pub mod stealth;
#[allow(dead_code)]
pub mod storage;
#[allow(dead_code)]
pub mod stream;
+204
View File
@@ -0,0 +1,204 @@
//! 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;
/// Default stealth JS payload compiled at build time.
/// The first line is a config placeholder that `build_stealth_script` replaces
/// at runtime with the actual locale/language settings.
const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js");
/// Chrome launch arguments that reduce automation fingerprint surface.
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
"--disable-blink-features=AutomationControlled",
"--use-gl=angle",
"--use-angle=default",
];
/// Build the stealth JS payload with the given locale.
/// Replaces the default `__abStealth` config line with one reflecting the
/// actual browser locale so that `navigator.language` patches are consistent.
pub fn build_stealth_script(locale: Option<&str>) -> String {
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 }};"#,
locale,
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
);
// Replace the placeholder first line
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
) {
format!("{}{}", config_line, rest)
} else {
// Fallback: prepend config and include everything
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
}
}
/// Apply stealth patches to a browser session:
/// 1. Inject init script (runs before any page JS on every navigation)
/// 2. Override User-Agent via CDP to remove HeadlessChrome markers
/// 3. Override navigator.userAgentData high-entropy hints
pub async fn apply_stealth(
client: &CdpClient,
session_id: &str,
locale: Option<&str>,
) -> Result<(), String> {
let script = build_stealth_script(locale);
// 1. Inject stealth scripts to run before page JS
client
.send_command(
"Page.addScriptToEvaluateOnNewDocument",
Some(json!({ "source": script })),
Some(session_id),
)
.await?;
// 2. Detect current User-Agent and clean up HeadlessChrome marker
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?;
}
}
Ok(())
}
/// Get the browser's User-Agent string via CDP.
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
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,
locale: Option<&str>,
) -> Result<(), String> {
let script = build_stealth_script(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,
})
}
File diff suppressed because it is too large Load Diff