From 47bdb163174d1f6c3c1eee427623bb693092d2d5 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sun, 19 Jul 2026 15:16:29 -0500 Subject: [PATCH] feat(deception): deceptive tripwire responses + canary credentials (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tripwire can now deceive a scanner instead of just denying it — something only possible because PHP owns the whole HTTP response (node middleware can't). - WebDecoy_Decoy_Response: per-site canary credentials derived from a stored secret (deterministic, recomputable, never colliding with real values). Response modes for a tripwire hit: block (403, default), notfound (404), decoy (200 with believable fake .env / wp-config / SQL dump / phpinfo content embedding canaries), tarpit (bounded slow-drip, <=10s). - Safety: decoy content is template-only and NEVER reads a real config value; a path with no template fails closed to 403. - Deception-first: decoy/notfound/tarpit modes do NOT locally block the IP, so the scanner keeps digging decoys — each hit another reported violation — while edge enforcement still fires via the reported clearance token. - Canary-use detection: a login attempt with a canary credential (which could only have come from a served decoy) is logged CRITICAL and blocked, via a priority-5 authenticate filter. - New Tripwires-tab 'Response' selector; served canaries recorded in the detections audit trail. Verified canary determinism, is_canary_credential, all four decoy templates embed canaries, fail-closed on unknown paths, and block-mode fallthrough. Closes #12. Part of #16. Co-authored-by: Claude --- admin/partials/settings-page.php | 24 ++ changelog.txt | 1 + includes/class-webdecoy-decoy-response.php | 257 +++++++++++++++++++++ webdecoy.php | 118 ++++++++++ 4 files changed, 400 insertions(+) create mode 100644 includes/class-webdecoy-decoy-response.php diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index 06c41b2..ed46b62 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -199,6 +199,30 @@ + + + + + + +

+ +

+ + diff --git a/changelog.txt b/changelog.txt index 51fdac7..cb528ee 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,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: 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. * Added: JS execution verification — detects non-JS HTTP scrapers (e.g., Scrapling Fetcher, curl_cffi) diff --git a/includes/class-webdecoy-decoy-response.php b/includes/class-webdecoy-decoy-response.php new file mode 100644 index 0000000..b56e08a --- /dev/null +++ b/includes/class-webdecoy-decoy-response.php @@ -0,0 +1,257 @@ + + */ + public static function canaries(): array + { + return [ + 'db_name' => 'wp_' . self::derive('db_name', 6), + 'db_user' => 'wpuser_' . self::derive('db_user', 6), + 'db_password' => 'Wd' . self::derive('db_password', 20), + 'db_host' => 'localhost', + 'auth_key' => self::derive('auth_key', 40), + 'admin_user' => 'admin_' . self::derive('admin_user', 6), + 'admin_password' => 'Wd' . self::derive('admin_password', 18), + 'aws_key' => 'AKIA' . strtoupper(self::derive('aws_key', 16)), + 'aws_secret' => self::derive('aws_secret', 40), + ]; + } + + /** + * Is a submitted credential value one of this site's canaries? A match means + * the value could only have come from a decoy we served — strong exfil + * evidence. Compared with hash_equals to avoid timing leaks. + */ + public static function is_canary_credential(string $value): bool + { + if ($value === '') { + return false; + } + foreach (self::canaries() as $canary) { + if (hash_equals($canary, $value)) { + return true; + } + } + return false; + } + + /** + * Serve a deceptive response for a tripwire hit and exit. Returns false + * (without emitting anything) when the mode is 'block' or when a decoy can't + * be produced for this path — the caller then serves the normal 403. + * + * @return bool false = fall back to the default block + */ + public function serve(string $path, string $mode): bool + { + if ($mode === 'notfound') { + $this->serve_404(); + return true; // exits + } + + if ($mode === 'tarpit') { + $this->serve_tarpit(); + return true; // exits + } + + if ($mode === 'decoy') { + $content = $this->decoy_for($path); + if ($content === null) { + return false; // no believable template — fail closed to 403 + } + $this->serve_body($content['body'], $content['type']); + return true; // exits + } + + return false; // 'block' or unknown — caller handles + } + + /** + * Build believable fake content for a known bait path, embedding canaries. + * Returns null when the path has no template (caller falls back to 403). + * + * @return array{body:string,type:string}|null + */ + private function decoy_for(string $path): ?array + { + $p = strtolower($path); + $c = self::canaries(); + + // Fake .env + if (substr($p, -4) === '.env' || strpos($p, '/.env') !== false) { + $body = "APP_ENV=production\n" + . "APP_DEBUG=false\n" + . "APP_KEY=base64:" . base64_encode($c['auth_key']) . "\n" + . "DB_CONNECTION=mysql\n" + . "DB_HOST={$c['db_host']}\n" + . "DB_PORT=3306\n" + . "DB_DATABASE={$c['db_name']}\n" + . "DB_USERNAME={$c['db_user']}\n" + . "DB_PASSWORD={$c['db_password']}\n" + . "AWS_ACCESS_KEY_ID={$c['aws_key']}\n" + . "AWS_SECRET_ACCESS_KEY={$c['aws_secret']}\n"; + return ['body' => $body, 'type' => 'text/plain']; + } + + // Fake wp-config backup + if (strpos($p, 'wp-config') !== false) { + $body = " $body, 'type' => 'text/plain']; + } + + // Fake SQL dump + if (substr($p, -4) === '.sql' || strpos($p, 'backup') !== false || strpos($p, 'dump') !== false) { + $body = "-- MySQL dump\n" + . "-- Host: {$c['db_host']} Database: {$c['db_name']}\n" + . "CREATE TABLE `wp_users` (\n" + . " `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n" + . " `user_login` varchar(60) NOT NULL,\n" + . " `user_pass` varchar(255) NOT NULL,\n" + . " PRIMARY KEY (`ID`)\n" + . ") ENGINE=InnoDB;\n" + . "INSERT INTO `wp_users` VALUES " + . "(1,'{$c['admin_user']}','\$P\$B" . self::derive('pw_hash', 30) . "');\n"; + return ['body' => $body, 'type' => 'text/plain']; + } + + // Fake phpinfo + if (strpos($p, 'phpinfo') !== false) { + $body = "phpinfo()" + . "

PHP Version 7.4.33

" + . "" + . "" + . "
SystemLinux web01 5.4.0
DOCUMENT_ROOT/var/www/html
DB_USER{$c['db_user']}
" + . ""; + return ['body' => $body, 'type' => 'text/html']; + } + + return null; // no believable template for this path + } + + /** + * The canaries served for a given path (for recording in detection metadata). + * Empty when the path has no decoy template. + * + * @return array + */ + public function served_canaries(string $path): array + { + return $this->decoy_for($path) === null ? [] : self::canaries(); + } + + private function serve_404(): void + { + nocache_headers(); + status_header(404); + header('Content-Type: text/html; charset=UTF-8'); + echo '404 Not Found

Not Found

The requested URL was not found on this server.

'; + exit; + } + + private function serve_body(string $body, string $type): void + { + nocache_headers(); + status_header(200); + header('Content-Type: ' . $type . '; charset=UTF-8'); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- template content, not user input + echo $body; + exit; + } + + /** + * Slow-drip response to burn scanner time, streamed and bounded. Ties up a + * PHP worker for up to TARPIT_MAX_SECONDS — hence off by default and + * documented as such. + */ + private function serve_tarpit(): void + { + nocache_headers(); + status_header(200); + header('Content-Type: text/html; charset=UTF-8'); + + // Cap by both our limit and any configured max_execution_time headroom. + $maxExec = (int) ini_get('max_execution_time'); + $budget = self::TARPIT_MAX_SECONDS; + if ($maxExec > 0) { + $budget = min($budget, max(1, $maxExec - 2)); + } + + echo 'Loading…'; + $start = time(); + $i = 0; + while ((time() - $start) < $budget) { + echo '\n"; + if (function_exists('flush')) { + @flush(); // phpcs:ignore + } + $i++; + usleep(500000); // 0.5s between drips + } + echo ''; + exit; + } +} diff --git a/webdecoy.php b/webdecoy.php index f52d339..0457907 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -219,6 +219,9 @@ private function load_options(): void 'tripwire_patterns' => [], // regex bodies (no delimiters) 'tripwire_action' => 'block', // block | throttle 'tripwire_dry_run' => false, // record violations without blocking + // How a tripwire hit responds: block (403), notfound (404), decoy + // (200 fake content w/ canary credentials), or tarpit (slow drip). + 'tripwire_response' => 'block', // Honeytoken: auto-inject a hidden decoy link on front-end pages and // arm its secret path as a tripwire. Only link-following scrapers @@ -609,6 +612,14 @@ private function init_hooks(): void add_filter('authenticate', [$this, 'check_login'], 30, 3); } + // Canary-credential login detection: a login attempt using a fake + // credential we only ever handed out via a decoy response is + // unambiguous exfiltration evidence. Active whenever tripwires are on + // (the canaries' source), independent of login protection. + if (!empty($this->options['tripwire_enabled']) || !empty($this->options['honeytoken_enabled'])) { + add_filter('authenticate', [$this, 'check_canary_login'], 5, 3); + } + if ($this->options['protect_registration']) { add_action('register_post', [$this, 'check_registration'], 10, 3); } @@ -739,6 +750,7 @@ public function load_includes(): void require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-violation-reporter.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-honeytoken.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-ip-enrichment.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-decoy-response.php'; if (class_exists('WooCommerce')) { require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php'; @@ -1065,6 +1077,29 @@ private function handle_rule_decision(\WebDecoy\Rules\RuleEngineResult $result, return; } + // Tripwire DENY with a deceptive response configured: serve fake content + // / 404 / tarpit instead of a plain 403. We deliberately do NOT locally + // block the IP in these modes — letting the scanner keep digging decoys + // gathers more evidence (each hit another reported violation), while + // edge enforcement still happens via the reported clearance token. + if ($result->rule === 'tripwire') { + $mode = $this->options['tripwire_response'] ?? 'block'; + if ($mode !== 'block') { + $path = (is_array($result->metadata) && isset($result->metadata['path'])) ? (string) $result->metadata['path'] : ''; + $decoy = new WebDecoy_Decoy_Response(); + if ($mode === 'decoy') { + $served = $decoy->served_canaries($path); + if ($served !== []) { + $this->log_decoy_served($path, $ip, $served); + } + } + if ($decoy->serve($path, $mode)) { + return; // deceptive response served + exited + } + // No believable template for this path — fall through to 403. + } + } + // 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; @@ -1073,6 +1108,38 @@ private function handle_rule_decision(\WebDecoy\Rules\RuleEngineResult $result, $this->block_request($this->options['block_page_message']); } + /** + * Record that a decoy (with canary credentials) was served, so the audit + * trail shows what was handed out. The full canary values are recomputable, + * so we store only the identifying markers here. + * + * @param array $canaries + */ + private function log_decoy_served(string $path, string $ip, array $canaries): void + { + global $wpdb; + + $flags_data = [ + 'flags' => ['decoy_served'], + 'metadata' => [ + 'rule' => 'tripwire', + 'decoy_path' => $path, + 'canary_db_user' => $canaries['db_user'] ?? '', + 'canary_admin_user' => $canaries['admin_user'] ?? '', + ], + ]; + + $wpdb->insert($wpdb->prefix . 'webdecoy_detections', [ + 'ip_address' => $ip, + 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '', + 'score' => 100, + 'threat_level' => \WebDecoy\DetectionResult::THREAT_HIGH, + 'source' => 'wordpress_plugin', + 'flags' => wp_json_encode($flags_data), + 'created_at' => current_time('mysql'), + ]); + } + /** * 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 @@ -1341,6 +1408,56 @@ public function filter_comment(array $commentdata): array return $commentdata; } + /** + * Detect a login attempt using a canary credential — a fake value that could + * only have come from a decoy response we served. That's unambiguous + * exfiltration evidence: log a high-severity detection, block the IP, and + * reject the login. + * + * @param \WP_User|\WP_Error|null $user + * @param string $username + * @param string $password + * @return \WP_User|\WP_Error|null + */ + public function check_canary_login($user, string $username, string $password) + { + if ($username === '' && $password === '') { + return $user; + } + + if ( + WebDecoy_Decoy_Response::is_canary_credential($username) + || WebDecoy_Decoy_Response::is_canary_credential($password) + ) { + $ip = $this->get_client_ip(); + + global $wpdb; + $wpdb->insert($wpdb->prefix . 'webdecoy_detections', [ + 'ip_address' => $ip, + 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '', + 'score' => 100, + 'threat_level' => \WebDecoy\DetectionResult::THREAT_CRITICAL, + 'source' => 'wordpress_plugin', + 'flags' => wp_json_encode([ + 'flags' => ['canary_credential_use'], + 'metadata' => ['reason' => 'Login attempt with a decoy (canary) credential'], + ]), + 'created_at' => current_time('mysql'), + ]); + + $blocker = new WebDecoy_Blocker(); + $duration = $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null; + $blocker->block($ip, 'Canary credential use (decoy exfiltration)', $duration); + + return new \WP_Error( + 'webdecoy_canary', + __('Access denied.', 'webdecoy') + ); + } + + return $user; + } + /** * Check login attempt * @@ -1611,6 +1728,7 @@ public function sanitize_options(array $input): array $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']); + $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['filter_rules'] = $this->sanitize_filter_rules($input['filter_rules'] ?? []);