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
11 changes: 11 additions & 0 deletions admin/partials/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,17 @@
</label>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Honeytoken Coupon', 'webdecoy'); ?></th>
<td>
<label>
<input type="checkbox" name="webdecoy_options[woo_honeytoken_coupons]" value="1"
<?php checked($options['woo_honeytoken_coupons'] ?? true); ?> />
<?php esc_html_e('Plant a hidden decoy coupon code', 'webdecoy'); ?>
</label>
<p class="description"><?php esc_html_e('A fake promo code is placed on cart/checkout pages where coupon-scraping bots look, but hidden from human shoppers. Applying it is a deterministic bot signal — recorded and (per your blocking setting) blocked. Zero false positives: no human ever sees the code.', 'webdecoy'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Velocity Limit', 'webdecoy'); ?></th>
<td>
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: WooCommerce honeytoken coupons — a hidden decoy promo code is planted on cart/checkout pages where coupon-scraping bots look but no human ever sees it. Applying it (classic checkout or Blocks/Store API) is a deterministic bot signal: recorded and, per your blocking setting, blocked. Optional daily rotation with a grace window; real coupons and shoppers are never affected.
* 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.
Expand Down
101 changes: 101 additions & 0 deletions includes/class-webdecoy-woocommerce.php
Original file line number Diff line number Diff line change
Expand Up @@ -518,8 +518,109 @@ public function get_suspicious_ips(int $threshold = 3): array
$threshold
), ARRAY_A) ?: [];
}

/**
* The honeytoken coupon code for this site — a fake promo code planted where
* coupon-scraping bots look but no human ever sees. Deterministic from a
* per-site secret; optionally rotates daily. Applying it is, by
* construction, an automated action.
*/
public function honeytoken_coupon(bool $rotate = false): string
{
$secret = get_option('webdecoy_coupon_secret', '');
if (!is_string($secret) || $secret === '') {
$secret = bin2hex(random_bytes(16));
add_option('webdecoy_coupon_secret', $secret, '', 'yes');
}
$label = $rotate ? ('day:' . gmdate('Y-m-d')) : 'stable';
return 'WD' . strtoupper(substr(hash_hmac('sha256', $label, $secret), 0, 8));
}

/**
* Hidden markup exposing the honeytoken coupon to page scrapers only. Placed
* in an HTML comment plus an offscreen, aria-hidden node so it never appears
* to a human or in the accessibility tree.
*/
public function render_coupon_bait(): void
{
if (empty($this->options['woo_honeytoken_coupons'])) {
return;
}
$code = $this->honeytoken_coupon(!empty($this->options['honeytoken_rotate']));
echo "\n<!-- promo code: " . esc_html($code) . " -->\n";
echo '<div aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">'
. '<span class="wd-promo" data-coupon="' . esc_attr($code) . '">' . esc_html($code) . '</span></div>' . "\n";
}

/**
* Coupon-load filter: if the code being applied is our honeytoken, record a
* detection (and optionally block), then report it invalid. Runs for both
* classic checkout and the Store API / Blocks, since both load coupons via
* WC_Coupon → this filter. Real coupons are untouched.
*
* @param mixed $data Coupon data (false when no matching coupon post).
* @param string $code The coupon code being looked up.
* @return mixed
*/
public function catch_coupon($data, string $code)
{
if (empty($this->options['woo_honeytoken_coupons'])) {
return $data;
}

$canary = $this->honeytoken_coupon(!empty($this->options['honeytoken_rotate']));
// Also honor the previous day's code during rotation grace.
$prev = null;
if (!empty($this->options['honeytoken_rotate'])) {
$secret = get_option('webdecoy_coupon_secret', '');
if (is_string($secret) && $secret !== '') {
$prev = 'WD' . strtoupper(substr(hash_hmac('sha256', 'day:' . gmdate('Y-m-d', time() - DAY_IN_SECONDS), $secret), 0, 8));
}
}

$normalized = strtoupper(trim($code));
if ($normalized === $canary || ($prev !== null && $normalized === $prev)) {
$ip = $this->get_client_ip();
$this->log_detection($ip, 'honeytoken_coupon', 100);

if (($this->options['block_action'] ?? 'block') === 'block') {
$this->blocker->block(
$ip,
'Honeytoken coupon applied',
($this->options['block_duration'] ?? 24) > 0 ? $this->options['block_duration'] : null
);
}

return false; // reject as a non-existent coupon
}

return $data;
}
}

