Skip to content

Commit aaaf8a8

Browse files
authored
Merge pull request #26 from WebDecoy/feat/deceptive-responses
feat: deceptive tripwire responses + canary credentials (#12)
2 parents 8d90ac9 + 47bdb16 commit aaaf8a8

4 files changed

Lines changed: 400 additions & 0 deletions

File tree

admin/partials/settings-page.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,30 @@
199199
</select>
200200
</td>
201201
</tr>
202+
<tr>
203+
<th scope="row">
204+
<label for="webdecoy_tripwire_response"><?php esc_html_e('Response', 'webdecoy'); ?></label>
205+
</th>
206+
<td>
207+
<select id="webdecoy_tripwire_response" name="webdecoy_options[tripwire_response]">
208+
<option value="block" <?php selected($options['tripwire_response'] ?? 'block', 'block'); ?>>
209+
<?php esc_html_e('Block — 403 Forbidden (default)', 'webdecoy'); ?>
210+
</option>
211+
<option value="notfound" <?php selected($options['tripwire_response'] ?? 'block', 'notfound'); ?>>
212+
<?php esc_html_e('Not Found — 404 (hide that anything is protected)', 'webdecoy'); ?>
213+
</option>
214+
<option value="decoy" <?php selected($options['tripwire_response'] ?? 'block', 'decoy'); ?>>
215+
<?php esc_html_e('Decoy — serve believable fake content with canary credentials', 'webdecoy'); ?>
216+
</option>
217+
<option value="tarpit" <?php selected($options['tripwire_response'] ?? 'block', 'tarpit'); ?>>
218+
<?php esc_html_e('Tarpit — slow-drip response to waste scanner time', 'webdecoy'); ?>
219+
</option>
220+
</select>
221+
<p class="description">
222+
<?php esc_html_e('Decoy mode serves fake .env / wp-config / SQL-dump / phpinfo content seeded with unique, per-site canary credentials — a later login attempt using one is logged as a critical exfiltration detection. Decoy/Not-Found/Tarpit deliberately keep feeding the scanner (no local IP block) to gather more evidence. Tarpit ties up a PHP worker for up to 10s — use sparingly.', 'webdecoy'); ?>
223+
</p>
224+
</td>
225+
</tr>
202226
<tr>
203227
<th scope="row"><?php esc_html_e('Dry Run', 'webdecoy'); ?></th>
204228
<td>

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* Added: Rule engine — deterministic rules evaluated before heuristic scoring; first DENY/THROTTLE wins, with dry-run (log without blocking). Parity with @webdecoy/node.
88
* 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.
99
* 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.
10+
* 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.
1011
* 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.
1112
* 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.
1213
* Added: JS execution verification — detects non-JS HTTP scrapers (e.g., Scrapling Fetcher, curl_cffi)
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if (!defined('ABSPATH')) {
6+
exit;
7+
}
8+
9+
/**
10+
* Deceptive tripwire responses (deception layer, beyond @webdecoy/node parity).
11+
*
12+
* WordPress owns the whole HTTP response, so a tripwire can *deceive* a scanner
13+
* instead of just denying it. A deceived scanner keeps digging (each fetch
14+
* another confidence-100 violation feeding enforcement) rather than pivoting
15+
* tools. Modes:
16+
*
17+
* - block : 403 (default; handled by the caller, not here)
18+
* - notfound: 404 — indistinguishable from an unprotected site
19+
* - decoy : 200 with believable fake content containing UNIQUE per-site
20+
* canary credentials
21+
* - tarpit : a slow-drip response that burns scanner time (bounded)
22+
*
23+
* Safety rails: decoy content is generated ENTIRELY from templates seeded by a
24+
* per-site secret — it never reads a real configuration value. If a template
25+
* can't be produced for the requested path, it fails closed (returns false so
26+
* the caller serves the normal 403).
27+
*
28+
* Canary credentials are deterministic per site and recomputable, so any later
29+
* *use* of one (e.g. a login attempt with the fake DB password) is attributable
30+
* evidence of exfiltration — see {@see is_canary_credential()}.
31+
*/
32+
class WebDecoy_Decoy_Response
33+
{
34+
/** Max wall-clock seconds a tarpit will hold a connection. */
35+
private const TARPIT_MAX_SECONDS = 10;
36+
37+
/**
38+
* Per-site canary secret (lazily created). Distinct from other secrets so
39+
* canaries can't be derived from an unrelated leaked value.
40+
*/
41+
private static function secret(): string
42+
{
43+
$secret = get_option('webdecoy_canary_secret', '');
44+
if (!is_string($secret) || $secret === '') {
45+
$secret = bin2hex(random_bytes(16));
46+
add_option('webdecoy_canary_secret', $secret, '', 'yes');
47+
}
48+
return $secret;
49+
}
50+
51+
private static function derive(string $label, int $len = 16): string
52+
{
53+
return substr(hash_hmac('sha256', $label, self::secret()), 0, $len);
54+
}
55+
56+
/**
57+
* The full set of canary credentials for this site (deterministic).
58+
*
59+
* @return array<string,string>
60+
*/
61+
public static function canaries(): array
62+
{
63+
return [
64+
'db_name' => 'wp_' . self::derive('db_name', 6),
65+
'db_user' => 'wpuser_' . self::derive('db_user', 6),
66+
'db_password' => 'Wd' . self::derive('db_password', 20),
67+
'db_host' => 'localhost',
68+
'auth_key' => self::derive('auth_key', 40),
69+
'admin_user' => 'admin_' . self::derive('admin_user', 6),
70+
'admin_password' => 'Wd' . self::derive('admin_password', 18),
71+
'aws_key' => 'AKIA' . strtoupper(self::derive('aws_key', 16)),
72+
'aws_secret' => self::derive('aws_secret', 40),
73+
];
74+
}
75+
76+
/**
77+
* Is a submitted credential value one of this site's canaries? A match means
78+
* the value could only have come from a decoy we served — strong exfil
79+
* evidence. Compared with hash_equals to avoid timing leaks.
80+
*/
81+
public static function is_canary_credential(string $value): bool
82+
{
83+
if ($value === '') {
84+
return false;
85+
}
86+
foreach (self::canaries() as $canary) {
87+
if (hash_equals($canary, $value)) {
88+
return true;
89+
}
90+
}
91+
return false;
92+
}
93+
94+
/**
95+
* Serve a deceptive response for a tripwire hit and exit. Returns false
96+
* (without emitting anything) when the mode is 'block' or when a decoy can't
97+
* be produced for this path — the caller then serves the normal 403.
98+
*
99+
* @return bool false = fall back to the default block
100+
*/
101+
public function serve(string $path, string $mode): bool
102+
{
103+
if ($mode === 'notfound') {
104+
$this->serve_404();
105+
return true; // exits
106+
}
107+
108+
if ($mode === 'tarpit') {
109+
$this->serve_tarpit();
110+
return true; // exits
111+
}
112+
113+
if ($mode === 'decoy') {
114+
$content = $this->decoy_for($path);
115+
if ($content === null) {
116+
return false; // no believable template — fail closed to 403
117+
}
118+
$this->serve_body($content['body'], $content['type']);
119+
return true; // exits
120+
}
121+
122+
return false; // 'block' or unknown — caller handles
123+
}
124+
125+
/**
126+
* Build believable fake content for a known bait path, embedding canaries.
127+
* Returns null when the path has no template (caller falls back to 403).
128+
*
129+
* @return array{body:string,type:string}|null
130+
*/
131+
private function decoy_for(string $path): ?array
132+
{
133+
$p = strtolower($path);
134+
$c = self::canaries();
135+
136+
// Fake .env
137+
if (substr($p, -4) === '.env' || strpos($p, '/.env') !== false) {
138+
$body = "APP_ENV=production\n"
139+
. "APP_DEBUG=false\n"
140+
. "APP_KEY=base64:" . base64_encode($c['auth_key']) . "\n"
141+
. "DB_CONNECTION=mysql\n"
142+
. "DB_HOST={$c['db_host']}\n"
143+
. "DB_PORT=3306\n"
144+
. "DB_DATABASE={$c['db_name']}\n"
145+
. "DB_USERNAME={$c['db_user']}\n"
146+
. "DB_PASSWORD={$c['db_password']}\n"
147+
. "AWS_ACCESS_KEY_ID={$c['aws_key']}\n"
148+
. "AWS_SECRET_ACCESS_KEY={$c['aws_secret']}\n";
149+
return ['body' => $body, 'type' => 'text/plain'];
150+
}
151+
152+
// Fake wp-config backup
153+
if (strpos($p, 'wp-config') !== false) {
154+
$body = "<?php\n"
155+
. "// WordPress configuration\n"
156+
. "define('DB_NAME', '{$c['db_name']}');\n"
157+
. "define('DB_USER', '{$c['db_user']}');\n"
158+
. "define('DB_PASSWORD', '{$c['db_password']}');\n"
159+
. "define('DB_HOST', '{$c['db_host']}');\n"
160+
. "define('AUTH_KEY', '{$c['auth_key']}');\n"
161+
. "\$table_prefix = 'wp_';\n";
162+
// Serve as text/plain so it isn't executed anywhere and is readable.
163+
return ['body' => $body, 'type' => 'text/plain'];
164+
}
165+
166+
// Fake SQL dump
167+
if (substr($p, -4) === '.sql' || strpos($p, 'backup') !== false || strpos($p, 'dump') !== false) {
168+
$body = "-- MySQL dump\n"
169+
. "-- Host: {$c['db_host']} Database: {$c['db_name']}\n"
170+
. "CREATE TABLE `wp_users` (\n"
171+
. " `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n"
172+
. " `user_login` varchar(60) NOT NULL,\n"
173+
. " `user_pass` varchar(255) NOT NULL,\n"
174+
. " PRIMARY KEY (`ID`)\n"
175+
. ") ENGINE=InnoDB;\n"
176+
. "INSERT INTO `wp_users` VALUES "
177+
. "(1,'{$c['admin_user']}','\$P\$B" . self::derive('pw_hash', 30) . "');\n";
178+
return ['body' => $body, 'type' => 'text/plain'];
179+
}
180+
181+
// Fake phpinfo
182+
if (strpos($p, 'phpinfo') !== false) {
183+
$body = "<!DOCTYPE html><html><head><title>phpinfo()</title></head><body>"
184+
. "<h1>PHP Version 7.4.33</h1>"
185+
. "<table><tr><td>System</td><td>Linux web01 5.4.0</td></tr>"
186+
. "<tr><td>DOCUMENT_ROOT</td><td>/var/www/html</td></tr>"
187+
. "<tr><td>DB_USER</td><td>{$c['db_user']}</td></tr></table>"
188+
. "</body></html>";
189+
return ['body' => $body, 'type' => 'text/html'];
190+
}
191+
192+
return null; // no believable template for this path
193+
}
194+
195+
/**
196+
* The canaries served for a given path (for recording in detection metadata).
197+
* Empty when the path has no decoy template.
198+
*
199+
* @return array<string,string>
200+
*/
201+
public function served_canaries(string $path): array
202+
{
203+
return $this->decoy_for($path) === null ? [] : self::canaries();
204+
}
205+
206+
private function serve_404(): void
207+
{
208+
nocache_headers();
209+
status_header(404);
210+
header('Content-Type: text/html; charset=UTF-8');
211+
echo '<!DOCTYPE html><html><head><title>404 Not Found</title></head><body><h1>Not Found</h1><p>The requested URL was not found on this server.</p></body></html>';
212+
exit;
213+
}
214+
215+
private function serve_body(string $body, string $type): void
216+
{
217+
nocache_headers();
218+
status_header(200);
219+
header('Content-Type: ' . $type . '; charset=UTF-8');
220+
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- template content, not user input
221+
echo $body;
222+
exit;
223+
}
224+
225+
/**
226+
* Slow-drip response to burn scanner time, streamed and bounded. Ties up a
227+
* PHP worker for up to TARPIT_MAX_SECONDS — hence off by default and
228+
* documented as such.
229+
*/
230+
private function serve_tarpit(): void
231+
{
232+
nocache_headers();
233+
status_header(200);
234+
header('Content-Type: text/html; charset=UTF-8');
235+
236+
// Cap by both our limit and any configured max_execution_time headroom.
237+
$maxExec = (int) ini_get('max_execution_time');
238+
$budget = self::TARPIT_MAX_SECONDS;
239+
if ($maxExec > 0) {
240+
$budget = min($budget, max(1, $maxExec - 2));
241+
}
242+
243+
echo '<!DOCTYPE html><html><head><title>Loading…</title></head><body>';
244+
$start = time();
245+
$i = 0;
246+
while ((time() - $start) < $budget) {
247+
echo '<!-- ' . str_repeat('.', 8) . " {$i} -->\n";
248+
if (function_exists('flush')) {
249+
@flush(); // phpcs:ignore
250+
}
251+
$i++;
252+
usleep(500000); // 0.5s between drips
253+
}
254+
echo '</body></html>';
255+
exit;
256+
}
257+
}

0 commit comments

Comments
 (0)