From f93bd91e0cab06f6daba17e118465ce5320a9779 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Mon, 17 Aug 2026 22:15:41 -0500 Subject: [PATCH] feat(agency): WP-CLI command + wp-config constants API (2.5.0) wp webdecoy status|config|allowlist|logs for deploy scripts, and three new wp-config.php constants: WEBDECOY_DEFAULT_MODE (forces + locks the mode; the settings sanitizer carries the stored value forward while forced so a save cannot silently drift it), WEBDECOY_HIDE_ADMIN_UI (menu, widget, and all notices including critical-moment and connect; plugins list stays visible on purpose), WEBDECOY_MAX_LOG_RETENTION (clamped 1-3650 days, wired into the cleanup cron). Constant parsing and CLI value validation live in pure classes with tests (94 passing). An unrecognized WEBDECOY_DEFAULT_MODE is ignored, never guessed: a typo must not pick a side between watching and enforcing. config keys are a closed whitelist: api_key is encrypted at rest and org fields belong to the connect flow, so neither is writable from the CLI. --- admin/partials/settings-page.php | 15 +- changelog.txt | 5 + includes/class-webdecoy-activator.php | 8 +- includes/class-webdecoy-cli.php | 373 ++++++++++++++++++++ includes/class-webdecoy-cloud-connect.php | 4 +- includes/class-webdecoy-critical-moment.php | 2 +- includes/class-webdecoy-runtime-config.php | 135 +++++++ readme.txt | 39 +- tests/RuntimeConfigTest.php | 106 ++++++ webdecoy.php | 46 ++- 10 files changed, 714 insertions(+), 19 deletions(-) create mode 100644 includes/class-webdecoy-cli.php create mode 100644 includes/class-webdecoy-runtime-config.php create mode 100644 tests/RuntimeConfigTest.php 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']);