// Honeytoken coupons: plant a hidden fake code and catch anyone who applies it.
add_action('woocommerce_before_cart', function () {
$options = get_option('webdecoy_options', []);
if (empty($options['protect_checkout']) || empty($options['woo_honeytoken_coupons'])) {
return;
}
(new WebDecoy_WooCommerce($options))->render_coupon_bait();
});
add_action('woocommerce_before_checkout_form', function () {
$options = get_option('webdecoy_options', []);
if (empty($options['protect_checkout']) || empty($options['woo_honeytoken_coupons'])) {
return;
}
(new WebDecoy_WooCommerce($options))->render_coupon_bait();
});
add_filter('woocommerce_get_shop_coupon_data', function ($data, $code) {
$options = get_option('webdecoy_options', []);
if (empty($options['protect_checkout']) || empty($options['woo_honeytoken_coupons'])) {
return $data;
}
return (new WebDecoy_WooCommerce($options))->catch_coupon($data, (string) $code);
}, 10, 2);

// Hook into WooCommerce payment completion - track success
add_action('woocommerce_payment_complete', function ($order_id) {
$options = get_option('webdecoy_options', []);
Expand Down
4 changes: 4 additions & 0 deletions webdecoy.php
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@
'protect_checkout' => true,
'checkout_velocity_limit' => 5,
'checkout_velocity_window' => 3600,
// Plant a hidden honeytoken coupon; applying it is a deterministic
// bot signal (no human ever sees the code).
'woo_honeytoken_coupons' => true,

// Client-side Scanner
'scanner_enabled' => true,
Expand Down Expand Up @@ -1108,7 +1111,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 1114 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 @@ -1230,7 +1233,7 @@
],
];

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

Check warning on line 1236 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 @@ -1271,7 +1274,7 @@
),
];

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

Check warning on line 1277 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 @@ -1335,7 +1338,7 @@
*/
private function is_challenge_verified(string $ip): bool
{
$cookie = isset($_COOKIE['webdecoy_verified']) ? sanitize_text_field($_COOKIE['webdecoy_verified']) : '';

Check failure on line 1341 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 @@ -1409,9 +1412,9 @@
'metadata' => $result->getMetadata(),
];

$wpdb->insert($table, [

Check warning on line 1415 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 1417 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 @@ -1511,7 +1514,7 @@
$ip = $this->get_client_ip();

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

Check warning on line 1517 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 @@ -1641,7 +1644,7 @@
{
$honeypot_name = 'webdecoy_hp_' . $context;

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

Check failure on line 1647 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Processing form data without nonce verification.

Check failure on line 1647 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 @@ -1834,6 +1837,7 @@
$sanitized['protect_checkout'] = !empty($input['protect_checkout']);
$sanitized['checkout_velocity_limit'] = max(1, intval($input['checkout_velocity_limit'] ?? 5));
$sanitized['checkout_velocity_window'] = max(60, intval($input['checkout_velocity_window'] ?? 3600));
$sanitized['woo_honeytoken_coupons'] = !empty($input['woo_honeytoken_coupons']);

// Proof-of-Work
$sanitized['pow_enabled'] = !empty($input['pow_enabled']);
Expand Down Expand Up @@ -2100,7 +2104,7 @@
}

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

Check failure on line 2107 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 @@ -2195,7 +2199,7 @@
$threat_level = 'LOW';
}

$wpdb->insert($table, [

Check warning on line 2202 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 @@ -2272,7 +2276,7 @@
return;
}

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

Check failure on line 2279 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 @@ -2347,8 +2351,8 @@
return;
}

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

Check failure on line 2354 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 2355 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 @@ -2374,7 +2378,7 @@
return;
}

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

Check failure on line 2381 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 @@ -2774,7 +2778,7 @@
return $transient;
}

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

Check failure on line 2781 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