Skip to main content

stygian_browser/
stealth.rs

1//! Stealth configuration and anti-detection features
2//!
3//! Provides navigator property spoofing and CDP injection scripts that make
4//! a headless Chrome instance appear identical to a real browser.
5//!
6//! # Overview
7//!
8//! 1. **Navigator spoofing** — Override `navigator.webdriver`, `platform`,
9//!    `userAgent`, `hardwareConcurrency`, `deviceMemory`, `maxTouchPoints`,
10//!    and `vendor` via `Object.defineProperty` so properties are non-configurable
11//!    and non-writable (harder to detect the override).
12//!
13//! 2. **WebGL spoofing** — Replace `getParameter` on `WebGLRenderingContext` and
14//!    `WebGL2RenderingContext` to return controlled vendor/renderer strings.
15//!
16//! # Example
17//!
18//! ```
19//! use stygian_browser::stealth::{NavigatorProfile, StealthConfig, StealthProfile};
20//!
21//! let profile = NavigatorProfile::windows_chrome();
22//! let script = StealthProfile::new(StealthConfig::default(), profile).injection_script();
23//! assert!(script.contains("'webdriver'"));
24//! ```
25
26use serde::{Deserialize, Serialize};
27
28use crate::freshness::signature_hash;
29
30// ─── StealthConfig ────────────────────────────────────────────────────────────
31
32/// Feature flags controlling which stealth techniques are active.
33///
34/// # Example
35///
36/// ```
37/// use stygian_browser::stealth::StealthConfig;
38/// let cfg = StealthConfig::default();
39/// assert!(cfg.spoof_navigator);
40/// ```
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[allow(clippy::struct_excessive_bools)] // public, stable config type — 5 orthogonal stealth flags read more clearly as bools than as a bitmask
43pub struct StealthConfig {
44    /// Override navigator properties (webdriver, platform, userAgent, etc.)
45    pub spoof_navigator: bool,
46    /// Replace WebGL getParameter with controlled vendor/renderer strings.
47    pub randomize_webgl: bool,
48    /// Randomise Canvas `toDataURL` fingerprint (stub — needs canvas noise).
49    pub randomize_canvas: bool,
50    /// Enable human-like behaviour simulation.
51    pub human_behavior: bool,
52    /// Enable CDP leak protection (remove `Runtime.enable` artifacts).
53    pub protect_cdp: bool,
54}
55
56impl Default for StealthConfig {
57    fn default() -> Self {
58        Self {
59            spoof_navigator: true,
60            randomize_webgl: true,
61            randomize_canvas: true,
62            human_behavior: true,
63            protect_cdp: true,
64        }
65    }
66}
67
68impl StealthConfig {
69    /// All stealth features enabled (maximum evasion).
70    #[must_use]
71    pub fn paranoid() -> Self {
72        Self::default()
73    }
74
75    /// Only navigator and CDP protection (low overhead).
76    #[must_use]
77    pub const fn minimal() -> Self {
78        Self {
79            spoof_navigator: true,
80            randomize_webgl: false,
81            randomize_canvas: false,
82            human_behavior: false,
83            protect_cdp: true,
84        }
85    }
86
87    /// All stealth features disabled.
88    #[must_use]
89    pub const fn disabled() -> Self {
90        Self {
91            spoof_navigator: false,
92            randomize_webgl: false,
93            randomize_canvas: false,
94            human_behavior: false,
95            protect_cdp: false,
96        }
97    }
98}
99
100// ─── NavigatorProfile ─────────────────────────────────────────────────────────
101
102/// A bundle of navigator property values that together form a convincing
103/// browser identity.
104///
105/// All properties are validated at construction time to guarantee that
106/// `platform` matches the OS fragment in `user_agent`.
107///
108/// # Example
109///
110/// ```
111/// use stygian_browser::stealth::NavigatorProfile;
112/// let p = NavigatorProfile::mac_chrome();
113/// assert_eq!(p.platform, "MacIntel");
114/// assert!(p.user_agent.contains("Mac OS X"));
115/// ```
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct NavigatorProfile {
118    /// Full `User-Agent` string (`navigator.userAgent` **and** the HTTP header).
119    pub user_agent: String,
120    /// Platform string e.g. `"Win32"`, `"MacIntel"`, `"Linux x86_64"`.
121    pub platform: String,
122    /// Browser vendor (`"Google Inc."`).
123    pub vendor: String,
124    /// Logical CPU core count. Realistic values: 4, 8, 12, 16.
125    pub hardware_concurrency: u8,
126    /// Device RAM in GiB. Realistic values: 4, 8, 16.
127    pub device_memory: u8,
128    /// Maximum simultaneous touch points (0 = no touchscreen, 10 = tablet/phone).
129    pub max_touch_points: u8,
130    /// WebGL vendor string (only used when `StealthConfig::randomize_webgl` is true).
131    pub webgl_vendor: String,
132    /// WebGL renderer string.
133    pub webgl_renderer: String,
134}
135
136impl NavigatorProfile {
137    /// A typical Windows 10 Chrome 131 profile.
138    #[must_use]
139    pub fn windows_chrome() -> Self {
140        Self {
141            user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
142                         (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
143                .to_string(),
144            platform: "Win32".to_string(),
145            vendor: "Google Inc.".to_string(),
146            hardware_concurrency: 8,
147            device_memory: 8,
148            max_touch_points: 0,
149            webgl_vendor: "Google Inc. (NVIDIA)".to_string(),
150            webgl_renderer:
151                "ANGLE (NVIDIA, NVIDIA GeForce GTX 1650 Direct3D11 vs_5_0 ps_5_0, D3D11)"
152                    .to_string(),
153        }
154    }
155
156    /// A typical macOS Chrome 131 profile.
157    #[must_use]
158    pub fn mac_chrome() -> Self {
159        Self {
160            user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
161                         (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
162                .to_string(),
163            platform: "MacIntel".to_string(),
164            vendor: "Google Inc.".to_string(),
165            hardware_concurrency: 8,
166            device_memory: 8,
167            max_touch_points: 0,
168            webgl_vendor: "Google Inc. (Intel)".to_string(),
169            webgl_renderer: "ANGLE (Intel, Apple M1 Pro, OpenGL 4.1)".to_string(),
170        }
171    }
172
173    /// A typical Linux Chrome 131 profile (common in data-centre environments).
174    #[must_use]
175    pub fn linux_chrome() -> Self {
176        Self {
177            user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
178                         (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
179                .to_string(),
180            platform: "Linux x86_64".to_string(),
181            vendor: "Google Inc.".to_string(),
182            hardware_concurrency: 4,
183            device_memory: 4,
184            max_touch_points: 0,
185            webgl_vendor: "Mesa/X.org".to_string(),
186            webgl_renderer: "llvmpipe (LLVM 15.0.7, 256 bits)".to_string(),
187        }
188    }
189
190    /// Stable signature hash for this navigator profile.
191    ///
192    /// Used by the freshness contract layer to detect identity
193    /// rotation: equal profiles produce equal hashes, and any field
194    /// change rotates the signature. The hash is deterministic and
195    /// I/O-free.
196    ///
197    /// # Example
198    ///
199    /// ```
200    /// use stygian_browser::stealth::NavigatorProfile;
201    ///
202    /// let p = NavigatorProfile::windows_chrome();
203    /// let sig = p.signature();
204    /// assert!(sig.starts_with("fnv64:"));
205    /// assert_eq!(sig, NavigatorProfile::windows_chrome().signature());
206    /// ```
207    #[must_use]
208    pub fn signature(&self) -> String {
209        use std::fmt::Write as _;
210        let mut buf = String::new();
211        let _ = writeln!(&mut buf, "ua={}", self.user_agent);
212        let _ = writeln!(&mut buf, "platform={}", self.platform);
213        let _ = writeln!(&mut buf, "vendor={}", self.vendor);
214        let _ = writeln!(&mut buf, "hw={}", self.hardware_concurrency);
215        let _ = writeln!(&mut buf, "dm={}", self.device_memory);
216        let _ = writeln!(&mut buf, "tp={}", self.max_touch_points);
217        let _ = writeln!(&mut buf, "webgl_vendor={}", self.webgl_vendor);
218        let _ = write!(&mut buf, "webgl_renderer={}", self.webgl_renderer);
219        signature_hash(&[buf.as_str()])
220    }
221}
222
223impl Default for NavigatorProfile {
224    fn default() -> Self {
225        Self::mac_chrome()
226    }
227}
228
229// ─── StealthProfile ───────────────────────────────────────────────────────────
230
231/// Combines [`StealthConfig`] (feature flags) with a [`NavigatorProfile`]
232/// (identity values) and produces a single JavaScript injection script.
233///
234/// # Example
235///
236/// ```
237/// use stygian_browser::stealth::{NavigatorProfile, StealthConfig, StealthProfile};
238///
239/// let profile = StealthProfile::new(StealthConfig::default(), NavigatorProfile::windows_chrome());
240/// let script = profile.injection_script();
241/// assert!(script.contains("Win32"));
242/// assert!(script.contains("NVIDIA"));
243/// ```
244pub struct StealthProfile {
245    config: StealthConfig,
246    navigator: NavigatorProfile,
247}
248
249impl StealthProfile {
250    /// Build a new profile from config flags and identity values.
251    #[must_use]
252    pub const fn new(config: StealthConfig, navigator: NavigatorProfile) -> Self {
253        Self { config, navigator }
254    }
255
256    /// Generate the JavaScript to inject via
257    /// `Page.addScriptToEvaluateOnNewDocument`.
258    ///
259    /// Returns an empty string if all stealth flags are disabled.
260    #[must_use]
261    pub fn injection_script(&self) -> String {
262        let mut parts: Vec<String> = Vec::new();
263
264        if self.config.spoof_navigator {
265            parts.push(self.navigator_spoof_script());
266        }
267
268        if self.config.randomize_webgl {
269            parts.push(self.webgl_spoof_script());
270        }
271
272        // Always inject chrome-object and userAgentData spoofing when navigator
273        // spoofing is active — both are Cloudflare Turnstile detection vectors.
274        if self.config.spoof_navigator {
275            parts.push(Self::chrome_object_script());
276            parts.push(self.user_agent_data_script());
277        }
278
279        if parts.is_empty() {
280            return String::new();
281        }
282
283        // Wrap in an IIFE so nothing leaks to page scope
284        format!(
285            "(function() {{\n  'use strict';\n{}\n}})();",
286            parts.join("\n\n")
287        )
288    }
289
290    // ─── Private helpers ──────────────────────────────────────────────────────
291
292    fn navigator_spoof_script(&self) -> String {
293        let nav = &self.navigator;
294
295        // Helper: Object.defineProperty with a fixed non-configurable value
296        // so the spoofed value cannot be overwritten by anti-bot scripts.
297        format!(
298            r"  // --- Navigator spoofing ---
299  (function() {{
300    const defineReadOnly = (target, prop, value) => {{
301      try {{
302        Object.defineProperty(target, prop, {{
303          get: () => value,
304          enumerable: true,
305          configurable: false,
306        }});
307      }} catch (_) {{}}
308    }};
309
310    // Remove the webdriver flag at both the prototype and instance levels.
311    // Cloudflare and pixelscan probe Navigator.prototype directly via
312    // Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver').
313    // In real Chrome the property is enumerable:false — matching that is
314    // essential; enumerable:true is a Turnstile detection signal.
315    // configurable:true is kept so polyfills don't throw on a second
316    // defineProperty call.
317    try {{
318      Object.defineProperty(Navigator.prototype, 'webdriver', {{
319        get: () => undefined,
320        enumerable: false,
321        configurable: true,
322      }});
323    }} catch (_) {{}}
324    defineReadOnly(navigator, 'webdriver', undefined);
325
326    // Platform / identity
327    defineReadOnly(navigator, 'platform',           {platform:?});
328    defineReadOnly(navigator, 'userAgent',          {user_agent:?});
329    defineReadOnly(navigator, 'vendor',             {vendor:?});
330    defineReadOnly(navigator, 'hardwareConcurrency', {hwc});
331    defineReadOnly(navigator, 'deviceMemory',        {dm});
332    defineReadOnly(navigator, 'maxTouchPoints',       {mtp});
333
334    // Permissions API — real browsers resolve 'notifications' as 'default'
335    if (navigator.permissions && navigator.permissions.query) {{
336      const origQuery = navigator.permissions.query.bind(navigator.permissions);
337      navigator.permissions.query = (params) => {{
338        if (params && params.name === 'notifications') {{
339          return Promise.resolve({{ state: Notification.permission, onchange: null }});
340        }}
341        return origQuery(params);
342      }};
343    }}
344  }})();",
345            platform = nav.platform,
346            user_agent = nav.user_agent,
347            vendor = nav.vendor,
348            hwc = nav.hardware_concurrency,
349            dm = nav.device_memory,
350            mtp = nav.max_touch_points,
351        )
352    }
353
354    fn chrome_object_script() -> String {
355        // Cloudflare Turnstile checks window.chrome.runtime, window.chrome.csi,
356        // and window.chrome.loadTimes — all present in real Chrome but absent
357        // in headless. Stubbing them removes these detection signals.
358        r"  // --- window.chrome object spoofing ---
359  (function() {
360    if (!window.chrome) {
361      Object.defineProperty(window, 'chrome', {
362        value: {},
363        enumerable: true,
364        configurable: false,
365        writable: false,
366      });
367    }
368    const chrome = window.chrome;
369    // chrome.runtime — checked by Turnstile; needs at least an object with
370    // id and connect stubs to pass duck-type checks.
371    if (!chrome.runtime) {
372      chrome.runtime = {
373        id: undefined,
374        connect: () => {},
375        sendMessage: () => {},
376        onMessage: { addListener: () => {}, removeListener: () => {} },
377      };
378    }
379    // chrome.csi and chrome.loadTimes — legacy APIs present in real Chrome.
380    if (!chrome.csi) {
381      chrome.csi = () => ({
382        startE: Date.now(),
383        onloadT: Date.now(),
384        pageT: 0,
385        tran: 15,
386      });
387    }
388    if (!chrome.loadTimes) {
389      chrome.loadTimes = () => ({
390        requestTime: Date.now() / 1000,
391        startLoadTime: Date.now() / 1000,
392        commitLoadTime: Date.now() / 1000,
393        finishDocumentLoadTime: Date.now() / 1000,
394        finishLoadTime: Date.now() / 1000,
395        firstPaintTime: Date.now() / 1000,
396        firstPaintAfterLoadTime: 0,
397        navigationType: 'Other',
398        wasFetchedViaSpdy: false,
399        wasNpnNegotiated: true,
400        npnNegotiatedProtocol: 'h2',
401        wasAlternateProtocolAvailable: false,
402        connectionInfo: 'h2',
403      });
404    }
405  })();"
406            .to_string()
407    }
408
409    fn user_agent_data_script(&self) -> String {
410        let nav = &self.navigator;
411        // Extract the major Chrome version from the UA string so that
412        // navigator.userAgentData.brands is consistent with navigator.userAgent.
413        // Mismatch between the two is a primary Cloudflare JA3/UA coherence check.
414        let version = nav
415            .user_agent
416            .split("Chrome/")
417            .nth(1)
418            .and_then(|s| s.split('.').next())
419            .unwrap_or("131");
420        let mobile = nav.max_touch_points > 0;
421        let platform = if nav.platform.contains("Win") {
422            "Windows"
423        } else if nav.platform.contains("Mac") {
424            "macOS"
425        } else {
426            "Linux"
427        };
428
429        format!(
430            r"  // --- navigator.userAgentData spoofing ---
431  (function() {{
432    const uaData = {{
433      brands: [
434        {{ brand: 'Google Chrome',  version: '{version}' }},
435        {{ brand: 'Chromium',       version: '{version}' }},
436        {{ brand: 'Not=A?Brand',    version: '99'        }},
437      ],
438      mobile: {mobile},
439      platform: '{platform}',
440      getHighEntropyValues: (hints) => Promise.resolve({{
441        brands: [
442          {{ brand: 'Google Chrome',  version: '{version}' }},
443          {{ brand: 'Chromium',       version: '{version}' }},
444          {{ brand: 'Not=A?Brand',    version: '99'        }},
445        ],
446        mobile: {mobile},
447        platform: '{platform}',
448        architecture: 'x86',
449        bitness: '64',
450        model: '',
451        platformVersion: '10.0.0',
452        uaFullVersion: '{version}.0.0.0',
453        fullVersionList: [
454          {{ brand: 'Google Chrome',  version: '{version}.0.0.0' }},
455          {{ brand: 'Chromium',       version: '{version}.0.0.0' }},
456          {{ brand: 'Not=A?Brand',    version: '99.0.0.0'        }},
457        ],
458      }}),
459      toJSON: () => ({{
460        brands: [
461          {{ brand: 'Google Chrome',  version: '{version}' }},
462          {{ brand: 'Chromium',       version: '{version}' }},
463          {{ brand: 'Not=A?Brand',    version: '99'        }},
464        ],
465        mobile: {mobile},
466        platform: '{platform}',
467      }}),
468    }};
469    try {{
470      Object.defineProperty(navigator, 'userAgentData', {{
471        get: () => uaData,
472        enumerable: true,
473        configurable: false,
474      }});
475    }} catch (_) {{}}
476  }})();"
477        )
478    }
479
480    fn webgl_spoof_script(&self) -> String {
481        let nav = &self.navigator;
482
483        format!(
484            r"  // --- WebGL fingerprint spoofing ---
485  (function() {{
486    const GL_VENDOR   = 0x1F00;
487    const GL_RENDERER = 0x1F01;
488
489    const spoofCtx = (ctx) => {{
490      if (!ctx) return;
491      const origGetParam = ctx.getParameter.bind(ctx);
492      ctx.getParameter = (param) => {{
493        if (param === GL_VENDOR)   return {webgl_vendor:?};
494        if (param === GL_RENDERER) return {webgl_renderer:?};
495        return origGetParam(param);
496      }};
497    }};
498
499    // Wrap HTMLCanvasElement.prototype.getContext
500    const origGetContext = HTMLCanvasElement.prototype.getContext;
501    HTMLCanvasElement.prototype.getContext = function(type, ...args) {{
502      const ctx = origGetContext.call(this, type, ...args);
503      if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') {{
504        spoofCtx(ctx);
505      }}
506      return ctx;
507    }};
508  }})();",
509            webgl_vendor = nav.webgl_vendor,
510            webgl_renderer = nav.webgl_renderer,
511        )
512    }
513}
514
515// ─── Stealth application ──────────────────────────────────────────────────────
516
517/// Inject all stealth scripts into a freshly opened browser page.
518///
519/// Scripts are registered with `Page.addScriptToEvaluateOnNewDocument` so they
520/// execute before any page-owned JavaScript on every subsequent navigation.
521/// Which scripts are injected is determined by
522/// [`crate::config::StealthLevel`]:
523///
524/// | Level      | Injected content                                                        |
525/// | ------------ | ------------------------------------------------------------------------- |
526/// | `None`     | Nothing                                                                 |
527/// | `Basic`    | CDP leak fix + `navigator.webdriver` removal + minimal navigator spoof  |
528/// | `Advanced` | Basic + full WebGL/navigator spoofing + fingerprint + WebRTC protection |
529///
530/// # Errors
531///
532/// Returns [`crate::error::BrowserError::CdpError`] if a CDP command fails.
533///
534/// # Example
535///
536/// ```no_run
537/// # async fn run(
538/// #     page: &chromiumoxide::Page,
539/// #     config: &stygian_browser::BrowserConfig,
540/// # ) -> stygian_browser::Result<()> {
541/// use stygian_browser::stealth::apply_stealth_to_page;
542/// apply_stealth_to_page(page, config).await?;
543/// # Ok(())
544/// # }
545/// ```
546#[allow(clippy::too_many_lines)]
547pub async fn apply_stealth_to_page(
548    page: &chromiumoxide::Page,
549    config: &crate::config::BrowserConfig,
550) -> crate::error::Result<()> {
551    use crate::cdp_protection::CdpProtection;
552    use crate::config::StealthLevel;
553    use crate::error::BrowserError;
554    use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
555
556    /// Inline helper: push one script as `AddScriptToEvaluateOnNewDocument`.
557    async fn inject_one(
558        page: &chromiumoxide::Page,
559        op: &'static str,
560        source: String,
561    ) -> crate::error::Result<()> {
562        use crate::error::BrowserError;
563        page.evaluate_on_new_document(AddScriptToEvaluateOnNewDocumentParams {
564            source,
565            world_name: None,
566            include_command_line_api: None,
567            run_immediately: None,
568        })
569        .await
570        .map_err(|e| BrowserError::CdpError {
571            operation: op.to_string(),
572            message: e.to_string(),
573        })?;
574        Ok(())
575    }
576
577    if config.stealth_level == StealthLevel::None {
578        return Ok(());
579    }
580
581    // For Advanced stealth, always resolve a coherent profile so every
582    // injected surface can derive from the same identity.
583    let effective_profile = (config.stealth_level == StealthLevel::Advanced).then(|| {
584        config
585            .fingerprint_profile
586            .clone()
587            .unwrap_or_else(|| fallback_profile_for_config(config))
588    });
589
590    // ── CDP hardening — runs FIRST to clean up binding remnants ───────────────
591    #[cfg(feature = "stealth")]
592    {
593        let hardening_script = crate::cdp_hardening::cdp_hardening_script(&config.cdp_hardening);
594        if !hardening_script.is_empty() {
595            inject_one(
596                page,
597                "AddScriptToEvaluateOnNewDocument(cdp-hardening)",
598                hardening_script,
599            )
600            .await?;
601        }
602    }
603
604    // ── Basic and above ────────────────────────────────────────────────────────
605    let cdp_script =
606        CdpProtection::new(config.cdp_fix_mode, config.source_url.clone()).build_injection_script();
607    if !cdp_script.is_empty() {
608        inject_one(page, "AddScriptToEvaluateOnNewDocument(cdp)", cdp_script).await?;
609    }
610
611    let (nav_profile, stealth_cfg) = match config.stealth_level {
612        StealthLevel::Basic => (NavigatorProfile::default(), StealthConfig::minimal()),
613        StealthLevel::Advanced => {
614            let profile = effective_profile
615                .as_ref()
616                .ok_or_else(|| BrowserError::ConfigError("missing advanced profile".to_string()))?;
617            (
618                navigator_profile_from_coherent_profile(profile),
619                StealthConfig::paranoid(),
620            )
621        }
622        StealthLevel::None => unreachable!(),
623    };
624    let nav_script = StealthProfile::new(stealth_cfg, nav_profile).injection_script();
625    if !nav_script.is_empty() {
626        inject_one(
627            page,
628            "AddScriptToEvaluateOnNewDocument(navigator)",
629            nav_script,
630        )
631        .await?;
632    }
633
634    // ── Advanced only ──────────────────────────────────────────────────────────
635    if config.stealth_level == StealthLevel::Advanced {
636        let profile = effective_profile
637            .as_ref()
638            .ok_or_else(|| BrowserError::ConfigError("missing advanced profile".to_string()))?;
639        let fp = fingerprint_from_coherent_profile(profile);
640        // Build one engine once so all spoofed surfaces share the same per-session seed.
641        let noise_seed = profile.noise_seed;
642        let noise_engine = crate::noise::NoiseEngine::new(noise_seed);
643        let noise_seed = noise_engine.seed();
644        let fp_script = crate::fingerprint::inject_fingerprint(&fp);
645        inject_one(
646            page,
647            "AddScriptToEvaluateOnNewDocument(fingerprint)",
648            fp_script,
649        )
650        .await?;
651
652        let webrtc_script = config.webrtc.injection_script();
653        if !webrtc_script.is_empty() {
654            inject_one(
655                page,
656                "AddScriptToEvaluateOnNewDocument(webrtc)",
657                webrtc_script,
658            )
659            .await?;
660        }
661
662        if config.noise.canvas_enabled {
663            let canvas_script = crate::canvas_noise::canvas_noise_script(&noise_engine);
664            inject_one(
665                page,
666                "AddScriptToEvaluateOnNewDocument(canvas-noise)",
667                canvas_script,
668            )
669            .await?;
670        }
671
672        // WebGL, audio, and rects noise (T39, T40, T41)
673        if config.noise.webgl_enabled {
674            let webgl_script =
675                crate::webgl_noise::webgl_noise_script(&profile.webgl, &noise_engine);
676            inject_one(
677                page,
678                "AddScriptToEvaluateOnNewDocument(webgl-noise)",
679                webgl_script,
680            )
681            .await?;
682        }
683
684        if config.noise.audio_enabled {
685            let audio_script = crate::audio_noise::audio_noise_script(&noise_engine);
686            inject_one(
687                page,
688                "AddScriptToEvaluateOnNewDocument(audio-noise)",
689                audio_script,
690            )
691            .await?;
692        }
693
694        if config.noise.rects_enabled {
695            let rects_script = crate::rects_noise::rects_noise_script(&noise_engine);
696            inject_one(
697                page,
698                "AddScriptToEvaluateOnNewDocument(rects-noise)",
699                rects_script,
700            )
701            .await?;
702        }
703
704        // Navigator coherence (T43) — always inject in Advanced using the
705        // effective coherent profile.
706        let nav_script = crate::navigator_coherence::navigator_coherence_script(profile);
707        inject_one(
708            page,
709            "AddScriptToEvaluateOnNewDocument(navigator-coherence)",
710            nav_script,
711        )
712        .await?;
713
714        // Timing noise (T44)
715        {
716            let timing_cfg = crate::timing_noise::TimingNoiseConfig {
717                enabled: true,
718                jitter_ms: 0.3,
719                seed: noise_seed,
720            };
721            let timing_script = crate::timing_noise::timing_noise_script(&timing_cfg);
722            inject_one(
723                page,
724                "AddScriptToEvaluateOnNewDocument(timing-noise)",
725                timing_script,
726            )
727            .await?;
728        }
729
730        // Peripheral stealth (T47)
731        {
732            let peripheral_cfg =
733                crate::peripheral_stealth::PeripheralStealthConfig::default_with_seed(noise_seed);
734            let peripheral_script =
735                crate::peripheral_stealth::peripheral_stealth_script_with_profile(
736                    &peripheral_cfg,
737                    Some(profile),
738                );
739            if !peripheral_script.is_empty() {
740                inject_one(
741                    page,
742                    "AddScriptToEvaluateOnNewDocument(peripheral-stealth)",
743                    peripheral_script,
744                )
745                .await?;
746            }
747        }
748    }
749
750    Ok(())
751}
752
753fn navigator_profile_from_coherent_profile(
754    profile: &crate::profile::FingerprintProfile,
755) -> NavigatorProfile {
756    NavigatorProfile {
757        user_agent: profile.browser.user_agent.clone(),
758        platform: profile.platform.platform_string.clone(),
759        vendor: "Google Inc.".to_string(),
760        hardware_concurrency: u8::try_from(profile.hardware.cores).unwrap_or(8),
761        device_memory: u8::try_from(profile.hardware.memory_gb).unwrap_or(8),
762        max_touch_points: profile.platform.max_touch_points,
763        webgl_vendor: profile.webgl.vendor.clone(),
764        webgl_renderer: profile.webgl.renderer.clone(),
765    }
766}
767
768fn fingerprint_from_coherent_profile(
769    profile: &crate::profile::FingerprintProfile,
770) -> crate::fingerprint::Fingerprint {
771    crate::fingerprint::Fingerprint {
772        user_agent: profile.browser.user_agent.clone(),
773        screen_resolution: (profile.screen.width, profile.screen.height),
774        // The v3 profile model does not currently include explicit
775        // timezone/language fields; derive deterministic values from profile
776        // seed so they stay stable per profile and vary across sessions.
777        timezone: fingerprint_timezone_from_coherent_profile(profile),
778        language: fingerprint_language_from_coherent_profile(profile),
779        platform: profile.platform.platform_string.clone(),
780        hardware_concurrency: profile.hardware.cores,
781        device_memory: profile.hardware.memory_gb,
782        webgl_vendor: Some(profile.webgl.vendor.clone()),
783        webgl_renderer: Some(profile.webgl.renderer.clone()),
784        canvas_noise: true,
785        fonts: Vec::new(),
786    }
787}
788
789fn fallback_profile_for_config(
790    config: &crate::config::BrowserConfig,
791) -> crate::profile::FingerprintProfile {
792    let seed = config.noise.seed.map_or_else(
793        || {
794            // Keep a stable fallback per BrowserConfig instance so all pages
795            // from the same instance share one coherent profile.
796            std::ptr::from_ref(config) as usize as u64
797        },
798        crate::noise::NoiseSeed::as_u64,
799    );
800
801    // Weighted mapping: windows 65%, macOS 20%, linux 5%, android 10%.
802    match seed % 100 {
803        0..=64 => crate::profile::FingerprintProfile::windows_chrome_136_rtx3060(),
804        65..=84 => crate::profile::FingerprintProfile::macos_chrome_136_m1(),
805        85..=89 => crate::profile::FingerprintProfile::linux_chrome_136_intel(),
806        _ => crate::profile::FingerprintProfile::android_chrome_136_pixel(),
807    }
808}
809
810fn deterministic_profile_choice<'a>(seed: u64, salt: &str, choices: &'a [&'a str]) -> &'a str {
811    let Some(default_choice) = choices.first().copied() else {
812        return "";
813    };
814
815    let mut hash = seed ^ 0xcbf2_9ce4_8422_2325_u64;
816    for byte in salt.as_bytes() {
817        hash ^= u64::from(*byte);
818        hash = hash.wrapping_mul(0x100_0000_01b3);
819    }
820
821    let Ok(len_u64) = u64::try_from(choices.len()) else {
822        return default_choice;
823    };
824    if len_u64 == 0 {
825        return default_choice;
826    }
827
828    let idx_u64 = hash % len_u64;
829    let Ok(idx) = usize::try_from(idx_u64) else {
830        return default_choice;
831    };
832
833    choices.get(idx).copied().unwrap_or(default_choice)
834}
835
836fn fingerprint_timezone_from_coherent_profile(
837    profile: &crate::profile::FingerprintProfile,
838) -> String {
839    const TZ_WINDOWS: &[&str] = &[
840        "America/New_York",
841        "America/Chicago",
842        "America/Denver",
843        "America/Los_Angeles",
844        "America/Toronto",
845    ];
846    const TZ_MAC: &[&str] = &[
847        "America/Los_Angeles",
848        "America/New_York",
849        "Europe/London",
850        "Europe/Paris",
851    ];
852    const TZ_LINUX: &[&str] = &[
853        "Europe/Berlin",
854        "Europe/Amsterdam",
855        "Europe/London",
856        "America/New_York",
857    ];
858    const TZ_ANDROID: &[&str] = &[
859        "Asia/Tokyo",
860        "Asia/Seoul",
861        "Asia/Singapore",
862        "Australia/Sydney",
863        "America/Los_Angeles",
864    ];
865
866    let choices = match profile.platform.os {
867        crate::profile::Os::Windows => TZ_WINDOWS,
868        crate::profile::Os::MacOs => TZ_MAC,
869        crate::profile::Os::Linux => TZ_LINUX,
870        crate::profile::Os::Android | crate::profile::Os::Ios => TZ_ANDROID,
871    };
872
873    deterministic_profile_choice(profile.noise_seed.as_u64(), "timezone", choices).to_string()
874}
875
876fn fingerprint_language_from_coherent_profile(
877    profile: &crate::profile::FingerprintProfile,
878) -> String {
879    const LANGUAGES: &[&str] = &[
880        "en-US", "en-GB", "fr-FR", "de-DE", "es-ES", "it-IT", "nl-NL", "pt-BR", "sv-SE",
881    ];
882
883    let keyboard = profile.platform.keyboard_layout.trim();
884    let normalized = match keyboard {
885        "en" | "en-US" | "en_US" | "us" | "US" => Some("en-US"),
886        "en-GB" | "en_GB" | "uk" | "UK" => Some("en-GB"),
887        "fr" | "fr-FR" | "fr_FR" => Some("fr-FR"),
888        "de" | "de-DE" | "de_DE" => Some("de-DE"),
889        "es" | "es-ES" | "es_ES" => Some("es-ES"),
890        "it" | "it-IT" | "it_IT" => Some("it-IT"),
891        "nl" | "nl-NL" | "nl_NL" => Some("nl-NL"),
892        "pt" | "pt-BR" | "pt_BR" => Some("pt-BR"),
893        "sv" | "sv-SE" | "sv_SE" => Some("sv-SE"),
894        _ => None,
895    };
896    if let Some(lang) = normalized {
897        return lang.to_string();
898    }
899
900    deterministic_profile_choice(profile.noise_seed.as_u64(), "language", LANGUAGES).to_string()
901}
902
903// ─── Tests ────────────────────────────────────────────────────────────────────
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    #[test]
910    fn disabled_config_produces_empty_script() {
911        let p = StealthProfile::new(StealthConfig::disabled(), NavigatorProfile::default());
912        assert_eq!(p.injection_script(), "");
913    }
914
915    #[test]
916    fn navigator_signature_is_stable_and_changes_with_profile() {
917        let a = NavigatorProfile::windows_chrome().signature();
918        let b = NavigatorProfile::windows_chrome().signature();
919        assert_eq!(a, b, "identical profiles must produce equal signatures");
920        let c = NavigatorProfile::mac_chrome().signature();
921        assert_ne!(a, c, "different profiles must produce different signatures");
922        assert!(a.starts_with("fnv64:"));
923    }
924
925    #[test]
926    fn navigator_script_contains_platform() {
927        let profile = NavigatorProfile::windows_chrome();
928        let p = StealthProfile::new(StealthConfig::minimal(), profile);
929        let script = p.injection_script();
930        assert!(script.contains("Win32"), "platform must be in script");
931        assert!(
932            script.contains("'webdriver'"),
933            "webdriver removal must be present"
934        );
935    }
936
937    #[test]
938    fn navigator_script_contains_user_agent() {
939        let p = StealthProfile::new(StealthConfig::minimal(), NavigatorProfile::mac_chrome());
940        let script = p.injection_script();
941        assert!(script.contains("Mac OS X"));
942        assert!(script.contains("MacIntel"));
943    }
944
945    #[test]
946    fn webgl_script_contains_vendor_renderer() {
947        let p = StealthProfile::new(
948            StealthConfig {
949                spoof_navigator: false,
950                randomize_webgl: true,
951                ..StealthConfig::disabled()
952            },
953            NavigatorProfile::windows_chrome(),
954        );
955        let script = p.injection_script();
956        assert!(
957            script.contains("NVIDIA"),
958            "WebGL vendor must appear in script"
959        );
960        assert!(
961            script.contains("getParameter"),
962            "WebGL method must be overridden"
963        );
964    }
965
966    #[test]
967    fn full_profile_wraps_in_iife() {
968        let p = StealthProfile::new(StealthConfig::default(), NavigatorProfile::default());
969        let script = p.injection_script();
970        assert!(script.starts_with("(function()"), "script must be an IIFE");
971        assert!(script.ends_with("})();"));
972    }
973
974    #[test]
975    fn navigator_profile_linux_has_correct_platform() {
976        assert_eq!(NavigatorProfile::linux_chrome().platform, "Linux x86_64");
977    }
978
979    #[test]
980    fn stealth_config_paranoid_equals_default() {
981        let a = StealthConfig::paranoid();
982        let b = StealthConfig::default();
983        assert_eq!(a.spoof_navigator, b.spoof_navigator);
984        assert_eq!(a.randomize_webgl, b.randomize_webgl);
985        assert_eq!(a.randomize_canvas, b.randomize_canvas);
986        assert_eq!(a.human_behavior, b.human_behavior);
987        assert_eq!(a.protect_cdp, b.protect_cdp);
988    }
989
990    #[test]
991    fn hardware_concurrency_reasonable() {
992        let p = NavigatorProfile::windows_chrome();
993        assert!(p.hardware_concurrency >= 2);
994        assert!(p.hardware_concurrency <= 64);
995    }
996
997    // ── T13: stealth level script generation ──────────────────────────────────
998
999    #[test]
1000    fn none_level_is_not_active() {
1001        use crate::config::StealthLevel;
1002        assert!(!StealthLevel::None.is_active());
1003    }
1004
1005    #[test]
1006    fn basic_level_cdp_script_removes_webdriver() {
1007        use crate::cdp_protection::{CdpFixMode, CdpProtection};
1008        let script = CdpProtection::new(CdpFixMode::AddBinding, None).build_injection_script();
1009        assert!(
1010            script.contains("webdriver"),
1011            "CDP protection script should remove navigator.webdriver"
1012        );
1013    }
1014
1015    #[test]
1016    fn basic_level_minimal_config_injects_navigator() {
1017        let config = StealthConfig::minimal();
1018        let profile = NavigatorProfile::default();
1019        let script = StealthProfile::new(config, profile).injection_script();
1020        assert!(
1021            !script.is_empty(),
1022            "Basic stealth should produce a navigator script"
1023        );
1024    }
1025
1026    #[test]
1027    fn advanced_level_paranoid_config_includes_webgl() {
1028        let config = StealthConfig::paranoid();
1029        let profile = NavigatorProfile::windows_chrome();
1030        let script = StealthProfile::new(config, profile).injection_script();
1031        assert!(
1032            script.contains("webgl") && script.contains("getParameter"),
1033            "Advanced stealth should spoof WebGL via getParameter patching"
1034        );
1035    }
1036
1037    #[test]
1038    fn advanced_level_fingerprint_script_non_empty() {
1039        use crate::fingerprint::{Fingerprint, inject_fingerprint};
1040        let fp = Fingerprint::random();
1041        let script = inject_fingerprint(&fp);
1042        assert!(
1043            !script.is_empty(),
1044            "Fingerprint injection script must not be empty"
1045        );
1046    }
1047
1048    #[test]
1049    fn stealth_level_ordering() {
1050        use crate::config::StealthLevel;
1051        assert!(!StealthLevel::None.is_active());
1052        assert!(StealthLevel::Basic.is_active());
1053        assert!(StealthLevel::Advanced.is_active());
1054    }
1055
1056    #[test]
1057    fn navigator_profile_basic_uses_default() {
1058        // Basic → default navigator profile (mac_chrome)
1059        let profile = NavigatorProfile::default();
1060        assert_eq!(profile.platform, "MacIntel");
1061    }
1062
1063    #[test]
1064    fn navigator_profile_advanced_uses_windows() {
1065        let profile = NavigatorProfile::windows_chrome();
1066        assert_eq!(profile.platform, "Win32");
1067    }
1068}