From 5a35271bc4d24ffbcf957d9c81130e7be948815c Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Tue, 18 Aug 2026 14:05:29 -0500 Subject: [PATCH] feat: surface the canary and email the admin when it trips (WebDecoy/app#679) Every zero-config install already arms a honeytoken canary; until now the owner could never see it (the hidden link skips logged-in users and the path appeared nowhere in admin) and a trip produced a database row and nothing else. Now: - the honeytoken settings section shows the canary URL with a 'Trip it now' link (and notes that an allowlisted IP's trip is ignored) - the Detections page empty state becomes the trip-it-yourself prompt - the dashboard widget offers the canary while there is nothing to show - a honeytoken trip emails the site admin (wp_mail, at most one per hour, canary_email_enabled default on, toggle in settings). Other tripwires stay email-silent: public scanners hit bait paths all day, but nothing legitimate ever touches the honeytoken. --- admin/partials/dashboard-widget.php | 17 ++++++ admin/partials/detections-page.php | 28 ++++++++- admin/partials/settings-page.php | 26 ++++++++ includes/class-webdecoy-canary-alert.php | 77 ++++++++++++++++++++++++ webdecoy.php | 22 +++++++ 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 includes/class-webdecoy-canary-alert.php diff --git a/admin/partials/dashboard-widget.php b/admin/partials/dashboard-widget.php index fc463ff..bf24f98 100644 --- a/admin/partials/dashboard-widget.php +++ b/admin/partials/dashboard-widget.php @@ -27,6 +27,23 @@ ?>
+ primary_path()); + ?> +

+ + + + +

+
diff --git a/admin/partials/detections-page.php b/admin/partials/detections-page.php index 7a32eca..3ca8f87 100644 --- a/admin/partials/detections-page.php +++ b/admin/partials/detections-page.php @@ -251,7 +251,33 @@ class="button ">
-

+ primary_path()); + } + ?> + +
+

+ +

+

+ +

+

+ + + + +

+
+ +

+ diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index 16aac73..104d9d6 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -310,6 +310,32 @@ + + + + + primary_path()); + ?> + + + + +
+ +
+ +

+ + + + +

+

diff --git a/includes/class-webdecoy-canary-alert.php b/includes/class-webdecoy-canary-alert.php new file mode 100644 index 0000000..7374221 --- /dev/null +++ b/includes/class-webdecoy-canary-alert.php @@ -0,0 +1,77 @@ + true, 'honeytoken_rotate' => false, // rotate the token daily (with grace) + // Email the admin when the honeytoken canary trips (app#679). + // Throttled to one per hour; the trip itself is the product working. + 'canary_email_enabled' => true, // WordPress-native traps. 'traps_fake_plugins' => true, // arm fake vulnerable-plugin paths (absent plugins only) @@ -1038,6 +1041,7 @@ public function load_includes(): void 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'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-canary-alert.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-cloud-connect.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-actor-intel.php'; @@ -1706,6 +1710,23 @@ private function log_rule_violations(array $violations): void { global $wpdb; + // Canary email (WebDecoy/app#679): a hit on the honeytoken path is the + // one trap nothing legitimate ever touches, so it emails the admin + // immediately, whether it was a bot that found the hidden link or the + // owner tripping it on purpose. Other tripwires (bait paths, fake + // plugins) stay email-silent: public scanners hit those all day. + $canary_paths = (new WebDecoy_Honeytoken(!empty($this->options['honeytoken_rotate'])))->active_paths(); + foreach ($violations as $violation) { + if (in_array($violation->path, $canary_paths, true)) { + WebDecoy_Canary_Alert::maybe_send( + (string) $violation->ip, + (string) $violation->path, + (string) ($violation->userAgent ?? '') + ); + break; + } + } + foreach ($violations as $violation) { $confidence = 100; if (is_array($violation->metadata) && isset($violation->metadata['confidence'])) { @@ -2394,6 +2415,7 @@ public function sanitize_options(array $input): array $sanitized['tripwire_response'] = in_array($input['tripwire_response'] ?? 'block', ['block', 'challenge', 'log', 'notfound', 'decoy', 'tarpit'], true) ? $input['tripwire_response'] : 'block'; $sanitized['honeytoken_enabled'] = !empty($input['honeytoken_enabled']); $sanitized['honeytoken_rotate'] = !empty($input['honeytoken_rotate']); + $sanitized['canary_email_enabled'] = !empty($input['canary_email_enabled']); $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']);