mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-03 18:06:54 +02:00
* fix(memory-ingest): --scan-secrets scans the rendered page and fails closed --scan-secrets ran gitleaks on the raw transcript .jsonl, then imported a page rendered from it. gitleaks' assignment rules don't match across a JSON-escaped quote (KEY=\"v\" on disk), so a secret the rendered page shows as KEY="v" was imported unflagged. And the gate skipped a file only on scanner "gitleaks" with findings, so a scan that errored (non-zero exit, 16MB maxBuffer overflow on a file with many findings, unparseable report) or could not run (gitleaks missing, slow-probe cooldown) imported the file unscanned. Scan the rendered page body, the exact bytes writeStaged() writes, via a new secretScanText() helper, and skip the file whenever the scan did not complete. Skipped files stay out of the state file, so the next run retries them. Reword the helper warnings and setup-gbrain/memory.md, which described the fail-open as intended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(test): reconcile Bun failure markers and footer counts * fix(sync-gbrain): verify source-scoped reads without mutation * fix(test): recognize grounded TTHW target choices structurally * fix(aside): make the readiness probe work under zsh and report why it failed The probe built its deadline into `_T` and expanded it unquoted, so `$_T aside repl …` only worked in a shell that word-splits. zsh does not: it looked for a command literally named "gtimeout 30", the probe answered ASIDE_NOT_RUNNING with Aside installed and ready, and every browsing skill fell back to the bundled Chromium in silence. zsh is the macOS default and Aside is macOS-only, so on a stock Mac the probe could never report READY. The deadline becomes a function, `_gs_d`. It receives the command as "$@", already split, so sh, bash and zsh all behave the same, and the gtimeout → timeout → perl alarm chain is unchanged. A 4th arm runs the call unbounded when none of the three is present, which is what the empty `_T` did before. Not `eval`: it re-parses the string, so the parens and `;` of the perl arm become syntax and that arm dies in bash *and* zsh — on a stock Mac, the arm that actually runs. On failure the probe now prints the CLI's reason after ASIDE_NOT_RUNNING:, the shape gstack-render already uses: the first line that starts with a capital letter, i.e. the CLI's own sentence or Node's `Error:` line below its loader frame. "Not running" covers states with different fixes — no window open for the profile, a NODE_OPTIONS preload that kills the CLI — and a bare verdict sent all of them to "open the Aside app". The BROWSER SETUP prose quotes that reason before asking the user to open the app. The text pin asserted the broken invocation verbatim, so it now pins the function and asserts neither `$_T aside repl` nor an eval form comes back. A second test executes the rendered probe in sh, bash and zsh on each of the four deadline arms with stubbed binaries on a narrowed PATH, plus two failing CLIs: one that prints its own sentence, one that crashes like Node with the useful line below the frame. The deadline function costs zero bytes against the lines it replaces; the reason costs 53 per copy of the probe (44 where the reworded BROWSER SETUP line gives 9 back). That moves four guards by the measured amount: plan-devex-review's skeleton cap to 68,550 (measured 68,544), plan-ceo-review's skeleton cap to 80,150 (measured 80,111) and union ratio to 1.081 (measured 1.0803), and plan-eng-review's union ratio to 1.151 (measured 1.1504). Fixes #2842, #2941. * Clarify engineering review startup and decision flow * Fix Windows readiness fixture PATH and command shim * fix(test): recognize grounded TTHW target choices structurally * Clarify engineering review startup and decision flow * fix(test): restrict QA-only fixture tools to its no-Edit contract * v1.90.0.0 fix(sync-gbrain): guard readiness verdicts and refresh metadata * fix(browse): validate canonical upload targets * fix(gbrain): classify structured PGLite busy response * fix(browse): preserve native extension runtime APIs * Fix displayless browser handoff ownership * Accept unique installed autoplan methodology aliases * fix(skills): preserve positional literals during installation * fix(browse): checksum installer contents through stdin * fix(test): normalize Windows checksum fixture paths * test: emulate unavailable shasum in Windows checksum fixture * fix(investigate): preserve owned freeze lifecycle * fix(review): preserve N+1 retry and Red Team completion * fix: bound Aside readiness and preserve safe fallback * test: exercise setup and Chromium on native ARM * fix: preserve install ownership and ARM browser selection * Fix gbrain ingest scan boundaries and seed observation * Refresh managed ship hooks and supervise expanded paid census * Reject resumed gbrain pages excluded by current policy * Recover zombie agent locks safely and enable CI Python venv * Repair paid actor declarations and Aside pitch assertions * Bump consolidated wave to next free minor release * Clarify CEO review admin choices and option tradeoffs * Preserve CEO mode handoff anchors in clarified workflow * Make Windows portability fixtures use shell-native paths * Restore ARM Bun alias and clarify ship review gates * Refresh ship workflow golden snapshots * Fix Windows DX documentation controls without piped stdin * Decode Codex child pipes without Bun's encoded-stream stall * Bound DX pre-review audit before product questions * Clarify trusted review-start read in paid revalidation * Bump consolidated wave to next free minor release * Clarify CEO review admin choices and option tradeoffs * Preserve CEO mode handoff anchors in clarified workflow * Make Windows portability fixtures use shell-native paths * Restore ARM Bun alias and clarify ship review gates * Refresh ship workflow golden snapshots * Fix Windows DX documentation controls without piped stdin * Decode Codex child pipes without Bun's encoded-stream stall * Bound DX pre-review audit before product questions * Clarify trusted review-start read in paid revalidation * Reconcile new main planning flow and paid judge census * fix: reconcile rebased planning and source-bound validation * test: pin cookie workflow judge to scored Sonnet model * fix: keep terminal agent boot out of module imports * fix: preserve pending-question uncertainty in engineering review * fix: stabilize Windows reliability-wave fixtures * fix: clarify design consultation research workflow * fix: preserve independent design consultation inputs * fix: resolve design taste scope and browser research guidance * fix: make consultation opt-in preflight unambiguous * test: await native Edge owner readiness or terminal result --------- Co-authored-by: Bruce Krysiak <brucek@alum.mit.edu> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Antonio Vitalic <antoninte99@gmail.com>
575 lines
25 KiB
TypeScript
575 lines
25 KiB
TypeScript
/**
|
|
* Stealth init script — Layer C of GBrowser's anti-detection plan.
|
|
*
|
|
* D7 (codex correction, kept): Layer C (the always-on default built by
|
|
* buildStealthScript) does NOT 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 opt-in EXTENDED_STEALTH_SCRIPT below (gated on
|
|
* GSTACK_STEALTH=extended, off by default) DOES fake plugins — that mode
|
|
* is the documented "actively lies, may break sites" escape hatch, not
|
|
* the default posture.
|
|
*
|
|
* 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 { BrowserContext } from 'playwright';
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
interface HostProfile {
|
|
hwConcurrency: number;
|
|
deviceMemory: number;
|
|
}
|
|
|
|
// Exported for the clamp/fallback unit test. The platform spoof is owned by
|
|
// the UA-CH cmdline switch in buildGStackLaunchArgs (which reads GSTACK_PLATFORM
|
|
// directly), so this profile only carries the values buildStealthScript bakes
|
|
// into the page-world script.
|
|
export 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 {
|
|
// Clamp to a plausible default: 0/NaN/negative/missing all fall back to 8.
|
|
// deviceMemory=0 or NaN would be a glaring bot tell, so never report it.
|
|
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' },
|
|
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 ────
|
|
// Static known-name list of Selenium / Playwright / PhantomJS / Nightmare
|
|
// globals. AUTOMATION_ARTIFACT_CLEANUP_SCRIPT (applied right after this on
|
|
// every path) covers the cdc_/__webdriver dynamic prefixes; this list is the
|
|
// fixed-name complement.
|
|
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 {}
|
|
})();`;
|
|
}
|
|
|
|
/**
|
|
* Extended-mode init script — six detection-vector patches. Applied
|
|
* AFTER the default mask, so the property-getter version remains in
|
|
* place if any of the deletion paths fail.
|
|
*
|
|
* Self-contained string so it can be passed to addInitScript({ content })
|
|
* without bundling concerns.
|
|
*/
|
|
export const EXTENDED_STEALTH_SCRIPT = `
|
|
(() => {
|
|
try {
|
|
// 1. Fully delete navigator.webdriver from the prototype so
|
|
// \`"webdriver" in navigator\` returns false (not just falsy).
|
|
delete Object.getPrototypeOf(navigator).webdriver;
|
|
} catch {}
|
|
|
|
try {
|
|
// 2. WebGL renderer spoof — SwiftShader is the canonical software-GPU
|
|
// tell. Spoof to a plausible Apple M1 Pro string.
|
|
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
|
WebGLRenderingContext.prototype.getParameter = function (parameter) {
|
|
// UNMASKED_VENDOR_WEBGL (37445) → 'Apple Inc.'
|
|
if (parameter === 37445) return 'Apple Inc.';
|
|
// UNMASKED_RENDERER_WEBGL (37446) → realistic Apple silicon string
|
|
if (parameter === 37446) return 'Apple M1 Pro, OpenGL 4.1';
|
|
return getParameter.call(this, parameter);
|
|
};
|
|
} catch {}
|
|
|
|
try {
|
|
// 3. navigator.plugins: real PluginArray with MimeType objects.
|
|
const makePlugin = (name, filename, desc, mimes) => {
|
|
const p = Object.create(Plugin.prototype);
|
|
Object.defineProperties(p, {
|
|
name: { get: () => name },
|
|
filename: { get: () => filename },
|
|
description: { get: () => desc },
|
|
length: { get: () => mimes.length },
|
|
});
|
|
mimes.forEach((m, i) => { p[i] = m; });
|
|
p.item = (i) => mimes[i];
|
|
p.namedItem = (n) => mimes.find((m) => m.type === n);
|
|
return p;
|
|
};
|
|
const makeMime = (type, suffixes, desc) => {
|
|
const m = Object.create(MimeType.prototype);
|
|
Object.defineProperties(m, {
|
|
type: { get: () => type },
|
|
suffixes: { get: () => suffixes },
|
|
description: { get: () => desc },
|
|
});
|
|
return m;
|
|
};
|
|
const pdfMime = makeMime('application/pdf', 'pdf', '');
|
|
const cpdfMime = makeMime('application/x-google-chrome-pdf', 'pdf', 'Portable Document Format');
|
|
const plugins = [
|
|
makePlugin('PDF Viewer', 'internal-pdf-viewer', '', [pdfMime]),
|
|
makePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', '', [cpdfMime]),
|
|
makePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', '', [cpdfMime]),
|
|
];
|
|
Object.defineProperty(navigator, 'plugins', {
|
|
get: () => {
|
|
const arr = Object.create(PluginArray.prototype);
|
|
Object.defineProperty(arr, 'length', { get: () => plugins.length });
|
|
plugins.forEach((p, i) => { arr[i] = p; });
|
|
arr.item = (i) => plugins[i];
|
|
arr.namedItem = (n) => plugins.find((p) => p.name === n);
|
|
arr.refresh = () => {};
|
|
return arr;
|
|
},
|
|
});
|
|
} catch {}
|
|
|
|
try {
|
|
// 4. window.chrome shape — chrome.app + chrome.runtime + loadTimes/csi.
|
|
if (!window.chrome) {
|
|
window.chrome = {};
|
|
}
|
|
if (!window.chrome.runtime) {
|
|
window.chrome.runtime = { OnInstalledReason: {}, OnRestartRequiredReason: {} };
|
|
}
|
|
if (!window.chrome.app) {
|
|
window.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 (!window.chrome.loadTimes) {
|
|
window.chrome.loadTimes = function () {
|
|
return { commitLoadTime: Date.now() / 1000, finishLoadTime: Date.now() / 1000 };
|
|
};
|
|
}
|
|
if (!window.chrome.csi) {
|
|
window.chrome.csi = function () {
|
|
return { startE: Date.now(), onloadT: Date.now(), pageT: 0, tran: 15 };
|
|
};
|
|
}
|
|
} catch {}
|
|
|
|
try {
|
|
// 5. mediaDevices — some headless builds drop it entirely.
|
|
if (!navigator.mediaDevices) {
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
get: () => ({ enumerateDevices: () => Promise.resolve([]) }),
|
|
});
|
|
}
|
|
} catch {}
|
|
|
|
try {
|
|
// 6. CDP cdc_* property cleanup. Chromium under CDP sets cdc_*-prefixed
|
|
// globals (driver injection markers); a bot detector finds them by
|
|
// iterating window keys. Strip all matching keys.
|
|
// Note: via applyStealth this is redundant with AUTOMATION_ARTIFACT_
|
|
// CLEANUP_SCRIPT (which runs first on every path). Kept so this script
|
|
// is self-sufficient if ever applied standalone.
|
|
for (const k of Object.keys(window)) {
|
|
if (k.startsWith('cdc_')) {
|
|
try { delete window[k]; } catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
})();
|
|
`;
|
|
|
|
function extendedModeEnabled(): boolean {
|
|
const v = process.env.GSTACK_STEALTH;
|
|
return v === 'extended' || v === '1' || v === 'true';
|
|
}
|
|
|
|
/**
|
|
* Strip automation runtime artifacts that don't belong in a real browser
|
|
* and that no fingerprint-synthesis layer can mask: ChromeDriver/CDP window
|
|
* globals (cdc_*, __webdriver*) and the Permissions API quirk where an
|
|
* automated Chromium reports notifications as 'denied' instead of real
|
|
* Chrome's 'prompt'. The 'prompt' answer is aligned with Layer C's
|
|
* Notification.permission = 'default' so the two surfaces stay consistent.
|
|
*
|
|
* Runs on EVERY launch path via applyStealth (headless launch(),
|
|
* launchHeaded(), handoff()) so the Notification/Permissions pairing never
|
|
* diverges by mode. Previously this lived inline in launchHeaded() only,
|
|
* which left headless and handoff with the Notification value but not the
|
|
* matching Permissions answer.
|
|
*/
|
|
export const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => {
|
|
// cdc_/__webdriver globals are injected by ChromeDriver/CDP. A detector
|
|
// finds them by iterating window keys. Strip immediately and again after a
|
|
// tick in case they are injected late.
|
|
const cleanup = () => {
|
|
for (const key of Object.keys(window)) {
|
|
if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
|
|
try { delete window[key]; } catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
}
|
|
}
|
|
};
|
|
cleanup();
|
|
setTimeout(cleanup, 0);
|
|
|
|
// Permissions API: automated Chromium returns 'denied' for notifications,
|
|
// a known tell. Return 'prompt' to match real Chrome (and Layer C's
|
|
// Notification.permission = 'default'). Tradeoff: this pins the
|
|
// notifications state to fresh-Chrome values for the whole session, so a
|
|
// site that actually grants/denies notifications would see a stale value.
|
|
// Acceptable for the automation/anti-detection use case (which does not
|
|
// drive real notification grants); only notifications is overridden.
|
|
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
|
|
if (originalQuery) {
|
|
window.navigator.permissions.query = (params) => {
|
|
if (params && params.name === 'notifications') {
|
|
return Promise.resolve({ state: 'prompt', onchange: null });
|
|
}
|
|
return originalQuery.call(window.navigator.permissions, params);
|
|
};
|
|
}
|
|
})();`;
|
|
|
|
/**
|
|
* Apply stealth patches to a fresh BrowserContext (or persistent context).
|
|
* Called by EVERY context-creation path in browser-manager — launch(),
|
|
* launchHeaded(), handoff(), AND recreateContext() (the useragent /
|
|
* viewport --scale rebuild) — so a context can never reach a page un-stealthed.
|
|
*
|
|
* Injection order (Playwright evaluates init scripts in registration order):
|
|
* 1. Layer C (buildStealthScript) — the always-on consistency-first default.
|
|
* 2. Automation-artifact cleanup (cdc_/__webdriver + Permissions shim),
|
|
* kept consistent with Layer C's Notification.permission alignment.
|
|
* 3. EXTENDED_STEALTH_SCRIPT — only when GSTACK_STEALTH=extended (off by
|
|
* default). Its window.chrome.* patches are `if (!...)`-guarded, so
|
|
* Layer C's richer shapes win; the extended-only additions (WebGL spoof,
|
|
* faked navigator.plugins, mediaDevices) apply on top.
|
|
*
|
|
* KNOWN LIMITATION (extended mode only, opt-in): extended's functions are NOT
|
|
* wrapped by Layer C's Function.prototype.toString proxy, so they stringify
|
|
* as injected code; its prototype-level navigator.webdriver delete is shadowed
|
|
* by Layer C's own-property getter (net behavior still matches real Chrome:
|
|
* webdriver present and false); and its hardcoded Apple-M1 WebGL string can
|
|
* disagree with the env-driven GPU spoof in buildGStackLaunchArgs on non-Apple
|
|
* hosts. This is acceptable for the documented "actively lies, may break sites"
|
|
* escape hatch; the consistency-first default (Layer C alone) has none of these.
|
|
*
|
|
* Host profile is resolved 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<void> {
|
|
const hw = readHostProfile();
|
|
await context.addInitScript({ content: buildStealthScript(hw) });
|
|
await context.addInitScript({ content: AUTOMATION_ARTIFACT_CLEANUP_SCRIPT });
|
|
if (extendedModeEnabled()) {
|
|
await context.addInitScript({ content: EXTENDED_STEALTH_SCRIPT });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Args added to chromium.launch's `args` to suppress the
|
|
* AutomationControlled blink feature. This is independent of the init
|
|
* script — it changes how Chromium identifies itself in the protocol layer.
|
|
*/
|
|
export const STEALTH_LAUNCH_ARGS = [
|
|
'--disable-blink-features=AutomationControlled',
|
|
];
|
|
|
|
/**
|
|
* Build the `--gstack-*=` cmdline switches that the Pack 1 Chromium
|
|
* patches read (webgl-vendor-spoof, ua-client-hints-stealth, worker-
|
|
* navigator-stealth). Values come from the GSTACK_* env vars that
|
|
* gbd populates from host_profile.go at startup.
|
|
*
|
|
* Each switch is only emitted when its env var is non-empty — empty
|
|
* env values fall through to the patch's "no override" path, which
|
|
* returns the real Chromium native value. This keeps the helper safe
|
|
* on builds that DO NOT have the C++ patches applied (gbrowser
|
|
* pre-Pack-1) and on hosts where gbd hasn't yet populated some
|
|
* fields (legacy installs).
|
|
*
|
|
* TRUSTED-SOURCE ONLY. These GSTACK_* values are populated by gbd from
|
|
* host_profile.go (system_profiler / sysctl) and become page-visible WebGL /
|
|
* UA-CH surface data. They are NOT sanitized here (passed verbatim into the
|
|
* argv array, which is injection-safe because Playwright spawns Chromium with
|
|
* an argv array, not a shell). NEVER route page content, HTTP headers, or any
|
|
* remote/untrusted input into these env vars — that would turn this into an
|
|
* argv-injection sink. readHostProfile() applies the same trust assumption.
|
|
*
|
|
* Mapping (gbd env → Chromium cmdline switch → C++ patch consumer):
|
|
* GSTACK_GPU_VENDOR → --gstack-gpu-vendor → webgl-vendor-spoof.patch
|
|
* GSTACK_GPU_RENDERER → --gstack-gpu-renderer → webgl-vendor-spoof.patch
|
|
* GSTACK_PLATFORM → --gstack-ua-platform → ua-client-hints-stealth.patch
|
|
* (maps MacARM/MacIntel → "macOS")
|
|
* GSTACK_GPU_CHIPSET → --gstack-ua-model → ua-client-hints-stealth.patch
|
|
* GSTACK_HW_CONCURRENCY → --gstack-hw-concurrency → worker-navigator-stealth.patch
|
|
* GSTACK_DEVICE_MEMORY → --gstack-device-memory → worker-navigator-stealth.patch
|
|
*/
|
|
export function buildGStackLaunchArgs(): string[] {
|
|
const env = (globalThis as any).process?.env ?? {};
|
|
const args: string[] = [];
|
|
|
|
const vendor = env.GSTACK_GPU_VENDOR;
|
|
if (vendor) args.push(`--gstack-gpu-vendor=${vendor}`);
|
|
|
|
const renderer = env.GSTACK_GPU_RENDERER;
|
|
if (renderer) args.push(`--gstack-gpu-renderer=${renderer}`);
|
|
|
|
// Map gbd's "MacARM"/"MacIntel" classification to the UA-CH "macOS"
|
|
// platform string Chromium emits natively. Other future platforms
|
|
// would map similarly (Win32 → "Windows", Linux → "Linux").
|
|
const platform = env.GSTACK_PLATFORM;
|
|
if (platform === 'MacARM' || platform === 'MacIntel') {
|
|
args.push('--gstack-ua-platform=macOS');
|
|
} else if (platform === 'Win32') {
|
|
args.push('--gstack-ua-platform=Windows');
|
|
} else if (platform && platform.startsWith('Linux')) {
|
|
args.push('--gstack-ua-platform=Linux');
|
|
}
|
|
|
|
const chipset = env.GSTACK_GPU_CHIPSET;
|
|
if (chipset) args.push(`--gstack-ua-model=${chipset}`);
|
|
|
|
const hw = env.GSTACK_HW_CONCURRENCY;
|
|
if (hw) args.push(`--gstack-hw-concurrency=${hw}`);
|
|
|
|
const memory = env.GSTACK_DEVICE_MEMORY;
|
|
if (memory) args.push(`--gstack-device-memory=${memory}`);
|
|
|
|
// Pack 2 / B11: suppress user-defined Error.prepareStackTrace during
|
|
// V8 stack-trace formatting. Closes the Cloudflare Bot Management canary
|
|
// trick where a page sets prepareStackTrace and watches for it to fire
|
|
// during CDP serialization.
|
|
//
|
|
// OPT-IN (off by default): only emitted when GSTACK_CDP_STEALTH is
|
|
// on/1/true. This switch is read by a C++ patch that only exists in
|
|
// gbrowser builds; gbd opts in by exporting GSTACK_CDP_STEALTH=on. Stock
|
|
// Playwright Chromium leaves it unset, so the flag never reaches a
|
|
// Chromium that wouldn't understand it. (Previously this was on-by-default
|
|
// unless GSTACK_CDP_STEALTH=off, which contradicted this very comment.)
|
|
const cdpStealth = env.GSTACK_CDP_STEALTH;
|
|
if (cdpStealth === 'on' || cdpStealth === '1' || cdpStealth === 'true') {
|
|
args.push('--gstack-suppress-prepare-stack-trace');
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
];
|
|
|
|
/** Test-only helper: report whether extended mode is currently active. */
|
|
export function isExtendedStealthEnabled(): boolean {
|
|
return extendedModeEnabled();
|
|
}
|