Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 38 additions & 1 deletion includes/class-webdecoy-behavioral-scorer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
98 changes: 97 additions & 1 deletion public/js/webdecoy-scanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -969,7 +1058,8 @@
apiTiming: detectAPITiming(),
permissionInconsistency: null,
webrtcIP: null,
behavioral: analyzeBehavior()
behavioral: analyzeBehavior(),
lieDetection: detectLies()
};

var honeypotValue = checkHoneypot();
Expand Down Expand Up @@ -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]);
Expand Down
75 changes: 75 additions & 0 deletions tests/StealthScorerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

/**
* Tests for the stealth (F1) scoring added to WebDecoy_Behavioral_Scorer.
*
* Verifies the strong/weak curve mirrors @webdecoy/node's, that stealth is a
* decisive max()-override (not diluted by the weighted model), and — critically
* — that it is false-positive-safe: no lies, or a single weak (privacy-extension)
* patch, must never push a legitimate visitor toward a block.
*
* Run: php tests/run.php
*/

if (!defined('ABSPATH')) {
define('ABSPATH', '/tmp/');
}
require_once dirname(__DIR__) . '/includes/class-webdecoy-behavioral-scorer.php';

$t = ['TestRunner', 'test'];
$true = ['TestRunner', 'assertTrue'];

function stealth_score(array $lies): float
{
$scorer = new WebDecoy_Behavioral_Scorer();
$res = $scorer->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 . ')');
});
Loading