From d9d1c9e72408d4b7f3c14493e1bd6a1b2c9e2a36 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sat, 22 Aug 2026 18:25:52 -0500 Subject: [PATCH 1/3] fix: the last four places we still promise things we do not do #476 withdrew blocking from the cross-site actor feed after measuring it against production: 2 of 4,866 addresses were ever seen at more than one site, 93% of attacker addresses were gone inside an hour, and 82% of feed entries were already a week stale with none still active. Blocking an abandoned address does not stop the attacker, it stops whoever holds it now. purge_feed_blocks() deletes the rows earlier versions wrote, and the two connected variants of the CRITICAL notice were rewritten with a comment above them saying not to claim a block. The unconnected variant went on offering "to block threats like it automatically" for another three weeks, four lines below that comment. A comment asking the next person not to do something is not a guard, so there is now a test: it reads every translatable string in the file and fails on one that promises to block, prevent or stop anything. Verified by putting the sentence back. The listing on wordpress.org had two more. It sold "email notifications", which the API refuses (#702) because decoys are public bait and a busy site records thousands of hits a day; email is the monthly report. And it said attackers get pushed to Cloudflare or AWS WAF without saying that happens in the dashboard, which reads, in a plugin readme, like something the plugin does. Both corrected; the WAF claim keeps its substance and gains its location. "Start Free Trial" is now "Create a free account", because there is no trial on this channel: connecting is free and stays free. The settings upsell claimed "email alert automation" for the same reason and is now the webhook plus the monthly report. No behaviour changes, so no version bump. The readme correction is live on wordpress.org only after an SVN sync, which is a separate deliberate step. Refs #759, #476, #702 --- admin/partials/settings-page.php | 6 +++- includes/class-webdecoy-critical-moment.php | 7 +++- readme.txt | 8 ++--- tests/CriticalMomentTest.php | 40 +++++++++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index f3897a6..8dd04ad 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -856,7 +856,11 @@
  • -
  • + +
  • diff --git a/includes/class-webdecoy-critical-moment.php b/includes/class-webdecoy-critical-moment.php index bbeb97c..928fd6e 100644 --- a/includes/class-webdecoy-critical-moment.php +++ b/includes/class-webdecoy-critical-moment.php @@ -217,7 +217,12 @@ private function build_notice(string $ip): array case 'unconnected': default: return [ - 'message' => __('A CRITICAL deception trap was just tripped. Connect to WebDecoy Cloud to see whether this attacker is already known across the network — and to block threats like it automatically.', 'webdecoy'), + // Was "and to block threats like it automatically". Connecting does + // not block anything: the cross-site feed is advisory on every plan + // and writes nothing to the block list (#476). The unconnected pitch + // was the last place in this file still promising it, three variants + // below a comment forbidding exactly that claim. + 'message' => __('A CRITICAL deception trap was just tripped. Connect to WebDecoy Cloud to see whether this attacker is already known across the network, and what it has been doing to other sites.', 'webdecoy'), 'cta_label' => __('Connect to WebDecoy Cloud', 'webdecoy'), 'cta_url' => admin_url('admin.php?page=webdecoy&tab=cloud'), 'type' => 'warning', diff --git a/readme.txt b/readme.txt index 97e4729..7bdd2d1 100644 --- a/readme.txt +++ b/readme.txt @@ -148,16 +148,16 @@ And because **monitor mode is the default**, baking WebDecoy into your boilerpla Connect an API key to unlock cloud-powered intelligence: -* **WAF Integrations**: arm your existing edge. Push confirmed attackers to Cloudflare or AWS WAF so they're blocked before a request ever reaches WordPress, plus webhooks for any other firewall +* **WAF Integrations**: arm your existing edge. From your WebDecoy dashboard, push confirmed attackers to Cloudflare or AWS WAF so they're blocked before a request ever reaches WordPress, plus webhooks for any other firewall * **IP Reputation**: AbuseIPDB integration, threat scoring * **VPN/Proxy Detection**: identify visitors hiding behind VPNs, proxies, and Tor * **GeoIP Enrichment**: geographic data from MaxMind * **Cloud Sync**: forward detections to a centralized dashboard * **Cross-Site Intelligence**: aggregate threat data from all WebDecoy customers * **Advanced Analytics**: cloud dashboard at app.webdecoy.com with indefinite history -* **Webhooks & Alerts**: automated response chains, email notifications +* **Webhooks & Alerts**: automated response chains, plus a monthly email report of what was caught -[Explore Plans](https://webdecoy.com/pricing) | [Start Free Trial](https://app.webdecoy.com/register) +[Explore Plans](https://webdecoy.com/pricing) | [Create a free account](https://app.webdecoy.com/register) = Threat Scoring = @@ -212,7 +212,7 @@ Firewalls match requests against known-bad signatures, and scanners look for inf = What does WebDecoy Cloud add? = -WebDecoy Cloud adds threat intelligence feeds (AbuseIPDB, VPNAPI, MaxMind), centralized monitoring across multiple sites, indefinite detection history, automated response features like webhooks and email alerts, and WAF integrations: confirmed attackers can be pushed to Cloudflare or AWS WAF so they're blocked at the edge, before ever reaching your server. [Compare plans](https://webdecoy.com/pricing). +WebDecoy Cloud adds threat intelligence feeds (AbuseIPDB, VPNAPI, MaxMind), centralized monitoring across multiple sites, indefinite detection history, a monthly email report, webhook automation, and WAF integrations: from your dashboard, confirmed attackers can be pushed to Cloudflare or AWS WAF so they're blocked at the edge, before ever reaching your server. [Compare plans](https://webdecoy.com/pricing). = Does WebDecoy slow down my site? = diff --git a/tests/CriticalMomentTest.php b/tests/CriticalMomentTest.php index a835a11..827e310 100644 --- a/tests/CriticalMomentTest.php +++ b/tests/CriticalMomentTest.php @@ -56,3 +56,43 @@ $same('connected_upgrade', WebDecoy_Critical_Moment::variant(true, true, false), 'known + no feed -> upgrade pitch'); $same('connected_covered', WebDecoy_Critical_Moment::variant(true, true, true), 'known + feed -> confirmation copy'); }); + +echo "\nCritical Moment: the copy must not promise a block\n"; + +/** + * The four message variants are built inside a method that calls WordPress, so + * they cannot be invoked here. Their text can still be read. + * + * That is worth doing because this exact claim has now been removed from this + * file twice. #476 measured the cross-site feed and withdrew blocking from it: + * 0.04% of addresses were ever seen at a second site, 82% of feed entries were + * already a week stale, and none were still active. The connected variants were + * corrected then, with a comment above them saying not to claim it. The + * unconnected variant kept promising "to block threats like it automatically" + * for another three weeks, four lines below that comment. + * + * A comment asking the next person not to do something is not a guard. This is. + */ +$t('no translatable string in the file offers to block anything', function () use ($true) { + $src = file_get_contents(dirname(__DIR__) . '/includes/class-webdecoy-critical-moment.php'); + $true($src !== false, 'source is readable'); + + // Only the translated strings: comments in this file discuss blocking at + // length, and must be free to keep doing so. + preg_match_all("/(?:__|_e|esc_html__|esc_html_e)\(\s*'((?:[^'\\\\]|\\\\.)*)'/", $src, $singles); + preg_match_all("/_n\(\s*'((?:[^'\\\\]|\\\\.)*)'\s*,\s*'((?:[^'\\\\]|\\\\.)*)'/", $src, $plurals); + + $strings = array_merge($singles[1], $plurals[1], $plurals[2]); + $true(count($strings) >= 4, 'found the message strings (' . count($strings) . ')'); + + foreach ($strings as $text) { + $lower = strtolower($text); + foreach (['block', 'prevent', 'stop them'] as $promise) { + $true( + strpos($lower, $promise) === false, + 'copy promises "' . $promise . '" — the feed is advisory on every plan and ' + . 'writes nothing to the block list (#476): ' . $text + ); + } + } +}); From 2818dccb5418d246b92a81461d6f0958821d521b Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sat, 22 Aug 2026 19:02:54 -0500 Subject: [PATCH 2/3] feat(cloud): show whether alerts are on, and where to configure them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alerts entitlement is served to every paid plan and the plugin has never read it. A site that upgraded got a flag it could not see and a feature it could not find. The Cloud tab now says which it is. On Pro: alerts are on, with a link to the dashboard where they are configured. On the free tier: what they are and that detection, blocking and the monthly report stay free, because the answer to "what do I lose by not paying" should be on the same line as the pitch. The plugin still sends nothing itself. That is deliberate and is the PRD's §9 guardrail: an entitlement check may gate a cloud response and never local behaviour. Everything this plugin does on its own keeps working on every plan, including with no account at all. Refs WebDecoy/app#762 --- admin/css/webdecoy-admin.css | 7 +++++-- admin/partials/settings-page.php | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/admin/css/webdecoy-admin.css b/admin/css/webdecoy-admin.css index f1f9af3..f14b2c5 100644 --- a/admin/css/webdecoy-admin.css +++ b/admin/css/webdecoy-admin.css @@ -1022,7 +1022,8 @@ white-space: nowrap; } -.webdecoy-digest-status { +.webdecoy-digest-status, +.webdecoy-alerts-status { display: flex; align-items: center; gap: 6px; @@ -1030,10 +1031,12 @@ margin: 16px 0 0; } -.webdecoy-digest-status .dashicons { +.webdecoy-digest-status .dashicons, +.webdecoy-alerts-status .dashicons { font-size: 18px; width: 18px; height: 18px; + flex-shrink: 0; } .webdecoy-connected-actions { diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index 8dd04ad..1bc2ae6 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -804,6 +804,7 @@ $wd_plan_slug = isset($options['plan']) ? (string) $options['plan'] : ''; $wd_plan_label = WebDecoy_Cloud_Connect::plan_label($wd_plan_slug); $wd_digest_on = !empty($wd_entitlements['digest']['enabled']); + $wd_alerts_on = !empty($wd_entitlements['features']['alerts']); ?>