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..ea491e4 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). 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)
* 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. 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
+ '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 +304,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 +618,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 +675,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 +750,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 +1371,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']);