From ea961e4faab39bdfe176966260f31624c3625e48 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 19 May 2026 10:10:23 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Layer=20C=20stealth=20=E2=80=94=20chrom?= =?UTF-8?q?e.*,=20Notification,=20per-install=20hardware,=20toString=20Pro?= =?UTF-8?q?xy=20(gbrowser=20T1+T3+D6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions stacked into the existing applyStealth() init script to close the visible automation tells that today push GBrowser users into Google's /sorry/index captcha and similar: T1 — Strip Playwright's automation default args: --enable-automation (kills "Chrome is being controlled" infobar) --disable-popup-blocking, --disable-component-update, --disable-default-apps (Patchright's list — each is a documented tell) Now centralized in STEALTH_IGNORE_DEFAULT_ARGS export, used by BOTH launchHeaded() and handoff() (the headless → headed re-launch path). D6 — Drop "GStackBrowser" UA branding suffix: Real Chrome's UA ends `Safari/537.36`, not `Safari/537.36 GStackBrowser`. The branded suffix was a high-entropy classifier for any vendor that grep'd UA for known automation/test-browser strings. Branding still lives in the wrapper .app name + Dock icon + tray — does not need to leak via the UA string for the product to be "GBrowser." Resolves the "looks like Chrome but identifies as GStackBrowser" contradiction codex review #18 flagged. T3 — Layer C init-script additions in stealth.ts: 1. Function.prototype.toString Proxy (must run first). Wraps every patched getter / function in a WeakSet so they report `function NAME() { [native code] }` at every recursion depth, defeating the depth-3+ integrity check (fn.toString.toString.toString().includes('[native code]')). 2. window.chrome.runtime / chrome.app / chrome.csi / chrome.loadTimes restoration with full enum shape (OnInstalledReason, PlatformArch, PlatformOs, etc.) + method bodies. Real Chrome ships these; their absence is universally checked. Vendor research (gbrowser plan deep-dive on Cloudflare + DataDome) confirmed both vendors probe this shape directly. 3. Notification.permission aligned to 'default'. The existing inline addInitScript already spoofs permissions.query({name:'notifications'}) to return 'prompt' — Notification.permission being 'denied' while Permissions returns 'prompt' is a cross-source inconsistency that detectors flag specifically. 4. Per-install hardware values via GSTACK_HW_CONCURRENCY / GSTACK_DEVICE_MEMORY env vars (set by gbd's host_profile.go from system_profiler + sysctl). Reporting real host values within the Chrome shape avoids the cross-user GBrowser fingerprint cluster that hardcoded defaults would create. Codex review #10 flagged hardcoding as creating contradictions across Apple Silicon / Intel / UA-CH architecture. 5. Selenium 25-global cleanup + PhantomJS + NightmareJS + Watir + Playwright (__pwInitScripts, __playwright__binding__) static-name deletion. The inline block continues to handle the dynamic cdc_/__webdriver/__selenium/__driver prefixes. D7 (codex correction) kept: still do NOT fake navigator.plugins or navigator.languages. Synthesizing those triggers MORE consistency flags from modern fingerprinters than letting Chromium surface them natively. Test coverage: - 15 new tests in stealth-layer-c.test.ts covering: launch-flag exports, script structure, toString-Proxy installs first, every spoof present, hardware values interpolated from input (not hardcoded), Selenium global cleanup spot-check, no GStackBrowser leak in stealth payload, backwards-compat exports preserved. - All 8 existing stealth-webdriver tests still pass. - All 2 existing browser-manager-unit tests still pass. For GBrowser specifically: this is the gstack-side half of Phase 1 / T1 + T3 + D6 in the anti-detection plan. The gbrowser repo's submodule pointer bump will land alongside this. --- browse/src/browser-manager.ts | 36 ++-- browse/src/stealth.ts | 286 ++++++++++++++++++++++++++-- browse/test/stealth-layer-c.test.ts | 127 ++++++++++++ 3 files changed, 421 insertions(+), 28 deletions(-) create mode 100644 browse/test/stealth-layer-c.test.ts diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index cdbd5fc50..272bc7d89 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -392,8 +392,14 @@ export class BrowserManager { if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err; } - // Build custom user agent: keep Chrome version for site compatibility, - // but replace "Chrome for Testing" branding with "GStackBrowser" + // Build custom user agent: report as stock Chrome with the version + // matching the underlying Chromium binary. D6 (codex #18 correction): + // the previous "GStackBrowser" branding suffix was itself a high-entropy + // classifier — sites grepping UA for known browser strings caught us + // immediately. Branding still lives in the wrapper .app name + Dock icon + // + tray; it does NOT need to be in the UA string for the product to be + // "GBrowser." Removing it resolves the "looks like Chrome but identifies + // as GStackBrowser" contradiction codex flagged. let customUA: string | undefined; if (!this.customUserAgent) { // Detect Chrome version from the Chromium binary @@ -406,13 +412,20 @@ export class BrowserManager { // Output like: "Google Chrome for Testing 145.0.6422.0" or "Chromium 145.0.6422.0" const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+\.\d+)/); const chromeVersion = versionMatch ? versionMatch[1] : '131.0.0.0'; - customUA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36 GStackBrowser`; + customUA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; } catch { // Fallback: generic modern Chrome UA - customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 GStackBrowser'; + customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; } } + // T1: strip Playwright's automation-tell defaults. STEALTH_IGNORE_DEFAULT_ARGS + // covers the originals (extension-loading blockers) plus --enable-automation + // (kills the "Chrome is being controlled by automated test software" infobar + // and the chrome-runtime shape changes Playwright otherwise triggers) and + // three more (--disable-popup-blocking, --disable-component-update, + // --disable-default-apps — each a documented automation tell per Patchright). + const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth'); this.context = await chromium.launchPersistentContext(userDataDir, { headless: false, args: launchArgs, @@ -420,11 +433,7 @@ export class BrowserManager { userAgent: this.customUserAgent || customUA, ...(executablePath ? { executablePath } : {}), ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), - // Playwright adds flags that block extension loading - ignoreDefaultArgs: [ - '--disable-extensions', - '--disable-component-extensions-with-background-pages', - ], + ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, }); this.browser = this.context.browser(); this.connectionMode = 'headed'; @@ -1301,15 +1310,16 @@ export class BrowserManager { const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); fs.mkdirSync(userDataDir, { recursive: true }); + // T1: same automation-tell-stripping defaults as launchHeaded(). + // The handoff path (headless → headed re-launch) takes the same + // anti-detection posture. + const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth'); newContext = await chromium.launchPersistentContext(userDataDir, { headless: false, args: launchArgs, viewport: null, ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), - ignoreDefaultArgs: [ - '--disable-extensions', - '--disable-component-extensions-with-background-pages', - ], + ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, timeout: 15000, }); } catch (err: unknown) { diff --git a/browse/src/stealth.ts b/browse/src/stealth.ts index 9c03d7d64..b072df291 100644 --- a/browse/src/stealth.ts +++ b/browse/src/stealth.ts @@ -1,34 +1,266 @@ /** - * Stealth init script — webdriver-mask only (D7, codex narrowed). + * Stealth init script — Layer C of GBrowser's anti-detection plan. * - * Modern anti-bot fingerprinters check consistency between navigator - * properties (plugins.length, languages, userAgent, platform). Faking those - * to fixed values (the wintermute approach) can flag MORE bot-like, not - * less, and breaks legitimate sites that reflect on these properties. + * D7 (codex correction, kept): we DON'T fake navigator.plugins or + * navigator.languages — modern fingerprinters cross-check those against + * userAgent / platform / OS, and synthesizing fixed values flags MORE + * bot-like, not less. Plugins and languages surface their native + * Chromium values. * - * The honest minimum is masking navigator.webdriver, which Chromium exposes - * as a known automation tell. Letting plugins/languages/chrome.runtime - * surface their native Chromium values keeps the fingerprint internally - * consistent. + * What this script DOES do (the new additions for Phase 1): + * 1. Mask navigator.webdriver (the canonical headless tell). + * 2. Restore window.chrome.runtime / app / csi / loadTimes — real + * Chrome ships them; their absence in headless/automation is a + * universally-checked tell. (Vendor research: Cloudflare + DataDome + * check chrome.runtime presence + enum shape.) + * 3. Align Notification.permission with the Permissions API spoof + * that the inline addInitScript already applies — `denied` while + * Permissions returns `prompt` is a cross-source inconsistency + * detectors flag. + * 4. Report per-install hardware values via GSTACK_HW_CONCURRENCY / + * GSTACK_DEVICE_MEMORY env vars (set by gbd at startup via + * system_profiler + sysctl). Per-install honesty avoids the + * cross-user fingerprint cluster a hardcoded default would create. + * 5. Install a Function.prototype.toString Proxy that makes every + * patched getter report `function ... { [native code] }` at every + * recursion depth — defeats the well-known depth-3 detection trick + * (`fn.toString.toString.toString().includes('[native code]')`) + * that breaks naive stealth tooling. + * + * Codex caveat (acknowledged): a Proxy on Function.prototype.toString + * still has detection surfaces (descriptors, Reflect.ownKeys, cross- + * realm identity). Phase 2's C++ patches make this layer obsolete by + * pushing the spoofs to native code where toString is truly native. + * Until then, this is the best JS-only approach. */ -import type { Browser, BrowserContext } from 'playwright'; +import type { BrowserContext } from 'playwright'; /** - * Init script applied to every page in a context. Runs in the page's main - * world before any other scripts. Idempotent — defining the same property - * twice in different contexts is fine. + * Host hardware values resolved at browser-manager startup. Values come + * from the gbd `host_profile.go` detection (system_profiler + sysctl + * on macOS), passed through the GSTACK_* env vars. Each field falls + * back to a documented default if the env var is missing or unparseable. */ -export const WEBDRIVER_MASK_SCRIPT = `Object.defineProperty(navigator, 'webdriver', { get: () => false });`; +interface HostProfile { + platform: string; + hwConcurrency: number; + deviceMemory: number; +} + +function readHostProfile(): HostProfile { + const env = (globalThis as any).process?.env ?? {}; + const concurrency = Number(env.GSTACK_HW_CONCURRENCY); + const memory = Number(env.GSTACK_DEVICE_MEMORY); + return { + platform: env.GSTACK_PLATFORM || 'MacIntel', + hwConcurrency: Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 8, + deviceMemory: Number.isFinite(memory) && memory > 0 ? memory : 8, + }; +} + +/** + * Build the full Layer C stealth init script. The function template- + * literal-interpolates the host values so they bake into the script the + * page sees — process.env is not accessible from a page-world init script, + * so values must be resolved by the browser-manager process before + * injection. + * + * The script is one big self-invoking function so all the patches + * happen atomically before any page code runs. Order matters: the + * Function.prototype.toString Proxy installs FIRST so all subsequent + * defineProperty getters are covered by its native-code lie. + */ +export function buildStealthScript(hw: HostProfile): string { + return `(() => { + // ──── Function.prototype.toString Proxy (must run first) ──── + // Make every patched getter / function below report + // 'function NAME() { [native code] }' at every recursion depth. + // Defeats fn.toString.toString.toString() integrity checks. + const patchedFns = new WeakSet(); + const nativeToString = Function.prototype.toString; + const toStringProxy = new Proxy(nativeToString, { + apply(target, thisArg, args) { + if (patchedFns.has(thisArg)) { + const name = (thisArg && thisArg.name) || ''; + return 'function ' + name + '() { [native code] }'; + } + return Reflect.apply(target, thisArg, args); + }, + }); + Object.defineProperty(Function.prototype, 'toString', { + value: toStringProxy, writable: true, configurable: true, + }); + const markNative = (fn, name) => { + if (name) { + try { Object.defineProperty(fn, 'name', { value: name }); } catch {} + } + patchedFns.add(fn); + return fn; + }; + + // ──── navigator.webdriver (canonical mask, kept from D7) ──── + try { + const webdriverGetter = markNative(function() { return false; }, 'get webdriver'); + Object.defineProperty(navigator, 'webdriver', { get: webdriverGetter, configurable: true }); + } catch {} + + // ──── window.chrome.* restoration ──── + // Real Chrome ships these objects with rich enum / method shape. + // Headless Chromium / Playwright's launch strips them. Their absence + // is a universally-checked tell (verified in Cloudflare + DataDome + // RE catalogs). We don't try to perfectly mimic — we ship plausible + // shape with native-code-looking methods. + try { + if (!('chrome' in window)) { + window.chrome = {}; + } + const chrome = window.chrome; + if (!chrome.runtime) { + chrome.runtime = { + OnInstalledReason: { CHROME_UPDATE: 'chrome_update', INSTALL: 'install', + SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update' }, + OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' }, + PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64', + X86_32: 'x86-32', X86_64: 'x86-64' }, + PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64', + X86_32: 'x86-32', X86_64: 'x86-64' }, + PlatformOs: { ANDROID: 'android', CROS: 'cros', LINUX: 'linux', + MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' }, + RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled', + UPDATE_AVAILABLE: 'update_available' }, + connect: markNative(function connect() { + throw new TypeError('Error in invocation of runtime.connect: No matching signature.'); + }, 'connect'), + sendMessage: markNative(function sendMessage() { + throw new TypeError('Error in invocation of runtime.sendMessage: No matching signature.'); + }, 'sendMessage'), + id: undefined, + }; + } + if (!chrome.app) { + chrome.app = { + isInstalled: false, + InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, + RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }, + }; + } + if (typeof chrome.csi !== 'function') { + chrome.csi = markNative(function csi() { + return { + onloadT: Date.now(), + pageT: performance.now(), + startE: Date.now() - 1000, + tran: 15, + }; + }, 'csi'); + } + if (typeof chrome.loadTimes !== 'function') { + chrome.loadTimes = markNative(function loadTimes() { + const t = performance.timing; + return { + requestTime: t.requestStart / 1000, + startLoadTime: t.requestStart / 1000, + commitLoadTime: t.responseStart / 1000, + finishDocumentLoadTime: t.domContentLoadedEventEnd / 1000, + finishLoadTime: t.loadEventEnd / 1000, + firstPaintTime: t.responseEnd / 1000, + firstPaintAfterLoadTime: 0, + navigationType: 'Other', + wasFetchedViaSpdy: true, + wasNpnNegotiated: true, + npnNegotiatedProtocol: 'h2', + wasAlternateProtocolAvailable: false, + connectionInfo: 'h2', + }; + }, 'loadTimes'); + } + } catch (err) { + // Non-fatal — page might have a stricter Content Security Policy + // that blocks property mutation on window. Leave chrome.* whatever + // shape it was; navigator.webdriver mask still applies. + } + + // ──── Notification.permission align with Permissions API ──── + // The inline addInitScript already overrides permissions.query for + // notifications → 'prompt'. Notification.permission must match + // ('default' in real Chrome on pages that haven't asked yet). + try { + if (typeof Notification !== 'undefined') { + const notificationPermissionGetter = markNative(function() { return 'default'; }, 'get permission'); + Object.defineProperty(Notification, 'permission', { + get: notificationPermissionGetter, + configurable: true, + }); + } + } catch {} + + // ──── Per-install hardware values from GSTACK_* env (T2) ──── + // gbd's host_profile.go fed real host values via cmdline env. Reporting + // those (not hardcoded defaults) avoids the cross-user GBrowser + // fingerprint cluster. + try { + const hwConcurrencyGetter = markNative(function() { return ${hw.hwConcurrency}; }, 'get hardwareConcurrency'); + Object.defineProperty(navigator, 'hardwareConcurrency', { + get: hwConcurrencyGetter, + configurable: true, + }); + } catch {} + try { + const deviceMemoryGetter = markNative(function() { return ${hw.deviceMemory}; }, 'get deviceMemory'); + Object.defineProperty(navigator, 'deviceMemory', { + get: deviceMemoryGetter, + configurable: true, + }); + } catch {} + + // ──── Selenium / Phantom / Nightmare / Playwright global cleanup ──── + // 25 Selenium globals + Playwright markers + PhantomJS/Nightmare + // traces. The inline addInitScript already covers cdc_/__webdriver + // dynamic prefixes — this is the static known-name list. + try { + const auto = [ + '__driver_evaluate', '__webdriver_evaluate', '__selenium_evaluate', '__fxdriver_evaluate', + '__driver_unwrapped', '__webdriver_unwrapped', '__selenium_unwrapped', '__fxdriver_unwrapped', + '_Selenium_IDE_Recorder', '_selenium', 'calledSelenium', + '$chrome_asyncScriptInfo', + '__$webdriverAsyncExecutor', '__webdriverFunc', + 'domAutomation', 'domAutomationController', + '__lastWatirAlert', '__lastWatirConfirm', '__lastWatirPrompt', + '__webdriver_script_fn', '_WEBDRIVER_ELEM_CACHE', + 'callPhantom', '_phantom', 'phantom', '__nightmare', + '__pwInitScripts', '__playwright__binding__', + ]; + for (const k of auto) { + try { delete window[k]; } catch {} + } + try { delete document.__webdriver_script_fn; } catch {} + } catch {} +})();`; +} /** * Apply stealth patches to a fresh BrowserContext (or persistent context). * Called by browser-manager.launch() and launchHeaded(). + * + * Resolves the host profile from process.env at call time so per-install + * values bake into the script before Playwright sends it to Chromium via + * Page.addScriptToEvaluateOnNewDocument. */ export async function applyStealth(context: BrowserContext): Promise { - await context.addInitScript({ content: WEBDRIVER_MASK_SCRIPT }); + const hw = readHostProfile(); + const script = buildStealthScript(hw); + await context.addInitScript({ content: script }); } +/** + * The legacy single-line webdriver mask, exported for backwards + * compatibility with any caller that uses it directly. New callers + * should use applyStealth() which includes this plus the Layer C + * additions. + */ +export const WEBDRIVER_MASK_SCRIPT = `Object.defineProperty(navigator, 'webdriver', { get: () => false });`; + /** * Args added to chromium.launch's `args` to suppress the * AutomationControlled blink feature. This is independent of the init @@ -37,3 +269,27 @@ export async function applyStealth(context: BrowserContext): Promise { export const STEALTH_LAUNCH_ARGS = [ '--disable-blink-features=AutomationControlled', ]; + +/** + * Playwright default args to strip via ignoreDefaultArgs. + * + * Playwright passes these by default. Each one is a visible automation + * tell at some layer: + * --enable-automation → infobar + chrome shape + * --disable-extensions → blocks our extension + * --disable-component-extensions-with-background-pages → blocks component ext + * --disable-popup-blocking → automation default + * --disable-component-update → automation default + * --disable-default-apps → affects plugin enum + * + * Used by browser-manager via spread into ignoreDefaultArgs to keep + * the list in one place across launchHeaded() and handoff(). + */ +export const STEALTH_IGNORE_DEFAULT_ARGS = [ + '--enable-automation', + '--disable-extensions', + '--disable-component-extensions-with-background-pages', + '--disable-popup-blocking', + '--disable-component-update', + '--disable-default-apps', +]; diff --git a/browse/test/stealth-layer-c.test.ts b/browse/test/stealth-layer-c.test.ts new file mode 100644 index 000000000..0ace1bd45 --- /dev/null +++ b/browse/test/stealth-layer-c.test.ts @@ -0,0 +1,127 @@ +/** + * stealth.ts Layer C additions (T3 + D6 for GBrowser anti-detection): + * verifies the build-time scaffolding without requiring a live browser. + * + * Live-browser verification of these spoofs in actual page contexts is + * covered by the gbrowser-side `test/anti-bot.test.sh` (Phase 1 / T7) + * which loads the probe page through the built GBrowser app post-bundle. + * These tests only exercise the JS script builder + the static export + * shapes — fast, hermetic, no chromium launch. + */ +import { describe, test, expect } from 'bun:test'; +import { + buildStealthScript, + WEBDRIVER_MASK_SCRIPT, + STEALTH_LAUNCH_ARGS, + STEALTH_IGNORE_DEFAULT_ARGS, +} from '../src/stealth'; + +describe('STEALTH_IGNORE_DEFAULT_ARGS — T1', () => { + test('includes --enable-automation (kills infobar)', () => { + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--enable-automation'); + }); + test('includes the 4 Patchright-recommended adds', () => { + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-popup-blocking'); + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-component-update'); + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-default-apps'); + }); + test('preserves the original extension-loading blockers', () => { + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-extensions'); + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-component-extensions-with-background-pages'); + }); +}); + +describe('buildStealthScript — T3 Layer C', () => { + const hw = { platform: 'MacARM', hwConcurrency: 16, deviceMemory: 8 }; + + test('builds a self-invoking function (atomic injection)', () => { + const s = buildStealthScript(hw); + expect(s.trim().startsWith('(() => {')).toBe(true); + expect(s.trim().endsWith('})();')).toBe(true); + }); + + test('installs the Function.prototype.toString Proxy FIRST', () => { + const s = buildStealthScript(hw); + const proxyIdx = s.indexOf('new Proxy(nativeToString'); + const webdriverIdx = s.indexOf("'webdriver'"); + expect(proxyIdx).toBeGreaterThan(0); + expect(webdriverIdx).toBeGreaterThan(proxyIdx); + }); + + test('navigator.webdriver getter returns false', () => { + const s = buildStealthScript(hw); + expect(s).toMatch(/Object\.defineProperty\(navigator, 'webdriver'/); + expect(s).toMatch(/return false/); + }); + + test('window.chrome.runtime ships full enum shape', () => { + const s = buildStealthScript(hw); + expect(s).toContain('OnInstalledReason'); + expect(s).toContain('PlatformArch'); + expect(s).toContain('PlatformOs'); + expect(s).toContain('RequestUpdateCheckStatus'); + // sendMessage / connect must throw native-shaped errors + expect(s).toContain('runtime.connect'); + expect(s).toContain('runtime.sendMessage'); + }); + + test('chrome.csi and chrome.loadTimes provide method bodies', () => { + const s = buildStealthScript(hw); + expect(s).toContain('chrome.csi = markNative(function csi()'); + expect(s).toContain('chrome.loadTimes = markNative(function loadTimes()'); + // loadTimes shape must include wasFetchedViaSpdy/connectionInfo — + // those are what real Chrome's loadTimes() returns on HTTP/2 sites. + expect(s).toContain('wasFetchedViaSpdy'); + expect(s).toContain('connectionInfo'); + }); + + test('Notification.permission aligned to default', () => { + const s = buildStealthScript(hw); + expect(s).toMatch(/Notification, 'permission'/); + expect(s).toMatch(/return 'default'/); + }); + + test('hardware values interpolated from host profile (NOT hardcoded)', () => { + const s = buildStealthScript({ platform: 'MacARM', hwConcurrency: 12, deviceMemory: 4 }); + expect(s).toContain('return 12'); + expect(s).toContain('return 4'); + expect(s).not.toMatch(/return 8;.*hardwareConcurrency/); + }); + + test('cleans up Selenium 25 globals + Playwright + Phantom + Nightmare', () => { + const s = buildStealthScript(hw); + // Spot-check a few from each category + expect(s).toContain('__webdriver_evaluate'); // Selenium + expect(s).toContain('domAutomationController'); // Chrome Driver classic + expect(s).toContain('__pwInitScripts'); // Playwright + expect(s).toContain('callPhantom'); // PhantomJS + expect(s).toContain('__nightmare'); // NightmareJS + expect(s).toContain('_Selenium_IDE_Recorder'); // Selenium IDE + }); + + test('uses markNative wrapper for every patched function', () => { + const s = buildStealthScript(hw); + // Every getter (hardwareConcurrency, deviceMemory, webdriver, Notification.permission) + // should be wrapped through markNative so the toString Proxy covers it. + const markNativeMatches = s.match(/markNative\(/g) || []; + // At least 8 markNative wrappings (webdriver, csi, loadTimes, connect, sendMessage, + // notification permission, hwConcurrency, deviceMemory) + expect(markNativeMatches.length).toBeGreaterThanOrEqual(7); + }); + + test('script does not include "GStackBrowser" branding string', () => { + const s = buildStealthScript(hw); + // D6: dropped from UA, must not leak in via stealth payload either. + expect(s).not.toContain('GStackBrowser'); + }); +}); + +describe('backwards-compat exports', () => { + test('WEBDRIVER_MASK_SCRIPT still exported', () => { + expect(WEBDRIVER_MASK_SCRIPT).toContain("'webdriver'"); + expect(WEBDRIVER_MASK_SCRIPT).toContain('false'); + }); + test('STEALTH_LAUNCH_ARGS still includes blink-features=AutomationControlled', () => { + expect(STEALTH_LAUNCH_ARGS).toContain('--disable-blink-features=AutomationControlled'); + }); +});