From 8cb83b86ffd990deb59a50708ddc4fee9ad08e4b Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sun, 19 Jul 2026 14:22:48 -0500 Subject: [PATCH 1/2] feat(rules): rule engine + tripwires + wd_clearance forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports @webdecoy/node's rule engine to PHP and adds the deception layer the plugin was missing despite the product name. - WebDecoy\Rules\{RuleEngine,TripwireRule,RuleContext,RuleResult, ViolationEvent,...}: pure, framework-agnostic SDK classes mirroring node's rules engine. First non-dry-run DENY/THROTTLE wins; every non-ALLOW result records a violation; dry-run logs without blocking. - TripwireRule: deterministic, zero-FP blocking of honeypot paths. Ships node's 18 DEFAULT_TRIPWIRE_PATHS verbatim; supports custom exact paths, prefixes, and (fail-open) regex patterns. - Clearance forwarding (#136 WP half): the wd_clearance cookie is attached ONLY to tripwire violations, so the deception signal — not heuristics — drives the durable device-fingerprint deny-list. - WebDecoy_Violation_Reporter: per-request buffer flushed once on shutdown via a non-blocking POST to /api/v1/sdk/violations/batch. Cloud-gated (needs an API key); local-only installs still enforce, just don't report. - Wired into early_check() before heuristic scoring (matches node's protect() flow). New Settings -> Tripwires tab. Violations always logged locally (visible in Detections, incl. dry-run). - tests/: dependency-free PHP runner porting node's rule-engine and tripwire test vectors (17 assertions, green). Closes #2, #3, #5. Part of #16. Co-authored-by: Claude --- admin/partials/settings-page.php | 87 ++++++ changelog.txt | 6 +- .../class-webdecoy-violation-reporter.php | 140 ++++++++++ sdk/src/Rules/RuleContext.php | 80 ++++++ sdk/src/Rules/RuleEngine.php | 124 ++++++++ sdk/src/Rules/RuleEngineResult.php | 54 ++++ sdk/src/Rules/RuleInterface.php | 26 ++ sdk/src/Rules/RuleResult.php | 60 ++++ sdk/src/Rules/TripwireRule.php | 148 ++++++++++ sdk/src/Rules/ViolationEvent.php | 125 +++++++++ tests/RulesTest.php | 212 ++++++++++++++ tests/bootstrap.php | 87 ++++++ tests/run.php | 16 ++ webdecoy.php | 264 ++++++++++++++++++ 14 files changed, 1428 insertions(+), 1 deletion(-) create mode 100644 includes/class-webdecoy-violation-reporter.php create mode 100644 sdk/src/Rules/RuleContext.php create mode 100644 sdk/src/Rules/RuleEngine.php create mode 100644 sdk/src/Rules/RuleEngineResult.php create mode 100644 sdk/src/Rules/RuleInterface.php create mode 100644 sdk/src/Rules/RuleResult.php create mode 100644 sdk/src/Rules/TripwireRule.php create mode 100644 sdk/src/Rules/ViolationEvent.php create mode 100644 tests/RulesTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/run.php diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index d08d3c4..dfbe862 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -24,6 +24,7 @@
+ +
+

+

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ + + +

+
+ + + +

+
+ + + +

+
+ + + +
+ +
+
+

diff --git a/changelog.txt b/changelog.txt index 8be52ae..d08f31d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,10 @@ *** WebDecoy Bot Detection Changelog *** -= 2.1.0 - 2026-03-03 = += 2.2.0 - Unreleased = +* 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). Custom exact paths, prefixes, and regex patterns; block or throttle; dry-run. New Settings → Tripwires tab. +* Added: wd_clearance forwarding — a tripwire hit carrying the visitor's wd_clearance cookie is reported so the WebDecoy Cloud can durably deny the actor's device fingerprint (rotation-proof lockout). Heuristic rules never carry the token. +* Added: Violation reporting — rule hits are batched and reported to the WebDecoy Cloud (premium) on request shutdown, fire-and-forget with no added page latency. Hits are always recorded locally in the Detections page. * Added: JS execution verification — detects non-JS HTTP scrapers (e.g., Scrapling Fetcher, curl_cffi) * Added: Challenge token meta tag injected on page serve for premium users * Added: Automatic page serve reporting to ingest service diff --git a/includes/class-webdecoy-violation-reporter.php b/includes/class-webdecoy-violation-reporter.php new file mode 100644 index 0000000..53efeb0 --- /dev/null +++ b/includes/class-webdecoy-violation-reporter.php @@ -0,0 +1,140 @@ +apiKey = $apiKey; + } + + /** + * Get (or lazily create) the per-request reporter singleton. + * + * @return WebDecoy_Violation_Reporter|null Null when reporting is disabled + * (no API key). + */ + public static function instance(string $apiKey): ?self + { + if ($apiKey === '') { + return null; + } + if (self::$instance === null) { + self::$instance = new self($apiKey); + } + return self::$instance; + } + + /** + * Queue violations for reporting and ensure a flush is scheduled. + * + * @param ViolationEvent[] $events + */ + public function report(array $events): void + { + if ($events === []) { + return; + } + foreach ($events as $event) { + $this->buffer[] = $event; + } + $this->ensureShutdownHook(); + } + + /** + * Register the shutdown flush exactly once. On a DENY the request often ends + * via exit() before shutdown handlers that were registered later — so when a + * flush is needed and we're already tearing down, flush inline instead. + */ + private function ensureShutdownHook(): void + { + if ($this->registered) { + return; + } + $this->registered = true; + if (function_exists('add_action')) { + add_action('shutdown', [$this, 'flush'], 0); + } + // Also flush on PHP shutdown as a backstop for early exit() paths + // (e.g. block responses that call exit before WP's shutdown action). + register_shutdown_function([$this, 'flush']); + } + + /** + * Flush the buffer to ingest. Idempotent: safe to call more than once (the + * buffer is drained on first call). + */ + public function flush(): void + { + if ($this->buffer === [] || $this->apiKey === '') { + return; + } + + $events = $this->buffer; + $this->buffer = []; + + foreach (array_chunk($events, self::BATCH_SIZE) as $batch) { + $payload = []; + foreach ($batch as $event) { + $payload[] = $event->toApiPayload(); + } + + if (!function_exists('wp_remote_post')) { + continue; + } + + wp_remote_post(self::ENDPOINT, [ + 'timeout' => 1, + 'blocking' => false, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $this->apiKey, + ], + 'body' => wp_json_encode(['events' => $payload]), + ]); + } + } +} diff --git a/sdk/src/Rules/RuleContext.php b/sdk/src/Rules/RuleContext.php new file mode 100644 index 0000000..0f1ff69 --- /dev/null +++ b/sdk/src/Rules/RuleContext.php @@ -0,0 +1,80 @@ + + */ + public $headers; + + /** @var int Unix timestamp in milliseconds. */ + public $timestamp; + + /** + * Optional IP enrichment data (security/location/network/reputation), used + * by filter rules referencing `ip.*` fields. Null when unavailable. + * + * @var array|null + */ + public $enrichment; + + /** + * @param array $headers + * @param array|null $enrichment + */ + public function __construct( + string $ip, + string $path, + string $method = 'GET', + string $userAgent = '', + array $headers = [], + ?int $timestamp = null, + ?array $enrichment = null + ) { + $this->ip = $ip; + $this->path = $path; + $this->method = $method; + $this->userAgent = $userAgent; + // Normalize header keys to lowercase for case-insensitive lookups. + $normalized = []; + foreach ($headers as $name => $value) { + $normalized[strtolower((string) $name)] = (string) $value; + } + $this->headers = $normalized; + $this->timestamp = $timestamp ?? (int) round(microtime(true) * 1000); + $this->enrichment = $enrichment; + } + + /** + * Case-insensitive header lookup. + */ + public function header(string $name): ?string + { + return $this->headers[strtolower($name)] ?? null; + } +} diff --git a/sdk/src/Rules/RuleEngine.php b/sdk/src/Rules/RuleEngine.php new file mode 100644 index 0000000..29e7b52 --- /dev/null +++ b/sdk/src/Rules/RuleEngine.php @@ -0,0 +1,124 @@ +rules = $rules; + } + + /** + * Pull the wd_clearance token from a request's Cookie header, if present. + * Mirrors node's extractClearance(). + */ + private static function extractClearance(RuleContext $context): ?string + { + $cookie = $context->header('cookie'); + if ($cookie === null || $cookie === '') { + return null; + } + foreach (explode(';', $cookie) as $part) { + $eq = strpos($part, '='); + if ($eq === false) { + continue; + } + if (trim(substr($part, 0, $eq)) === 'wd_clearance') { + $value = trim(substr($part, $eq + 1)); + return $value !== '' ? $value : null; + } + } + return null; + } + + /** + * Evaluate all rules against the request context. + */ + public function evaluate(RuleContext $context): RuleEngineResult + { + $violations = []; + $deciding = null; + + $iso = self::isoTimestamp($context->timestamp); + + foreach ($this->rules as $rule) { + $result = $rule->evaluate($context); + + if ($result->action !== RuleResult::ALLOW) { + $isDryRun = $result->isDryRun(); + + // Tripwire hits (a real user can't reach a honeypot path) carry + // the actor's wd_clearance token so the backend can deny its + // device fingerprint — the deception signal driving enforcement. + $clearance = $result->rule === 'tripwire' + ? self::extractClearance($context) + : null; + + $violations[] = new ViolationEvent( + $result->rule, + $result->action, + $context->ip, + $context->path, + $context->method, + $context->userAgent !== '' ? $context->userAgent : null, + $result->reason, + $clearance, + $result->metadata !== [] ? $result->metadata : null, + $isDryRun, + $iso + ); + + // First non-ALLOW, non-dry-run result decides the outcome. + if ($deciding === null && !$isDryRun) { + $deciding = $result; + } + } + } + + if ($deciding !== null) { + return new RuleEngineResult( + $deciding->action, + $deciding->rule, + $deciding->reason, + $deciding->metadata !== [] ? $deciding->metadata : null, + $violations + ); + } + + return new RuleEngineResult(RuleResult::ALLOW, null, null, null, $violations); + } + + /** + * Convert a millisecond Unix timestamp to an ISO-8601 UTC string with + * milliseconds (e.g. 2026-07-19T18:30:00.000Z) to match node's + * `new Date(ts).toISOString()`. + */ + private static function isoTimestamp(int $millis): string + { + $seconds = intdiv($millis, 1000); + $ms = $millis % 1000; + return gmdate('Y-m-d\TH:i:s', $seconds) . sprintf('.%03dZ', $ms); + } +} diff --git a/sdk/src/Rules/RuleEngineResult.php b/sdk/src/Rules/RuleEngineResult.php new file mode 100644 index 0000000..9a3f7f8 --- /dev/null +++ b/sdk/src/Rules/RuleEngineResult.php @@ -0,0 +1,54 @@ +|null Metadata from the deciding rule. */ + public $metadata; + + /** + * Every non-ALLOW result recorded this pass — including dry-run hits — for + * reporting. Distinct from the single deciding result above. + * + * @var ViolationEvent[] + */ + public $violations; + + /** + * @param ViolationEvent[] $violations + * @param array|null $metadata + */ + public function __construct( + string $action, + ?string $rule = null, + ?string $reason = null, + ?array $metadata = null, + array $violations = [] + ) { + $this->action = $action; + $this->rule = $rule; + $this->reason = $reason; + $this->metadata = $metadata; + $this->violations = $violations; + } + + public function isAllowed(): bool + { + return $this->action === RuleResult::ALLOW; + } +} diff --git a/sdk/src/Rules/RuleInterface.php b/sdk/src/Rules/RuleInterface.php new file mode 100644 index 0000000..e1f4e8e --- /dev/null +++ b/sdk/src/Rules/RuleInterface.php @@ -0,0 +1,26 @@ + Rule-specific metadata (e.g. confidence, + * retryAfter, dryRun). + */ + public $metadata; + + /** + * @param array $metadata + */ + public function __construct(string $action, string $rule, ?string $reason = null, array $metadata = []) + { + $this->action = $action; + $this->rule = $rule; + $this->reason = $reason; + $this->metadata = $metadata; + } + + public static function allow(string $rule): self + { + return new self(self::ALLOW, $rule); + } + + public function isAllow(): bool + { + return $this->action === self::ALLOW; + } + + public function isDryRun(): bool + { + return !empty($this->metadata['dryRun']); + } +} diff --git a/sdk/src/Rules/TripwireRule.php b/sdk/src/Rules/TripwireRule.php new file mode 100644 index 0000000..6549609 --- /dev/null +++ b/sdk/src/Rules/TripwireRule.php @@ -0,0 +1,148 @@ + Exact paths as a lookup set. */ + private $exact; + + /** @var string[] */ + private $prefixes; + + /** @var string[] Regex patterns (PCRE, without delimiters). */ + private $patterns; + + /** @var string DENY or THROTTLE. */ + private $action; + + /** @var bool */ + private $dryRun; + + /** + * @param array $config { + * @type string[] $paths Extra exact paths. + * @type string[] $prefixes Match paths that start with any of these. + * @type string[] $patterns Regex patterns (PCRE bodies, no delimiters). + * @type bool $includeDefaults Merge DEFAULT_TRIPWIRE_PATHS (default true). + * @type string $action DENY (default) or THROTTLE. + * @type bool $dryRun Record but don't block (default false). + * } + */ + public function __construct(array $config = []) + { + $paths = $config['paths'] ?? []; + if ($config['includeDefaults'] ?? true) { + $paths = array_merge($paths, self::DEFAULT_TRIPWIRE_PATHS); + } + + $this->exact = []; + foreach ($paths as $path) { + $this->exact[$path] = true; + } + + $this->prefixes = $config['prefixes'] ?? []; + $this->patterns = $config['patterns'] ?? []; + $this->action = $config['action'] ?? RuleResult::DENY; + $this->dryRun = $config['dryRun'] ?? false; + } + + public function getName(): string + { + return 'tripwire'; + } + + /** + * Strip query string and fragment for path matching (mirrors node's + * normalizePath). + */ + private static function normalizePath(string $path): string + { + $path = explode('#', $path, 2)[0]; + $path = explode('?', $path, 2)[0]; + return $path; + } + + public function evaluate(RuleContext $context): RuleResult + { + $path = self::normalizePath($context->path); + + $hit = isset($this->exact[$path]); + + if (!$hit) { + foreach ($this->prefixes as $prefix) { + if ($prefix !== '' && strpos($path, $prefix) === 0) { + $hit = true; + break; + } + } + } + + if (!$hit) { + foreach ($this->patterns as $pattern) { + // Invalid regex must not throw; treat as non-match (fail-open). + $delimited = '#' . str_replace('#', '\\#', $pattern) . '#'; + // phpcs:ignore + if (@preg_match($delimited, $path) === 1) { + $hit = true; + break; + } + } + } + + if ($hit) { + return new RuleResult( + $this->dryRun ? RuleResult::ALLOW : $this->action, + 'tripwire', + 'Tripwire hit: ' . $path . ' — hidden honeypot path, deterministic automated-intent signal', + ['path' => $path, 'dryRun' => $this->dryRun, 'confidence' => 100] + ); + } + + return RuleResult::allow('tripwire'); + } +} diff --git a/sdk/src/Rules/ViolationEvent.php b/sdk/src/Rules/ViolationEvent.php new file mode 100644 index 0000000..fa1dc14 --- /dev/null +++ b/sdk/src/Rules/ViolationEvent.php @@ -0,0 +1,125 @@ +|null */ + public $metadata; + + /** @var bool */ + public $dryRun; + + /** @var string ISO-8601 timestamp. */ + public $timestamp; + + /** + * @param array|null $metadata + */ + public function __construct( + string $rule, + string $action, + string $ip, + ?string $path, + ?string $method, + ?string $userAgent, + ?string $reason, + ?string $clearance, + ?array $metadata, + bool $dryRun, + string $timestamp + ) { + $this->rule = $rule; + $this->action = $action; + $this->ip = $ip; + $this->path = $path; + $this->method = $method; + $this->userAgent = $userAgent; + $this->reason = $reason; + $this->clearance = $clearance; + $this->metadata = $metadata; + $this->dryRun = $dryRun; + $this->timestamp = $timestamp; + } + + /** + * Serialize to the ingest batch wire format. Field names match the Go + * struct's JSON tags exactly (rule, action, ip, path, method, userAgent, + * reason, clearance, metadata, dryRun, timestamp). + * + * @return array + */ + public function toApiPayload(): array + { + $payload = [ + 'rule' => $this->rule, + 'action' => $this->action, + 'ip' => $this->ip, + 'dryRun' => $this->dryRun, + 'timestamp' => $this->timestamp, + ]; + + // Optional fields are omitted when null so the payload stays compact and + // matches the pointer/omitempty semantics on the Go side. + if ($this->path !== null) { + $payload['path'] = $this->path; + } + if ($this->method !== null) { + $payload['method'] = $this->method; + } + if ($this->userAgent !== null) { + $payload['userAgent'] = $this->userAgent; + } + if ($this->reason !== null) { + $payload['reason'] = $this->reason; + } + if ($this->clearance !== null) { + $payload['clearance'] = $this->clearance; + } + if ($this->metadata !== null) { + $payload['metadata'] = $this->metadata; + } + + return $payload; + } +} diff --git a/tests/RulesTest.php b/tests/RulesTest.php new file mode 100644 index 0000000..8da6da1 --- /dev/null +++ b/tests/RulesTest.php @@ -0,0 +1,212 @@ +result = $result; + } + + public function getName(): string + { + return $this->result->rule; + } + + public function evaluate(RuleContext $context): RuleResult + { + return $this->result; + } +} + +function ctx_cookie(?string $cookie = null): RuleContext +{ + return new RuleContext( + '203.0.113.5', + '/wp-admin.php', + 'GET', + '', + $cookie !== null ? ['cookie' => $cookie] : [], + 1700000000000 + ); +} + +function ctx_path(string $path): RuleContext +{ + return new RuleContext('203.0.113.10', $path, 'GET', '', [], 1700000000000); +} + +$t = ['TestRunner', 'test']; +$eq = ['TestRunner', 'assertSame']; +$true = ['TestRunner', 'assertTrue']; +$null = ['TestRunner', 'assertNull']; +$match = ['TestRunner', 'assertMatches']; + +echo "RuleEngine clearance forwarding (#136)\n"; + +$t('attaches the wd_clearance token to a tripwire violation', function () use ($eq) { + $engine = new RuleEngine([new FixedRule(new RuleResult(RuleResult::DENY, 'tripwire', 'honeypot path'))]); + $res = $engine->evaluate(ctx_cookie('foo=1; wd_clearance=TOK123; bar=2')); + $eq('tripwire', $res->violations[0]->rule); + $eq('TOK123', $res->violations[0]->clearance); +}); + +$t('does NOT attach clearance to non-tripwire rules', function () use ($eq, $null) { + $engine = new RuleEngine([new FixedRule(new RuleResult(RuleResult::DENY, 'filter', 'ip.tor'))]); + $res = $engine->evaluate(ctx_cookie('wd_clearance=TOK123')); + $eq('filter', $res->violations[0]->rule); + $null($res->violations[0]->clearance); +}); + +$t('leaves clearance null for a tripwire when there is no cookie', function () use ($null) { + $engine = new RuleEngine([new FixedRule(new RuleResult(RuleResult::DENY, 'tripwire', 'honeypot path'))]); + $res = $engine->evaluate(ctx_cookie()); + $null($res->violations[0]->clearance); +}); + +$t('leaves clearance null when the cookie has no wd_clearance', function () use ($null) { + $engine = new RuleEngine([new FixedRule(new RuleResult(RuleResult::DENY, 'tripwire', 'honeypot path'))]); + $res = $engine->evaluate(ctx_cookie('session=abc; theme=dark')); + $null($res->violations[0]->clearance); +}); + +echo "\nRuleEngine ordering & dry-run semantics\n"; + +$t('first non-dry-run DENY wins; dry-run records but does not decide', function () use ($eq, $true) { + $engine = new RuleEngine([ + new FixedRule(new RuleResult(RuleResult::DENY, 'dry', 'logged', ['dryRun' => true])), + new FixedRule(new RuleResult(RuleResult::DENY, 'real', 'blocked')), + ]); + $res = $engine->evaluate(ctx_path('/x')); + $eq('real', $res->rule, 'deciding rule'); + $eq(2, count($res->violations), 'both violations recorded'); + $true($res->violations[0]->dryRun, 'first violation marked dry-run'); +}); + +$t('all-ALLOW yields ALLOW with no violations', function () use ($eq, $true) { + $engine = new RuleEngine([new FixedRule(RuleResult::allow('noop'))]); + $res = $engine->evaluate(ctx_path('/')); + $true($res->isAllowed(), 'engine allows'); + $eq(0, count($res->violations)); +}); + +$t('THROTTLE metadata (retryAfter) is preserved on the result', function () use ($eq) { + $engine = new RuleEngine([new FixedRule(new RuleResult(RuleResult::THROTTLE, 'ratelimit', 'slow down', ['retryAfter' => 30]))]); + $res = $engine->evaluate(ctx_path('/')); + $eq(RuleResult::THROTTLE, $res->action); + $eq(30, $res->metadata['retryAfter']); +}); + +echo "\nTripwireRule\n"; + +$t('DENYs built-in scanner-bait paths', function () use ($eq, $true) { + $rule = new TripwireRule(); + foreach (['/.env', '/.git/config', '/wp-config.php'] as $p) { + $eq(RuleResult::DENY, $rule->evaluate(ctx_path($p))->action, $p); + } + $true(count(TripwireRule::DEFAULT_TRIPWIRE_PATHS) > 5); +}); + +$t('ALLOWs normal application paths', function () use ($eq) { + $rule = new TripwireRule(); + foreach (['/', '/products', '/api/users', '/about'] as $p) { + $eq(RuleResult::ALLOW, $rule->evaluate(ctx_path($p))->action, $p); + } +}); + +$t('DENYs a registered honeytoken path and reports confidence 100', function () use ($eq, $match) { + $rule = new TripwireRule(['paths' => ['/__wd/abc123']]); + $res = $rule->evaluate(ctx_path('/__wd/abc123')); + $eq(RuleResult::DENY, $res->action); + $eq(100, $res->metadata['confidence']); + $match('/Tripwire hit/', $res->reason); +}); + +$t('strips query string and fragment before matching', function () use ($eq) { + $rule = new TripwireRule(); + $eq(RuleResult::DENY, $rule->evaluate(ctx_path('/.env?foo=bar'))->action); + $eq(RuleResult::DENY, $rule->evaluate(ctx_path('/.git/config#x'))->action); +}); + +$t('respects includeDefaults: false', function () use ($eq) { + $rule = new TripwireRule(['paths' => ['/trap'], 'includeDefaults' => false]); + $eq(RuleResult::ALLOW, $rule->evaluate(ctx_path('/.env'))->action); + $eq(RuleResult::DENY, $rule->evaluate(ctx_path('/trap'))->action); +}); + +$t('supports prefixes and patterns', function () use ($eq) { + $rule = new TripwireRule([ + 'prefixes' => ['/.git/'], + 'patterns' => ['/admin-backup'], + 'includeDefaults' => false, + ]); + $eq(RuleResult::DENY, $rule->evaluate(ctx_path('/.git/anything/deep'))->action); + $eq(RuleResult::DENY, $rule->evaluate(ctx_path('/admin-backup.zip'))->action); + $eq(RuleResult::ALLOW, $rule->evaluate(ctx_path('/git-guide'))->action); +}); + +$t('dryRun logs but does not block', function () use ($eq, $true, $match) { + $rule = new TripwireRule(['dryRun' => true]); + $res = $rule->evaluate(ctx_path('/.env')); + $eq(RuleResult::ALLOW, $res->action); + $true($res->metadata['dryRun']); + $match('/Tripwire hit/', $res->reason); +}); + +$t('invalid regex pattern fails open (no match, no throw)', function () use ($eq) { + // An unbalanced group would throw if not guarded. + $rule = new TripwireRule(['patterns' => ['('], 'includeDefaults' => false]); + $eq(RuleResult::ALLOW, $rule->evaluate(ctx_path('/anything'))->action); +}); + +echo "\ntripwire through the RuleEngine\n"; + +$t('a honeytoken hit produces a DENY + a recorded violation', function () use ($eq, $true) { + $engine = new RuleEngine([new TripwireRule(['paths' => ['/__wd/deadbeef'], 'includeDefaults' => false])]); + + $allow = $engine->evaluate(ctx_path('/products')); + $eq(RuleResult::ALLOW, $allow->action); + $eq(0, count($allow->violations)); + + $deny = $engine->evaluate(ctx_path('/__wd/deadbeef')); + $eq(RuleResult::DENY, $deny->action); + $eq('tripwire', $deny->rule); + $true(count($deny->violations) > 0); +}); + +echo "\nViolationEvent wire format\n"; + +$t('toApiPayload matches the ingest ViolationEventRequest shape', function () use ($eq, $true) { + $engine = new RuleEngine([new TripwireRule(['includeDefaults' => true])]); + $res = $engine->evaluate(ctx_cookie('wd_clearance=ABC') ); + // ctx_cookie path is /wp-admin.php which is not a default tripwire; craft one: + $ctx = new RuleContext('9.9.9.9', '/.env', 'GET', 'curl/8', ['cookie' => 'wd_clearance=ABC'], 1700000000000); + $res = $engine->evaluate($ctx); + $payload = $res->violations[0]->toApiPayload(); + $eq('tripwire', $payload['rule']); + $eq('DENY', $payload['action']); + $eq('9.9.9.9', $payload['ip']); + $eq('/.env', $payload['path']); + $eq('ABC', $payload['clearance']); + $eq(false, $payload['dryRun']); + $eq('2023-11-14T22:13:20.000Z', $payload['timestamp'], 'ISO ms timestamp'); + $true(isset($payload['metadata']['confidence'])); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..f41913a --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,87 @@ +getMessage() . "\n"; + return; + } + self::$passed++; + echo " ✓ {$name}\n"; + } + + /** + * @param mixed $expected + * @param mixed $actual + */ + public static function assertSame($expected, $actual, string $msg = ''): void + { + if ($expected !== $actual) { + throw new \Exception( + ($msg !== '' ? $msg . ': ' : '') . + 'expected ' . var_export($expected, true) . ', got ' . var_export($actual, true) + ); + } + } + + public static function assertTrue(bool $cond, string $msg = ''): void + { + if (!$cond) { + throw new \Exception($msg !== '' ? $msg : 'expected true, got false'); + } + } + + public static function assertNull($value, string $msg = ''): void + { + if ($value !== null) { + throw new \Exception(($msg !== '' ? $msg . ': ' : '') . 'expected null, got ' . var_export($value, true)); + } + } + + public static function assertMatches(string $pattern, string $subject, string $msg = ''): void + { + if (preg_match($pattern, $subject) !== 1) { + throw new \Exception(($msg !== '' ? $msg . ': ' : '') . "'{$subject}' does not match {$pattern}"); + } + } + + public static function summary(): int + { + echo "\n" . self::$passed . ' passed, ' . self::$failed . " failed\n"; + return self::$failed === 0 ? 0 : 1; + } +} diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..cd29c2a --- /dev/null +++ b/tests/run.php @@ -0,0 +1,16 @@ + 60, 'rate_limit_window' => 60, + // Tripwires (F4 deception layer). Deterministic, zero-false-positive: + // a request for a scanner-bait honeypot path is automated by + // construction. Off by default so an upgrade never changes behavior + // until the site owner opts in. + 'tripwire_enabled' => false, + 'tripwire_include_defaults' => true, + 'tripwire_paths' => [], // extra exact paths + 'tripwire_prefixes' => [], // startsWith matches + 'tripwire_patterns' => [], // regex bodies (no delimiters) + 'tripwire_action' => 'block', // block | throttle + 'tripwire_dry_run' => false, // record violations without blocking + // Form Protection 'protect_comments' => true, 'protect_login' => true, @@ -283,6 +303,73 @@ private function sanitize_trusted_proxies($input): string return implode("\n", array_unique($valid)); } + /** + * Sanitize a newline/comma separated list of URL paths into a clean array. + * Each entry is normalized to begin with a leading slash. + * + * @param mixed $input + * @return string[] + */ + private function sanitize_path_list($input): array + { + if (is_array($input)) { + $entries = $input; + } else { + $entries = preg_split('/[\r\n,]+/', (string) $input) ?: []; + } + + $valid = []; + foreach ($entries as $entry) { + $entry = trim((string) $entry); + if ($entry === '') { + continue; + } + // Strip whitespace and control chars; keep it a bare path. + $entry = sanitize_text_field($entry); + if ($entry === '') { + continue; + } + if ($entry[0] !== '/') { + $entry = '/' . $entry; + } + $valid[] = $entry; + } + + return array_values(array_unique($valid)); + } + + /** + * Sanitize a newline separated list of regex bodies (no delimiters). + * Discards any pattern that isn't a valid PCRE so a bad rule can never + * throw at evaluation time. + * + * @param mixed $input + * @return string[] + */ + private function sanitize_pattern_list($input): array + { + if (is_array($input)) { + $entries = $input; + } else { + $entries = preg_split('/[\r\n]+/', (string) $input) ?: []; + } + + $valid = []; + foreach ($entries as $entry) { + $entry = trim((string) $entry); + if ($entry === '') { + continue; + } + $delimited = '#' . str_replace('#', '\\#', $entry) . '#'; + // phpcs:ignore + if (@preg_match($delimited, '') !== false) { + $valid[] = $entry; + } + } + + return array_values(array_unique($valid)); + } + /** * Get the scanner ID (auto-generated from site URL) * @@ -530,6 +617,7 @@ public function load_includes(): void require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-pow.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-behavioral-scorer.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-violation-reporter.php'; if (class_exists('WooCommerce')) { require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php'; @@ -586,6 +674,36 @@ public function early_check(): void return; } + // Deterministic rule engine (tripwires / filters). Runs before heuristic + // scoring: a rule DENY/THROTTLE short-circuits and scoring never runs, + // matching @webdecoy/node's protect() flow. Rules also record violations + // (reported to the cloud) even when they don't decide the outcome. + $engine = $this->build_rule_engine(); + if ($engine !== null) { + $context = $this->build_rule_context($ip); + $engineResult = $engine->evaluate($context); + + if ($engineResult->violations) { + // Log locally so hits show in the Detections admin page even for + // local-only installs and dry-run rules (which record but don't + // block). + $this->log_rule_violations($engineResult->violations); + + // Report to the cloud (premium only). Tripwire hits carry + // wd_clearance so the backend can durably deny the actor's + // device fingerprint. + $reporter = WebDecoy_Violation_Reporter::instance($this->is_premium() ? (string) $this->options['api_key'] : ''); + if ($reporter !== null) { + $reporter->report($engineResult->violations); + } + } + + if (!$engineResult->isAllowed()) { + $this->handle_rule_decision($engineResult, $ip); + return; + } + } + // Check rate limit if ($this->options['rate_limit_enabled']) { $rateLimiter = new WebDecoy_Rate_Limiter(); @@ -631,6 +749,143 @@ public function early_check(): void } } + /** + * Build the rule engine from current settings, or null when no rules are + * configured (so the common case adds zero overhead). + * + * @return \WebDecoy\Rules\RuleEngine|null + */ + private function build_rule_engine(): ?\WebDecoy\Rules\RuleEngine + { + $rules = []; + + if (!empty($this->options['tripwire_enabled'])) { + $rules[] = new \WebDecoy\Rules\TripwireRule([ + 'paths' => is_array($this->options['tripwire_paths'] ?? null) ? $this->options['tripwire_paths'] : [], + 'prefixes' => is_array($this->options['tripwire_prefixes'] ?? null) ? $this->options['tripwire_prefixes'] : [], + 'patterns' => is_array($this->options['tripwire_patterns'] ?? null) ? $this->options['tripwire_patterns'] : [], + 'includeDefaults' => !empty($this->options['tripwire_include_defaults']), + 'action' => ($this->options['tripwire_action'] ?? 'block') === 'throttle' + ? \WebDecoy\Rules\RuleResult::THROTTLE + : \WebDecoy\Rules\RuleResult::DENY, + 'dryRun' => !empty($this->options['tripwire_dry_run']), + ]); + } + + if ($rules === []) { + return null; + } + + return new \WebDecoy\Rules\RuleEngine($rules); + } + + /** + * Build the rule context for the current request (trusted-proxy-resolved IP, + * path, method, UA, and headers — the Cookie header carries wd_clearance). + * + * @param string $ip + * @return \WebDecoy\Rules\RuleContext + */ + private function build_rule_context(string $ip): \WebDecoy\Rules\RuleContext + { + $method = isset($_SERVER['REQUEST_METHOD']) + ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) + : 'GET'; + $userAgent = isset($_SERVER['HTTP_USER_AGENT']) + ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) + : ''; + + // Only the headers rules actually read. The Cookie header is passed raw + // (not sanitize_text_field'd) so the wd_clearance token value survives + // intact for forwarding. + $headers = []; + if (isset($_SERVER['HTTP_COOKIE'])) { + $headers['cookie'] = (string) wp_unslash($_SERVER['HTTP_COOKIE']); + } + + return new \WebDecoy\Rules\RuleContext( + $ip, + $this->get_request_path(), + $method, + $userAgent, + $headers + ); + } + + /** + * Act on a non-ALLOW rule-engine decision: DENY blocks, THROTTLE serves 429. + * + * @param \WebDecoy\Rules\RuleEngineResult $result + * @param string $ip + */ + private function handle_rule_decision(\WebDecoy\Rules\RuleEngineResult $result, string $ip): void + { + if ($result->action === \WebDecoy\Rules\RuleResult::THROTTLE) { + $retryAfter = 60; + if (is_array($result->metadata) && isset($result->metadata['retryAfter'])) { + $retryAfter = max(1, intval($result->metadata['retryAfter'])); + } + nocache_headers(); + status_header(429); + header('Retry-After: ' . $retryAfter); + wp_die( + esc_html__('Too many requests. Please try again later.', 'webdecoy'), + esc_html__('Too Many Requests', 'webdecoy'), + ['response' => 429] + ); + return; + } + + // DENY: record the block, then serve the block page / wp_die. + $blocker = new WebDecoy_Blocker(); + $duration = $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null; + $reason = $result->reason ?? ('Rule enforced: ' . ($result->rule ?? 'rule')); + $blocker->block($ip, $reason, $duration); + $this->block_request($this->options['block_page_message']); + } + + /** + * Record rule-engine violations in the local detections table so they show + * in the Detections admin page — including dry-run hits and installs with no + * API key. One row per recorded violation. + * + * @param \WebDecoy\Rules\ViolationEvent[] $violations + */ + private function log_rule_violations(array $violations): void + { + global $wpdb; + + foreach ($violations as $violation) { + $confidence = 100; + if (is_array($violation->metadata) && isset($violation->metadata['confidence'])) { + $confidence = intval($violation->metadata['confidence']); + } + + $flags_data = [ + 'flags' => [$violation->rule . '_rule'], + 'metadata' => array_merge( + is_array($violation->metadata) ? $violation->metadata : [], + [ + 'rule' => $violation->rule, + 'rule_enforced' => !$violation->dryRun, + 'dry_run' => $violation->dryRun, + 'reason' => $violation->reason, + ] + ), + ]; + + $wpdb->insert($wpdb->prefix . 'webdecoy_detections', [ + 'ip_address' => $violation->ip, + 'user_agent' => $violation->userAgent ?? '', + 'score' => $confidence, + 'threat_level' => \WebDecoy\DetectionResult::THREAT_HIGH, + 'source' => 'wordpress_plugin', + 'flags' => wp_json_encode($flags_data), + 'created_at' => current_time('mysql'), + ]); + } + } + /** * Get the current request path for detection * @@ -1115,6 +1370,15 @@ public function sanitize_options(array $input): array $sanitized['rate_limit_requests'] = max(1, intval($input['rate_limit_requests'] ?? 60)); $sanitized['rate_limit_window'] = max(1, intval($input['rate_limit_window'] ?? 60)); + // Tripwires + $sanitized['tripwire_enabled'] = !empty($input['tripwire_enabled']); + $sanitized['tripwire_include_defaults'] = !empty($input['tripwire_include_defaults']); + $sanitized['tripwire_paths'] = $this->sanitize_path_list($input['tripwire_paths'] ?? ''); + $sanitized['tripwire_prefixes'] = $this->sanitize_path_list($input['tripwire_prefixes'] ?? ''); + $sanitized['tripwire_patterns'] = $this->sanitize_pattern_list($input['tripwire_patterns'] ?? ''); + $sanitized['tripwire_action'] = in_array($input['tripwire_action'] ?? 'block', ['block', 'throttle'], true) ? $input['tripwire_action'] : 'block'; + $sanitized['tripwire_dry_run'] = !empty($input['tripwire_dry_run']); + // Form Protection $sanitized['protect_comments'] = !empty($input['protect_comments']); $sanitized['protect_login'] = !empty($input['protect_login']); From ef3813f146216c88cd2c4260371e1e655b29f610 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sun, 19 Jul 2026 14:29:37 -0500 Subject: [PATCH 2/2] feat(tripwires): enable tripwires by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in scanner-bait paths carry no false-positive risk for real visitors, so protection is active out of the box (no users yet — no migration concern). --- changelog.txt | 2 +- webdecoy.php | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/changelog.txt b/changelog.txt index d08f31d..ea491e4 100644 --- a/changelog.txt +++ b/changelog.txt @@ -2,7 +2,7 @@ = 2.2.0 - Unreleased = * 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). Custom exact paths, prefixes, and regex patterns; block or throttle; dry-run. New Settings → Tripwires tab. +* 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. * Added: wd_clearance forwarding — a tripwire hit carrying the visitor's wd_clearance cookie is reported so the WebDecoy Cloud can durably deny the actor's device fingerprint (rotation-proof lockout). Heuristic rules never carry the token. * Added: Violation reporting — rule hits are batched and reported to the WebDecoy Cloud (premium) on request shutdown, fire-and-forget with no added page latency. Hits are always recorded locally in the Detections page. * Added: JS execution verification — detects non-JS HTTP scrapers (e.g., Scrapling Fetcher, curl_cffi) diff --git a/webdecoy.php b/webdecoy.php index cca2528..05ecb26 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -187,9 +187,10 @@ private function load_options(): void // Tripwires (F4 deception layer). Deterministic, zero-false-positive: // a request for a scanner-bait honeypot path is automated by - // construction. Off by default so an upgrade never changes behavior - // until the site owner opts in. - 'tripwire_enabled' => false, + // construction. On by default — the built-in bait paths carry no + // false-positive risk for real visitors, so protection is active + // out of the box. + 'tripwire_enabled' => true, 'tripwire_include_defaults' => true, 'tripwire_paths' => [], // extra exact paths 'tripwire_prefixes' => [], // startsWith matches