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
39 changes: 39 additions & 0 deletions admin/partials/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,45 @@
</td>
</tr>
</table>

<h3><?php esc_html_e('WordPress Traps', 'webdecoy'); ?></h3>
<p class="description">
<?php esc_html_e('Traps targeting the recon patterns WordPress scanners run. Each turns a probe into a deterministic detection.', 'webdecoy'); ?>
</p>

<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('Fake Vulnerable Plugins', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[traps_fake_plugins]" value="1"
<?php checked($options['traps_fake_plugins'] ?? true); ?> />
<?php esc_html_e('Trap requests to known scanner-targeted plugin paths (only for plugins not actually installed here)', 'webdecoy'); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Author Enumeration', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[traps_author_enum]" value="1"
<?php checked($options['traps_author_enum'] ?? true); ?> />
<?php esc_html_e('Trap ?author=N and REST user enumeration — returns a canary username instead of leaking real ones (a later login with it is flagged as exfiltration)', 'webdecoy'); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('XML-RPC', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[traps_xmlrpc]" value="1"
<?php checked($options['traps_xmlrpc'] ?? false); ?> />
<?php esc_html_e('Trap xmlrpc.php probing', 'webdecoy'); ?>
</label>
<p class="description"><?php esc_html_e('Off by default: legitimate clients (Jetpack, the WordPress mobile app, some pingbacks) use XML-RPC. Only enable if your site does not.', 'webdecoy'); ?></p>
</td>
</tr>
</table>
</div>

<!-- Rules Tab -->
Expand Down
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions includes/class-webdecoy-decoy-response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
];
Expand Down
183 changes: 183 additions & 0 deletions includes/class-webdecoy-wp-traps.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

declare(strict_types=1);

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

/**
* WordPress-native tripwires (beyond @webdecoy/node parity).
*
* Traps that require knowing WordPress internals — the recon patterns every
* WP scanner (wpscan-style) runs — which the framework-agnostic Node SDK can't
* express. Each turns a probe into a deterministic detection, and where useful
* feeds the attacker a canary they'll incriminate themselves with later:
*
* - Fake vulnerable-plugin paths: known scanner-hammered plugin slugs, armed
* as tripwire prefixes ONLY when that plugin is not actually installed — so
* a real installed plugin's routes are never shadowed.
* - XML-RPC probing (opt-in, off by default since legit clients use it).
* - Author enumeration (?author=N and the REST users endpoint): instead of
* leaking real usernames, records the probe and hands back a canary username.
* A later login attempt with that canary is caught as exfiltration
* (see WebDecoy_Decoy_Response::is_canary_credential()).
*
* Path-based traps are returned as tripwire prefixes/paths and flow through the
* normal engine (DENY + violation + clearance + optional decoy). The query- and
* REST-based traps hook WP directly and record a synthetic tripwire violation
* via the callback supplied to register().
*/
class WebDecoy_WP_Traps
{
/**
* Well-known, historically-targeted plugin slugs that scanners probe for.
* Armed only when the slug is NOT installed here.
*
* @var string[]
*/
private const KNOWN_VULN_SLUGS = [
'wp-file-manager',
'revslider',
'wp-gdpr-compliance',
'duplicator',
'wp-fastest-cache',
'wp-symposium',
'wp-mobile-detector',
'simple-fields',
'work-the-flow-file-upload',
'wp-support-plus-responsive-ticket-system',
];

/** @var callable|null Called with a WebDecoy\Rules\ViolationEvent-like record. */
private $recorder;

/**
* @param callable|null $recorder function(string $rule, string $path, int|string $confidence): void
* Records + reports a synthetic tripwire hit.
*/
public function __construct(?callable $recorder = null)
{
$this->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 '<!DOCTYPE html><html><head><title>Author</title><meta name="robots" content="noindex"></head><body>';
echo '<h1>Posts by ' . esc_html($canary) . '</h1><p>No posts found.</p>';
echo '</body></html>';
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);
}
}
}
86 changes: 86 additions & 0 deletions webdecoy.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@
'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.
Expand Down Expand Up @@ -679,6 +684,12 @@
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()) {
Expand Down Expand Up @@ -758,6 +769,7 @@
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';
Expand Down Expand Up @@ -922,6 +934,27 @@
]);
}

// 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.
Expand Down Expand Up @@ -969,6 +1002,56 @@
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
Expand Down Expand Up @@ -1025,7 +1108,7 @@
// latter, plus the raw cookie.
$headers = $this->collect_request_headers();
if (isset($_SERVER['HTTP_COOKIE'])) {
$headers['cookie'] = (string) wp_unslash($_SERVER['HTTP_COOKIE']);

Check failure on line 1111 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Detected usage of a non-sanitized input variable: $_SERVER['HTTP_COOKIE']
}

