From f0fa14f8989c7f054afc62148e68a8e20e0cd08c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 12 Aug 2026 21:08:13 +0800 Subject: [PATCH] feat: add stealth modules for chrome runtime, screen, and permissions (Task 12) - chrome-runtime.ts: injects window.chrome object with runtime, loadTimes, csi, app - screen.ts: overrides screen.width/height/avail/colorDepth, window.outerWidth/Height - permissions.ts: hooks navigator.permissions.query() for 4 permission types - tests: 15 integration tests (6 chrome, 6 screen, 5 permissions), skip when VISIONL_INTEGRATION != '1' - typecheck: passed (0 errors) --- .../__tests__/stealth-chrome-screen.test.ts | 290 ++++++++++++++++++ packages/daemon/src/stealth/chrome-runtime.ts | 66 ++++ packages/daemon/src/stealth/permissions.ts | 45 +++ packages/daemon/src/stealth/screen.ts | 47 +++ 4 files changed, 448 insertions(+) create mode 100644 packages/daemon/src/__tests__/stealth-chrome-screen.test.ts create mode 100644 packages/daemon/src/stealth/chrome-runtime.ts create mode 100644 packages/daemon/src/stealth/permissions.ts create mode 100644 packages/daemon/src/stealth/screen.ts diff --git a/packages/daemon/src/__tests__/stealth-chrome-screen.test.ts b/packages/daemon/src/__tests__/stealth-chrome-screen.test.ts new file mode 100644 index 0000000..c8e81db --- /dev/null +++ b/packages/daemon/src/__tests__/stealth-chrome-screen.test.ts @@ -0,0 +1,290 @@ +// Integration tests for chrome-runtime, screen, and permissions stealth modules / chrome-runtime、screen、permissions 隐身模块集成测试 +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium } from 'playwright-extra'; +import StealthPlugin from 'puppeteer-extra-plugin-stealth'; +import type { Browser, BrowserContext } from 'playwright'; +import type { FingerprintProfile } from '@visionl/core'; +import { injectChromeRuntime } from '../stealth/chrome-runtime.js'; +import { injectScreenStealth } from '../stealth/screen.js'; +import { injectPermissionsStealth } from '../stealth/permissions.js'; + +chromium.use(StealthPlugin()); + +const integration = process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip; + +const testProfile: FingerprintProfile = { + id: 'fp_stealth_chrome_screen', + name: 'Stealth Chrome+Screen+Permissions Test', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + platform: 'Win32', + languages: ['en-US', 'en'], + acceptLanguage: 'en-US,en;q=0.9', + screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 }, + viewport: { width: 1280, height: 720 }, + webgl: { vendor: 'Google Inc.', renderer: 'ANGLE (NVIDIA GeForce RTX 3060)' }, + timezone: 'America/New_York', + permissions: { + notifications: 'denied', + geolocation: 'granted', + camera: 'denied', + microphone: 'denied', + }, + behavior: { + mouseMoveDelay: { min: 50, max: 150 }, + keyPressDelay: { min: 80, max: 200 }, + scrollStepDelay: { min: 30, max: 100 }, + }, + canvasNoise: { enabled: true, strength: 0.5 }, +}; + +// ==================== chrome-runtime / Chrome 运行时注入测试 ==================== +integration('injectChromeRuntime', () => { + let browser: Browser; + let context: BrowserContext; + + beforeAll(async () => { + browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + }); + + afterAll(async () => { + if (context) await context.close(); + if (browser) await browser.close(); + }); + + it('should set typeof window.chrome to "object"', async () => { + context = await browser.newContext(); + await injectChromeRuntime(context); + const page = await context.newPage(); + await page.goto('about:blank'); + + const result = await page.evaluate(() => typeof (window as any).chrome); + expect(result).toBe('object'); + }); + + it('should have chrome.runtime defined', async () => { + context = await browser.newContext(); + await injectChromeRuntime(context); + const page = await context.newPage(); + await page.goto('about:blank'); + + const hasRuntime = await page.evaluate(() => !!(window as any).chrome.runtime); + expect(hasRuntime).toBe(true); + }); + + it('should have chrome.loadTimes defined as function', async () => { + context = await browser.newContext(); + await injectChromeRuntime(context); + const page = await context.newPage(); + await page.goto('about:blank'); + + const loadTimesType = await page.evaluate(() => typeof (window as any).chrome.loadTimes); + expect(loadTimesType).toBe('function'); + }); + + it('should have chrome.csi defined as function', async () => { + context = await browser.newContext(); + await injectChromeRuntime(context); + const page = await context.newPage(); + await page.goto('about:blank'); + + const csiType = await page.evaluate(() => typeof (window as any).chrome.csi); + expect(csiType).toBe('function'); + }); + + it('should have chrome.app defined as object', async () => { + context = await browser.newContext(); + await injectChromeRuntime(context); + const page = await context.newPage(); + await page.goto('about:blank'); + + const appType = await page.evaluate(() => typeof (window as any).chrome.app); + expect(appType).toBe('object'); + }); + + it('should have chrome.app.isInstalled === false', async () => { + context = await browser.newContext(); + await injectChromeRuntime(context); + const page = await context.newPage(); + await page.goto('about:blank'); + + const isInstalled = await page.evaluate(() => (window as any).chrome.app.isInstalled); + expect(isInstalled).toBe(false); + }); +}); + +// ==================== screen / 屏幕属性隐身测试 ==================== +integration('injectScreenStealth', () => { + let browser: Browser; + let context: BrowserContext; + + beforeAll(async () => { + browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + }); + + afterAll(async () => { + if (context) await context.close(); + if (browser) await browser.close(); + }); + + it('should set screen.width from profile', async () => { + context = await browser.newContext({ viewport: testProfile.viewport }); + await injectScreenStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const width = await page.evaluate(() => screen.width); + expect(width).toBe(testProfile.screen.width); + }); + + it('should set screen.height from profile', async () => { + context = await browser.newContext({ viewport: testProfile.viewport }); + await injectScreenStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const height = await page.evaluate(() => screen.height); + expect(height).toBe(testProfile.screen.height); + }); + + it('should set screen.availHeight < screen.height (taskbar subtracted)', async () => { + context = await browser.newContext({ viewport: testProfile.viewport }); + await injectScreenStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const { height, availHeight } = await page.evaluate(() => ({ + height: screen.height, + availHeight: screen.availHeight, + })); + expect(availHeight).toBeLessThan(height); + }); + + it('should set screen.colorDepth from profile', async () => { + context = await browser.newContext({ viewport: testProfile.viewport }); + await injectScreenStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const colorDepth = await page.evaluate(() => screen.colorDepth); + expect(colorDepth).toBe(testProfile.screen.colorDepth); + }); + + it('should set window.outerWidth > window.innerWidth (decorations)', async () => { + context = await browser.newContext({ viewport: testProfile.viewport }); + await injectScreenStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const { outer, inner } = await page.evaluate(() => ({ + outer: window.outerWidth, + inner: window.innerWidth, + })); + expect(outer).toBeGreaterThan(inner); + }); + + it('should set window.outerHeight > window.innerHeight (decorations)', async () => { + context = await browser.newContext({ viewport: testProfile.viewport }); + await injectScreenStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const { outer, inner } = await page.evaluate(() => ({ + outer: window.outerHeight, + inner: window.innerHeight, + })); + expect(outer).toBeGreaterThan(inner); + }); +}); + +// ==================== permissions / 权限查询隐身测试 ==================== +integration('injectPermissionsStealth', () => { + let browser: Browser; + let context: BrowserContext; + + beforeAll(async () => { + browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + }); + + afterAll(async () => { + if (context) await context.close(); + if (browser) await browser.close(); + }); + + it('should override notifications permission from profile', async () => { + context = await browser.newContext(); + await injectPermissionsStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const status = await page.evaluate(async () => { + const result = await navigator.permissions.query({ name: 'notifications' }); + return result.state; + }); + expect(status).toBe(testProfile.permissions.notifications); + }); + + it('should override geolocation permission from profile', async () => { + context = await browser.newContext(); + await injectPermissionsStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const status = await page.evaluate(async () => { + const result = await navigator.permissions.query({ name: 'geolocation' }); + return result.state; + }); + expect(status).toBe(testProfile.permissions.geolocation); + }); + + it('should override camera permission from profile', async () => { + context = await browser.newContext(); + await injectPermissionsStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const status = await page.evaluate(async () => { + const result = await navigator.permissions.query({ name: 'camera' }); + return result.state; + }); + expect(status).toBe(testProfile.permissions.camera); + }); + + it('should override microphone permission from profile', async () => { + context = await browser.newContext(); + await injectPermissionsStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const status = await page.evaluate(async () => { + const result = await navigator.permissions.query({ name: 'microphone' }); + return result.state; + }); + expect(status).toBe(testProfile.permissions.microphone); + }); + + it('should not intercept unknown permission names', async () => { + context = await browser.newContext(); + await injectPermissionsStealth(context, testProfile); + const page = await context.newPage(); + await page.goto('about:blank'); + + const shouldThrow = await page.evaluate(async () => { + try { + await navigator.permissions.query({ name: 'unknown-perm' as any }); + return false; + } catch { + return true; + } + }); + expect(shouldThrow).toBe(true); + }); +}); diff --git a/packages/daemon/src/stealth/chrome-runtime.ts b/packages/daemon/src/stealth/chrome-runtime.ts new file mode 100644 index 0000000..9ea12d7 --- /dev/null +++ b/packages/daemon/src/stealth/chrome-runtime.ts @@ -0,0 +1,66 @@ +// Stealth — inject window.chrome object for headless detection avoidance / 隐身 — 注入 window.chrome 对象以规避无头检测 +import type { BrowserContext } from 'playwright'; + +export async function injectChromeRuntime(context: BrowserContext): Promise { + await context.addInitScript(() => { + // Headless Chrome has typeof window.chrome === 'undefined' — inject it / 无头模式下缺少 chrome 对象,注入以伪装真实 Chrome + const win = window as any; + + if (typeof win.chrome === 'undefined') { + win.chrome = {}; + } + + if (!win.chrome.runtime) { + win.chrome.runtime = { + PlatformOs: { mac: 'mac', win: 'win', android: 'android', cros: 'cros', linux: 'linux', openbsd: 'openbsd', fuchsia: 'fuchsia' }, + PlatformArch: { arm: 'arm', arm64: 'arm64', x86_32: 'x86-32', x86_64: 'x86-64', mips: 'mips', mips64: 'mips64' }, + PlatformNaclArch: { arm: 'arm', x86_32: 'x86-32', x86_64: 'x86-64', mips: 'mips', mips64: 'mips64' }, + RequestUpdateCheckStatus: { throttled: 'throttled', no_update: 'no_update', update_available: 'update_available' }, + OnInstalledReason: { install: 'install', update: 'update', chrome_update: 'chrome_update', shared_module_update: 'shared_module_update' }, + OnRestartRequiredReason: { app_update: 'app_update', os_update: 'os_update', periodic: 'periodic' }, + id: void 0, + getManifest(): object { return { version: '0.0.0', name: '', manifest_version: 3 }; }, + getURL(path: string): string { return `chrome-extension://invalid/${path}`; }, + lastError: void 0, + }; + } + + if (!win.chrome.loadTimes) { + win.chrome.loadTimes = () => ({ + requestTime: Date.now() / 1000, + startLoadTime: Date.now() / 1000, + commitLoadTime: Date.now() / 1000, + finishDocumentLoadTime: Date.now() / 1000, + finishLoadTime: Date.now() / 1000, + firstPaintTime: Date.now() / 1000, + firstPaintAfterLoadTime: 0, + navigationType: 'Other', + wasFetchedViaSpdy: true, + wasNpnNegotiated: true, + npnNegotiatedProtocol: 'http/1.1', + wasAlternateProtocolAvailable: false, + connectionInfo: 'http/1.1', + }); + } + + if (!win.chrome.csi) { + win.chrome.csi = () => ({ + startE: 0, + onloadT: 0, + pageT: 0, + tran: 15, + }); + } + + if (!win.chrome.app) { + win.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' }, + getDetails(): Array { return []; }, + getIsInstalled(): boolean { return false; }, + runningState(): string { return 'cannot_run'; }, + }; + } + }); +} diff --git a/packages/daemon/src/stealth/permissions.ts b/packages/daemon/src/stealth/permissions.ts new file mode 100644 index 0000000..d18bdee --- /dev/null +++ b/packages/daemon/src/stealth/permissions.ts @@ -0,0 +1,45 @@ +// Stealth — override navigator.permissions.query() at JS level / 隐身 — 在 JS 层面覆盖 navigator.permissions.query() +import type { BrowserContext } from 'playwright'; +import type { FingerprintProfile, PermissionState } from '@visionl/core'; + +const WATCHED_NAMES = ['notifications', 'geolocation', 'camera', 'microphone'] as const; + +export async function injectPermissionsStealth( + context: BrowserContext, + profile: FingerprintProfile, +): Promise { + const perms: Record = {}; + + for (const name of WATCHED_NAMES) { + perms[name] = profile.permissions[name]; + } + + await context.addInitScript((opts) => { + const permissionsNameSet = new Set(opts.watchedNames); + const permissionsMap: Record = opts.stateMap; + + const origQuery = navigator.permissions.query.bind(navigator.permissions); + + navigator.permissions.query = function (descriptor: PermissionDescriptor): Promise { + const name = descriptor.name; + + // Intercept watched permission names only / 仅拦截关注的权限名称 + if (permissionsNameSet.has(name) && name in permissionsMap) { + return Promise.resolve({ + name, + state: permissionsMap[name], + onchange: null, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent(): boolean { return true; }, + } as PermissionStatus); + } + + // Pass through other queries / 透传其他权限查询 + return origQuery(descriptor); + }; + }, { + watchedNames: [...WATCHED_NAMES], + stateMap: perms, + }); +} diff --git a/packages/daemon/src/stealth/screen.ts b/packages/daemon/src/stealth/screen.ts new file mode 100644 index 0000000..3701e4e --- /dev/null +++ b/packages/daemon/src/stealth/screen.ts @@ -0,0 +1,47 @@ +// Stealth — override screen and window dimension properties / 隐身 — 覆盖屏幕和窗口尺寸属性 +import type { BrowserContext } from 'playwright'; +import type { FingerprintProfile } from '@visionl/core'; + +const TASKBAR_HEIGHT = 40; // typical taskbar height in px / 典型任务栏高度(像素) + +export async function injectScreenStealth( + context: BrowserContext, + profile: FingerprintProfile, +): Promise { + const { screen: screenProfile, viewport } = profile; + + await context.addInitScript((opts) => { + // Override screen object / 覆盖 screen 对象 + Object.defineProperty(screen, 'width', { + get: () => opts.screenWidth, + }); + Object.defineProperty(screen, 'height', { + get: () => opts.screenHeight, + }); + Object.defineProperty(screen, 'availWidth', { + get: () => opts.availWidth, + }); + Object.defineProperty(screen, 'availHeight', { + get: () => opts.availHeight, + }); + Object.defineProperty(screen, 'colorDepth', { + get: () => opts.colorDepth, + }); + + // Override window.outerWidth/Height > inner for window decorations / 覆盖 outerWidth/Height 使其 > inner 模拟窗口装饰 + Object.defineProperty(window, 'outerWidth', { + get: () => opts.outerWidth, + }); + Object.defineProperty(window, 'outerHeight', { + get: () => opts.outerHeight, + }); + }, { + screenWidth: screenProfile.width, + screenHeight: screenProfile.height, + availWidth: screenProfile.width, + availHeight: screenProfile.height - TASKBAR_HEIGHT, + colorDepth: screenProfile.colorDepth, + outerWidth: viewport.width + 16, // window frame chrome / 窗口边框 + outerHeight: viewport.height + 72, // title bar + window frame / 标题栏 + 窗口边框 + }); +}