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
17 changes: 17 additions & 0 deletions admin/partials/dashboard-widget.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@
?>

<div class="webdecoy-widget">
<?php
// Trip-it-yourself prompt (app#679): shown only while the widget has
// nothing to report — once real detections exist, the numbers speak.
$webdecoy_dw_options = get_option('webdecoy_options', []);
if (
empty($stats['total'])
&& (!isset($webdecoy_dw_options['honeytoken_enabled']) || !empty($webdecoy_dw_options['honeytoken_enabled']))
) :
$webdecoy_dw_canary = home_url((new WebDecoy_Honeytoken(!empty($webdecoy_dw_options['honeytoken_rotate'])))->primary_path());
?>
<p>
<?php esc_html_e('Your canary is live. Trip it yourself and watch the detection arrive:', 'webdecoy'); ?>
<a href="<?php echo esc_url($webdecoy_dw_canary); ?>" target="_blank" rel="noopener">
<?php esc_html_e('open your canary', 'webdecoy'); ?>
</a>
</p>
<?php endif; ?>
<div class="webdecoy-widget-stats">
<div class="webdecoy-stat">
<span class="webdecoy-stat-value"><?php echo esc_html(number_format($stats['total'])); ?></span>
Expand Down
28 changes: 27 additions & 1 deletion admin/partials/detections-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,33 @@ class="button <?php echo empty($active_range) ? 'button-primary' : ''; ?>">
</div>

<?php if (empty($detections)) : ?>
<p><?php esc_html_e('No detections found.', 'webdecoy'); ?></p>
<?php
// Trip-it-yourself empty state (app#679): a fresh install already has a
// live canary; the fastest proof it works is tripping it on purpose.
$webdecoy_ed_options = get_option('webdecoy_options', []);
$webdecoy_ed_canary = '';
if (!isset($webdecoy_ed_options['honeytoken_enabled']) || !empty($webdecoy_ed_options['honeytoken_enabled'])) {
$webdecoy_ed_canary = home_url((new WebDecoy_Honeytoken(!empty($webdecoy_ed_options['honeytoken_rotate'])))->primary_path());
}
?>
<?php if ($webdecoy_ed_canary !== '') : ?>
<div class="notice notice-info inline">
<p>
<strong><?php esc_html_e('Nothing here yet. Trip your canary and watch this page catch it.', 'webdecoy'); ?></strong>
</p>
<p>
<?php esc_html_e('Your site already has a live canary: a secret path behind an invisible link that nothing legitimate ever requests. Open it and this page records the hit, exactly as it would for a real bot. The alert email lands in your admin inbox too.', 'webdecoy'); ?>
</p>
<p>
<a href="<?php echo esc_url($webdecoy_ed_canary); ?>" target="_blank" rel="noopener" class="button button-primary">
<?php esc_html_e('Trip your canary', 'webdecoy'); ?>
</a>
<code><?php echo esc_html($webdecoy_ed_canary); ?></code>
</p>
</div>
<?php else : ?>
<p><?php esc_html_e('No detections found.', 'webdecoy'); ?></p>
<?php endif; ?>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
Expand Down
26 changes: 26 additions & 0 deletions admin/partials/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,32 @@
</label>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Email on Trip', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[canary_email_enabled]" value="1"
<?php checked($options['canary_email_enabled'] ?? true); ?> />
<?php esc_html_e('Email the site admin when the canary trips (at most one email per hour)', 'webdecoy'); ?>
</label>
</td>
</tr>
<?php if (!empty($options['honeytoken_enabled'])) :
$webdecoy_canary_url = home_url((new WebDecoy_Honeytoken(!empty($options['honeytoken_rotate'])))->primary_path());
?>
<tr>
<th scope="row"><?php esc_html_e('Your Canary', 'webdecoy'); ?></th>
<td>
<code><?php echo esc_html($webdecoy_canary_url); ?></code>
<p class="description">
<?php esc_html_e('This is the secret path behind the hidden link. Open it yourself in a new tab: the detection appears on the Detections page, and the alert email arrives. Tripping your own canary is the fastest proof the whole pipeline works. (If your IP is on the allowlist, the trip is ignored.)', 'webdecoy'); ?>
<a href="<?php echo esc_url($webdecoy_canary_url); ?>" target="_blank" rel="noopener">
<?php esc_html_e('Trip it now', 'webdecoy'); ?>
</a>
</p>
</td>
</tr>
<?php endif; ?>
</table>

