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
87 changes: 87 additions & 0 deletions admin/partials/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<div class="webdecoy-tabs">
<nav class="nav-tab-wrapper">
<a href="#tab-detection" class="nav-tab nav-tab-active"><?php esc_html_e('Protection', 'webdecoy'); ?></a>
<a href="#tab-tripwires" class="nav-tab"><?php esc_html_e('Tripwires', 'webdecoy'); ?></a>
<a href="#tab-bots" class="nav-tab"><?php esc_html_e('Good Bots', 'webdecoy'); ?></a>
<a href="#tab-blocking" class="nav-tab"><?php esc_html_e('Blocking', 'webdecoy'); ?></a>
<a href="#tab-forms" class="nav-tab"><?php esc_html_e('Forms', 'webdecoy'); ?></a>
Expand Down Expand Up @@ -124,6 +125,92 @@
</table>
</div>

<!-- Tripwires Tab -->
<div id="tab-tripwires-tab" class="webdecoy-tab-content">
<h2><?php esc_html_e('Tripwires', 'webdecoy'); ?></h2>
<p class="description">
<?php esc_html_e('Tripwires are hidden honeypot paths that no real visitor ever requests — scanner-bait like /.env or /.git/config. A request for one is automated by construction, so it is blocked deterministically with zero false positives. Tripwire hits are the strongest deception signal and, with a WebDecoy Cloud key, drive durable device-fingerprint lockouts.', 'webdecoy'); ?>
</p>

<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('Enable Tripwires', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[tripwire_enabled]" value="1"
<?php checked($options['tripwire_enabled'] ?? false); ?> />
<?php esc_html_e('Deterministically block requests to honeypot paths', 'webdecoy'); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Built-in Bait Paths', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[tripwire_include_defaults]" value="1"
<?php checked($options['tripwire_include_defaults'] ?? true); ?> />
<?php esc_html_e('Include the built-in scanner-bait list (/.env, /.git/config, /wp-config.php, and more)', 'webdecoy'); ?>
</label>
</td>
</tr>
<tr>
<th scope="row">
<label for="webdecoy_tripwire_paths"><?php esc_html_e('Custom Paths', 'webdecoy'); ?></label>
</th>
<td>
<textarea id="webdecoy_tripwire_paths" name="webdecoy_options[tripwire_paths]"
rows="4" class="large-text code" placeholder="/secret-admin&#10;/old-backup.tar.gz"><?php echo esc_textarea(implode("\n", (array) ($options['tripwire_paths'] ?? []))); ?></textarea>
<p class="description"><?php esc_html_e('Exact paths, one per line. Each is matched exactly (query string and fragment are ignored).', 'webdecoy'); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="webdecoy_tripwire_prefixes"><?php esc_html_e('Path Prefixes', 'webdecoy'); ?></label>
</th>
<td>
<textarea id="webdecoy_tripwire_prefixes" name="webdecoy_options[tripwire_prefixes]"
rows="3" class="large-text code" placeholder="/.git/&#10;/vendor/"><?php echo esc_textarea(implode("\n", (array) ($options['tripwire_prefixes'] ?? []))); ?></textarea>
<p class="description"><?php esc_html_e('Any request path starting with one of these is a hit. One prefix per line.', 'webdecoy'); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="webdecoy_tripwire_patterns"><?php esc_html_e('Regex Patterns', 'webdecoy'); ?></label>
</th>
<td>
<textarea id="webdecoy_tripwire_patterns" name="webdecoy_options[tripwire_patterns]"
rows="3" class="large-text code" placeholder="\.(sql|bak|old)$"><?php echo esc_textarea(implode("\n", (array) ($options['tripwire_patterns'] ?? []))); ?></textarea>
<p class="description"><?php esc_html_e('Advanced: PCRE patterns without delimiters, one per line. Invalid patterns are discarded on save.', 'webdecoy'); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="webdecoy_tripwire_action"><?php esc_html_e('Action', 'webdecoy'); ?></label>
</th>
<td>
<select id="webdecoy_tripwire_action" name="webdecoy_options[tripwire_action]">
<option value="block" <?php selected($options['tripwire_action'] ?? 'block', 'block'); ?>>
<?php esc_html_e('Block (recommended)', 'webdecoy'); ?>
</option>
<option value="throttle" <?php selected($options['tripwire_action'] ?? 'block', 'throttle'); ?>>
<?php esc_html_e('Throttle (429 Too Many Requests)', 'webdecoy'); ?>
</option>
</select>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Dry Run', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[tripwire_dry_run]" value="1"
<?php checked($options['tripwire_dry_run'] ?? false); ?> />
<?php esc_html_e('Record tripwire hits without blocking (test against live traffic first)', 'webdecoy'); ?>
</label>
</td>
</tr>
</table>
</div>

<!-- Good Bots Tab -->
<div id="tab-bots-tab" class="webdecoy-tab-content">
<h2><?php esc_html_e('Good Bot Handling', 'webdecoy'); ?></h2>
Expand Down
6 changes: 5 additions & 1 deletion changelog.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
140 changes: 140 additions & 0 deletions includes/class-webdecoy-violation-reporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

use WebDecoy\Rules\ViolationEvent;

if (!defined('ABSPATH')) {
exit;
}

/**
* Buffers rule-engine violations and flushes them to the ingest service.
*
* PHP has no persistent process, so unlike @webdecoy/node's timer-based
* reporter this buffers per request and flushes once on `shutdown` with a
* single non-blocking POST (fire-and-forget: timeout 1s, blocking false) — the
* same pattern the plugin already uses for page-serve and detection forwarding.
* A typical request produces 0–1 events, so per-request flush is the natural
* unit and never adds latency to the response.
*
* Best-effort by design, mirroring node: on failure events are dropped rather
* than allowed to become a reliability liability. (Durable DB spooling +
* cron-based retry is the P1 follow-up in #7.)
*
* Reporting is a cloud feature: with no API key configured, nothing is sent —
* rules still enforce locally. This mirrors node, whose reporter is only wired
* when an apiKey is present.
*/
class WebDecoy_Violation_Reporter
{
/** Ingest batch endpoint. */
private const ENDPOINT = 'https://ingest.webdecoy.com/api/v1/sdk/violations/batch';

/** Max events per POST body, matching node's batch size. */
private const BATCH_SIZE = 100;

/** @var WebDecoy_Violation_Reporter|null */
private static $instance = null;

/** @var ViolationEvent[] */
private $buffer = [];

/** @var string */
private $apiKey;

/** @var bool Whether the shutdown flush has been registered. */
private $registered = false;

public function __construct(string $apiKey)
{
$this->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]),
]);
}
}
}
80 changes: 80 additions & 0 deletions sdk/src/Rules/RuleContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace WebDecoy\Rules;

/**
* Request context evaluated by the rule engine.
*
* Mirrors the `RuleContext` shape in @webdecoy/node
* (packages/webdecoy/src/rules/types.ts) so rule semantics stay identical
* across the two SDKs.
*/
class RuleContext
{
/** @var string Trusted-proxy-resolved client IP. */
public $ip;

/** @var string Request path (may include query/fragment; rules normalize). */
public $path;

/** @var string HTTP method. */
public $method;

/** @var string User agent, or empty string. */
public $userAgent;

/**
* Request headers, keyed by lowercased header name.
*
* @var array<string,string>
*/
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<string,mixed>|null
*/
public $enrichment;

/**
* @param array<string,string> $headers
* @param array<string,mixed>|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;
}
}
Loading
Loading