diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php
index 9575beb..16aac73 100644
--- a/admin/partials/settings-page.php
+++ b/admin/partials/settings-page.php
@@ -500,11 +500,24 @@
+
+
+
+ WEBDECOY_DEFAULT_MODE'
+ );
+ ?>
+
+
diff --git a/changelog.txt b/changelog.txt
index 8dee742..fc42b5a 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,10 @@
*** WebDecoy Bot Detection Changelog ***
+= 2.5.0 - 2026-08-17 =
+* Added: a wp webdecoy WP-CLI command for agency deploy scripts. wp webdecoy status reports mode, cloud connection, detection counts, active blocks, and retention. wp webdecoy config list/get/set covers every safe setting, including config set mode monitor|block. wp webdecoy allowlist add|remove|list manages the IP allowlist (whitelist your agency VPN across every client site in one loop). wp webdecoy logs flush clears the local detection log.
+* Added: wp-config.php constants for code-locked configuration. WEBDECOY_DEFAULT_MODE ('monitor' or 'block') forces the mode, overrides the stored setting, and locks the admin toggle; a settings save while forced can no longer silently drift the stored mode. WEBDECOY_HIDE_ADMIN_UI hides the WebDecoy menu, dashboard widget, and admin notices for white-label installs (the plugin stays visible in the Plugins list on purpose). WEBDECOY_MAX_LOG_RETENTION overrides the 30-day detection retention (1 to 3650 days).
+* Changed: an unrecognized WEBDECOY_DEFAULT_MODE value is ignored rather than guessed. Forcing 'block' on a typo would enforce on a site that asked to watch; forcing 'monitor' would disarm one that asked to enforce.
+
= 2.4.1 - 2026-08-17 =
* Fixed: cloud features switch on immediately after one-click connect. The connection itself succeeded, but the premium status stayed off until a later background revalidation, so the JS verification token and cloud reporting were silently inactive at the exact moment you had just connected.
* Fixed: the dashboard widget's "Learn more" link landed on the Protection tab instead of the WebDecoy Cloud tab.
diff --git a/includes/class-webdecoy-activator.php b/includes/class-webdecoy-activator.php
index 09445e6..412cc16 100644
--- a/includes/class-webdecoy-activator.php
+++ b/includes/class-webdecoy-activator.php
@@ -293,10 +293,14 @@ public static function uninstall(): void
gmdate('Y-m-d H:i:s', strtotime('-1 hour'))
));
- // Clean up old detections (keep 30 days)
+ // Clean up old detections. 30 days by default; WEBDECOY_MAX_LOG_RETENTION
+ // in wp-config.php overrides it (agencies keep client databases light).
+ $retention_days = class_exists('WebDecoy_Runtime_Config')
+ ? WebDecoy_Runtime_Config::log_retention_days()
+ : 30;
$wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->prefix}webdecoy_detections WHERE created_at < %s",
- gmdate('Y-m-d H:i:s', strtotime('-30 days'))
+ gmdate('Y-m-d H:i:s', strtotime("-{$retention_days} days"))
));
// Clean up old checkout attempts (keep 7 days)
diff --git a/includes/class-webdecoy-cli.php b/includes/class-webdecoy-cli.php
new file mode 100644
index 0000000..045b18c
--- /dev/null
+++ b/includes/class-webdecoy-cli.php
@@ -0,0 +1,373 @@
+
+ * wp webdecoy config set
+ * wp webdecoy allowlist list|add|remove []
+ * wp webdecoy logs flush [--yes]
+ *
+ * Deliberately a plain class (no `extends WP_CLI_Command`) so this file can
+ * be parsed and its pure helpers tested without WP-CLI present.
+ *
+ * @package WebDecoy
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class WebDecoy_CLI_Command
+{
+ /**
+ * The settings a deploy script may touch, with their validation.
+ *
+ * A closed list on purpose: api_key, site_key and the organization fields
+ * are managed by the connect flow (and the stored api_key is encrypted, so
+ * writing it raw here would corrupt it). `mode` is a virtual key mapping
+ * to monitor_mode, because "monitor or block" is the question an agency
+ * actually asks.
+ *
+ * @var array, min?: int, max?: int}>
+ */
+ private const CONFIG_KEYS = [
+ 'mode' => ['type' => 'mode'],
+ 'enabled' => ['type' => 'bool'],
+ 'sensitivity' => ['type' => 'enum', 'values' => ['low', 'medium', 'high']],
+ 'min_score_to_block' => ['type' => 'int', 'min' => 0, 'max' => 100],
+ 'block_action' => ['type' => 'enum', 'values' => ['block', 'challenge', 'log']],
+ 'block_duration' => ['type' => 'int', 'min' => 0, 'max' => 8760],
+ 'allow_search_engines' => ['type' => 'bool'],
+ 'allow_social_bots' => ['type' => 'bool'],
+ 'block_ai_crawlers' => ['type' => 'bool'],
+ 'protect_comments' => ['type' => 'bool'],
+ 'protect_login' => ['type' => 'bool'],
+ 'protect_registration' => ['type' => 'bool'],
+ 'rate_limit_enabled' => ['type' => 'bool'],
+ 'rate_limit_requests' => ['type' => 'int', 'min' => 1, 'max' => 100000],
+ 'rate_limit_window' => ['type' => 'int', 'min' => 1, 'max' => 86400],
+ 'behind_cloudflare' => ['type' => 'bool'],
+ ];
+
+ /**
+ * Show what WebDecoy is doing on this site.
+ *
+ * ## EXAMPLES
+ *
+ * wp webdecoy status
+ *
+ * @when after_wp_load
+ */
+ public function status($args, $assoc_args): void
+ {
+ global $wpdb;
+ $options = get_option('webdecoy_options', []);
+
+ if (defined('WEBDECOY_DISABLE') && WEBDECOY_DISABLE) {
+ $mode = 'DISABLED (WEBDECOY_DISABLE constant)';
+ } elseif (!empty($options['monitor_mode'])) {
+ $mode = 'monitor (detects and records, blocks nothing)';
+ } else {
+ $mode = 'blocking';
+ }
+ if (WebDecoy_Runtime_Config::forced_monitor_mode() !== null) {
+ $mode .= ' [forced by WEBDECOY_DEFAULT_MODE]';
+ }
+
+ $detections = $wpdb->prefix . 'webdecoy_detections';
+ $blocked = $wpdb->prefix . 'webdecoy_blocked_ips';
+ $now = gmdate('Y-m-d H:i:s');
+
+ $total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$detections}"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix
+ $last24 = (int) $wpdb->get_var($wpdb->prepare(
+ "SELECT COUNT(*) FROM {$detections} WHERE created_at > %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ gmdate('Y-m-d H:i:s', strtotime('-24 hours'))
+ ));
+ $active_blocks = (int) $wpdb->get_var($wpdb->prepare(
+ "SELECT COUNT(*) FROM {$blocked} WHERE expires_at IS NULL OR expires_at > %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $now
+ ));
+
+ $cloud = 'not connected (100% local)';
+ if (!empty($options['api_key'])) {
+ $org = $options['organization_name'] ?: 'connected';
+ $plan = $options['plan'] ?: 'unknown plan';
+ $cloud = sprintf('%s (%s)', $org, $plan);
+ }
+
+ $rows = [
+ ['field' => 'version', 'value' => WEBDECOY_VERSION],
+ ['field' => 'mode', 'value' => $mode],
+ ['field' => 'cloud', 'value' => $cloud],
+ ['field' => 'detections_total', 'value' => (string) $total],
+ ['field' => 'detections_24h', 'value' => (string) $last24],
+ ['field' => 'active_blocks', 'value' => (string) $active_blocks],
+ ['field' => 'allowlist_entries', 'value' => (string) count((array) ($options['ip_allowlist'] ?? []))],
+ ['field' => 'log_retention_days', 'value' => (string) WebDecoy_Runtime_Config::log_retention_days()],
+ ];
+ \WP_CLI\Utils\format_items('table', $rows, ['field', 'value']);
+ }
+
+ /**
+ * Read or write WebDecoy settings.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : list, get, or set.
+ *
+ * []
+ * : The setting name. `wp webdecoy config list` shows the available keys.
+ *
+ * []
+ * : The new value (for set).
+ *
+ * ## EXAMPLES
+ *
+ * wp webdecoy config set mode monitor
+ * wp webdecoy config set block_ai_crawlers true
+ * wp webdecoy config get sensitivity
+ *
+ * @when after_wp_load
+ */
+ public function config($args, $assoc_args): void
+ {
+ $action = $args[0] ?? 'list';
+ $key = $args[1] ?? null;
+ $options = get_option('webdecoy_options', []);
+ if (!is_array($options)) {
+ $options = [];
+ }
+
+ if ($action === 'list') {
+ $rows = [];
+ foreach (self::CONFIG_KEYS as $name => $spec) {
+ $rows[] = [
+ 'key' => $name,
+ 'value' => self::display_value($name, $options),
+ 'type' => $spec['type'] === 'enum' ? implode('|', $spec['values']) : $spec['type'],
+ ];
+ }
+ \WP_CLI\Utils\format_items('table', $rows, ['key', 'value', 'type']);
+ return;
+ }
+
+ if ($key === null || !isset(self::CONFIG_KEYS[$key])) {
+ \WP_CLI::error(sprintf(
+ 'Unknown setting %s. Run `wp webdecoy config list` for the available keys.',
+ $key === null ? '(none)' : "'{$key}'"
+ ));
+ }
+
+ if ($action === 'get') {
+ \WP_CLI::log(self::display_value($key, $options));
+ return;
+ }
+
+ if ($action !== 'set') {
+ \WP_CLI::error("Unknown action '{$action}'. Use list, get, or set.");
+ }
+
+ if (!isset($args[2])) {
+ \WP_CLI::error("Missing value: wp webdecoy config set {$key} ");
+ }
+
+ $parsed = self::parse_config_value($key, $args[2]);
+ if ($parsed === null) {
+ \WP_CLI::error(self::value_help($key, $args[2]));
+ }
+
+ [$real_key, $value] = $parsed;
+ $options[$real_key] = $value;
+ update_option('webdecoy_options', $options);
+
+ if ($real_key === 'monitor_mode' && WebDecoy_Runtime_Config::forced_monitor_mode() !== null) {
+ \WP_CLI::warning('WEBDECOY_DEFAULT_MODE is defined in wp-config.php and overrides this setting at runtime.');
+ }
+ \WP_CLI::success("{$key} = " . self::display_value($key, $options));
+ }
+
+ /**
+ * Manage the IP allowlist (addresses that bypass all detection).
+ *
+ * ## OPTIONS
+ *
+ *
+ * : list, add, or remove.
+ *
+ * []
+ * : An IPv4/IPv6 address or CIDR range (for add/remove).
+ *
+ * ## EXAMPLES
+ *
+ * wp webdecoy allowlist add 203.0.113.7
+ * wp webdecoy allowlist add 2001:db8::/48
+ * wp webdecoy allowlist remove 203.0.113.7
+ *
+ * @when after_wp_load
+ */
+ public function allowlist($args, $assoc_args): void
+ {
+ $action = $args[0] ?? 'list';
+ $options = get_option('webdecoy_options', []);
+ if (!is_array($options)) {
+ $options = [];
+ }
+ $list = array_values(array_filter(array_map('strval', (array) ($options['ip_allowlist'] ?? []))));
+
+ if ($action === 'list') {
+ if ($list === []) {
+ \WP_CLI::log('(empty)');
+ return;
+ }
+ foreach ($list as $entry) {
+ \WP_CLI::log($entry);
+ }
+ return;
+ }
+
+ if ($action !== 'add' && $action !== 'remove') {
+ \WP_CLI::error("Unknown action '{$action}'. Use list, add, or remove.");
+ }
+
+ $entry = WebDecoy_Runtime_Config::validate_ip_or_cidr($args[1] ?? '');
+ if ($entry === null) {
+ \WP_CLI::error('Not a valid IP address or CIDR range.');
+ }
+
+ if ($action === 'add') {
+ if (in_array($entry, $list, true)) {
+ \WP_CLI::log("{$entry} is already on the allowlist.");
+ return;
+ }
+ $list[] = $entry;
+ } else {
+ if (!in_array($entry, $list, true)) {
+ \WP_CLI::error("{$entry} is not on the allowlist.");
+ }
+ $list = array_values(array_diff($list, [$entry]));
+ }
+
+ $options['ip_allowlist'] = $list;
+ update_option('webdecoy_options', $options);
+ \WP_CLI::success(sprintf('%s %s. Allowlist now has %d entr%s.',
+ $action === 'add' ? 'Added' : 'Removed', $entry, count($list), count($list) === 1 ? 'y' : 'ies'));
+ }
+
+ /**
+ * Manage the local detection log.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : flush deletes every recorded detection.
+ *
+ * [--yes]
+ * : Skip the confirmation prompt.
+ *
+ * ## EXAMPLES
+ *
+ * wp webdecoy logs flush --yes
+ *
+ * @when after_wp_load
+ */
+ public function logs($args, $assoc_args): void
+ {
+ global $wpdb;
+ $action = $args[0] ?? '';
+
+ if ($action !== 'flush') {
+ \WP_CLI::error("Unknown action '{$action}'. Use: wp webdecoy logs flush");
+ }
+
+ \WP_CLI::confirm('Delete ALL recorded detections on this site?', $assoc_args);
+
+ $detections = $wpdb->prefix . 'webdecoy_detections';
+ $deleted = $wpdb->query("DELETE FROM {$detections}"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix
+ \WP_CLI::success(sprintf('Deleted %d detection%s.', (int) $deleted, (int) $deleted === 1 ? '' : 's'));
+ }
+
+ /**
+ * Parse and validate a config value.
+ *
+ * Pure, so it is testable without WP-CLI. Returns [stored_key, value] or
+ * null when the value is invalid for the key.
+ *
+ * @return array{0: string, 1: mixed}|null
+ */
+ public static function parse_config_value(string $key, string $raw): ?array
+ {
+ $spec = self::CONFIG_KEYS[$key] ?? null;
+ if ($spec === null) {
+ return null;
+ }
+
+ switch ($spec['type']) {
+ case 'mode':
+ $monitor = WebDecoy_Runtime_Config::forced_monitor_mode($raw);
+ return $monitor === null ? null : ['monitor_mode', $monitor];
+ case 'bool':
+ $lowered = strtolower(trim($raw));
+ if (in_array($lowered, ['true', '1', 'on', 'yes'], true)) {
+ return [$key, true];
+ }
+ if (in_array($lowered, ['false', '0', 'off', 'no'], true)) {
+ return [$key, false];
+ }
+ return null;
+ case 'int':
+ if (!is_numeric($raw)) {
+ return null;
+ }
+ $value = (int) $raw;
+ if ($value < ($spec['min'] ?? PHP_INT_MIN) || $value > ($spec['max'] ?? PHP_INT_MAX)) {
+ return null;
+ }
+ return [$key, $value];
+ case 'enum':
+ $lowered = strtolower(trim($raw));
+ return in_array($lowered, $spec['values'], true) ? [$key, $lowered] : null;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Human-readable current value for a config key. Pure.
+ *
+ * @param array $options
+ */
+ public static function display_value(string $key, array $options): string
+ {
+ if ($key === 'mode') {
+ return empty($options['monitor_mode']) ? 'block' : 'monitor';
+ }
+ $value = $options[$key] ?? null;
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ }
+ return (string) ($value ?? '');
+ }
+
+ /**
+ * The error message for an invalid value. Pure.
+ */
+ public static function value_help(string $key, string $raw): string
+ {
+ $spec = self::CONFIG_KEYS[$key];
+ switch ($spec['type']) {
+ case 'mode':
+ return "'{$raw}' is not a mode. Use: monitor or block.";
+ case 'bool':
+ return "'{$raw}' is not a boolean. Use: true or false.";
+ case 'int':
+ return sprintf("'%s' is out of range for %s (%d-%d).", $raw, $key, $spec['min'] ?? 0, $spec['max'] ?? 0);
+ case 'enum':
+ return sprintf("'%s' is not valid for %s. Use: %s.", $raw, $key, implode(', ', $spec['values']));
+ default:
+ return "'{$raw}' is not valid for {$key}.";
+ }
+ }
+}
diff --git a/includes/class-webdecoy-cloud-connect.php b/includes/class-webdecoy-cloud-connect.php
index dc29fbe..89c586d 100644
--- a/includes/class-webdecoy-cloud-connect.php
+++ b/includes/class-webdecoy-cloud-connect.php
@@ -92,7 +92,9 @@ public function register(): void
add_action('admin_post_webdecoy_cloud_connect', [$this, 'handle_connect']);
add_action('admin_post_webdecoy_cloud_disconnect', [$this, 'handle_disconnect']);
add_action('admin_init', [$this, 'maybe_handle_return']);
- add_action('admin_notices', [$this, 'render_notices']);
+ if (!WebDecoy_Runtime_Config::hide_admin_ui()) {
+ add_action('admin_notices', [$this, 'render_notices']);
+ }
}
/**
diff --git a/includes/class-webdecoy-critical-moment.php b/includes/class-webdecoy-critical-moment.php
index dd0253c..bbeb97c 100644
--- a/includes/class-webdecoy-critical-moment.php
+++ b/includes/class-webdecoy-critical-moment.php
@@ -51,7 +51,7 @@ class WebDecoy_Critical_Moment
*/
public function register(): void
{
- if (is_admin()) {
+ if (is_admin() && !WebDecoy_Runtime_Config::hide_admin_ui()) {
add_action('admin_notices', [$this, 'render']);
}
}
diff --git a/includes/class-webdecoy-runtime-config.php b/includes/class-webdecoy-runtime-config.php
new file mode 100644
index 0000000..e15a825
--- /dev/null
+++ b/includes/class-webdecoy-runtime-config.php
@@ -0,0 +1,135 @@
+= 0 && (int) $bits <= 128) {
+ return $entry;
+ }
+ return null;
+ }
+
+ return filter_var($entry, FILTER_VALIDATE_IP) ? $entry : null;
+ }
+}
diff --git a/readme.txt b/readme.txt
index ecb4196..e600c43 100644
--- a/readme.txt
+++ b/readme.txt
@@ -4,7 +4,7 @@ Donate link: https://webdecoy.com
Tags: bot detection, security, spam protection, woocommerce, ai bots
Requires at least: 6.1
Tested up to: 7.0
-Stable tag: 2.4.1
+Stable tag: 2.5.0
Requires PHP: 7.4
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -115,16 +115,34 @@ Registration spam and comment spam are the same disease: automation pointed at y
= Private by design: 100% local, GDPR-friendly =
-Until you deliberately connect a WebDecoy Cloud account, the plugin makes **zero external connections**: your visitors' browsers never contact a third-party server, and neither does yours. No external CAPTCHA service, no CDN-loaded scripts, no data leaving your site. Detection data lives in your own WordPress database and is cleaned up automatically after 30 days. If you build privacy-conscious or GDPR-focused sites, that is the property to check for: there is no third-party data processor to disclose.
+Until you deliberately connect a WebDecoy Cloud account, the plugin makes **zero external connections**:
-= Built for agencies and boilerplate builds =
+* No visitor IP addresses sent to external servers
+* No US data transfers and no third-party data processor to disclose
+* No third-party cookies and no external CAPTCHA service
+* No CDN-loaded scripts (even Chart.js for the admin charts is bundled locally)
-A single line adds real protection to every site you ship:
+Detection data lives in your own WordPress database and is cleaned up automatically after 30 days (configurable). If you build privacy-conscious or GDPR-focused sites, this is the architecture you have been filtering for: cloud CAPTCHAs and cloud WAFs make your visitors someone else's data; WebDecoy keeps them yours.
-* `wp plugin install webdecoy --activate` and it is running, in monitor mode, with sensible defaults
-* **Monitor mode by default**: WebDecoy detects, logs, and reports everything but blocks nothing until you switch blocking on, so it cannot break a client site on day one
-* Emergency off switch: `define('WEBDECOY_DISABLE', true);` in wp-config.php
-* No account, license key, or per-site signup for the free tier, so it drops straight into a build template or deployment script
+= Built for agencies: configure everything in code =
+
+Agencies do not click through wp-admin on 80 client sites. WebDecoy is fully controllable from a deploy script:
+
+`wp plugin install webdecoy --activate`
+`wp webdecoy config set mode monitor`
+`wp webdecoy allowlist add 203.0.113.7`
+`wp webdecoy status`
+
+The `wp webdecoy` command covers status, every safe setting (`config list`), the IP allowlist (whitelist your agency VPN across every client site in one loop), and log flushing.
+
+Settings you never want a client to change live in wp-config.php:
+
+* `WEBDECOY_DEFAULT_MODE` ('monitor' or 'block'): forces the mode and locks the admin toggle
+* `WEBDECOY_HIDE_ADMIN_UI` (true): hides the WebDecoy menu, dashboard widget, and notices from the client's view
+* `WEBDECOY_MAX_LOG_RETENTION` (days): keep client databases light
+* `WEBDECOY_DISABLE` (true): emergency kill switch
+
+And because **monitor mode is the default**, baking WebDecoy into your boilerplate cannot break a client site on day one: it detects, logs, and reports everything but blocks nothing until you decide otherwise.
= Premium Features (Optional WebDecoy Cloud) =
@@ -266,6 +284,11 @@ The bundled good-bot list (sdk/src/GoodBotList.php) stores a documentation URL f
== Changelog ==
+= 2.5.0 =
+* Added: a wp webdecoy WP-CLI command for agency deploy scripts. wp webdecoy status reports mode, cloud connection, detection counts, active blocks, and retention. wp webdecoy config list/get/set covers every safe setting, including config set mode monitor|block. wp webdecoy allowlist add|remove|list manages the IP allowlist (whitelist your agency VPN across every client site in one loop). wp webdecoy logs flush clears the local detection log.
+* Added: wp-config.php constants for code-locked configuration. WEBDECOY_DEFAULT_MODE ('monitor' or 'block') forces the mode, overrides the stored setting, and locks the admin toggle; a settings save while forced can no longer silently drift the stored mode. WEBDECOY_HIDE_ADMIN_UI hides the WebDecoy menu, dashboard widget, and admin notices for white-label installs (the plugin stays visible in the Plugins list on purpose). WEBDECOY_MAX_LOG_RETENTION overrides the 30-day detection retention (1 to 3650 days).
+* Changed: an unrecognized WEBDECOY_DEFAULT_MODE value is ignored rather than guessed. Forcing 'block' on a typo would enforce on a site that asked to watch; forcing 'monitor' would disarm one that asked to enforce.
+
= 2.4.1 =
* Fixed: cloud features switch on immediately after one-click connect. The connection itself succeeded, but the premium status stayed off until a later background revalidation, so the JS verification token and cloud reporting were silently inactive at the exact moment you had just connected.
* Fixed: the dashboard widget's "Learn more" link landed on the Protection tab instead of the WebDecoy Cloud tab.
diff --git a/tests/RuntimeConfigTest.php b/tests/RuntimeConfigTest.php
new file mode 100644
index 0000000..54a507a
--- /dev/null
+++ b/tests/RuntimeConfigTest.php
@@ -0,0 +1,106 @@
+ true]));
+ $same('block', WebDecoy_CLI_Command::display_value('mode', ['monitor_mode' => false]));
+ $same('true', WebDecoy_CLI_Command::display_value('protect_login', ['protect_login' => true]));
+ $same('75', WebDecoy_CLI_Command::display_value('min_score_to_block', ['min_score_to_block' => 75]));
+});
diff --git a/webdecoy.php b/webdecoy.php
index dbceb0c..9f23797 100644
--- a/webdecoy.php
+++ b/webdecoy.php
@@ -3,7 +3,7 @@
* Plugin Name: WebDecoy Bot Detection
* Plugin URI: https://webdecoy.com/wordpress
* Description: Protect your WordPress site from bots, spam, and carding attacks with WebDecoy's advanced threat detection.
- * Version: 2.4.1
+ * Version: 2.5.0
* Requires at least: 6.1
* Requires PHP: 7.4
* Author: WebDecoy
@@ -41,12 +41,16 @@ function str_starts_with(string $haystack, string $needle): bool
}
// Plugin constants
-define('WEBDECOY_VERSION', '2.4.1');
+define('WEBDECOY_VERSION', '2.5.0');
define('WEBDECOY_PLUGIN_FILE', __FILE__);
define('WEBDECOY_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WEBDECOY_PLUGIN_URL', plugin_dir_url(__FILE__));
define('WEBDECOY_PLUGIN_BASENAME', plugin_basename(__FILE__));
+// Code-level configuration (wp-config.php constants). Loaded before anything
+// reads options, because WEBDECOY_DEFAULT_MODE participates in load_options().
+require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-runtime-config.php';
+
// Load the SDK (bundled)
$sdk_paths = [
WEBDECOY_PLUGIN_DIR . 'sdk/',
@@ -314,6 +318,14 @@ private function load_options(): void
$saved = get_option('webdecoy_options', []);
$this->options = array_merge($defaults, $saved);
+ // WEBDECOY_DEFAULT_MODE forces the mode from wp-config.php, overriding
+ // whatever is stored: agencies pin 'monitor' (or 'block') in a config
+ // that clients cannot edit, and a database reset cannot undo.
+ $forced = WebDecoy_Runtime_Config::forced_monitor_mode();
+ if ($forced !== null) {
+ $this->options['monitor_mode'] = $forced;
+ }
+
// Decrypt API key if it's encrypted
if (!empty($this->options['api_key']) && $this->is_encrypted($this->options['api_key'])) {
$this->options['api_key'] = $this->decrypt_value($this->options['api_key']);
@@ -855,14 +867,19 @@ private function init_hooks(): void
// Admin hooks
if (is_admin()) {
- add_action('admin_menu', [$this, 'admin_menu']);
+ // WEBDECOY_HIDE_ADMIN_UI removes the visible surfaces (menu, widget,
+ // notices) for white-label agency installs. Upgrade routines, settings
+ // registration and proxy sampling still run: hidden is not disabled.
+ if (!WebDecoy_Runtime_Config::hide_admin_ui()) {
+ add_action('admin_menu', [$this, 'admin_menu']);
+ add_action('admin_notices', [$this, 'render_state_notices']);
+ add_action('wp_dashboard_setup', [$this, 'dashboard_widget']);
+ }
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_init', [$this, 'maybe_upgrade']);
// Sample "are we behind a proxy" from a trusted (admin) request, so the
// front end never has to consult a client-controlled header.
add_action('admin_init', [$this, 'maybe_flag_proxy']);
- add_action('admin_notices', [$this, 'render_state_notices']);
- add_action('wp_dashboard_setup', [$this, 'dashboard_widget']);
add_action('admin_enqueue_scripts', [$this, 'admin_scripts']);
}
@@ -1023,6 +1040,13 @@ public function load_includes(): void
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-wp-traps.php';
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-cloud-connect.php';
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-actor-intel.php';
+
+ // WP-CLI surface for agency deploy scripts: wp webdecoy status|config|
+ // allowlist|logs. Only loaded when WP-CLI is actually running.
+ if (defined('WP_CLI') && WP_CLI) {
+ require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-cli.php';
+ \WP_CLI::add_command('webdecoy', 'WebDecoy_CLI_Command');
+ }
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-actor-feed.php';
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-critical-moment.php';
@@ -2261,7 +2285,17 @@ public function sanitize_options(array $input): array
// stored as an array of valid entries.
$allowlist = $this->sanitize_trusted_proxies($input['ip_allowlist'] ?? '');
$sanitized['ip_allowlist'] = $allowlist === '' ? [] : explode("\n", $allowlist);
- $sanitized['monitor_mode'] = !empty($input['monitor_mode']);
+ if (WebDecoy_Runtime_Config::forced_monitor_mode() !== null) {
+ // The mode is forced by WEBDECOY_DEFAULT_MODE and its checkbox is
+ // disabled, so the form never posts it. Carry the STORED value
+ // forward rather than reading the absent field as false: otherwise
+ // every settings save silently drifts the stored mode to
+ // 'blocking', which detonates the day the constant is removed.
+ $stored = get_option('webdecoy_options', []);
+ $sanitized['monitor_mode'] = !empty(is_array($stored) ? ($stored['monitor_mode'] ?? true) : true);
+ } else {
+ $sanitized['monitor_mode'] = !empty($input['monitor_mode']);
+ }
$sanitized['block_action'] = in_array($input['block_action'] ?? 'block', ['block', 'challenge', 'log']) ? $input['block_action'] : 'block';
$sanitized['block_duration'] = max(0, intval($input['block_duration'] ?? 1));
$sanitized['show_block_page'] = !empty($input['show_block_page']);
|