<h3><?php esc_html_e('WordPress Traps', 'webdecoy'); ?></h3>
Expand Down
77 changes: 77 additions & 0 deletions includes/class-webdecoy-canary-alert.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php
/**
* Canary trip email (WebDecoy/app#679).
*
* The honeytoken path is a canary: nothing legitimate ever requests it, so a
* hit is worth telling the site owner about immediately. Until now a trip
* produced a database row and nothing anyone would notice. This emails the
* admin, canarytokens-style: place the token, trip it yourself, and the real
* alert arrives. Works with zero cloud connection.
*
* Throttled to one email per hour: the first trip in a burst is the story,
* and a scanner walking the site must not turn the inbox into a log file.
*
* @package WebDecoy
*/

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

class WebDecoy_Canary_Alert
{
private const THROTTLE_TRANSIENT = 'webdecoy_canary_email_sent';
private const THROTTLE_SECONDS = HOUR_IN_SECONDS;

/**
* Send the canary email if enabled and not throttled.
*
* @param string $ip Source IP of the trip.
* @param string $path The canary path that was hit.
* @param string $ua User agent of the trip.
*/
public static function maybe_send(string $ip, string $path, string $ua): void
{
$options = get_option('webdecoy_options', []);
$enabled = !isset($options['canary_email_enabled']) || !empty($options['canary_email_enabled']);
if (!$enabled) {
return;
}

if (get_transient(self::THROTTLE_TRANSIENT)) {
return;
}
set_transient(self::THROTTLE_TRANSIENT, time(), self::THROTTLE_SECONDS);

$to = get_option('admin_email');
if (!$to || !is_email($to)) {
return;
}

$site = wp_parse_url(home_url(), PHP_URL_HOST);
$subject = sprintf(
/* translators: %s: site hostname */
__('[WebDecoy] Your canary tripped on %s', 'webdecoy'),
$site
);

$detections_url = admin_url('admin.php?page=webdecoy-detections');
$body = sprintf(
/* translators: 1: canary path, 2: source IP, 3: user agent, 4: UTC time, 5: detections page URL */
__(
"Something requested your canary path. Nothing legitimate ever does, so this is either a bot that found your hidden link, or you testing the alert pipeline. Either way: it works.\n\n" .
"Path: %1\$s\nSource IP: %2\$s\nUser agent: %3\$s\nTime: %4\$s (UTC)\n\n" .
"See the detection: %5\$s\n\n" .
"You get at most one of these emails per hour. Disable them under WebDecoy settings, Tripwires tab.",
'webdecoy'
),
$path,
$ip,
$ua !== '' ? $ua : __('(none)', 'webdecoy'),
gmdate('Y-m-d H:i:s'),
$detections_url
);

wp_mail($to, $subject, $body);
}
}
22 changes: 22 additions & 0 deletions webdecoy.php
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@
// ever hit it — deterministic, zero false positives. On by default.
'honeytoken_enabled' => 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)
Expand Down Expand Up @@ -1038,6 +1041,7 @@
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';

Expand Down Expand Up @@ -1684,7 +1688,7 @@
],
];

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

Check warning on line 1691 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 All @@ -1706,6 +1710,23 @@
{
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'])) {
Expand All @@ -1725,7 +1746,7 @@
),
];

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

Check warning on line 1749 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 @@ -1875,7 +1896,7 @@
'metadata' => $result->getMetadata(),
];

$wpdb->insert($table, [

Check warning on line 1899 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' => $result->getScore(),
Expand Down Expand Up @@ -1956,7 +1977,7 @@
// Local log, inlined rather than via log_detection(): that path also
// queues the critical-moment alert for CRITICAL rows, and a test must
// not page anyone.
$wpdb->insert($wpdb->prefix . 'webdecoy_detections', [

Check warning on line 1980 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' => $result->getScore(),
Expand Down Expand Up @@ -2050,7 +2071,7 @@
$ip = $this->get_client_ip();

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

Check warning on line 2074 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 @@ -2394,6 +2415,7 @@
$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']);
Expand Down Expand Up @@ -2786,7 +2808,7 @@
$threat_level = 'LOW';
}

$wpdb->insert($table, [

Check warning on line 2811 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
Loading