From 61f8fe90d998255195a12e08fa16e4e66879c7ba Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sun, 19 Jul 2026 15:22:52 -0500 Subject: [PATCH] feat(detection): stealth-browser detection (F1) (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the node 0.4.0 headline capability the plugin was missing: detecting scrapers that patch native browser functions to hide automation (puppeteer-extra-stealth, botasaurus) — precisely the class that evades the scanner's existing fingerprint checks. - webdecoy-scanner.js detectLies(): probes for patched native functions via the [native code] toString tell. Split by false-positive risk: strong (near-zero FP): patched Function.prototype.toString, modified navigator.webdriver getter — automation signatures. weak (privacy extensions can trigger): permissions.query, toDataURL, WebGLRenderingContext.getParameter, mediaDevices.enumerateDevices, Notification.requestPermission. Scored on the 0-100 scale: 1 strong=70, 2 strong=82 (blocks alone); a lone weak tell scores 0; multiple weak cap at 40 (never auto-block). Lie flags are forwarded for attribution. - WebDecoy_Behavioral_Scorer: new stealth category applied as a max() override (not a re-weight) so it's decisive when present but preserves the existing PoW-verify model otherwise. Mirrors node's curve (0.7 base, +0.12 per additional strong tell, cap 0.97). FP-safety is explicit in tests: stock Chrome/Safari/Firefox and single-weak (privacy-extension) sessions never reach a blocking score. 41 assertions green; JS scoring math cross-checked. Closes #11. Part of #16. Co-authored-by: Claude --- changelog.txt | 1 + includes/class-webdecoy-behavioral-scorer.php | 39 +++++++- public/js/webdecoy-scanner.js | 98 ++++++++++++++++++- tests/StealthScorerTest.php | 75 ++++++++++++++ 4 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/StealthScorerTest.php diff --git a/changelog.txt b/changelog.txt index cb528ee..4e6df12 100644 --- a/changelog.txt +++ b/changelog.txt @@ -3,6 +3,7 @@ = 2.2.0 - Unreleased = * Added: Filter rules — write expression-based rules (e.g. `ip.tor or ip.abuse_score > 50`, `ip.country in ["CN","RU"] and req.path matches "^/wp-login"`) evaluated before scoring; block or throttle, with dry-run. New Settings → Rules tab with a rule builder and parse-on-save validation. Expression language is byte-for-byte compatible with @webdecoy/node. * Added: IP enrichment — VPN/proxy/Tor, geo, ASN, and abuse-score data (WebDecoy Cloud) powering the ip.* filter-rule fields, cached 1 hour, fetched only when a rule needs it, fail-open. +* Added: Stealth-browser detection (F1) — catches scrapers that patch native browser functions to hide automation (puppeteer-extra-stealth, botasaurus, etc.), the class of tool that defeats conventional fingerprinting. Strong tells (patched Function.prototype.toString, a modified navigator.webdriver getter) are decisive; weak tells that real privacy extensions can trigger are scored gently so they never block a legitimate visitor. * Added: Clearance client — bundled @webdecoy/client browser script that silently mints a wd_clearance cookie for real visitors (idle-deferred, once per session, no proof-of-work). Enables tripwire/decoy hits to durably lock out the offending device. Configured via a new publishable Site Key in the WebDecoy Cloud tab. * Added: Rule engine — deterministic rules evaluated before heuristic scoring; first DENY/THROTTLE wins, with dry-run (log without blocking). Parity with @webdecoy/node. * Added: Tripwires (deception layer) — deterministic, zero-false-positive blocking of hidden honeypot paths (scanner-bait like /.env, /.git/config, /wp-config.php). On by default. Custom exact paths, prefixes, and regex patterns; block or throttle; dry-run. New Settings → Tripwires tab. diff --git a/includes/class-webdecoy-behavioral-scorer.php b/includes/class-webdecoy-behavioral-scorer.php index f0799e0..ca4fe90 100644 --- a/includes/class-webdecoy-behavioral-scorer.php +++ b/includes/class-webdecoy-behavioral-scorer.php @@ -54,13 +54,50 @@ public function score(array $signals): array $total += $cat_score * self::WEIGHTS[$category]; } + // Stealth (F1) is applied as an override, not a weighted term: a patched + // native function is the single strongest bot signal (node weights it + // 0.30, the highest category), so a confirmed stealth tell should be + // decisive on its own. Taking the max preserves the existing weighted + // model when no lie data is present. Fed via $signals['lies']. + $stealth_score = $this->score_stealth($signals['lies'] ?? [], $detections); + $category_scores['stealth'] = $stealth_score; + $final = max($total, $stealth_score); + return [ - 'score' => round(min(1.0, max(0.0, $total)), 4), + 'score' => round(min(1.0, max(0.0, $final)), 4), 'category_scores' => $category_scores, 'detections' => $detections, ]; } + /** + * Score stealth-browser (F1) lie detection. Mirrors the scanner's strong/weak + * split and @webdecoy/node's stealth curve (0.7 base, +0.12 per additional + * strong tell, capped 0.97). Weak tells alone are never decisive (real + * privacy extensions can trigger them). + * + * @param array $lies ['strong' => string[], 'weak' => string[]] + */ + private function score_stealth(array $lies, array &$detections): float + { + $strong = isset($lies['strong']) && is_array($lies['strong']) ? count($lies['strong']) : 0; + $weak = isset($lies['weak']) && is_array($lies['weak']) ? count($lies['weak']) : 0; + + if ($strong > 0) { + $score = min(0.7 + ($strong - 1) * 0.12 + $weak * 0.03, 0.97); + $detections[] = ['category' => 'stealth', 'signal' => 'patched_native_functions', 'confidence' => $score]; + return $score; + } + + if ($weak >= 2) { + $score = min(0.3 + $weak * 0.05, 0.5); + $detections[] = ['category' => 'stealth', 'signal' => 'multiple_weak_patches', 'confidence' => $score]; + return $score; + } + + return 0.0; + } + /** * Score mouse + click + scroll behavior */ diff --git a/public/js/webdecoy-scanner.js b/public/js/webdecoy-scanner.js index 9b19538..6a5ddf0 100644 --- a/public/js/webdecoy-scanner.js +++ b/public/js/webdecoy-scanner.js @@ -31,6 +31,80 @@ } } + /** + * Stealth-browser (F1) detection: native functions patched to hide automation. + * + * A real, unmodified browser never rewrites its own native functions. Stealth + * tooling (puppeteer-extra-stealth, botasaurus, etc.) patches them to spoof + * away fingerprint tells — but the patch itself is detectable: a patched + * function's toString() no longer reports "[native code]". This catches the + * exact class of scraper that defeats conventional fingerprinting. + * + * Signals are split by false-positive risk: + * - strong: near-zero FP. A patched Function.prototype.toString or a + * modified navigator.webdriver getter is an automation signature. + * - weak: privacy extensions (canvas/WebGL blockers) legitimately patch + * some of these, so they're reported but scored gently, never decisive + * on their own. + */ + function detectLies() { + var strong = []; + var weak = []; + + function isPatched(fn) { + try { + return typeof fn === 'function' && fn.toString().indexOf('[native code]') === -1; + } catch (e) { + return false; + } + } + + try { + // Function.prototype.toString itself patched → stealth plugins do this to + // hide their other patches. Extremely strong tell. + if (isPatched(Function.prototype.toString)) { + strong.push('Function.prototype.toString'); + } + + // navigator.webdriver getter replaced with a non-native function → the + // browser is actively lying about being automated. + try { + var desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator), 'webdriver') || + Object.getOwnPropertyDescriptor(navigator, 'webdriver'); + if (desc && typeof desc.get === 'function' && + desc.get.toString().indexOf('[native code]') === -1) { + strong.push('navigator.webdriver getter'); + } + } catch (e) { /* ignore */ } + + // Weak: real privacy tooling patches some of these. + if (navigator.permissions && isPatched(navigator.permissions.query)) { + weak.push('navigator.permissions.query'); + } + if (window.Notification && isPatched(window.Notification.requestPermission)) { + weak.push('Notification.requestPermission'); + } + if (window.HTMLCanvasElement && isPatched(window.HTMLCanvasElement.prototype.toDataURL)) { + weak.push('HTMLCanvasElement.toDataURL'); + } + if (window.WebGLRenderingContext && isPatched(window.WebGLRenderingContext.prototype.getParameter)) { + weak.push('WebGLRenderingContext.getParameter'); + } + if (navigator.mediaDevices && isPatched(navigator.mediaDevices.enumerateDevices)) { + weak.push('mediaDevices.enumerateDevices'); + } + } catch (e) { + return { detected: false, strong: [], weak: [], signals: [] }; + } + + return { + detected: strong.length > 0 || weak.length > 0, + strong: strong, + weak: weak, + signals: strong.concat(weak) + }; + } + /** * Detects headless browser indicators */ @@ -607,6 +681,21 @@ score += Math.min(signals.apiTiming.signals.length * 15, 30); } + // Stealth-browser (F1): patched native functions. The single strongest bot + // signal. Strong tells are near-certain automation; weak tells (which real + // privacy extensions can trigger) are scored gently so they never block a + // legitimate user on their own. + if (signals.lieDetection && signals.lieDetection.detected) { + var strongCount = signals.lieDetection.strong.length; + var weakCount = signals.lieDetection.weak.length; + if (strongCount > 0) { + score += Math.min(70 + (strongCount - 1) * 12 + weakCount * 3, 97); + } else if (weakCount >= 2) { + score += Math.min(20 + weakCount * 5, 40); + } + // A single weak tell alone contributes nothing (likely a privacy tool). + } + // Behavioral analysis scoring if (signals.behavioral && signals.behavioral.detected) { var behavioralSignals = signals.behavioral.signals; @@ -969,7 +1058,8 @@ apiTiming: detectAPITiming(), permissionInconsistency: null, webrtcIP: null, - behavioral: analyzeBehavior() + behavioral: analyzeBehavior(), + lieDetection: detectLies() }; var honeypotValue = checkHoneypot(); @@ -1068,6 +1158,12 @@ } if (signals.honeypotTriggered) flags.push('honeypot'); + if (signals.lieDetection && signals.lieDetection.detected) { + for (var s = 0; s < signals.lieDetection.signals.length; s++) { + flags.push('lie:' + signals.lieDetection.signals[s]); + } + } + if (signals.chromeInconsistency && signals.chromeInconsistency.detected) { for (var j = 0; j < signals.chromeInconsistency.signals.length; j++) { flags.push(signals.chromeInconsistency.signals[j]); diff --git a/tests/StealthScorerTest.php b/tests/StealthScorerTest.php new file mode 100644 index 0000000..4bf1e6d --- /dev/null +++ b/tests/StealthScorerTest.php @@ -0,0 +1,75 @@ +score(['lies' => $lies]); + return $res['category_scores']['stealth']; +} + +function final_score(array $signals): float +{ + $scorer = new WebDecoy_Behavioral_Scorer(); + $res = $scorer->score($signals); + return $res['score']; +} + +echo "\nStealth (F1) scoring\n"; + +$t('no lie data -> stealth 0, no false positive', function () use ($true) { + $true(stealth_score([]) === 0.0, 'empty lies = 0'); + // A session with no behavioral data at all must not be pushed up by stealth. + $true(final_score([]) < 0.5, 'empty session stays low'); +}); + +$t('one strong tell is decisive (>= 0.7)', function () use ($true) { + $s = stealth_score(['strong' => ['Function.prototype.toString'], 'weak' => []]); + $true($s >= 0.7 && $s <= 0.75, 'single strong ~0.7 (got ' . $s . ')'); +}); + +$t('additional strong tells increase the score toward the cap', function () use ($true) { + $two = stealth_score(['strong' => ['a', 'b'], 'weak' => []]); + $three = stealth_score(['strong' => ['a', 'b', 'c'], 'weak' => []]); + $true($two > 0.8 && $two <= 0.85, 'two strong ~0.82'); + $true($three > $two, 'monotonic increase'); + $true(stealth_score(['strong' => array_fill(0, 10, 'x'), 'weak' => []]) <= 0.97, 'capped at 0.97'); +}); + +$t('one strong tell overrides a low behavioral score (max, not diluted)', function () use ($true) { + // Even with otherwise-human-looking (empty) behavior, a strong lie decides. + $final = final_score(['lies' => ['strong' => ['navigator.webdriver getter'], 'weak' => []]]); + $true($final >= 0.7, 'strong lie makes the final score decisive (got ' . $final . ')'); +}); + +$t('FALSE-POSITIVE SAFETY: a single weak patch is never decisive', function () use ($true) { + $s = stealth_score(['strong' => [], 'weak' => ['HTMLCanvasElement.toDataURL']]); + $true($s === 0.0, 'one weak tell (e.g. a canvas-blocker extension) scores 0'); + $final = final_score(['lies' => ['strong' => [], 'weak' => ['HTMLCanvasElement.toDataURL']]]); + $true($final < 0.5, 'a privacy-extension user is never blocked by stealth alone'); +}); + +$t('multiple weak patches are suspicious but still not decisive', function () use ($true) { + $s = stealth_score(['strong' => [], 'weak' => ['a', 'b', 'c']]); + $true($s > 0.0 && $s < 0.6, 'multiple weak tells score modestly (<0.6), never auto-block (got ' . $s . ')'); +});