// Fetch IP enrichment only when a filter rule needs it (premium only).
Expand Down Expand Up @@ -1147,7 +1230,7 @@
],
];

$wpdb->insert($wpdb->prefix . 'webdecoy_detections', [

Check warning on line 1233 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
'score' => 100,
Expand Down Expand Up @@ -1188,7 +1271,7 @@
),
];

$wpdb->insert($wpdb->prefix . 'webdecoy_detections', [

Check warning on line 1274 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $violation->ip,
'user_agent' => $violation->userAgent ?? '',
'score' => $confidence,
Expand Down Expand Up @@ -1252,7 +1335,7 @@
*/
private function is_challenge_verified(string $ip): bool
{
$cookie = isset($_COOKIE['webdecoy_verified']) ? sanitize_text_field($_COOKIE['webdecoy_verified']) : '';

Check failure on line 1338 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_COOKIE['webdecoy_verified'] not unslashed before sanitization. Use wp_unslash() or similar
if (empty($cookie)) {
return false;
}
Expand Down Expand Up @@ -1326,9 +1409,9 @@
'metadata' => $result->getMetadata(),
];

$wpdb->insert($table, [

Check warning on line 1412 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '',

Check failure on line 1414 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_SERVER['HTTP_USER_AGENT'] not unslashed before sanitization. Use wp_unslash() or similar
'score' => $result->getScore(),
'threat_level' => $result->getThreatLevel(),
'source' => 'wordpress_plugin',
Expand Down Expand Up @@ -1428,7 +1511,7 @@
$ip = $this->get_client_ip();

global $wpdb;
$wpdb->insert($wpdb->prefix . 'webdecoy_detections', [

Check warning on line 1514 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
'score' => 100,
Expand Down Expand Up @@ -1558,7 +1641,7 @@
{
$honeypot_name = 'webdecoy_hp_' . $context;

if (isset($_POST[$honeypot_name]) && !empty($_POST[$honeypot_name])) {

Check failure on line 1644 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Processing form data without nonce verification.

Check failure on line 1644 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Processing form data without nonce verification.
// Honeypot triggered - definitely a bot
$ip = $this->get_client_ip();
$blocker = new WebDecoy_Blocker();
Expand Down Expand Up @@ -1730,6 +1813,9 @@
$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
Expand Down Expand Up @@ -2014,7 +2100,7 @@
}

// Get detection data
$detection_json = isset($_POST['detection']) ? wp_unslash($_POST['detection']) : '';

Check failure on line 2103 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Detected usage of a non-sanitized input variable: $_POST['detection']
$detection = json_decode($detection_json, true);

if (!$detection || !is_array($detection)) {
Expand Down Expand Up @@ -2109,7 +2195,7 @@
$threat_level = 'LOW';
}

$wpdb->insert($table, [

Check warning on line 2198 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => $user_agent,
'score' => $score,
Expand Down Expand Up @@ -2186,7 +2272,7 @@
return;
}

$api_key = sanitize_text_field($_POST['api_key'] ?? '');

Check failure on line 2275 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['api_key'] not unslashed before sanitization. Use wp_unslash() or similar

// Decrypt if encrypted
if (!empty($api_key) && $this->is_encrypted($api_key)) {
Expand Down Expand Up @@ -2261,8 +2347,8 @@
return;
}

$ip = sanitize_text_field($_POST['ip'] ?? '');

Check failure on line 2350 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['ip'] not unslashed before sanitization. Use wp_unslash() or similar
$reason = sanitize_text_field($_POST['reason'] ?? '');

Check failure on line 2351 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['reason'] not unslashed before sanitization. Use wp_unslash() or similar
$duration = intval($_POST['duration'] ?? 24);

if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP)) {
Expand All @@ -2288,7 +2374,7 @@
return;
}

$ip = sanitize_text_field($_POST['ip'] ?? '');

Check failure on line 2377 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['ip'] not unslashed before sanitization. Use wp_unslash() or similar

if (empty($ip)) {
wp_send_json_error(['message' => __('Invalid IP address.', 'webdecoy')]);
Expand Down Expand Up @@ -2688,7 +2774,7 @@
return $transient;
}

$transient->response[WEBDECOY_PLUGIN_BASENAME] = (object) [

Check failure on line 2777 in webdecoy.php

View workflow job for this annotation

GitHub Actions / Static Analysis

Access to an undefined property object::$response.
'slug' => 'webdecoy',
'plugin' => WEBDECOY_PLUGIN_BASENAME,
'new_version' => $update_info['version'],
Expand Down
Loading