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