From d269c87b824a720bf17bb93a2e482d707174c3d9 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 7 Jun 2026 13:20:55 +0100 Subject: [PATCH] feat(stealth): embeddable evasion init script with apply hints --- internal/stealth/assets/evasions.js | 25 +++++++++++++++++ internal/stealth/script.go | 43 +++++++++++++++++++++++++++++ internal/stealth/script_test.go | 21 ++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 internal/stealth/assets/evasions.js create mode 100644 internal/stealth/script.go create mode 100644 internal/stealth/script_test.go diff --git a/internal/stealth/assets/evasions.js b/internal/stealth/assets/evasions.js new file mode 100644 index 0000000..49f4a9a --- /dev/null +++ b/internal/stealth/assets/evasions.js @@ -0,0 +1,25 @@ +(() => { + // navigator.webdriver + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + // languages + Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); + // plugins (non-empty) + Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); + // chrome runtime stub + window.chrome = window.chrome || { runtime: {} }; + // permissions query consistency + const origQuery = window.navigator.permissions && window.navigator.permissions.query; + if (origQuery) { + window.navigator.permissions.query = (params) => + params && params.name === 'notifications' + ? Promise.resolve({ state: Notification.permission }) + : origQuery(params); + } + // WebGL vendor/renderer spoof + const getParam = WebGLRenderingContext.prototype.getParameter; + WebGLRenderingContext.prototype.getParameter = function (p) { + if (p === 37445) return 'Google Inc.'; // UNMASKED_VENDOR_WEBGL + if (p === 37446) return 'ANGLE (Generic GPU)'; // UNMASKED_RENDERER_WEBGL + return getParam.call(this, p); + }; +})(); diff --git a/internal/stealth/script.go b/internal/stealth/script.go new file mode 100644 index 0000000..5b10e3b --- /dev/null +++ b/internal/stealth/script.go @@ -0,0 +1,43 @@ +package stealth + +import _ "embed" + +//go:embed assets/evasions.js +var evasionsJS string + +// ScriptParams selects the target engine for apply instructions. +type ScriptParams struct { + Engine string // playwright | cdp | generic +} + +// ApplyInfo tells the caller how to inject the script. +type ApplyInfo struct { + How string `json:"how"` + API string `json:"api"` +} + +// ScriptResult is the injectable evasion bundle plus metadata. +type ScriptResult struct { + Script string `json:"script"` + Apply ApplyInfo `json:"apply"` + IncludedEvasions []string `json:"included_evasions"` +} + +// StealthScript returns the evasion init script and engine-specific apply hints. +func StealthScript(p ScriptParams) ScriptResult { + apply := ApplyInfo{How: "Inject before any page script runs.", API: "page.addInitScript(script)"} + switch p.Engine { + case "cdp": + apply.API = "Page.addScriptToEvaluateOnNewDocument({source: script})" + case "generic", "": + apply.API = "evaluate the script on every new document before navigation" + } + return ScriptResult{ + Script: evasionsJS, + Apply: apply, + IncludedEvasions: []string{ + "navigator.webdriver", "navigator.languages", "navigator.plugins", + "chrome.runtime", "permissions.query", "WebGL vendor/renderer", + }, + } +} diff --git a/internal/stealth/script_test.go b/internal/stealth/script_test.go new file mode 100644 index 0000000..2ccf7b9 --- /dev/null +++ b/internal/stealth/script_test.go @@ -0,0 +1,21 @@ +package stealth + +import ( + "strings" + "testing" +) + +func TestStealthScript(t *testing.T) { + res := StealthScript(ScriptParams{Engine: "playwright"}) + if res.Script == "" { + t.Fatal("empty script") + } + for _, want := range []string{"navigator.webdriver", "languages", "WebGL", "chrome"} { + if !strings.Contains(res.Script, want) { + t.Fatalf("script missing %q", want) + } + } + if res.Apply.How == "" || len(res.IncludedEvasions) == 0 { + t.Fatalf("missing apply metadata: %+v", res) + } +}