diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index d53f6d7..fe67bc1 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -288,6 +288,45 @@ + +

+

+ +

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

+
diff --git a/changelog.txt b/changelog.txt index 44f1925..b289a61 100644 --- a/changelog.txt +++ b/changelog.txt @@ -9,6 +9,7 @@ * 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: Honeytoken — automatically injects an invisible decoy link on front-end pages pointing at a secret per-site path; only link-following scrapers ever request it, and a hit is armed as a tripwire. On by default, with optional daily rotation. Never shown to real visitors or logged-in users. +* Added: WordPress-native traps — fake vulnerable-plugin paths (armed only for plugins not actually installed, so real plugins are never shadowed), optional XML-RPC probing trap (off by default), and author-enumeration protection: ?author=N and REST user enumeration return a canary username instead of leaking real ones, and a later login attempt with that canary is flagged as a critical exfiltration detection. * Added: Deceptive tripwire responses — a tripwire hit can serve a 404, believable fake content (fake .env / wp-config / SQL dump / phpinfo seeded with unique per-site canary credentials), or a slow-drip tarpit, instead of a plain 403. A later login attempt using a canary credential is logged as a critical exfiltration detection and blocked. Decoy content is template-only and never exposes real configuration. * 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 reported to the WebDecoy Cloud (premium), spooled to a local queue and delivered on request shutdown (after the response is handed back, so no added page latency). Survives brief ingest outages: retried once by a cron safety net, then dropped, with a hard queue cap. Hits are always recorded locally in the Detections page. diff --git a/includes/class-webdecoy-decoy-response.php b/includes/class-webdecoy-decoy-response.php index b56e08a..0ec2c0e 100644 --- a/includes/class-webdecoy-decoy-response.php +++ b/includes/class-webdecoy-decoy-response.php @@ -68,6 +68,7 @@ public static function canaries(): array 'auth_key' => self::derive('auth_key', 40), 'admin_user' => 'admin_' . self::derive('admin_user', 6), 'admin_password' => 'Wd' . self::derive('admin_password', 18), + 'author_user' => 'editor_' . self::derive('author_user', 6), 'aws_key' => 'AKIA' . strtoupper(self::derive('aws_key', 16)), 'aws_secret' => self::derive('aws_secret', 40), ]; diff --git a/includes/class-webdecoy-wp-traps.php b/includes/class-webdecoy-wp-traps.php new file mode 100644 index 0000000..2574fa3 --- /dev/null +++ b/includes/class-webdecoy-wp-traps.php @@ -0,0 +1,183 @@ +recorder = $recorder; + } + + /** + * Tripwire path prefixes for fake vulnerable-plugin routes — only for + * plugins that are NOT installed, so a genuinely installed plugin's paths + * are never trapped. + * + * @return string[] + */ + public static function plugin_path_prefixes(): array + { + $prefixes = []; + $plugin_dir = defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR : (defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR . '/plugins' : ''); + + foreach (self::KNOWN_VULN_SLUGS as $slug) { + if ($plugin_dir !== '' && is_dir($plugin_dir . '/' . $slug)) { + continue; // real plugin present — never trap it + } + $prefixes[] = '/wp-content/plugins/' . $slug . '/'; + } + + return $prefixes; + } + + /** + * Register the query/REST-based traps (author enumeration). Path-based traps + * are handled by the engine via plugin_path_prefixes(); this covers the ones + * the engine can't express. + */ + public function register(bool $author_enum): void + { + if ($author_enum) { + // Catch ?author=N enumeration before WP resolves and leaks the real + // username via canonical redirect. + add_action('template_redirect', [$this, 'guard_author_enum'], 0); + // Return canary users from the unauthenticated REST users endpoint. + add_filter('rest_request_after_callbacks', [$this, 'guard_rest_users'], 10, 3); + } + } + + /** + * Detect ?author=N enumeration by an unauthenticated visitor and respond + * with a fake author page exposing a canary username instead of the real + * one. Records the probe. + */ + public function guard_author_enum(): void + { + if (is_user_logged_in()) { + return; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if (!isset($_GET['author'])) { + return; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $author = sanitize_text_field(wp_unslash($_GET['author'])); + if (!ctype_digit((string) $author)) { + return; // /?author=slug is not numeric enumeration + } + + $this->record('tripwire', '/?author=' . $author); + + $canary = WebDecoy_Decoy_Response::canaries()['author_user'] ?? 'editor'; + + nocache_headers(); + status_header(200); + header('Content-Type: text/html; charset=UTF-8'); + echo 'Author'; + echo '

Posts by ' . esc_html($canary) . '

No posts found.

'; + echo ''; + exit; + } + + /** + * For an unauthenticated GET of the REST users collection, replace the + * response with a canary user so enumeration harvests a trap identity, and + * record the probe. Authenticated requests (block editor, etc.) are + * untouched. + * + * @param \WP_REST_Response|\WP_HTTP_Response|\WP_Error $response + * @param array $handler + * @param \WP_REST_Request $request + * @return mixed + */ + public function guard_rest_users($response, $handler, $request) + { + if (is_user_logged_in()) { + return $response; + } + if (!($request instanceof \WP_REST_Request)) { + return $response; + } + $route = (string) $request->get_route(); + if (strpos($route, '/wp/v2/users') !== 0) { + return $response; + } + if (strtoupper($request->get_method()) !== 'GET') { + return $response; + } + + $this->record('tripwire', $route); + + $canary = WebDecoy_Decoy_Response::canaries()['author_user'] ?? 'editor'; + $fake = [[ + 'id' => 1, + 'name' => $canary, + 'slug' => $canary, + ]]; + + return new \WP_REST_Response($fake, 200); + } + + /** + * Record a synthetic tripwire hit through the supplied recorder. + */ + private function record(string $rule, string $path): void + { + if (is_callable($this->recorder)) { + call_user_func($this->recorder, $rule, $path); + } + } +} diff --git a/webdecoy.php b/webdecoy.php index 0c17aa2..c9ff333 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -235,6 +235,11 @@ private function load_options(): void 'honeytoken_enabled' => true, 'honeytoken_rotate' => false, // rotate the token daily (with grace) + // WordPress-native traps. + 'traps_fake_plugins' => true, // arm fake vulnerable-plugin paths (absent plugins only) + 'traps_xmlrpc' => false, // trap xmlrpc.php (off: legit clients use it) + 'traps_author_enum' => true, // trap ?author=N + REST user enumeration + // Filter rules: expression-based rules evaluated by the rule engine. // Each entry: ['expression'=>string, 'action'=>'block'|'throttle', // 'dry_run'=>bool, 'name'=>string]. Empty by default. @@ -679,6 +684,12 @@ private function init_hooks(): void add_action('wp_footer', [$this, 'inject_honeytoken_link'], 99); } + // WordPress-native query/REST traps (author enumeration). Registered + // after includes are loaded so the traps class is available. + if (!empty($this->options['traps_author_enum'])) { + add_action('plugins_loaded', [$this, 'register_wp_traps'], 20); + } + // JS execution verification: inject challenge token meta tag and report page serve // Only active when scanner is enabled and API key is configured (premium) if ($this->options['scanner_enabled'] && !is_admin() && $this->is_premium()) { @@ -758,6 +769,7 @@ public function load_includes(): void require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-ip-enrichment.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-decoy-response.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-rate-limit-rule.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-wp-traps.php'; if (class_exists('WooCommerce')) { require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php'; @@ -922,6 +934,27 @@ private function build_rule_engine(): ?\WebDecoy\Rules\RuleEngine ]); } + // WordPress-native path traps: fake vulnerable-plugin routes (only for + // absent plugins) and, optionally, xmlrpc.php. Armed as a tripwire so + // they get the full DENY + violation + clearance + optional-decoy path. + $trap_prefixes = []; + $trap_paths = []; + if (!empty($this->options['traps_fake_plugins'])) { + $trap_prefixes = array_merge($trap_prefixes, WebDecoy_WP_Traps::plugin_path_prefixes()); + } + if (!empty($this->options['traps_xmlrpc'])) { + $trap_paths[] = '/xmlrpc.php'; + } + if ($trap_prefixes !== [] || $trap_paths !== []) { + $rules[] = new \WebDecoy\Rules\TripwireRule([ + 'paths' => $trap_paths, + 'prefixes' => $trap_prefixes, + 'includeDefaults' => false, + 'action' => $action, + 'dryRun' => $dryRun, + ]); + } + // Filter (expression) rules. A malformed stored expression is skipped // defensively so it can never fatal a request (it was already flagged in // the admin on save). Track whether any rule needs IP enrichment. @@ -969,6 +1002,56 @@ private function build_rule_engine(): ?\WebDecoy\Rules\RuleEngine return new \WebDecoy\Rules\RuleEngine($rules); } + /** + * Register the WordPress-native query/REST traps (author enumeration), + * wired to record synthetic tripwire violations. + */ + public function register_wp_traps(): void + { + $traps = new WebDecoy_WP_Traps([$this, 'record_synthetic_tripwire']); + $traps->register(!empty($this->options['traps_author_enum'])); + } + + /** + * Record + report a synthetic tripwire hit for traps that don't flow through + * the rule engine (author enumeration, REST user probing). Logged locally + * and, when premium, reported with the wd_clearance token so the actor's + * device fingerprint can be durably denied — same as an engine tripwire. + * + * @param string $rule + * @param string $path + */ + public function record_synthetic_tripwire(string $rule, string $path): void + { + $ip = $this->get_client_ip(); + $ua = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; + $clearance = null; + if ($rule === 'tripwire' && isset($_COOKIE['wd_clearance'])) { + $clearance = sanitize_text_field(wp_unslash($_COOKIE['wd_clearance'])); + } + + $event = new \WebDecoy\Rules\ViolationEvent( + $rule, + \WebDecoy\Rules\RuleResult::DENY, + $ip, + $path, + isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : 'GET', + $ua !== '' ? $ua : null, + 'WordPress trap hit: ' . $path, + $clearance, + ['path' => $path, 'confidence' => 100, 'trap' => true], + false, + gmdate('Y-m-d\TH:i:s') . '.000Z' + ); + + $this->log_rule_violations([$event]); + + $reporter = WebDecoy_Violation_Reporter::instance($this->is_premium() ? (string) $this->options['api_key'] : ''); + if ($reporter !== null) { + $reporter->report([$event]); + } + } + /** * Cron: drain any spooled violation reports that the per-request shutdown * flush couldn't deliver (e.g. a brief ingest outage). A non-empty API key @@ -1730,6 +1813,9 @@ public function sanitize_options(array $input): array $sanitized['tripwire_response'] = in_array($input['tripwire_response'] ?? 'block', ['block', 'notfound', 'decoy', 'tarpit'], true) ? $input['tripwire_response'] : 'block'; $sanitized['honeytoken_enabled'] = !empty($input['honeytoken_enabled']); $sanitized['honeytoken_rotate'] = !empty($input['honeytoken_rotate']); + $sanitized['traps_fake_plugins'] = !empty($input['traps_fake_plugins']); + $sanitized['traps_xmlrpc'] = !empty($input['traps_xmlrpc']); + $sanitized['traps_author_enum'] = !empty($input['traps_author_enum']); $sanitized['filter_rules'] = $this->sanitize_filter_rules($input['filter_rules'] ?? []); // Form Protection