diff --git a/admin/css/webdecoy-admin.css b/admin/css/webdecoy-admin.css index d0544f4..f1f9af3 100644 --- a/admin/css/webdecoy-admin.css +++ b/admin/css/webdecoy-admin.css @@ -322,6 +322,71 @@ color: #721c24; } +/* Network-block badge (rows auto-synced from WebDecoy network intelligence). */ +.webdecoy-badge-network { + background: #e7f0fb; + color: #1e50a0; +} + +/* Actor-intel "Known" badge on the Detections list. */ +.webdecoy-badge-known { + background: #fbeaea; + color: #a02222; +} + +/* ========================================================================== + Actor Intel column (Detections) + ========================================================================== */ + +.column-intel { width: 200px; } + +.webdecoy-intel-locked { + color: #a7aaad; + font-size: 16px; + width: 16px; + height: 16px; +} + +.webdecoy-intel-na, +.webdecoy-intel-new { + color: #646970; +} + +.webdecoy-intel-known { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.webdecoy-intel-meta { + font-size: 11px; + color: #646970; +} + +.webdecoy-chip { + display: inline-block; + font-size: 10px; + line-height: 1.6; + padding: 1px 6px; + border-radius: 3px; + background: #f0f0f0; + color: #3c434a; +} + +.webdecoy-chip-alert { + background: #fbeaea; + color: #a02222; +} + +/* ========================================================================== + Post-CRITICAL moment notice + ========================================================================== */ + +.webdecoy-critical-moment .button { + margin-top: 4px; +} + /* ========================================================================== Filters & Actions Bar ========================================================================== */ diff --git a/admin/partials/blocked-ips-page.php b/admin/partials/blocked-ips-page.php index 401eef6..867c8bb 100644 --- a/admin/partials/blocked-ips-page.php +++ b/admin/partials/blocked-ips-page.php @@ -134,7 +134,15 @@ -
- + diff --git a/includes/class-webdecoy-activator.php b/includes/class-webdecoy-activator.php index 71698cf..c8d2aa8 100644 --- a/includes/class-webdecoy-activator.php +++ b/includes/class-webdecoy-activator.php @@ -48,6 +48,7 @@ public static function deactivate(): void wp_clear_scheduled_hook('webdecoy_cleanup_expired'); wp_clear_scheduled_hook('webdecoy_sync_blocked_ips'); wp_clear_scheduled_hook('webdecoy_sync_entitlements'); + wp_clear_scheduled_hook('webdecoy_sync_actor_feed'); // Flush rewrite rules flush_rewrite_rules(); @@ -260,12 +261,15 @@ public static function uninstall(): void delete_option('webdecoy_options'); delete_option('webdecoy_db_version'); delete_option('webdecoy_entitlements'); + delete_option('webdecoy_actor_feed_cursor'); + delete_option('webdecoy_critical_moment_last'); // Clear scheduled events wp_clear_scheduled_hook('webdecoy_cleanup_expired'); wp_clear_scheduled_hook('webdecoy_sync_blocked_ips'); wp_clear_scheduled_hook('webdecoy_flush_violations'); wp_clear_scheduled_hook('webdecoy_sync_entitlements'); + wp_clear_scheduled_hook('webdecoy_sync_actor_feed'); } } diff --git a/includes/class-webdecoy-actor-feed.php b/includes/class-webdecoy-actor-feed.php new file mode 100644 index 0000000..02238cc --- /dev/null +++ b/includes/class-webdecoy-actor-feed.php @@ -0,0 +1,524 @@ +prefix (a trusted value) into +// otherwise-prepared statements, exactly as WebDecoy_Blocker does. +// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared +// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared +// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery +// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching + +// Prevent direct access. +if (!defined('ABSPATH')) { + exit; +} + +/** + * WebDecoy Actor Feed controller. + */ +class WebDecoy_Actor_Feed +{ + /** Hourly cron hook that pulls the feed and refreshes network blocks. */ + public const CRON_HOOK = 'webdecoy_sync_actor_feed'; + + /** Shared actor feed endpoint (Bearer API key; same auth as entitlements). */ + private const FEED_ENDPOINT = 'https://ingest.webdecoy.com/api/v1/sdk/actor-feed'; + + /** Option persisting the delta cursor (`since`) between syncs. */ + private const CURSOR_OPTION = 'webdecoy_actor_feed_cursor'; + + /** created_by marker for rows this class owns. */ + public const CREATED_BY = 'webdecoy-network'; + + /** Entitlement feature key that gates the whole mechanism. */ + private const FEATURE = 'actor_feed'; + + /** Rolling block lifetime (hours). Refreshed every sync for live feed IPs. */ + private const EXPIRY_HOURS = 48; + + /** Hard cap on the number of network-owned rows. */ + private const MAX_NETWORK_ROWS = 2000; + + /** Safety cap on pagination follows per sync. */ + private const MAX_PAGES = 25; + + /** Seconds per hour — literal to keep the class loadable without WordPress. */ + private const HOUR = 3600; + + /** @var WebDecoy_Blocker|null Lazily created for allowlist checks. */ + private $blocker = null; + + /** + * Wire up the cron. The handler is registered unconditionally (wp-cron can + * fire on any front-end request); the schedule itself is reconciled against + * the current connection + entitlement state on each admin load. + */ + public function register(): void + { + add_action(self::CRON_HOOK, [$this, 'sync']); + + if (is_admin()) { + add_action('admin_init', [$this, 'maybe_reconcile_schedule']); + } + } + + /** + * Schedule the hourly sync when connected + entitled; unschedule otherwise. + * Cheap: reads two options, no HTTP. Keeps the schedule tracking entitlement + * changes (including a downgrade or disconnect) on the next admin page load. + */ + public function maybe_reconcile_schedule(): void + { + if (self::is_enabled()) { + if (!wp_next_scheduled(self::CRON_HOOK)) { + // Stagger the first run slightly so activation/connect returns fast. + wp_schedule_event(time() + 60, 'hourly', self::CRON_HOOK); + } + } else { + self::unschedule(); + } + } + + /** + * Whether the feed mechanism may run: connected AND entitled to actor_feed. + */ + public static function is_enabled(): bool + { + if (!class_exists('WebDecoy_Cloud_Connect') || !WebDecoy_Cloud_Connect::is_connected()) { + return false; + } + $entitlements = WebDecoy_Cloud_Connect::get_entitlements(); + return !empty($entitlements['features'][self::FEATURE]); + } + + /** + * Remove any scheduled sync (idempotent). + */ + public static function unschedule(): void + { + wp_clear_scheduled_hook(self::CRON_HOOK); + } + + /** + * Cron handler: pull the feed (following pagination) and reconcile the + * network-owned blocked rows. Self-guards so it makes no request unless the + * site is both connected and entitled. + */ + public function sync(): void + { + if (!self::is_enabled()) { + self::unschedule(); + return; + } + + $api_key = $this->api_key(); + if ($api_key === '') { + self::unschedule(); + return; + } + + $cursor = (int) get_option(self::CURSOR_OPTION, 0); + $latest_cursor = $cursor; + + $ip_map = []; // ip => ['actor_id' => string, 'last_seen' => int] + $pages = 0; + + do { + $requested = $latest_cursor; + $result = $this->fetch_page($api_key, $requested); + + if ($result['status'] === 'upgrade') { + // Entitlement revoked server-side (403 upgrade_required). Stop + // quietly and stop scheduling; the next entitlements sync will + // confirm the downgrade locally. + self::unschedule(); + return; + } + if ($result['status'] !== 'ok') { + // Transient error — keep the cursor and retry next hour. Any rows + // already present keep protecting until their 48h expiry lapses. + return; + } + + $normalized = self::normalize_feed_page($result['body']); + $ip_map = self::merge_actor_ips($ip_map, $normalized['actors']); + + $advanced = $normalized['next_since'] > $requested; + if ($advanced) { + $latest_cursor = $normalized['next_since']; + } + $pages++; + + $continue = self::should_continue( + $normalized['complete'], + $advanced, + $pages, + self::MAX_PAGES, + count($ip_map), + self::MAX_NETWORK_ROWS + ); + } while ($continue); + + // Exclude the user's allowlist + invalid IPs (pure pass), then apply the + // authoritative per-IP allowlist gate (covers CIDR) inside apply_blocks. + $insertable = self::exclude_allowlisted($ip_map, $this->allowlist()); + + $this->apply_blocks($insertable); + $this->enforce_cap(); + + update_option(self::CURSOR_OPTION, $latest_cursor, false); + } + + /** + * Fetch one feed page. Returns a status envelope so the caller can tell a + * transient failure ('retry') from an entitlement revocation ('upgrade'). + * + * @return array{status:string,body:array} + */ + private function fetch_page(string $api_key, int $since): array + { + if (!function_exists('wp_remote_get')) { + return ['status' => 'retry', 'body' => []]; + } + + $url = self::FEED_ENDPOINT . '?since=' . rawurlencode((string) $since); + + $response = wp_remote_get($url, [ + 'timeout' => 8, + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $api_key, + ], + ]); + + if (is_wp_error($response)) { + return ['status' => 'retry', 'body' => []]; + } + + $code = (int) wp_remote_retrieve_response_code($response); + $body = json_decode((string) wp_remote_retrieve_body($response), true); + $body = is_array($body) ? $body : []; + + if ($code === 403) { + return ['status' => 'upgrade', 'body' => $body]; + } + if ($code < 200 || $code >= 300) { + return ['status' => 'retry', 'body' => []]; + } + + return ['status' => 'ok', 'body' => $body]; + } + + /** + * Upsert the given IPs as network-owned blocked rows with a rolling expiry. + * Never touches a row owned by anyone else, and never inserts an allowlisted + * IP (exact or CIDR). + * + * @param array $ip_map + */ + private function apply_blocks(array $ip_map): void + { + if ($ip_map === []) { + return; + } + + global $wpdb; + $table = $wpdb->prefix . 'webdecoy_blocked_ips'; + $now = current_time('mysql'); + $expires_at = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_HOURS * self::HOUR)); + $blocker = $this->blocker(); + + foreach ($ip_map as $ip => $meta) { + $ip = (string) $ip; + + // Authoritative allowlist gate — covers CIDR ranges the pure filter + // can't. Never override a user's explicit allow. + if ($blocker->is_allowlisted($ip)) { + continue; + } + + $reason = sprintf( + /* translators: %s: actor id */ + 'WebDecoy network: known attacker (actor %s)', + (string) $meta['actor_id'] + ); + + $existing = $wpdb->get_row( + $wpdb->prepare("SELECT id, created_by FROM {$table} WHERE ip_address = %s", $ip), + ARRAY_A + ); + + if ($existing === null) { + $wpdb->insert($table, [ + 'ip_address' => $ip, + 'reason' => $reason, + 'blocked_at' => $now, + 'expires_at' => $expires_at, + 'created_by' => self::CREATED_BY, + ]); + } elseif (($existing['created_by'] ?? '') === self::CREATED_BY) { + // Refresh our own row: extend expiry and refresh the last-seen + // marker (blocked_at) so eviction ordering stays meaningful. + $wpdb->update( + $table, + [ + 'reason' => $reason, + 'blocked_at' => $now, + 'expires_at' => $expires_at, + ], + ['ip_address' => $ip, 'created_by' => self::CREATED_BY] + ); + } + // else: a human/system block already exists for this IP — leave it + // completely untouched (its reason/expiry are the user's to manage). + + wp_cache_delete('webdecoy_blocked_' . $ip, 'webdecoy'); + } + } + + /** + * Trim network-owned rows back to the cap, evicting the oldest first. + * + * blocked_at proxies last_seen: it is refreshed to "now" for every IP still + * present in the feed each sync, so the smallest blocked_at is the row that + * has gone longest without appearing in the feed — the right one to drop. + */ + private function enforce_cap(): void + { + global $wpdb; + $table = $wpdb->prefix . 'webdecoy_blocked_ips'; + + $count = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE created_by = %s", + self::CREATED_BY + )); + if ($count <= self::MAX_NETWORK_ROWS) { + return; + } + + $excess = $count - self::MAX_NETWORK_ROWS; + $ids = $wpdb->get_col($wpdb->prepare( + "SELECT id FROM {$table} WHERE created_by = %s ORDER BY blocked_at ASC, id ASC LIMIT %d", + self::CREATED_BY, + $excess + )); + + if (!$ids) { + return; + } + + $placeholders = implode(',', array_fill(0, count($ids), '%d')); + $wpdb->query($wpdb->prepare( + "DELETE FROM {$table} WHERE id IN ({$placeholders})", + $ids + )); + } + + /** + * Decrypted API key from the plugin's in-request options. + */ + private function api_key(): string + { + if (!function_exists('webdecoy')) { + return ''; + } + $options = webdecoy()->get_options(); + return isset($options['api_key']) ? (string) $options['api_key'] : ''; + } + + /** + * The user's IP allowlist (raw list; CIDR handled by the blocker). + * + * @return array + */ + private function allowlist(): array + { + $options = get_option('webdecoy_options', []); + if (!is_array($options)) { + return []; + } + $allow = isset($options['ip_allowlist']) ? $options['ip_allowlist'] : []; + return is_array($allow) ? $allow : []; + } + + private function blocker(): WebDecoy_Blocker + { + if ($this->blocker === null) { + $this->blocker = new WebDecoy_Blocker(); + } + return $this->blocker; + } + + // --------------------------------------------------------------------- + // Pure helpers (no WordPress calls) — unit-tested in tests/ActorFeedTest.php. + // --------------------------------------------------------------------- + + /** + * Normalize one raw feed response into typed actors + pagination signals. + * Drops malformed actors (missing id or no usable IPs). + * + * @param array $body + * @return array{actors:array,last_seen:int}>,next_since:int,complete:bool} + */ + public static function normalize_feed_page(array $body): array + { + $actors_out = []; + $actors_in = isset($body['actors']) && is_array($body['actors']) ? $body['actors'] : []; + + foreach ($actors_in as $actor) { + if (!is_array($actor)) { + continue; + } + $id = isset($actor['id']) ? (string) $actor['id'] : ''; + $last_seen = isset($actor['last_seen']) ? (int) $actor['last_seen'] : 0; + + $ips_in = isset($actor['ips']) && is_array($actor['ips']) ? $actor['ips'] : []; + $ips = []; + foreach ($ips_in as $ip) { + if (!is_string($ip)) { + continue; + } + $ip = trim($ip); + if ($ip !== '') { + $ips[] = $ip; + } + } + + if ($id === '' || $ips === []) { + continue; + } + $actors_out[] = ['id' => $id, 'ips' => $ips, 'last_seen' => $last_seen]; + } + + return [ + 'actors' => $actors_out, + 'next_since' => isset($body['next_since']) ? (int) $body['next_since'] : 0, + 'complete' => !empty($body['complete']), + ]; + } + + /** + * Merge a page's actors into an ip => {actor_id,last_seen} accumulator, + * keeping the most-recent last_seen when an IP appears more than once. + * + * @param array $accumulated + * @param array,last_seen:int}> $actors + * @return array + */ + public static function merge_actor_ips(array $accumulated, array $actors): array + { + foreach ($actors as $actor) { + $id = isset($actor['id']) ? (string) $actor['id'] : ''; + $last_seen = isset($actor['last_seen']) ? (int) $actor['last_seen'] : 0; + $ips = isset($actor['ips']) && is_array($actor['ips']) ? $actor['ips'] : []; + + foreach ($ips as $ip) { + $ip = (string) $ip; + if ($ip === '') { + continue; + } + if (!isset($accumulated[$ip]) || $last_seen > $accumulated[$ip]['last_seen']) { + $accumulated[$ip] = ['actor_id' => $id, 'last_seen' => $last_seen]; + } + } + } + return $accumulated; + } + + /** + * Drop invalid IPs and any exact allowlist match. (CIDR allowlist ranges are + * enforced separately at insert time via the blocker.) + * + * @param array $ip_map + * @param array $allowlist + * @return array + */ + public static function exclude_allowlisted(array $ip_map, array $allowlist): array + { + $out = []; + foreach ($ip_map as $ip => $meta) { + $ip = (string) $ip; + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + continue; + } + if (in_array($ip, $allowlist, true)) { + continue; + } + $out[$ip] = $meta; + } + return $out; + } + + /** + * Given ip => sort-key (last_seen / blocked_at epoch) and a cap, return the + * IPs to evict (oldest sort-key first) to bring the set within the cap. + * + * @param array $rows + * @return array + */ + public static function select_evictions(array $rows, int $cap): array + { + if ($cap < 0) { + $cap = 0; + } + if (count($rows) <= $cap) { + return []; + } + asort($rows); // ascending: oldest first + $excess = count($rows) - $cap; + return array_slice(array_keys($rows), 0, $excess); + } + + /** + * Pagination decision. Continue only while the feed is incomplete, the + * cursor actually advanced (guards against a stuck cursor), and neither the + * page nor row safety cap is hit. + */ + public static function should_continue( + bool $complete, + bool $advanced, + int $pages, + int $max_pages, + int $collected, + int $max_rows + ): bool { + if ($complete) { + return false; + } + if (!$advanced) { + return false; + } + if ($pages >= $max_pages) { + return false; + } + if ($collected >= $max_rows) { + return false; + } + return true; + } +} diff --git a/includes/class-webdecoy-actor-intel.php b/includes/class-webdecoy-actor-intel.php new file mode 100644 index 0000000..21cf009 --- /dev/null +++ b/includes/class-webdecoy-actor-intel.php @@ -0,0 +1,223 @@ +apiKey = $apiKey; + } + + /** + * Resolve intel for a set of IPs. Returns ip => normalized-intel-array for + * every input IP that resolved (cached or freshly fetched); IPs that failed + * to resolve are simply absent from the map (caller shows an em-dash). + * + * At most one batched request is made per call, covering only the uncached, + * valid, unique IPs (capped at 50). + * + * @param array $ips + * @return array> + */ + public function lookup(array $ips): array + { + $ips = self::unique_ips($ips, self::MAX_IPS); + if ($ips === [] || $this->apiKey === '') { + return []; + } + + $out = []; + $need = []; + + foreach ($ips as $ip) { + $cached = get_transient(self::CACHE_PREFIX . md5($ip)); + if (is_array($cached)) { + $out[$ip] = $cached; + } elseif ($cached === 'none') { + // Known-negative: resolved, but not present in the network. + continue; + } else { + $need[] = $ip; + } + } + + if ($need !== []) { + $fetched = $this->fetch($need); + if ($fetched !== null) { + foreach ($need as $ip) { + if (isset($fetched[$ip]) && is_array($fetched[$ip])) { + $normalized = self::normalize_result($fetched[$ip]); + set_transient(self::CACHE_PREFIX . md5($ip), $normalized, self::TTL); + $out[$ip] = $normalized; + } else { + // Successful response that omitted this IP — cache the + // negative so we don't re-ask for an hour. + set_transient(self::CACHE_PREFIX . md5($ip), 'none', self::TTL); + } + } + } + // On outright failure ($fetched === null) we cache nothing: the IPs + // stay unresolved and simply render as em-dashes this page load. + } + + return $out; + } + + /** + * POST the batch and return the raw `results` map, or null on any failure. + * + * @param array $ips + * @return array|null + */ + private function fetch(array $ips): ?array + { + if (!function_exists('wp_remote_post')) { + return null; + } + + $response = wp_remote_post(self::ENDPOINT, [ + 'timeout' => self::TIMEOUT, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $this->apiKey, + ], + 'body' => wp_json_encode(['ips' => array_values($ips)]), + ]); + + if (is_wp_error($response)) { + return null; + } + if ((int) wp_remote_retrieve_response_code($response) !== 200) { + return null; + } + + $body = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($body) || !isset($body['results']) || !is_array($body['results'])) { + return null; + } + + return $body['results']; + } + + // --------------------------------------------------------------------- + // Pure helpers (no WordPress calls) — unit-tested in tests/ActorIntelTest.php. + // --------------------------------------------------------------------- + + /** + * Normalize one IP's raw intel entry into a complete, typed shape. Honors + * the server's `redacted` flag: when redacted, the enriched fields + * (tor/vpn/abuse_score/actor) are forced null regardless of what the payload + * contains, so a free-connected teaser can never leak Pro-only intel. + * + * @param array $raw + * @return array{known:bool,network_detections:int,first_seen:int,last_seen:int,redacted:bool,tor:?bool,vpn:?bool,abuse_score:?int,actor:?array{id:string,sites:int}} + */ + public static function normalize_result(array $raw): array + { + $redacted = !empty($raw['redacted']); + + $result = [ + 'known' => !empty($raw['known']), + 'network_detections' => isset($raw['network_detections']) ? max(0, (int) $raw['network_detections']) : 0, + 'first_seen' => isset($raw['first_seen']) ? (int) $raw['first_seen'] : 0, + 'last_seen' => isset($raw['last_seen']) ? (int) $raw['last_seen'] : 0, + 'redacted' => $redacted, + 'tor' => null, + 'vpn' => null, + 'abuse_score' => null, + 'actor' => null, + ]; + + if (!$redacted) { + if (array_key_exists('tor', $raw)) { + $result['tor'] = (bool) $raw['tor']; + } + if (array_key_exists('vpn', $raw)) { + $result['vpn'] = (bool) $raw['vpn']; + } + if (isset($raw['abuse_score'])) { + $result['abuse_score'] = max(0, min(100, (int) $raw['abuse_score'])); + } + if (isset($raw['actor']) && is_array($raw['actor'])) { + $result['actor'] = [ + 'id' => isset($raw['actor']['id']) ? (string) $raw['actor']['id'] : '', + 'sites' => isset($raw['actor']['sites']) ? max(0, (int) $raw['actor']['sites']) : 0, + ]; + } + } + + return $result; + } + + /** + * Dedupe, validate, and cap a list of IPs for a batch request. + * + * @param array $ips + * @return array + */ + public static function unique_ips(array $ips, int $max): array + { + if ($max < 0) { + $max = 0; + } + $seen = []; + foreach ($ips as $ip) { + if (count($seen) >= $max) { + break; + } + $ip = is_string($ip) ? trim($ip) : ''; + if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) { + continue; + } + $seen[$ip] = true; + } + return array_keys($seen); + } +} diff --git a/includes/class-webdecoy-cloud-connect.php b/includes/class-webdecoy-cloud-connect.php index c7b1951..dc29fbe 100644 --- a/includes/class-webdecoy-cloud-connect.php +++ b/includes/class-webdecoy-cloud-connect.php @@ -47,9 +47,15 @@ class WebDecoy_Cloud_Connect */ private const ENTITLEMENTS_ENDPOINT = 'https://ingest.webdecoy.com/api/v1/sdk/entitlements'; - /** Where the "Upgrade" link points once connected. */ + /** Where the generic "Upgrade" link points once connected. */ private const BILLING_URL = 'https://app.webdecoy.com/billing'; + /** WordPress-channel checkout entry point (contract §4). */ + private const WP_BILLING_URL = 'https://app.webdecoy.com/billing/wordpress'; + + /** Valid WordPress plan slugs the checkout accepts. */ + private const WP_PLANS = ['wp_pro', 'wp_woo', 'wp_agency']; + /** Transient holding the pending connect nonce (site-scoped, one flow at a time). */ private const NONCE_TRANSIENT = 'webdecoy_connect_nonce'; @@ -357,13 +363,42 @@ public static function is_connected(): bool } /** - * The URL the "Upgrade" link points to. + * The URL the generic "Upgrade" link points to. */ public static function billing_url(): string { return self::BILLING_URL; } + /** + * The WordPress-channel checkout URL for a given plan (contract §4): + * `.../billing/wordpress?organization_id={id}&plan={wp_pro|wp_woo|wp_agency}`. + * + * The org id defaults to the stored one when not supplied. An unrecognized + * plan falls back to `wp_pro`. + */ + public static function wp_upgrade_url(string $plan = 'wp_pro', string $organization_id = ''): string + { + if (!in_array($plan, self::WP_PLANS, true)) { + $plan = 'wp_pro'; + } + + if ($organization_id === '') { + $options = get_option('webdecoy_options', []); + if (is_array($options) && isset($options['organization_id'])) { + $organization_id = (string) $options['organization_id']; + } + } + + // http_build_query encodes each value exactly once (add_query_arg does not). + $query = http_build_query([ + 'organization_id' => $organization_id, + 'plan' => $plan, + ]); + + return self::WP_BILLING_URL . '?' . $query; + } + // --------------------------------------------------------------------- // Pure helpers (no WordPress calls) — unit-tested in tests/CloudConnectTest.php. // --------------------------------------------------------------------- diff --git a/includes/class-webdecoy-critical-moment.php b/includes/class-webdecoy-critical-moment.php new file mode 100644 index 0000000..2d1ca8f --- /dev/null +++ b/includes/class-webdecoy-critical-moment.php @@ -0,0 +1,249 @@ + $ip, 'at' => time()], self::NOTICE_TTL); + update_option(self::LAST_OPTION, time(), false); + } + + /** + * Render (and consume) any pending moment. One-shot + dismissible. + */ + public function render(): void + { + if (!current_user_can('manage_options')) { + return; + } + + $pending = get_transient(self::NOTICE_TRANSIENT); + if (!is_array($pending) || empty($pending['ip']) || !is_string($pending['ip'])) { + return; + } + + $ip = $pending['ip']; + // Consume immediately so it shows exactly once. + delete_transient(self::NOTICE_TRANSIENT); + + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + return; + } + + $notice = $this->build_notice($ip); + if ($notice['message'] === '') { + return; + } + + $class = ($notice['type'] === 'success') ? 'notice-success' : 'notice-warning'; + ?> + + + + + + + + + + + + + + get_options() : []; + $org_id = isset($options['organization_id']) ? (string) $options['organization_id'] : ''; + $api_key = isset($options['api_key']) ? (string) $options['api_key'] : ''; + + if ($api_key !== '' && class_exists('WebDecoy_Actor_Intel')) { + $intel = new WebDecoy_Actor_Intel($api_key); + $map = $intel->lookup([$ip]); + $entry = isset($map[$ip]) && is_array($map[$ip]) ? $map[$ip] : null; + if ($entry !== null && !empty($entry['known'])) { + $known = true; + $days = self::days_since((int) $entry['first_seen'], time()); + } + } + } + + switch (self::variant($connected, $known, $has_actor_feed)) { + case 'connected_upgrade': + $n = max(1, $days); + return [ + 'message' => sprintf( + /* translators: %d: number of days since first seen on the network */ + _n( + 'This attacker was first seen on the WebDecoy network %d day ago — on Pro it would have been blocked before its first request.', + 'This attacker was first seen on the WebDecoy network %d days ago — on Pro it would have been blocked before its first request.', + $n, + 'webdecoy' + ), + $n + ), + 'cta_label' => __('Upgrade to Pro', 'webdecoy'), + 'cta_url' => class_exists('WebDecoy_Cloud_Connect') + ? WebDecoy_Cloud_Connect::wp_upgrade_url('wp_pro', $org_id) + : '', + 'type' => 'warning', + ]; + + case 'connected_covered': + return [ + 'message' => __('This attacker is already blocked network-wide — your actor feed already covers threats like this before they reach your site.', 'webdecoy'), + 'cta_label' => __('View blocked IPs', 'webdecoy'), + 'cta_url' => admin_url('admin.php?page=webdecoy-blocked'), + 'type' => 'success', + ]; + + case 'connected_unknown': + return [ + 'message' => __('A CRITICAL deception trap was just tripped on your site. Review the detection for the full picture.', 'webdecoy'), + 'cta_label' => __('View detections', 'webdecoy'), + 'cta_url' => admin_url('admin.php?page=webdecoy-detections'), + 'type' => 'warning', + ]; + + 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'), + 'cta_label' => __('Connect to WebDecoy Cloud', 'webdecoy'), + 'cta_url' => admin_url('admin.php?page=webdecoy&tab=cloud'), + 'type' => 'warning', + ]; + } + } + + // --------------------------------------------------------------------- + // Pure helpers (no WordPress calls) — unit-tested in tests/CriticalMomentTest.php. + // --------------------------------------------------------------------- + + /** + * Throttle decision: allow a new moment only when none has fired, or the + * window has fully elapsed since the last one. + */ + public static function should_queue(int $last_fired_at, int $now, int $window): bool + { + if ($last_fired_at <= 0) { + return true; + } + return ($now - $last_fired_at) >= $window; + } + + /** + * Whole days between a first-seen epoch and now. Clamped to 0 for missing or + * future timestamps. + */ + public static function days_since(int $first_seen, int $now): int + { + if ($first_seen <= 0 || $now <= $first_seen) { + return 0; + } + return (int) floor(($now - $first_seen) / 86400); + } + + /** + * Select the copy variant from the connection + intel + entitlement state. + */ + public static function variant(bool $connected, bool $known, bool $has_actor_feed): string + { + if (!$connected) { + return 'unconnected'; + } + if (!$known) { + return 'connected_unknown'; + } + if ($has_actor_feed) { + return 'connected_covered'; + } + return 'connected_upgrade'; + } +} diff --git a/includes/class-webdecoy-ip-enrichment.php b/includes/class-webdecoy-ip-enrichment.php index d0784f7..69662b1 100644 --- a/includes/class-webdecoy-ip-enrichment.php +++ b/includes/class-webdecoy-ip-enrichment.php @@ -53,6 +53,21 @@ public function enrich(string $ip): ?array return null; } + // Entitlement gate (P1 contract §3): enrichment is a paid Cloud feature. + // When we positively know the org isn't entitled (a synced entitlements + // cache with features.enrichment false), fail silently to no-enrichment — + // no remote call is made, and ip.* fields resolve to undefined just as + // they do without a key. A never-synced cache (fetched_at 0, e.g. a + // manual-key install) falls through and lets the server make the call + // (it 403s wordpress-channel orgs that lack enrichment). + if (class_exists('WebDecoy_Cloud_Connect')) { + $entitlements = WebDecoy_Cloud_Connect::get_entitlements(); + $fetched_at = isset($entitlements['fetched_at']) ? (int) $entitlements['fetched_at'] : 0; + if ($fetched_at > 0 && empty($entitlements['features']['enrichment'])) { + return null; + } + } + $cacheKey = 'webdecoy_enrich_' . md5($ip); $cached = get_transient($cacheKey); if (is_array($cached)) { diff --git a/tests/ActorFeedTest.php b/tests/ActorFeedTest.php new file mode 100644 index 0000000..88eb6b2 --- /dev/null +++ b/tests/ActorFeedTest.php @@ -0,0 +1,131 @@ + [ + ['id' => 'a1', 'ips' => ['1.1.1.1', '2.2.2.2'], 'last_seen' => 100], + ['id' => '', 'ips' => ['3.3.3.3']], // no id -> dropped + ['id' => 'a3', 'ips' => []], // no ips -> dropped + ['id' => 'a4', 'ips' => ['4.4.4.4', 5, ' ', '6.6.6.6']], // non-string/blank ip filtered + 'garbage', // non-array -> skipped + ], + 'next_since' => 555, + 'complete' => false, + ]); + + $same(2, count($page['actors']), 'only well-formed actors survive'); + $same('a1', $page['actors'][0]['id']); + $same(['1.1.1.1', '2.2.2.2'], $page['actors'][0]['ips']); + $same(['4.4.4.4', '6.6.6.6'], $page['actors'][1]['ips'], 'non-string and blank ips removed'); + $same(555, $page['next_since']); + $true($page['complete'] === false, 'complete is a strict bool'); +}); + +$t('missing pagination fields default safely', function () use ($same, $true) { + $page = WebDecoy_Actor_Feed::normalize_feed_page([]); + $same([], $page['actors']); + $same(0, $page['next_since'], 'absent next_since -> 0'); + $true($page['complete'] === false, 'absent complete -> false'); +}); + +echo "\nActor Feed: cross-page IP merge\n"; + +$t('merges ips across actors, keeping the most-recent last_seen', function () use ($same) { + $acc = WebDecoy_Actor_Feed::merge_actor_ips([], [ + ['id' => 'a1', 'ips' => ['1.1.1.1'], 'last_seen' => 100], + ['id' => 'a2', 'ips' => ['2.2.2.2'], 'last_seen' => 200], + ]); + // Second page: 1.1.1.1 reappears with a newer last_seen under a different actor. + $acc = WebDecoy_Actor_Feed::merge_actor_ips($acc, [ + ['id' => 'a9', 'ips' => ['1.1.1.1'], 'last_seen' => 500], + ]); + + $same(2, count($acc), 'ip set is deduped'); + $same('a9', $acc['1.1.1.1']['actor_id'], 'newer last_seen wins the actor attribution'); + $same(500, $acc['1.1.1.1']['last_seen']); + $same(200, $acc['2.2.2.2']['last_seen']); +}); + +$t('an older repeat does not overwrite a newer record', function () use ($same) { + $acc = WebDecoy_Actor_Feed::merge_actor_ips([], [ + ['id' => 'new', 'ips' => ['9.9.9.9'], 'last_seen' => 900], + ]); + $acc = WebDecoy_Actor_Feed::merge_actor_ips($acc, [ + ['id' => 'old', 'ips' => ['9.9.9.9'], 'last_seen' => 100], + ]); + $same('new', $acc['9.9.9.9']['actor_id'], 'stale duplicate is ignored'); + $same(900, $acc['9.9.9.9']['last_seen']); +}); + +echo "\nActor Feed: allowlist exclusion\n"; + +$t('never emits an allowlisted or invalid IP', function () use ($same, $true) { + $map = [ + '1.1.1.1' => ['actor_id' => 'a', 'last_seen' => 1], + '2.2.2.2' => ['actor_id' => 'b', 'last_seen' => 2], // allowlisted + 'not-an-ip' => ['actor_id' => 'c', 'last_seen' => 3], // invalid + '2001:db8::1' => ['actor_id' => 'd', 'last_seen' => 4], + ]; + $out = WebDecoy_Actor_Feed::exclude_allowlisted($map, ['2.2.2.2']); + + $true(isset($out['1.1.1.1']), 'non-allowlisted ipv4 kept'); + $true(isset($out['2001:db8::1']), 'valid ipv6 kept'); + $true(!isset($out['2.2.2.2']), 'exact allowlist match excluded'); + $true(!isset($out['not-an-ip']), 'invalid ip excluded'); + $same(2, count($out)); +}); + +echo "\nActor Feed: cap / eviction ordering\n"; + +$t('no eviction while at or under the cap', function () use ($same) { + $rows = ['1.1.1.1' => 100, '2.2.2.2' => 200]; + $same([], WebDecoy_Actor_Feed::select_evictions($rows, 2), 'exactly at cap keeps everything'); + $same([], WebDecoy_Actor_Feed::select_evictions($rows, 5), 'under cap keeps everything'); +}); + +$t('evicts the oldest-seen rows first, exactly the overage', function () use ($same) { + $rows = [ + 'newest' => 500, + 'oldest' => 100, + 'middle' => 300, + ]; + // Cap of 1 -> must drop the two oldest, keeping only "newest". + $evict = WebDecoy_Actor_Feed::select_evictions($rows, 1); + $same(['oldest', 'middle'], $evict, 'ascending last_seen order, count == overage'); +}); + +echo "\nActor Feed: pagination continue decision\n"; + +$t('stops on complete, stalled cursor, or a cap; else continues', function () use ($true) { + // complete -> stop even if a cursor was returned. + $true(WebDecoy_Actor_Feed::should_continue(true, true, 1, 25, 10, 2000) === false, 'complete stops'); + // cursor did not advance -> stop (guards against an infinite loop). + $true(WebDecoy_Actor_Feed::should_continue(false, false, 1, 25, 10, 2000) === false, 'stalled cursor stops'); + // page cap reached -> stop. + $true(WebDecoy_Actor_Feed::should_continue(false, true, 25, 25, 10, 2000) === false, 'page cap stops'); + // row cap reached -> stop. + $true(WebDecoy_Actor_Feed::should_continue(false, true, 1, 25, 2000, 2000) === false, 'row cap stops'); + // otherwise keep paginating. + $true(WebDecoy_Actor_Feed::should_continue(false, true, 1, 25, 10, 2000) === true, 'incomplete + advanced continues'); +}); diff --git a/tests/ActorIntelTest.php b/tests/ActorIntelTest.php new file mode 100644 index 0000000..441c6f2 --- /dev/null +++ b/tests/ActorIntelTest.php @@ -0,0 +1,108 @@ + 1, // truthy -> true + 'network_detections' => '42', // numeric string -> int + 'first_seen' => 1600000000, + 'last_seen' => 1600100000, + 'redacted' => false, + 'tor' => 1, + 'vpn' => 0, + 'abuse_score' => 250, // clamped to 100 + 'actor' => ['id' => 'act_7', 'sites' => '9'], + ]); + + $same(true, $r['known']); + $same(42, $r['network_detections']); + $same(1600000000, $r['first_seen']); + $same(1600100000, $r['last_seen']); + $same(false, $r['redacted']); + $same(true, $r['tor'], 'truthy tor coerced to bool'); + $same(false, $r['vpn'], 'falsy vpn coerced to bool'); + $same(100, $r['abuse_score'], 'abuse score clamped to 0..100'); + $true(is_array($r['actor']), 'actor present'); + $same('act_7', $r['actor']['id']); + $same(9, $r['actor']['sites']); +}); + +$t('a redacted (free-connected) result withholds every enriched field', function () use ($same, $null) { + $r = WebDecoy_Actor_Intel::normalize_result([ + 'known' => true, + 'network_detections' => 5, + 'first_seen' => 1600000000, + 'last_seen' => 1600100000, + 'redacted' => true, + // Even if the server leaks these, the client must NOT surface them: + 'tor' => true, + 'vpn' => true, + 'abuse_score' => 88, + 'actor' => ['id' => 'act_x', 'sites' => 3], + ]); + + $same(true, $r['known'], 'teaser still exposes known'); + $same(5, $r['network_detections'], 'teaser still exposes the count'); + $same(1600000000, $r['first_seen']); + $same(true, $r['redacted']); + $null($r['tor'], 'tor withheld when redacted'); + $null($r['vpn'], 'vpn withheld when redacted'); + $null($r['abuse_score'], 'abuse score withheld when redacted'); + $null($r['actor'], 'actor withheld when redacted'); +}); + +$t('a sparse result fills sane defaults and never fatals', function () use ($same, $null) { + $r = WebDecoy_Actor_Intel::normalize_result([]); + $same(false, $r['known']); + $same(0, $r['network_detections']); + $same(0, $r['first_seen']); + $same(0, $r['last_seen']); + $same(false, $r['redacted']); + $null($r['tor']); + $null($r['vpn']); + $null($r['abuse_score']); + $null($r['actor']); +}); + +$t('negative counts are floored at zero', function () use ($same) { + $r = WebDecoy_Actor_Intel::normalize_result(['network_detections' => -3, 'abuse_score' => -10]); + $same(0, $r['network_detections']); + $same(0, $r['abuse_score']); +}); + +echo "\nActor Intel: IP batching\n"; + +$t('unique_ips dedupes, drops invalids, and caps the batch', function () use ($same) { + $out = WebDecoy_Actor_Intel::unique_ips( + ['1.1.1.1', '1.1.1.1', ' 2.2.2.2 ', 'nope', '', 3, '2001:db8::5'], + 50 + ); + $same(['1.1.1.1', '2.2.2.2', '2001:db8::5'], $out, 'deduped, trimmed, invalids removed'); +}); + +$t('unique_ips honors the max cap', function () use ($same) { + $out = WebDecoy_Actor_Intel::unique_ips(['1.1.1.1', '2.2.2.2', '3.3.3.3'], 2); + $same(['1.1.1.1', '2.2.2.2'], $out, 'stops at the cap'); + $same([], WebDecoy_Actor_Intel::unique_ips(['1.1.1.1'], 0), 'cap of 0 yields nothing'); +}); diff --git a/tests/CriticalMomentTest.php b/tests/CriticalMomentTest.php new file mode 100644 index 0000000..a835a11 --- /dev/null +++ b/tests/CriticalMomentTest.php @@ -0,0 +1,58 @@ + allow'); + $true(WebDecoy_Critical_Moment::should_queue(-1, 1000000, $week) === true, 'sentinel <= 0 -> allow'); +}); + +$t('throttle blocks within the window and reopens at the boundary', function () use ($true, $week) { + $now = 10000000; + $true(WebDecoy_Critical_Moment::should_queue($now - 1, $now, $week) === false, '1s later still blocked'); + $true(WebDecoy_Critical_Moment::should_queue($now - ($week - 1), $now, $week) === false, 'just inside window blocked'); + $true(WebDecoy_Critical_Moment::should_queue($now - $week, $now, $week) === true, 'exactly at window reopens'); + $true(WebDecoy_Critical_Moment::should_queue($now - ($week + 1), $now, $week) === true, 'past window allowed'); +}); + +echo "\nCritical Moment: days since first seen\n"; + +$t('days_since floors whole days and clamps to zero', function () use ($same) { + $now = 1000000000; + $same(0, WebDecoy_Critical_Moment::days_since(0, $now), 'no first_seen -> 0'); + $same(0, WebDecoy_Critical_Moment::days_since($now + 5000, $now), 'future first_seen -> 0'); + $same(0, WebDecoy_Critical_Moment::days_since($now - 3600, $now), 'under a day -> 0'); + $same(1, WebDecoy_Critical_Moment::days_since($now - 86400, $now), 'exactly one day'); + $same(3, WebDecoy_Critical_Moment::days_since($now - (3 * 86400 + 100), $now), 'floors partial days'); +}); + +echo "\nCritical Moment: copy variant\n"; + +$t('variant reflects connection, intel, and entitlement', function () use ($same) { + $same('unconnected', WebDecoy_Critical_Moment::variant(false, false, false), 'not connected'); + $same('unconnected', WebDecoy_Critical_Moment::variant(false, true, true), 'connection is the gate'); + $same('connected_unknown', WebDecoy_Critical_Moment::variant(true, false, false), 'connected but IP unknown'); + $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'); +}); diff --git a/uninstall.php b/uninstall.php index 90e9e0a..e8e8f0b 100644 --- a/uninstall.php +++ b/uninstall.php @@ -47,11 +47,14 @@ delete_option('webdecoy_api_last_error'); delete_option('webdecoy_encryption_key'); delete_option('webdecoy_entitlements'); + delete_option('webdecoy_actor_feed_cursor'); + delete_option('webdecoy_critical_moment_last'); // Clear scheduled events wp_clear_scheduled_hook('webdecoy_cleanup_expired'); wp_clear_scheduled_hook('webdecoy_sync_blocked_ips'); wp_clear_scheduled_hook('webdecoy_sync_entitlements'); + wp_clear_scheduled_hook('webdecoy_sync_actor_feed'); } // Delete all transients with webdecoy_ prefix @@ -94,11 +97,14 @@ delete_option('webdecoy_api_last_error'); delete_option('webdecoy_encryption_key'); delete_option('webdecoy_entitlements'); + delete_option('webdecoy_actor_feed_cursor'); + delete_option('webdecoy_critical_moment_last'); // Clear scheduled events for this site wp_clear_scheduled_hook('webdecoy_cleanup_expired'); wp_clear_scheduled_hook('webdecoy_sync_blocked_ips'); wp_clear_scheduled_hook('webdecoy_sync_entitlements'); + wp_clear_scheduled_hook('webdecoy_sync_actor_feed'); // Delete transients for this site $wpdb->query( diff --git a/webdecoy.php b/webdecoy.php index 89e7540..2b49102 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -145,6 +145,20 @@ final class WebDecoy_Plugin */ private ?WebDecoy_Cloud_Connect $cloud_connect = null; + /** + * Actor feed controller (hourly network-block sync — Pro+ only). + * + * @var WebDecoy_Actor_Feed|null + */ + private ?WebDecoy_Actor_Feed $actor_feed = null; + + /** + * Post-CRITICAL moment controller (one-shot upgrade notice). + * + * @var WebDecoy_Critical_Moment|null + */ + private ?WebDecoy_Critical_Moment $critical_moment = null; + /** * Get plugin instance * @@ -793,12 +807,25 @@ public function load_includes(): void 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-cloud-connect.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-actor-intel.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-actor-feed.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-critical-moment.php'; // One-click Cloud connect + entitlements sync. Makes no external request // until the admin explicitly clicks "Connect". $this->cloud_connect = new WebDecoy_Cloud_Connect(); $this->cloud_connect->register(); + // Actor feed: hourly network-block sync. Self-guards to make no external + // request unless connected AND entitled to the actor feed (Pro+). + $this->actor_feed = new WebDecoy_Actor_Feed(); + $this->actor_feed->register(); + + // Post-CRITICAL moment: one-shot, throttled upgrade notice. Queued from + // the CRITICAL detection insert sites; renders in wp-admin only. + $this->critical_moment = new WebDecoy_Critical_Moment(); + $this->critical_moment->register(); + if (class_exists('WooCommerce')) { require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php'; } @@ -1074,6 +1101,27 @@ public function record_synthetic_tripwire(string $rule, string $path): void } } + /** + * Queue the post-CRITICAL upgrade moment when a detection is recorded at + * CRITICAL severity (the decoy/canary exfiltration path). No-op for any + * lower severity and self-throttled to at most one notice every 7 days. + * Never makes an external request at this point — the intel lookup that + * personalises the copy is deferred to the admin render. + * + * @param string $ip + * @param string $threat_level + */ + private function flag_critical_moment(string $ip, string $threat_level): void + { + if ($this->critical_moment === null) { + return; + } + if ($threat_level !== \WebDecoy\DetectionResult::THREAT_CRITICAL) { + return; + } + $this->critical_moment->maybe_queue($ip); + } + /** * Cron: drain any spooled violation reports that the per-request shutdown * flush couldn't deliver (e.g. a brief ingest outage). A non-empty API key @@ -1440,6 +1488,8 @@ private function log_detection(\WebDecoy\DetectionResult $result, string $ip): v 'flags' => json_encode($flags_data), 'created_at' => current_time('mysql'), ]); + + $this->flag_critical_moment($ip, $result->getThreatLevel()); } /** @@ -1546,6 +1596,9 @@ public function check_canary_login($user, string $username, string $password) 'created_at' => current_time('mysql'), ]); + // Confirmed decoy exfiltration is always CRITICAL — surface the moment. + $this->flag_critical_moment($ip, \WebDecoy\DetectionResult::THREAT_CRITICAL); + $blocker = new WebDecoy_Blocker(); $duration = $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null; $blocker->block($ip, 'Canary credential use (decoy exfiltration)', $duration); @@ -2252,6 +2305,8 @@ private function log_client_detection(array $detection, string $ip): void 'flags' => $flags_json, 'created_at' => current_time('mysql'), ]); + + $this->flag_critical_moment($ip, $threat_level); } /**
+ + +
+ + + +