Changeset 3490327
- Timestamp:
- 03/24/2026 07:39:27 PM (10 days ago)
- Location:
- vulnity/trunk
- Files:
-
- 1 added
- 15 edited
-
CHANGELOG.md (modified) (1 diff)
-
includes/alerts/class-alert-base.php (modified) (2 diffs)
-
includes/alerts/class-auto-update-alert.php (added)
-
includes/alerts/class-plugin-change-alert.php (modified) (2 diffs)
-
includes/alerts/class-suspicious-query-alert.php (modified) (1 diff)
-
includes/alerts/class-theme-change-alert.php (modified) (4 diffs)
-
includes/class-alert-manager.php (modified) (2 diffs)
-
includes/class-anti-collapse.php (modified) (2 diffs)
-
includes/class-core.php (modified) (6 diffs)
-
includes/class-inventory-sync.php (modified) (2 diffs)
-
includes/class-mitigation-manager.php (modified) (5 diffs)
-
includes/class-siem-connector.php (modified) (10 diffs)
-
readme.txt (modified) (3 diffs)
-
uninstall.php (modified) (1 diff)
-
views/admin-dashboard.php (modified) (8 diffs)
-
vulnity.php (modified) (8 diffs)
Legend:
- Unmodified
- Added
- Removed
-
vulnity/trunk/CHANGELOG.md
r3478206 r3490327 1 1 # Changelog - Vulnity Security 2 3 ## [1.2.2] - 2026-03-19 4 5 ### Auto-Update System — Bug Fixes & SIEM Bidirectionality 6 7 #### Fixed 8 - **Anti-collapse dedup blocking second toggle:** `generate_hash()` produced the same hash for all `auto_update_state_changed` events because those events carry no `ip`, `user_id` or `action` fields. Hash now includes plugin/theme toggle state (`:p=0/1:t=0/1`), making each combination distinct. 9 - **`/real-time-alerts` auth failure:** Endpoint requires HMAC-SHA256 (`x-signature` + `x-site-id` + `x-plugin-version`) but plugin was sending `x-vulnity-token`. `send_alert()` now selects headers based on target endpoint. 10 - **Missing `remediation` field:** SIEM canonical schema requires `remediation.summary` and `remediation.steps`. Added to `send_auto_update_state_event()`. 11 - **`version_old` missing from auto-update events:** By `upgrader_process_complete` files are already replaced, making the old version unrecoverable. Fixed by hooking `upgrader_pre_install` to capture the version before WP overwrites plugin/theme files. 12 - **Updates re-triggered when disabling auto-update:** `ajax_save_auto_update()` was running `apply_push_trigger_update()` whenever at least one type was ON, including when disabling. Now reads previous state first and only triggers for types that are *newly* being enabled. 13 - **`wp_doing_cron()` too restrictive:** Check prevented auto-update alerts when `wp_maybe_auto_update()` was invoked outside a standard cron context (e.g. via `wp eval`). Replaced with `$upgrader instanceof WP_Automatic_Updater`, which correctly identifies WP's native updater in all contexts. 14 - **Single-file plugin slug resolving to `.`:** `dirname('hello-dolly.php')` returns `'.'`, breaking slug-based lookups. Fixed with `basename($file, '.php')` fallback in both `apply_push_trigger_update()` and `on_auto_update_complete()`. 15 16 #### Added 17 - **`triggered_by` field:** Distinguishes `siem_manual` (SIEM explicit request), `siem_auto_update` (triggered by enabling auto-update), and `wp_auto_updater` (WordPress native cron updater) in manual-update and auto-update events. 18 19 #### Changed 20 - **Auto-update toggles are now read-only in the admin panel.** An info banner redirects administrators to the SIEM (Sistema section) to enable or disable auto-updates, preventing state divergence between WP admin and the SIEM. 21 22 #### Coding Standards 23 - Replaced `parse_url()` with `wp_parse_url()` across `class-core.php` and `class-auto-update-alert.php`. 24 - Added `phpcs:disable/enable` comment for nonce verification in `ajax_save_auto_update()` (nonce already verified via `verify_admin_ajax_request()`). 25 - Updated `readme.txt` Stable tag from 1.2.1 to 1.2.2. 26 27 --- 2 28 3 29 ## [1.2.1] - 2026-03-09 -
vulnity/trunk/includes/alerts/class-alert-base.php
r3478185 r3490327 73 73 do_action('vulnity_alert_created', $alert['id'], $alert); 74 74 75 // Try to send to SIEM with retry logic 76 $send_result = $this->send_to_siem_with_retry($alert); 77 75 // Try to send to SIEM with retry logic. 76 // In user-request context (not cron/REST/CLI) defer to shutdown to avoid 77 // blocking the response if the SIEM is slow or unreachable. 78 if ( defined( 'DOING_CRON' ) || defined( 'REST_REQUEST' ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) { 79 $send_result = $this->send_to_siem_with_retry( $alert ); 80 } else { 81 $alert_deferred = $alert; 82 add_action( 'shutdown', function () use ( $alert_deferred ) { 83 $this->send_to_siem_with_retry( $alert_deferred ); 84 }, 5 ); 85 $send_result = true; 86 } 87 78 88 // If alert type triggers inventory scan, schedule it 79 89 if ($send_result && in_array($this->alert_type, $this->inventory_trigger_types)) { … … 391 401 392 402 /** 393 * Process retry queue - called by cron 403 * Process retry queue - called by cron. 404 * Handles: success removal, failure counting, max-attempts exhaustion → failed_alerts, 405 * orphan cleanup (alert scrolled out of the 100-cap), and stale already-sent entries. 394 406 */ 395 407 public static function process_retry_queue() { 396 408 $queue = get_option('vulnity_retry_queue', array()); 397 409 398 410 if (empty($queue)) { 399 411 return; 400 412 } 401 413 402 414 $alerts = get_option('vulnity_alerts', array()); 403 $siem = Vulnity_SIEM_Connector::get_instance(); 404 $processed = array(); 405 $sent_alerts = array(); 406 415 $siem = Vulnity_SIEM_Connector::get_instance(); 416 417 // Max attempts per severity (mirrors $retry_config on instances) 418 $retry_max = array( 419 'critical' => 5, 420 'high' => 3, 421 'medium' => 2, 422 'low' => 1, 423 'info' => 1, 424 ); 425 426 $processed = array(); // IDs to remove from queue (success / exhausted / orphan) 427 $sent_alerts = array(); // For dispatching after DB writes 428 407 429 foreach ($queue as $queue_item) { 408 // Find the alert 409 $alert = null; 410 foreach ($alerts as $stored_alert) { 411 if ($stored_alert['id'] === $queue_item['id']) { 412 $alert = $stored_alert; 430 $alert_id = $queue_item['id']; 431 $alert = null; 432 $alert_index = null; 433 434 foreach ($alerts as $idx => $stored_alert) { 435 if (isset($stored_alert['id']) && $stored_alert['id'] === $alert_id) { 436 $alert = $stored_alert; 437 $alert_index = $idx; 413 438 break; 414 439 } 415 440 } 416 417 if ($alert && !$alert['sent_to_siem']) { 418 $result = $siem->send_alert($alert); 419 420 if ($result['success']) { 421 $sent_alert = self::apply_sent_result_to_alerts($alerts, $alert['id'], $result); 422 if ($sent_alert !== null) { 423 $sent_alerts[] = array( 424 'id' => $alert['id'], 425 'alert' => $sent_alert, 426 ); 427 } 428 $processed[] = $alert['id']; 429 vulnity_log('[Vulnity] Alert ' . $alert['id'] . ' sent successfully from retry queue'); 430 } 431 } 432 } 433 434 // Update alerts 441 442 // Orphan: scrolled out of the 100-entry cap or manually cleared. 443 if ($alert === null) { 444 $processed[] = $alert_id; 445 vulnity_log('[Vulnity] Retry queue: removing orphan entry ' . $alert_id); 446 continue; 447 } 448 449 // Stale: already successfully sent (race condition guard). 450 if (!empty($alert['sent_to_siem'])) { 451 $processed[] = $alert_id; 452 continue; 453 } 454 455 $severity = isset($retry_max[$alert['severity']]) ? $alert['severity'] : 'low'; 456 $max_attempts = $retry_max[$severity]; 457 $retry_count = isset($alert['retry_count']) ? (int) $alert['retry_count'] : 0; 458 459 $result = $siem->send_alert($alert); 460 461 if ($result['success']) { 462 $sent_alert = self::apply_sent_result_to_alerts($alerts, $alert_id, $result); 463 if ($sent_alert !== null) { 464 $sent_alerts[] = array('id' => $alert_id, 'alert' => $sent_alert); 465 } 466 $processed[] = $alert_id; 467 vulnity_log('[Vulnity] Alert ' . $alert_id . ' sent successfully from retry queue'); 468 } else { 469 // Increment retry count in the local alerts array. 470 $retry_count++; 471 if ($alert_index !== null) { 472 $alerts[$alert_index]['retry_count'] = $retry_count; 473 $alerts[$alert_index]['last_retry'] = current_time('mysql'); 474 $alerts[$alert_index]['last_error'] = isset($result['error']) ? $result['error'] : 'unknown'; 475 } 476 477 vulnity_log('[Vulnity] Retry queue attempt ' . $retry_count . '/' . $max_attempts . ' failed for ' . $alert_id . ': ' . (isset($result['error']) ? $result['error'] : 'unknown')); 478 479 if ($retry_count >= $max_attempts) { 480 // Move to failed_alerts and remove from queue. 481 $failed = get_option('vulnity_failed_alerts', array()); 482 $failed[] = array( 483 'alert' => $alert, 484 'failed_at' => current_time('mysql'), 485 'reason' => 'Max retry attempts exceeded via cron (' . $retry_count . '/' . $max_attempts . ')', 486 ); 487 $failed = array_slice($failed, -100); 488 update_option('vulnity_failed_alerts', $failed); 489 $processed[] = $alert_id; 490 vulnity_log('[Vulnity] Alert ' . $alert_id . ' moved to failed_alerts after ' . $retry_count . ' attempts'); 491 } 492 // If under max_attempts: leave in queue for the next cron run. 493 } 494 } 495 496 // Persist updated retry metadata (counts, last_retry, last_error). 435 497 update_option('vulnity_alerts', $alerts); 436 437 // Remove processed items from queue498 499 // Remove processed entries (success + exhausted + orphans + stale). 438 500 if (!empty($processed)) { 439 501 $queue = array_filter($queue, function($item) use ($processed) { 440 return !in_array($item['id'], $processed );502 return !in_array($item['id'], $processed, true); 441 503 }); 442 504 update_option('vulnity_retry_queue', array_values($queue)); -
vulnity/trunk/includes/alerts/class-plugin-change-alert.php
r3478185 r3490327 3 3 4 4 class Vulnity_Plugin_Change_Alert extends Vulnity_Alert_Base { 5 5 6 /** Set to true while push_trigger_update is running to suppress duplicate alerts. */ 7 private static $siem_update_active = false; 8 9 /** Captures version before each upgrade so we can show old → new in the title. */ 10 private static $pre_update_versions = array(); 11 12 public static function set_siem_update_mode( $active ) { 13 self::$siem_update_active = (bool) $active; 14 } 15 16 public static function is_siem_update_mode() { 17 return self::$siem_update_active; 18 } 19 6 20 public function __construct() { 7 21 $this->alert_type = 'plugin_change'; 8 22 parent::__construct(); 9 23 } 10 24 11 25 protected function register_hooks() { 12 add_action('activated_plugin', array($this, 'on_plugin_activated'), 10, 2); 13 add_action('deactivated_plugin', array($this, 'on_plugin_deactivated'), 10, 2); 14 add_action('upgrader_process_complete', array($this, 'on_plugin_updated'), 10, 2); 15 add_action('deleted_plugin', array($this, 'on_plugin_deleted'), 10, 2); 16 } 17 26 add_action('activated_plugin', array($this, 'on_plugin_activated'), 10, 2); 27 add_action('deactivated_plugin', array($this, 'on_plugin_deactivated'), 10, 2); 28 add_filter('upgrader_pre_install', array($this, 'capture_pre_install_version'), 10, 2); 29 add_action('upgrader_process_complete', array($this, 'on_plugin_updated'), 10, 2); 30 add_action('deleted_plugin', array($this, 'on_plugin_deleted'), 10, 2); 31 } 32 33 /** 34 * Capture the current (old) version just before WordPress replaces the plugin files. 35 */ 36 public function capture_pre_install_version( $response, $hook_extra ) { 37 if ( ! empty( $hook_extra['plugin'] ) ) { 38 $full_path = WP_PLUGIN_DIR . '/' . $hook_extra['plugin']; 39 if ( file_exists( $full_path ) ) { 40 $data = get_plugin_data( $full_path, false, false ); 41 self::$pre_update_versions[ $hook_extra['plugin'] ] = isset( $data['Version'] ) ? $data['Version'] : 'unknown'; 42 } 43 } 44 return $response; 45 } 46 18 47 public function on_plugin_activated($plugin, $network_wide) { 48 // Suppress during SIEM-triggered updates to avoid duplicate alerts 49 if ( self::$siem_update_active ) { 50 return; 51 } 19 52 $this->evaluate(array( 20 'action' => 'activated',21 'plugin' => $plugin,22 'network_wide' => $network_wide 53 'action' => 'activated', 54 'plugin' => $plugin, 55 'network_wide' => $network_wide, 23 56 )); 24 57 } 25 58 26 59 public function on_plugin_deactivated($plugin, $network_wide) { 60 // Suppress during SIEM-triggered updates to avoid duplicate alerts 61 if ( self::$siem_update_active ) { 62 return; 63 } 27 64 $this->evaluate(array( 28 'action' => 'deactivated',29 'plugin' => $plugin,30 'network_wide' => $network_wide 65 'action' => 'deactivated', 66 'plugin' => $plugin, 67 'network_wide' => $network_wide, 31 68 )); 32 69 } 33 70 34 71 public function on_plugin_updated($upgrader, $hook_extra) { 35 if ($hook_extra['type'] !== 'plugin') { 36 return; 37 } 38 39 if ($hook_extra['action'] === 'install') { 72 if ( empty( $hook_extra['type'] ) || $hook_extra['type'] !== 'plugin' ) { 73 return; 74 } 75 76 // Update alerts are emitted exclusively through the canonical 77 // plugin_update/theme_update event pipeline. 78 if ( ! empty( $hook_extra['action'] ) && $hook_extra['action'] === 'update' ) { 79 return; 80 } 81 82 if ( ! empty( $hook_extra['action'] ) && $hook_extra['action'] === 'install' ) { 40 83 $this->evaluate(array( 41 'action' => 'installed',42 'plugins' => isset( $hook_extra['plugins']) ? $hook_extra['plugins'] : array()84 'action' => 'installed', 85 'plugins' => isset( $hook_extra['plugins'] ) ? $hook_extra['plugins'] : array(), 43 86 )); 44 } elseif ($hook_extra['action'] === 'update') { 45 $this->evaluate(array( 46 'action' => 'updated', 47 'plugins' => isset($hook_extra['plugins']) ? $hook_extra['plugins'] : array() 48 )); 49 } 50 } 51 87 return; 88 } 89 90 } 91 52 92 public function on_plugin_deleted($plugin_file, $deleted) { 53 93 if ($deleted) { 54 94 $this->evaluate(array( 55 95 'action' => 'deleted', 56 'plugin' => $plugin_file 96 'plugin' => $plugin_file, 57 97 )); 58 98 } 59 99 } 60 100 61 101 protected function evaluate($data) { 62 102 $current_user_info = $this->get_current_user_info(); 63 64 $severity = 'medium';65 $title = '';66 $message = '';103 104 $severity = 'medium'; 105 $title = ''; 106 $message = ''; 67 107 $plugin_info = array(); 68 69 if ( isset($data['plugin']) && !is_array($data['plugin']) && file_exists(WP_PLUGIN_DIR . '/' . $data['plugin'])) {70 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $data['plugin']);108 109 if ( isset( $data['plugin'] ) && ! is_array( $data['plugin'] ) && file_exists( WP_PLUGIN_DIR . '/' . $data['plugin'] ) ) { 110 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $data['plugin'] ); 71 111 } else { 72 112 $plugin_data = array(); 73 113 } 74 75 if ( !empty($plugin_data)) {114 115 if ( ! empty( $plugin_data ) ) { 76 116 $plugin_info = array( 77 'name' => $plugin_data['Name'],117 'name' => $plugin_data['Name'], 78 118 'version' => $plugin_data['Version'], 79 'author' => $plugin_data['Author'],80 'file' => isset($data['plugin']) ? $data['plugin'] : ''119 'author' => $plugin_data['Author'], 120 'file' => isset( $data['plugin'] ) ? $data['plugin'] : '', 81 121 ); 82 122 } 83 84 switch ( $data['action']) {123 124 switch ( $data['action'] ) { 85 125 case 'activated': 86 126 $severity = 'medium'; 87 $title = 'Plugin Activated';88 $message = sprintf(127 $title = 'Plugin Activated'; 128 $message = sprintf( 89 129 'Plugin "%s" was activated by user "%s" from IP %s', 90 130 $plugin_info['name'], … … 93 133 ); 94 134 break; 95 135 96 136 case 'deactivated': 97 137 $severity = 'low'; 98 $title = 'Plugin Deactivated'; 138 $title = 'Plugin Deactivated'; 139 $message = sprintf( 140 'Plugin "%s" was deactivated by user "%s" from IP %s', 141 ! empty( $plugin_info['name'] ) ? $plugin_info['name'] : 'Unknown', 142 $current_user_info['user_login'], 143 $current_user_info['user_ip'] 144 ); 145 break; 146 147 case 'installed': 148 $severity = 'info'; 149 $title = 'Plugin Installed'; 150 $message = sprintf( 151 'New plugin was installed by user "%s" from IP %s', 152 $current_user_info['user_login'], 153 $current_user_info['user_ip'] 154 ); 155 break; 156 157 case 'updated': 158 $severity = 'info'; 159 $version_info = isset( $data['version_info'] ) ? $data['version_info'] : array(); 160 $plugins = isset( $data['plugins'] ) ? $data['plugins'] : array(); 161 162 if ( count( $plugins ) === 1 ) { 163 $plugin_file = reset( $plugins ); 164 $info = isset( $version_info[ $plugin_file ] ) ? $version_info[ $plugin_file ] : array(); 165 $name = ! empty( $info['name'] ) ? $info['name'] : $plugin_file; 166 $old_v = ! empty( $info['old_version'] ) ? $info['old_version'] : 'unknown'; 167 $new_v = ! empty( $info['new_version'] ) ? $info['new_version'] : 'unknown'; 168 $title = sprintf( 'Plugin Update: %s %s → %s', $name, $old_v, $new_v ); 169 } else { 170 $names = array(); 171 foreach ( $plugins as $pf ) { 172 $names[] = ! empty( $version_info[ $pf ]['name'] ) ? $version_info[ $pf ]['name'] : $pf; 173 } 174 $title = sprintf( 'Plugin Update: %s', implode( ', ', $names ) ); 175 } 176 99 177 $message = sprintf( 100 'Plugin "%s" was deactivated by user "%s" from IP %s', 101 !empty($plugin_info['name']) ? $plugin_info['name'] : 'Unknown', 102 $current_user_info['user_login'], 103 $current_user_info['user_ip'] 104 ); 105 break; 106 107 case 'installed': 108 $severity = 'high'; 109 $title = 'New Plugin Installed'; 110 $message = sprintf( 111 'New plugin was installed by user "%s" from IP %s', 112 $current_user_info['user_login'], 113 $current_user_info['user_ip'] 114 ); 115 break; 116 117 case 'updated': 118 $severity = 'low'; 119 $title = 'Plugin Updated'; 120 $message = sprintf( 121 'Plugin was updated by user "%s" from IP %s', 122 $current_user_info['user_login'], 123 $current_user_info['user_ip'] 124 ); 125 break; 126 178 'Plugin updated by user "%s" from IP %s', 179 $current_user_info['user_login'], 180 $current_user_info['user_ip'] 181 ); 182 break; 183 127 184 case 'deleted': 128 185 $severity = 'high'; 129 $title = 'Plugin Deleted';130 $message = sprintf(186 $title = 'Plugin Deleted'; 187 $message = sprintf( 131 188 'Plugin "%s" was deleted by user "%s" from IP %s', 132 ! empty($plugin_info['name']) ? $plugin_info['name'] : 'Unknown',133 $current_user_info['user_login'], 134 $current_user_info['user_ip'] 135 ); 136 break; 137 } 138 139 if ( empty($title)) {140 return; 141 } 142 189 ! empty( $plugin_info['name'] ) ? $plugin_info['name'] : 'Unknown', 190 $current_user_info['user_login'], 191 $current_user_info['user_ip'] 192 ); 193 break; 194 } 195 196 if ( empty( $title ) ) { 197 return; 198 } 199 143 200 $this->create_alert(array( 144 201 'severity' => $severity, 145 'title' => $title, 146 'message' => $message, 147 'details' => array( 148 'action' => $data['action'], 149 'plugin_info' => $plugin_info, 150 'network_wide' => isset($data['network_wide']) ? $data['network_wide'] : false, 151 'user_id' => $current_user_info['user_id'], 152 'user_login' => $current_user_info['user_login'], 153 'user_email' => $current_user_info['user_email'], 154 'user_ip' => $current_user_info['user_ip'], 155 'timestamp' => current_time('mysql') 156 ) 202 'title' => $title, 203 'message' => $message, 204 'details' => array( 205 'action' => $data['action'], 206 'plugin_info' => $plugin_info, 207 'version_info' => isset( $data['version_info'] ) ? $data['version_info'] : array(), 208 'network_wide' => isset( $data['network_wide'] ) ? $data['network_wide'] : false, 209 'user_id' => $current_user_info['user_id'], 210 'user_login' => $current_user_info['user_login'], 211 'user_email' => $current_user_info['user_email'], 212 'user_ip' => $current_user_info['user_ip'], 213 'timestamp' => current_time('mysql'), 214 ), 157 215 )); 158 216 } -
vulnity/trunk/includes/alerts/class-suspicious-query-alert.php
r3478185 r3490327 632 632 633 633 private function check_patterns($value, $type, $parameter, &$detections) { 634 // Truncate to a safe length before regex evaluation to prevent ReDoS 635 // with crafted long inputs that cause catastrophic backtracking. 636 if ( strlen( $value ) > 2000 ) { 637 $value = substr( $value, 0, 2000 ); 638 } 639 634 640 // Check SQL injection patterns 635 641 foreach ($this->sql_injection_patterns as $pattern) { -
vulnity/trunk/includes/alerts/class-theme-change-alert.php
r3448563 r3490327 3 3 4 4 class Vulnity_Theme_Change_Alert extends Vulnity_Alert_Base { 5 5 6 /** Set to true while push_trigger_update is running to suppress duplicate alerts. */ 7 private static $siem_update_active = false; 8 9 public static function set_siem_update_mode( $active ) { 10 self::$siem_update_active = (bool) $active; 11 } 12 13 public static function is_siem_update_mode() { 14 return self::$siem_update_active; 15 } 16 6 17 public function __construct() { 7 18 $this->alert_type = 'theme_change'; … … 16 27 17 28 public function on_theme_switched($new_name, $new_theme, $old_theme) { 29 $old_name = ($old_theme instanceof WP_Theme && $old_theme->exists()) 30 ? $old_theme->get('Name') 31 : 'Unknown'; 32 $old_slug = ($old_theme instanceof WP_Theme && $old_theme->exists()) 33 ? $old_theme->get_stylesheet() 34 : ''; 35 $new_slug = ($new_theme instanceof WP_Theme && $new_theme->exists()) 36 ? $new_theme->get_stylesheet() 37 : ''; 38 39 if ($old_name !== 'Unknown' || $old_slug !== '') { 40 $this->evaluate(array( 41 'action' => 'deactivated', 42 'theme' => $old_name, 43 'theme_slug' => $old_slug, 44 'related_theme' => $new_name, 45 )); 46 } 47 18 48 $this->evaluate(array( 19 'action' => 'switched', 20 'new_theme' => $new_name, 21 'old_theme' => $old_theme ? $old_theme->get('Name') : 'Unknown' 49 'action' => 'activated', 50 'theme' => $new_name, 51 'theme_slug' => $new_slug, 52 'related_theme' => $old_name, 22 53 )); 23 54 } 24 55 25 56 public function on_theme_updated($upgrader, $hook_extra) { 26 if ( $hook_extra['type'] !== 'theme') {57 if ( empty( $hook_extra['type'] ) || $hook_extra['type'] !== 'theme' ) { 27 58 return; 28 59 } 29 30 if ($hook_extra['action'] === 'install') { 60 61 // Update alerts are emitted exclusively through the canonical 62 // plugin_update/theme_update event pipeline. 63 if ( ! empty( $hook_extra['action'] ) && $hook_extra['action'] === 'update' ) { 64 return; 65 } 66 67 if ( ! empty( $hook_extra['action'] ) && $hook_extra['action'] === 'install' ) { 31 68 $this->evaluate(array( 32 69 'action' => 'installed', 33 'themes' => isset($hook_extra['themes']) ? $hook_extra['themes'] : array()34 ));35 } elseif ($hook_extra['action'] === 'update') {36 $this->evaluate(array(37 'action' => 'updated',38 70 'themes' => isset($hook_extra['themes']) ? $hook_extra['themes'] : array() 39 71 )); … … 58 90 59 91 switch ($data['action']) { 92 case 'activated': 93 $severity = 'medium'; 94 $title = 'Theme Activated'; 95 $message = sprintf( 96 'Theme "%s" was activated by user "%s" from IP %s', 97 $data['theme'], 98 $current_user_info['user_login'], 99 $current_user_info['user_ip'] 100 ); 101 break; 102 103 case 'deactivated': 104 $severity = 'low'; 105 $title = 'Theme Deactivated'; 106 $message = sprintf( 107 'Theme "%s" was deactivated by user "%s" from IP %s', 108 $data['theme'], 109 $current_user_info['user_login'], 110 $current_user_info['user_ip'] 111 ); 112 break; 113 60 114 case 'switched': 61 115 $severity = 'high'; … … 81 135 82 136 case 'updated': 83 $severity = ' low';137 $severity = 'info'; 84 138 $title = 'Theme Updated'; 85 139 $message = sprintf( -
vulnity/trunk/includes/class-alert-manager.php
r3478185 r3490327 36 36 'class-core-update-alert.php', 37 37 'class-suspicious-query-alert.php', 38 'class-scanner-detection-alert.php' 38 'class-scanner-detection-alert.php', 39 'class-auto-update-alert.php', 39 40 ); 40 41 … … 57 58 'Vulnity_Core_Update_Alert', 58 59 'Vulnity_Suspicious_Query_Alert', 59 'Vulnity_Scanner_Detection_Alert' 60 'Vulnity_Scanner_Detection_Alert', 61 'Vulnity_Auto_Update_Alert', 60 62 ); 61 63 -
vulnity/trunk/includes/class-anti-collapse.php
r3460003 r3490327 121 121 private function generate_hash($alert) { 122 122 $key = $alert['type'] . ':'; 123 123 124 124 if (isset($alert['details']['ip'])) { 125 125 $key .= $alert['details']['ip'] . ':'; … … 131 131 $key .= $alert['details']['action']; 132 132 } 133 133 134 // For auto_update_state_changed, include toggle state so each unique 135 // combination (plugins ON/OFF, themes ON/OFF) is treated as distinct. 136 if ( $alert['type'] === 'auto_update_state_changed' && isset( $alert['details'] ) ) { 137 $key .= ':p=' . ( ! empty( $alert['details']['auto_update_plugins'] ) ? '1' : '0' ); 138 $key .= ':t=' . ( ! empty( $alert['details']['auto_update_themes'] ) ? '1' : '0' ); 139 } 140 134 141 return md5($key); 135 142 } -
vulnity/trunk/includes/class-core.php
r3478185 r3490327 141 141 add_action('wp_ajax_vulnity_block_ip', array($this->mitigation_manager, 'ajax_block_ip')); 142 142 add_action('wp_ajax_vulnity_sync_mitigation', array($this->mitigation_manager, 'ajax_sync_mitigation')); 143 // NOTE: vulnity_save_auto_update is intentionally NOT registered here. 144 // Auto-update state is controlled exclusively by the SIEM via the REST push_auto_update action. 145 // The admin UI is read-only to prevent WP ↔ SIEM desynchronisation. 143 146 } 144 147 … … 447 450 } 448 451 452 // ------------------------------------------------------------------------- 453 // Push Trigger Update 454 // ------------------------------------------------------------------------- 455 456 /** 457 * Handle push_trigger_update: update specific or all plugins/themes on demand. 458 * 459 * @param array $body Decoded JSON payload from the SIEM. 460 * @return array Structured response with per-component results. 461 */ 462 private function apply_push_trigger_update( $body ) { 463 // Normalize boolean flags from the external payload. 464 // filter_var handles JSON booleans (true/false), integers (1/0), 465 // and defensive strings ("true"/"false") uniformly, so a SIEM that 466 // mistakenly sends the string "false" is still interpreted correctly. 467 $flag_auto_trigger = filter_var( isset( $body['auto_trigger'] ) ? $body['auto_trigger'] : false, FILTER_VALIDATE_BOOLEAN ); 468 $flag_initial_scan = filter_var( isset( $body['initial_scan'] ) ? $body['initial_scan'] : false, FILTER_VALIDATE_BOOLEAN ); 469 $flag_update_all = filter_var( isset( $body['update_all'] ) ? $body['update_all'] : false, FILTER_VALIDATE_BOOLEAN ); 470 471 // Determine origin so events carry the right triggered_by value 472 $triggered_by = $flag_auto_trigger ? 'siem_auto_update' : 'siem_manual'; 473 474 // 1. Persist exclusions so the auto-update cron also respects them 475 $exclusions = isset( $body['exclusions'] ) && is_array( $body['exclusions'] ) 476 ? $body['exclusions'] 477 : array(); 478 update_option( 'vulnity_update_exclusions', $exclusions ); 479 480 // 2. Load WP upgrader infrastructure (required in REST API context) 481 require_once ABSPATH . 'wp-admin/includes/plugin.php'; 482 require_once ABSPATH . 'wp-admin/includes/update.php'; 483 require_once ABSPATH . 'wp-admin/includes/file.php'; 484 require_once ABSPATH . 'wp-admin/includes/misc.php'; 485 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; 486 require_once ABSPATH . 'wp-admin/includes/theme.php'; 487 WP_Filesystem(); 488 489 // 3. Refresh update cache when requested 490 if ( $flag_initial_scan ) { 491 wp_update_plugins(); 492 wp_update_themes(); 493 } 494 495 // 4. Build the work list [['slug' => ..., 'type' => 'plugin'|'theme']] 496 $to_update = array(); 497 498 if ( $flag_update_all || $flag_auto_trigger ) { 499 // Optional filter: restrict to a single component type 500 $component_type = isset( $body['component_type'] ) ? sanitize_text_field( $body['component_type'] ) : ''; 501 502 if ( $component_type !== 'theme' ) { 503 foreach ( get_plugin_updates() as $plugin_file => $data ) { 504 $slug = dirname( $plugin_file ); 505 if ( $slug === '.' ) { 506 // Single-file plugin (e.g. hello-dolly.php) — use filename without extension 507 $slug = basename( $plugin_file, '.php' ); 508 } 509 $to_update[] = array( 'slug' => $slug, 'type' => 'plugin' ); 510 } 511 } 512 513 if ( $component_type !== 'plugin' ) { 514 foreach ( array_keys( get_theme_updates() ) as $theme_slug ) { 515 $to_update[] = array( 'slug' => $theme_slug, 'type' => 'theme' ); 516 } 517 } 518 } elseif ( ! empty( $body['components'] ) && is_array( $body['components'] ) ) { 519 foreach ( $body['components'] as $component ) { 520 if ( empty( $component['slug'] ) || empty( $component['type'] ) ) { 521 continue; 522 } 523 $to_update[] = array( 524 'slug' => sanitize_text_field( $component['slug'] ), 525 'type' => sanitize_text_field( $component['type'] ), 526 ); 527 } 528 } 529 530 // 5. Build exclusion lookup set 531 $excluded = array(); 532 foreach ( $exclusions as $ex ) { 533 if ( ! empty( $ex['component_slug'] ) && ! empty( $ex['component_type'] ) ) { 534 $excluded[ $ex['component_type'] . ':' . $ex['component_slug'] ] = true; 535 } 536 } 537 538 // 6. Process updates 539 // Suppress the generic plugin_change / theme_change alerts during SIEM-triggered upgrades 540 if ( class_exists( 'Vulnity_Plugin_Change_Alert' ) ) { 541 Vulnity_Plugin_Change_Alert::set_siem_update_mode( true ); 542 } 543 if ( class_exists( 'Vulnity_Theme_Change_Alert' ) ) { 544 Vulnity_Theme_Change_Alert::set_siem_update_mode( true ); 545 } 546 547 $results = array(); 548 $updated = 0; 549 $failed = 0; 550 $skipped = 0; 551 $skin = new Automatic_Upgrader_Skin(); 552 553 // Save the update transients BEFORE the loop. 554 // upgrader_process_complete fires wp_clean_plugins_cache() after each upgrade, 555 // which deletes the update_plugins transient. Without restoring it, the second 556 // Plugin_Upgrader::upgrade() call finds an empty transient and returns false. 557 $saved_plugin_transient = get_site_transient( 'update_plugins' ); 558 $saved_theme_transient = get_site_transient( 'update_themes' ); 559 560 foreach ( $to_update as $component ) { 561 $slug = $component['slug']; 562 $type = $component['type']; 563 564 if ( isset( $excluded[ $type . ':' . $slug ] ) ) { 565 $results[] = array( 'slug' => $slug, 'type' => $type, 'status' => 'skipped', 'reason' => 'excluded' ); 566 $skipped++; 567 continue; 568 } 569 570 if ( $type === 'plugin' ) { 571 $plugin_file = $this->get_plugin_file_by_slug( $slug ); 572 573 if ( ! $plugin_file ) { 574 $results[] = array( 'slug' => $slug, 'type' => $type, 'status' => 'failed', 'error' => 'Plugin not found' ); 575 $failed++; 576 continue; 577 } 578 579 // Restore transient so Plugin_Upgrader::upgrade() finds the entry 580 // (a previous upgrade may have cleared it via wp_clean_plugins_cache) 581 set_site_transient( 'update_plugins', $saved_plugin_transient ); 582 583 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, false, false ); 584 $old_version = isset( $plugin_data['Version'] ) ? $plugin_data['Version'] : 'unknown'; 585 586 $upgrader = new Plugin_Upgrader( $skin ); 587 $result = $upgrader->upgrade( $plugin_file ); 588 589 if ( $result === true ) { 590 wp_cache_delete( 'plugins', 'plugins' ); 591 $plugin_data_new = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, false, false ); 592 $new_version = isset( $plugin_data_new['Version'] ) ? $plugin_data_new['Version'] : 'unknown'; 593 $results[] = array( 'slug' => $slug, 'type' => $type, 'status' => 'updated', 'old_version' => $old_version, 'new_version' => $new_version ); 594 $updated++; 595 $this->send_manual_update_event( 'plugin', $slug, $plugin_file, $old_version, $new_version, $triggered_by ); 596 $this->send_outdated_resolved_event( 'plugin', $slug, $triggered_by ); 597 } else { 598 $error = 'Update failed'; 599 if ( is_wp_error( $result ) ) { 600 $error = $result->get_error_message(); 601 } elseif ( $result === null ) { 602 $error = 'Filesystem not accessible'; 603 } 604 $results[] = array( 'slug' => $slug, 'type' => $type, 'status' => 'failed', 'error' => $error ); 605 $failed++; 606 $this->send_update_failed_event( 'plugin', $slug, $old_version, $error, $triggered_by ); 607 } 608 609 } elseif ( $type === 'theme' ) { 610 // Restore transient so Theme_Upgrader::upgrade() finds the entry 611 set_site_transient( 'update_themes', $saved_theme_transient ); 612 613 $theme = wp_get_theme( $slug ); 614 $old_version = $theme->exists() ? $theme->get( 'Version' ) : 'unknown'; 615 616 $upgrader = new Theme_Upgrader( $skin ); 617 $result = $upgrader->upgrade( $slug ); 618 619 if ( $result === true ) { 620 wp_clean_themes_cache(); 621 $theme_new = wp_get_theme( $slug ); 622 $new_version = $theme_new->exists() ? $theme_new->get( 'Version' ) : 'unknown'; 623 $results[] = array( 'slug' => $slug, 'type' => $type, 'status' => 'updated', 'old_version' => $old_version, 'new_version' => $new_version ); 624 $updated++; 625 $this->send_manual_update_event( 'theme', $slug, $slug, $old_version, $new_version, $triggered_by ); 626 $this->send_outdated_resolved_event( 'theme', $slug, $triggered_by ); 627 } else { 628 $error = 'Update failed'; 629 if ( is_wp_error( $result ) ) { 630 $error = $result->get_error_message(); 631 } elseif ( $result === null ) { 632 $error = 'Filesystem not accessible'; 633 } 634 $results[] = array( 'slug' => $slug, 'type' => $type, 'status' => 'failed', 'error' => $error ); 635 $failed++; 636 $this->send_update_failed_event( 'theme', $slug, $old_version, $error, $triggered_by ); 637 } 638 } 639 } 640 641 // Restore normal alert behaviour 642 if ( class_exists( 'Vulnity_Plugin_Change_Alert' ) ) { 643 Vulnity_Plugin_Change_Alert::set_siem_update_mode( false ); 644 } 645 if ( class_exists( 'Vulnity_Theme_Change_Alert' ) ) { 646 Vulnity_Theme_Change_Alert::set_siem_update_mode( false ); 647 } 648 649 // 7. Inventory sync — runs immediately so Supabase reflects new versions. 650 // After doing it, cancel any deferred sync that handle_updates may have queued 651 // for the same upgrade batch to avoid a duplicate request 60 s later. 652 $sync_triggered = false; 653 if ( $updated > 0 ) { 654 $sync_result = $this->inventory_sync->perform_sync( 'manual' ); 655 $sync_triggered = ! empty( $sync_result['success'] ); 656 wp_clear_scheduled_hook( Vulnity_Inventory_Sync::DEFERRED_SYNC_HOOK ); 657 } 658 659 return array( 660 'success' => true, 661 'results' => $results, 662 'updated_count' => $updated, 663 'failed_count' => $failed, 664 'skipped_count' => $skipped, 665 'sync_triggered' => $sync_triggered, 666 ); 667 } 668 669 /** 670 * Find the plugin file path (relative to WP_PLUGIN_DIR) from a plugin slug. 671 * 672 * @param string $slug Directory name / slug (e.g. "akismet") 673 * @return string|null Plugin file path or null if not found. 674 */ 675 private function get_plugin_file_by_slug( $slug ) { 676 foreach ( get_plugins() as $plugin_file => $plugin_data ) { 677 if ( dirname( $plugin_file ) === $slug ) { 678 return $plugin_file; 679 } 680 } 681 // Single-file plugin (e.g. hello-dolly.php) 682 if ( file_exists( WP_PLUGIN_DIR . '/' . $slug . '.php' ) ) { 683 return $slug . '.php'; 684 } 685 return null; 686 } 687 688 /** 689 * Build and send a canonical plugin_update/theme_update event to the SIEM. 690 * 691 * @param string $component_type 'plugin' or 'theme' 692 * @param string $slug Component slug 693 * @param string $identifier Plugin file path or theme slug (used in source.path) 694 * @param string $old_version Version before update 695 * @param string $new_version Version after update 696 */ 697 private function send_manual_update_event( $component_type, $slug, $identifier, $old_version, $new_version, $triggered_by = 'siem_manual' ) { 698 if ( $triggered_by === 'siem_auto_update' ) { 699 $event_type = $component_type === 'plugin' ? 'system_auto_update_plugin' : 'system_auto_update_theme'; 700 $update_mode = 'automatic'; 701 } else { 702 $event_type = $component_type === 'plugin' ? 'system_manual_update_plugin' : 'system_manual_update_theme'; 703 $update_mode = 'manual'; 704 } 705 706 $config = get_option( 'vulnity_config', array() ); 707 $site_id = isset( $config['site_id'] ) ? $config['site_id'] : ''; 708 $domain = (string) wp_parse_url( home_url(), PHP_URL_HOST ); 709 710 $event = array( 711 'type' => $event_type, 712 'schema_version' => '1.0', 713 'event_version' => '1.0', 714 'event_uuid' => wp_generate_uuid4(), 715 'site_id' => $site_id, 716 'domain' => $domain, 717 'event_type' => $event_type, 718 'severity' => 'info', 719 'occurred_at' => gmdate( 'c' ), 720 'detected_at' => gmdate( 'c' ), 721 'dedup_key' => $event_type . ':' . $site_id . ':' . $slug . ':' . $update_mode . ':' . gmdate( 'Y-m-d-H-i' ), 722 'summary' => sprintf( 723 '%s %s actualizado de %s a %s', 724 ucfirst( $component_type ), $slug, $old_version, $new_version 725 ), 726 'description' => sprintf( 727 '%s ha actualizado el %s "%s" de la versión %s a la versión %s en %s.', 728 $triggered_by === 'siem_auto_update' ? 'La activación de actualizaciones automáticas' : 'El SIEM', 729 $component_type, $slug, $old_version, $new_version, $domain 730 ), 731 'source' => array( 'path' => $identifier ), 732 'details' => array( 733 'component_type' => $component_type, 734 'component_slug' => $slug, 735 'version_old' => $old_version, 736 'version_new' => $new_version, 737 'auto_update' => $update_mode === 'automatic', 738 'update_mode' => $update_mode, 739 'triggered_by' => $triggered_by, 740 ), 741 'remediation' => array( 742 'summary' => 'Verificar que el sitio funciona correctamente tras la actualización.', 743 'steps' => array( 744 'Revisar el sitio web para confirmar funcionamiento normal', 745 'Verificar el changelog de la nueva versión', 746 ), 747 ), 748 'ui' => array( 'group' => 'Sistema', 'icon' => 'refresh-cw', 'color' => 'blue' ), 749 'trace' => array( 750 'plugin_version' => defined( 'VULNITY_VERSION' ) ? VULNITY_VERSION : 'unknown', 751 'wordpress_version' => get_bloginfo( 'version' ), 752 'php_version' => PHP_VERSION, 753 ), 754 ); 755 756 vulnity_log( '[Vulnity] Sending update event: ' . $event_type . ' (' . $update_mode . ') for ' . $slug ); 757 Vulnity_SIEM_Connector::get_instance()->send_alert( $event ); 758 } 759 760 /** 761 * Send a resolution event to close the open plugin_outdated / theme_outdated 762 * alert in the SIEM after a successful update. 763 * 764 * @param string $component_type 'plugin' or 'theme' 765 * @param string $slug Component slug 766 * @param string $triggered_by Origin of the update ('siem_manual' | 'siem_auto_update') 767 */ 768 private function send_outdated_resolved_event( $component_type, $slug, $triggered_by = 'siem_manual' ) { 769 $internal_type = $component_type === 'plugin' ? 'plugin_outdated_resolved' : 'theme_outdated_resolved'; 770 $siem_event_type = $component_type === 'plugin' ? 'plugin_outdated' : 'theme_outdated'; 771 772 $config = get_option( 'vulnity_config', array() ); 773 $site_id = isset( $config['site_id'] ) ? $config['site_id'] : ''; 774 $domain = (string) wp_parse_url( home_url(), PHP_URL_HOST ); 775 776 $event = array( 777 'type' => $internal_type, 778 'schema_version' => '1.0', 779 'event_version' => '1.0', 780 'event_uuid' => wp_generate_uuid4(), 781 'site_id' => $site_id, 782 'domain' => $domain, 783 'event_type' => $siem_event_type, 784 'status' => 'resolved', 785 'severity' => 'info', 786 'occurred_at' => gmdate( 'c' ), 787 'detected_at' => gmdate( 'c' ), 788 'dedup_key' => $siem_event_type . ':' . $site_id . ':' . $slug, 789 'summary' => sprintf( '%s %s ha sido actualizado y ya no está desactualizado.', ucfirst( $component_type ), $slug ), 790 'source' => array( 'path' => $slug ), 791 'details' => array( 792 'component_type' => $component_type, 793 'component_slug' => $slug, 794 'resolved_by' => $triggered_by, 795 ), 796 'ui' => array( 'group' => 'Sistema', 'icon' => 'check-circle', 'color' => 'green' ), 797 ); 798 799 vulnity_log( '[Vulnity] Sending outdated-resolved event for ' . $component_type . ' ' . $slug ); 800 Vulnity_SIEM_Connector::get_instance()->send_alert( $event ); 801 } 802 803 /** 804 * Send a failure event to the SIEM when an update could not be applied. 805 * 806 * @param string $component_type 'plugin' or 'theme' 807 * @param string $slug Component slug 808 * @param string $old_version Version that was installed when the update was attempted 809 * @param string $error Error message 810 * @param string $triggered_by Origin ('siem_auto_update' | 'siem_manual') 811 */ 812 private function send_update_failed_event( $component_type, $slug, $old_version, $error, $triggered_by = 'siem_manual' ) { 813 $event_type = $component_type === 'plugin' ? 'system_auto_update_plugin' : 'system_auto_update_theme'; 814 $config = get_option( 'vulnity_config', array() ); 815 $site_id = isset( $config['site_id'] ) ? $config['site_id'] : ''; 816 $domain = (string) wp_parse_url( home_url(), PHP_URL_HOST ); 817 818 $event = array( 819 'type' => $event_type, 820 'schema_version' => '1.0', 821 'event_version' => '1.0', 822 'event_uuid' => wp_generate_uuid4(), 823 'site_id' => $site_id, 824 'domain' => $domain, 825 'event_type' => $event_type, 826 'severity' => 'medium', 827 'occurred_at' => gmdate( 'c' ), 828 'detected_at' => gmdate( 'c' ), 829 'dedup_key' => $event_type . ':failed:' . $site_id . ':' . $slug . ':' . gmdate( 'Y-m-d-H-i' ), 830 'summary' => sprintf( 'Fallo al actualizar %s "%s" desde v%s', $component_type, $slug, $old_version ), 831 'source' => array( 'path' => $slug ), 832 'details' => array( 833 'component_type' => $component_type, 834 'component_slug' => $slug, 835 'version_old' => $old_version, 836 'result' => 'failed', 837 'error' => $error, 838 'trigger' => $triggered_by === 'siem_auto_update' ? 'siem_hourly_auto_check' : 'siem_manual', 839 ), 840 'ui' => array( 'group' => 'Sistema', 'icon' => 'alert-triangle', 'color' => 'orange' ), 841 'trace' => array( 842 'plugin_version' => defined( 'VULNITY_VERSION' ) ? VULNITY_VERSION : 'unknown', 843 'wordpress_version' => get_bloginfo( 'version' ), 844 'php_version' => PHP_VERSION, 845 ), 846 ); 847 848 vulnity_log( '[Vulnity] Sending update-failed event for ' . $component_type . ' ' . $slug . ': ' . $error ); 849 Vulnity_SIEM_Connector::get_instance()->send_alert( $event ); 850 } 851 852 // ------------------------------------------------------------------------- 853 854 public function ajax_save_auto_update() { 855 if ( ! $this->verify_admin_ajax_request() ) { 856 return; 857 } 858 859 if ( ! current_user_can( 'manage_options' ) ) { 860 wp_send_json_error( 'Insufficient permissions' ); 861 return; 862 } 863 864 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce is verified earlier in this handler via verify_admin_ajax_request(). 865 $plugins_on = isset( $_POST['auto_update_plugins'] ) && $_POST['auto_update_plugins'] === '1'; 866 $themes_on = isset( $_POST['auto_update_themes'] ) && $_POST['auto_update_themes'] === '1'; 867 // phpcs:enable WordPress.Security.NonceVerification.Missing 868 869 // Read previous state BEFORE saving so we know what changed 870 $prev_plugins_on = get_option( 'vulnity_auto_update_plugins', '0' ) === '1'; 871 $prev_themes_on = get_option( 'vulnity_auto_update_themes', '0' ) === '1'; 872 873 update_option( 'vulnity_auto_update_plugins', $plugins_on ? '1' : '0' ); 874 update_option( 'vulnity_auto_update_themes', $themes_on ? '1' : '0' ); 875 876 vulnity_log( '[Vulnity] Auto-update settings saved: plugins=' . ( $plugins_on ? '1' : '0' ) . ' themes=' . ( $themes_on ? '1' : '0' ) ); 877 878 $immediate_result = array( 879 'updated' => array(), 880 'failed' => array(), 881 'skipped' => array(), 882 'updated_count' => 0, 883 'failed_count' => 0, 884 'skipped_count' => 0, 885 'sync_triggered' => false, 886 ); 887 888 // Only trigger immediate updates for types that are NEWLY being enabled. 889 // Disabling a type, or keeping an already-enabled type ON, does not re-run updates. 890 $newly_plugins = $plugins_on && ! $prev_plugins_on; 891 $newly_themes = $themes_on && ! $prev_themes_on; 892 893 if ( $newly_plugins || $newly_themes ) { 894 $component_type = ( $newly_plugins && $newly_themes ) ? null : ( $newly_plugins ? 'plugin' : 'theme' ); 895 $immediate_result = $this->apply_push_trigger_update( array( 896 'update_all' => true, 897 'initial_scan' => true, 898 'auto_trigger' => true, 899 'component_type' => $component_type, 900 'exclusions' => array(), 901 ) ); 902 } 903 904 // Notify SIEM about the state change (Plugin → SIEM direction) 905 $current_user = wp_get_current_user(); 906 $this->send_auto_update_state_event( $plugins_on, $themes_on, 'wordpress_admin', $current_user->user_login ); 907 908 wp_send_json_success( array( 909 'auto_update_plugins' => $plugins_on ? '1' : '0', 910 'auto_update_themes' => $themes_on ? '1' : '0', 911 'immediate_update' => $immediate_result, 912 ) ); 913 } 914 915 /** 916 * Send a canonical event to the SIEM when auto-update state changes from the WP admin. 917 * Not called when the change originates from the SIEM (to avoid loops). 918 */ 919 private function send_auto_update_state_event( $plugins_on, $themes_on, $changed_by = 'wordpress_admin', $admin_user = '' ) { 920 $config = get_option( 'vulnity_config', array() ); 921 $site_id = isset( $config['site_id'] ) ? $config['site_id'] : ''; 922 $domain = wp_parse_url( home_url(), PHP_URL_HOST ); 923 924 $active_scopes = array(); 925 if ( $plugins_on ) { $active_scopes[] = 'plugins'; } 926 if ( $themes_on ) { $active_scopes[] = 'themes'; } 927 $any_on = $plugins_on || $themes_on; 928 929 // Use precise wording: "activadas" only when ALL scopes are ON, 930 // "desactivadas" when ALL are OFF, "actualizadas" for partial changes. 931 if ( $plugins_on && $themes_on ) { 932 $state_summary = 'activadas'; 933 $state_desc = 'activado'; 934 } elseif ( ! $plugins_on && ! $themes_on ) { 935 $state_summary = 'desactivadas'; 936 $state_desc = 'desactivado'; 937 } else { 938 $state_summary = 'actualizadas'; 939 $state_desc = 'actualizado el estado de'; 940 } 941 942 $summary = 'Actualizaciones automáticas ' . $state_summary . ' desde el panel de administración' 943 . ( ! empty( $active_scopes ) ? ' (' . implode( ', ', $active_scopes ) . ')' : '' ); 944 945 $event = array( 946 'type' => 'auto_update_state_changed', 947 'schema_version' => '1.0', 948 'event_version' => '1.0', 949 'event_uuid' => wp_generate_uuid4(), 950 'site_id' => $site_id, 951 'domain' => $domain, 952 'event_type' => 'system.auto_update_state_changed', 953 'severity' => 'info', 954 'occurred_at' => gmdate( 'c' ), 955 'detected_at' => gmdate( 'c' ), 956 'dedup_key' => 'system.auto_update_state_changed:' . $site_id . ':p=' . ( $plugins_on ? '1' : '0' ) . ':t=' . ( $themes_on ? '1' : '0' ) . ':' . gmdate( 'Y-m-d-H-i' ), 957 'summary' => $summary, 958 'description' => sprintf( 959 'El administrador "%s" ha %s las actualizaciones automáticas en %s. Plugins: %s | Temas: %s.', 960 $admin_user, 961 $state_desc, 962 $domain, 963 $plugins_on ? 'ON' : 'OFF', 964 $themes_on ? 'ON' : 'OFF' 965 ), 966 'details' => array( 967 'auto_update_plugins' => $plugins_on, 968 'auto_update_themes' => $themes_on, 969 'active_scopes' => $active_scopes, 970 'changed_by' => $changed_by, 971 'admin_user' => $admin_user, 972 ), 973 'remediation' => array( 974 'summary' => 'Evento informativo, no requiere acción.', 975 'steps' => array(), 976 ), 977 'ui' => array( 'group' => 'Sistema', 'icon' => 'refresh-cw', 'color' => $any_on ? 'green' : 'slate' ), 978 'trace' => array( 'plugin_version' => VULNITY_VERSION, 'wp_version' => get_bloginfo( 'version' ) ), 979 ); 980 981 vulnity_log( '[Vulnity] Sending auto_update_state_changed event. plugins=' . ( $plugins_on ? '1' : '0' ) . ' themes=' . ( $themes_on ? '1' : '0' ) ); 982 Vulnity_SIEM_Connector::get_instance()->send_alert( $event ); 983 } 984 449 985 private function pair_plugin($site_id, $pair_code, $public_ip_whitelist = '') { 450 986 if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $site_id)) { … … 628 1164 } 629 1165 630 // =====================================================631 // AQUÍ VA EL MÉTODO unpair_plugin632 // =====================================================633 1166 public function unpair_plugin($reason = 'manual') { 634 1167 $config = get_option('vulnity_config'); … … 872 1405 $config = get_option('vulnity_config'); 873 1406 874 if (empty($config['site_id']) || empty($config['token'])) {875 vulnity_log('[Vulnity] verify_siem_request: config missing ( site_id or token empty)');1407 if (empty($config['site_id']) || (empty($config['token']) && empty($config['signing_secret']))) { 1408 vulnity_log('[Vulnity] verify_siem_request: config missing (need site_id + token or signing_secret)'); 876 1409 return false; 877 1410 } … … 1004 1537 break; 1005 1538 1539 case 'push_trigger_update': 1540 $update_result = $this->apply_push_trigger_update( $body ); 1541 1542 // Enrich the response so the SIEM can act without parsing individual results: 1543 // - partial: true → some updated, some failed (success stays true) 1544 // - success: false → nothing updated and at least one failure 1545 $u_updated = $update_result['updated_count']; 1546 $u_failed = $update_result['failed_count']; 1547 $update_result['partial'] = ( $u_failed > 0 && $u_updated > 0 ); 1548 if ( $u_failed > 0 && $u_updated === 0 ) { 1549 $update_result['success'] = false; 1550 } 1551 1552 vulnity_log( '[Vulnity] receive-mitigation action=push_trigger_update updated=' . $u_updated . ' failed=' . $u_failed . ' skipped=' . $update_result['skipped_count'] . ' partial=' . ( $update_result['partial'] ? '1' : '0' ) ); 1553 return new WP_REST_Response( $update_result, 200 ); 1554 1555 case 'push_auto_update': 1556 // Normalize: handles JSON booleans, integers, and defensive strings ("false"/"true"). 1557 $plugins_on = filter_var( isset( $body['auto_update_plugins'] ) ? $body['auto_update_plugins'] : false, FILTER_VALIDATE_BOOLEAN ); 1558 $themes_on = filter_var( isset( $body['auto_update_themes'] ) ? $body['auto_update_themes'] : false, FILTER_VALIDATE_BOOLEAN ); 1559 1560 // Read previous state BEFORE saving to detect which scopes are newly enabled. 1561 $prev_plugins_on = get_option( 'vulnity_auto_update_plugins', '0' ) === '1'; 1562 $prev_themes_on = get_option( 'vulnity_auto_update_themes', '0' ) === '1'; 1563 1564 update_option( 'vulnity_auto_update_plugins', $plugins_on ? '1' : '0' ); 1565 update_option( 'vulnity_auto_update_themes', $themes_on ? '1' : '0' ); 1566 vulnity_log( '[Vulnity] receive-mitigation action=push_auto_update plugins=' . ( $plugins_on ? '1' : '0' ) . ' themes=' . ( $themes_on ? '1' : '0' ) ); 1567 1568 $immediate_result = array( 1569 'updated' => array(), 1570 'failed' => array(), 1571 'skipped' => array(), 1572 'updated_count' => 0, 1573 'failed_count' => 0, 1574 'skipped_count' => 0, 1575 'sync_triggered'=> false, 1576 ); 1577 1578 // Only trigger immediate updates for scopes that are NEWLY being enabled. 1579 // Disabling a scope, or keeping an already-enabled scope ON, must NOT re-run updates. 1580 $newly_plugins = $plugins_on && ! $prev_plugins_on; 1581 $newly_themes = $themes_on && ! $prev_themes_on; 1582 1583 if ( $newly_plugins || $newly_themes ) { 1584 $component_type = ( $newly_plugins && $newly_themes ) ? null : ( $newly_plugins ? 'plugin' : 'theme' ); 1585 $trigger_body = array( 1586 'update_all' => true, 1587 'initial_scan' => true, 1588 'auto_trigger' => true, 1589 'component_type' => $component_type, 1590 'exclusions' => isset( $body['exclusions'] ) ? $body['exclusions'] : array(), 1591 ); 1592 $immediate_result = $this->apply_push_trigger_update( $trigger_body ); 1593 } 1594 1595 return new WP_REST_Response( array( 1596 'success' => true, 1597 'auto_updates_active' => $plugins_on || $themes_on, 1598 'auto_update_plugins' => $plugins_on, 1599 'auto_update_themes' => $themes_on, 1600 'message' => ( $newly_plugins || $newly_themes ) 1601 ? 'Auto-updates activated and immediate update triggered.' 1602 : ( ( $plugins_on || $themes_on ) 1603 ? 'Auto-updates state synchronized (no change detected).' 1604 : 'Auto-updates deactivated.' ), 1605 'immediate_update' => $immediate_result, 1606 ), 200 ); 1607 1006 1608 case 'remote_disconnect': 1007 1609 $this->perform_remote_disconnect($body); … … 1079 1681 1080 1682 if ( $applied !== false ) { 1683 // apply_remote_settings() defers .htaccess sync to the next admin_init visit 1684 // (maybe_sync_server_hardening_rules requires is_admin() context). 1685 // In REST context is_admin() is false, so we call the sync methods directly 1686 // to ensure .htaccess is written immediately and not left in a partial state. 1687 $static_security->maybe_sync_login_htaccess_rule( $applied ); 1688 $static_security->maybe_sync_common_paths_htaccess_rule( $applied ); 1689 1081 1690 vulnity_log( '[Vulnity] Hardening settings updated by SIEM: ' . wp_json_encode( $applied ) ); 1082 1691 return true; -
vulnity/trunk/includes/class-inventory-sync.php
r3460003 r3490327 19 19 add_action('vulnity_sync_inventory', array($this, 'perform_sync')); 20 20 add_action(self::DEFERRED_SYNC_HOOK, array($this, 'perform_deferred_update_sync')); 21 add_action('vulnity_inventory_sync_needed', array($this, 'perform_sync')); 21 22 22 23 if (!wp_next_scheduled('vulnity_sync_inventory')) { … … 329 330 public function handle_updates($upgrader, $hook_extra) { 330 331 if (isset($hook_extra['type']) && in_array($hook_extra['type'], array('plugin', 'theme'))) { 332 // WP_Automatic_Updater triggers vulnity_inventory_sync_needed → perform_sync() immediately. 333 // Queuing a second deferred sync would duplicate the request, so skip it. 334 if ( $upgrader instanceof WP_Automatic_Updater ) { 335 return; 336 } 331 337 $this->queue_update_sync(); 332 338 } -
vulnity/trunk/includes/class-mitigation-manager.php
r3478185 r3490327 1230 1230 'suspicious_query_alert', 1231 1231 'suspicious_query', 1232 'plugin_deleted', 1233 'theme_installed', 1232 1234 ), 1233 1235 'medium' => array( … … 1238 1240 'plugin.installed_or_activated', 1239 1241 'plugin_activated', 1242 'theme_activated', 1240 1243 'plugin_change', 1241 1244 'plugin_change_alert', … … 1246 1249 'rest_access_no_auth', 1247 1250 'theme_changed', 1251 'theme_deleted', 1248 1252 'theme_change', 1249 1253 'theme_change_alert', … … 1259 1263 'low' => array( 1260 1264 'admin_user_exists', 1265 'plugin_deactivated', 1266 'theme_deactivated', 1261 1267 ), 1262 1268 'info' => array( … … 1267 1273 'site_paired', 1268 1274 'site_unpaired', 1275 'plugin_update', 1276 'theme_update', 1277 'plugin_installed', 1269 1278 ), 1270 1279 ); -
vulnity/trunk/includes/class-siem-connector.php
r3478185 r3490327 28 28 $config = get_option('vulnity_config'); 29 29 30 if (empty($config['site_id']) || empty($config['token'])) {31 vulnity_log('[Vulnity] Cannot send alert to SIEM - missing configuration ');30 if (empty($config['site_id']) || (empty($config['token']) && empty($config['signing_secret']))) { 31 vulnity_log('[Vulnity] Cannot send alert to SIEM - missing configuration (need site_id + token or signing_secret)'); 32 32 return array('success' => false, 'error' => 'Missing configuration'); 33 33 } … … 50 50 51 51 $formatted_payload = $this->format_alert_for_endpoint($alert, $endpoint); 52 53 // Headers with token authentication 54 $headers = array( 55 'Content-Type' => 'application/json', 56 'x-vulnity-token' => $config['token'], 57 'x-site-id' => $config['site_id'] 58 ); 59 52 $body_json = wp_json_encode($formatted_payload); 53 54 // /real-time-alerts uses HMAC auth (same as /scan-site-info inventory endpoint) 55 if ( $endpoint === '/real-time-alerts' && ! empty( $config['signing_secret'] ) ) { 56 $signature = base64_encode( hash_hmac( 'sha256', $body_json, $config['signing_secret'], true ) ); 57 $headers = array( 58 'Content-Type' => 'application/json', 59 'x-signature' => $signature, 60 'x-site-id' => $config['site_id'], 61 'x-plugin-version' => VULNITY_VERSION, 62 ); 63 } else { 64 // Default token authentication for all other endpoints 65 $headers = array( 66 'Content-Type' => 'application/json', 67 'x-vulnity-token' => $config['token'], 68 'x-site-id' => $config['site_id'], 69 ); 70 } 71 60 72 $args = array( 61 'method' => 'POST',62 'headers' => $headers,63 'body' => wp_json_encode($formatted_payload),64 'timeout' => 30,65 'sslverify' => true,73 'method' => 'POST', 74 'headers' => $headers, 75 'body' => $body_json, 76 'timeout' => 30, 77 'sslverify' => true, 66 78 'redirection' => 5, 67 79 'httpversion' => '1.1', 68 'blocking' => true80 'blocking' => true, 69 81 ); 70 82 … … 96 108 97 109 if ($status_code === 200 || $status_code === 201) { 98 vulnity_log('[Vulnity] Alert sent to SIEM successfully: ' . $alert['type'] . ' (ID: ' . $alert['id']. ')');110 vulnity_log('[Vulnity] Alert sent to SIEM successfully: ' . $alert['type'] . ' (ID: ' . ( isset( $alert['id'] ) ? $alert['id'] : 'n/a' ) . ')'); 99 111 return array( 100 112 'success' => true, … … 136 148 } 137 149 150 // Special handling for canonical update, resolved-outdated and 151 // state-change events (pass through to /real-time-alerts as-is). 152 if (in_array($alert['type'], array( 153 'plugin_update', 154 'theme_update', 155 'system_auto_update_plugin', 156 'system_auto_update_theme', 157 'system_manual_update_plugin', 158 'system_manual_update_theme', 159 'plugin_outdated_resolved', 160 'theme_outdated_resolved', 161 'auto_update_state_changed', 162 ), true)) { 163 return $this->format_auto_update_event($alert); 164 } 165 166 // Outdated components report — pass through as-is (same canonical format) 167 if ($alert['type'] === 'outdated_components') { 168 $payload = $alert; 169 unset($payload['type']); 170 return $payload; 171 } 172 138 173 // Special handling for system alerts (panic mode, recovery) 139 174 if (in_array($alert['type'], array('system_panic', 'system_recovery'))) { … … 149 184 if ($endpoint === '/plugin-change-alert') { 150 185 return $this->format_plugin_change_alert($alert); 186 } 187 188 // Special handling for theme-change-alert 189 if ($endpoint === '/theme-change-alert') { 190 return $this->format_theme_change_alert($alert); 151 191 } 152 192 … … 303 343 'installed' => 'plugin_installed', 304 344 'deleted' => 'plugin_deleted', 305 'updated' => 'plugin_update d'345 'updated' => 'plugin_update' 306 346 ); 307 347 308 348 $action = isset($details['action']) ? $details['action'] : 'updated'; 309 $mapped_type = isset($action_map[$action]) ? $action_map[$action] : 'plugin_update d';349 $mapped_type = isset($action_map[$action]) ? $action_map[$action] : 'plugin_update'; 310 350 311 351 return array( 312 352 'alert_type' => $mapped_type, // Use mapped type instead of 'plugin_change' 353 'severity' => $alert['severity'], 354 'title' => $alert['title'], 355 'message' => $alert['message'], 356 'details' => $details, 357 'site_id' => $this->get_site_id() 358 ); 359 } 360 361 /** 362 * Format theme change alert with specific alert_type values, mirroring the 363 * plugin change alert naming scheme. 364 */ 365 private function format_theme_change_alert($alert) { 366 $details = isset($alert['details']) ? $alert['details'] : array(); 367 368 $action_map = array( 369 'activated' => 'theme_activated', 370 'deactivated' => 'theme_deactivated', 371 'installed' => 'theme_installed', 372 'deleted' => 'theme_deleted', 373 'updated' => 'theme_update', 374 'switched' => 'theme_changed', 375 ); 376 377 $action = isset($details['action']) ? $details['action'] : 'switched'; 378 $mapped_type = isset($action_map[$action]) ? $action_map[$action] : 'theme_change'; 379 380 return array( 381 'alert_type' => $mapped_type, 313 382 'severity' => $alert['severity'], 314 383 'title' => $alert['title'], … … 422 491 return isset($generic_map[$alert_type]) ? $generic_map[$alert_type] : 'custom_alert'; 423 492 } 424 493 494 /** 495 * Format auto-update events. 496 * The canonical payload is already built by Vulnity_Auto_Update_Alert; 497 * strip the internal 'type' routing key before sending. 498 */ 499 private function format_auto_update_event($alert) { 500 $payload = $alert; 501 unset($payload['type']); 502 return $payload; 503 } 504 425 505 /** 426 506 * Get the appropriate endpoint for each alert type … … 444 524 'plugin_change' => '/plugin-change-alert', 445 525 'theme_change' => '/theme-change-alert', 526 'plugin_update' => '/real-time-alerts', 527 'theme_update' => '/real-time-alerts', 446 528 'core_updated' => '/core-update-alert', 447 529 'suspicious_query' => '/suspicious-query-alert', … … 449 531 'scanner_detected' => '/scanner-detected-alert', 450 532 'system_panic' => '/generic-alert', 451 'system_recovery' => '/generic-alert' 452 ); 453 533 'system_recovery' => '/generic-alert', 534 'system_auto_update_plugin' => '/real-time-alerts', 535 'system_auto_update_theme' => '/real-time-alerts', 536 'system_manual_update_plugin' => '/real-time-alerts', 537 'system_manual_update_theme' => '/real-time-alerts', 538 'plugin_outdated_resolved' => '/real-time-alerts', 539 'theme_outdated_resolved' => '/real-time-alerts', 540 'auto_update_state_changed' => '/real-time-alerts', 541 'outdated_components' => '/real-time-alerts', 542 ); 543 454 544 return isset($endpoints[$alert_type]) ? $endpoints[$alert_type] : '/generic-alert'; 455 545 } … … 633 723 } 634 724 } 635 -
vulnity/trunk/readme.txt
r3478206 r3490327 4 4 Tested up to: 6.9 5 5 Requires PHP: 7.4 6 Stable tag: 1.2. 16 Stable tag: 1.2.2 7 7 License: GPLv2 or later 8 8 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 68 68 69 69 == Changelog == 70 = 1.2.2 = 71 * Fixed anti-collapse dedup system blocking subsequent auto-update state toggle events due to identical hash. 72 * Fixed wrong authentication headers for `/real-time-alerts` endpoint (now uses HMAC-SHA256 signature instead of token). 73 * Fixed missing `remediation` field in auto-update state events sent to the SIEM. 74 * Fixed `version_old` not captured in auto-update events; now recorded via `upgrader_pre_install` hook before files are replaced. 75 * Fixed auto-update trigger running on disable; updates now only fire for newly enabled component types. 76 * Fixed auto-update event detection using `instanceof WP_Automatic_Updater` instead of `wp_doing_cron()` for broader compatibility. 77 * Fixed single-file plugin slug resolving to `.` (e.g. hello-dolly) in update event payloads. 78 * Added `triggered_by` field to update events: `siem_manual`, `siem_auto_update`, or `wp_auto_updater`. 79 * Auto-update toggles in the admin panel are now read-only; changes must be made from the SIEM. 80 * Replaced `parse_url()` with `wp_parse_url()` for WordPress coding standards compliance. 81 82 = 1.2.1 = 83 * Plugin Check compatibility improvements for filesystem and nonce-related warnings. 84 * Runtime validation improvements for scanner detection, file editor monitoring, and firewall state serialization. 85 86 = 1.2.0 = 87 * Fixed login URL rename validation against existing pages/posts and reserved WordPress routes. 88 * Fixed uninstall cron cleanup to use `wp_unschedule_hook()` for complete removal. 89 * Fixed heartbeat, mitigation sync, and alert buffer crons not cancelled on plugin disconnect. 90 70 91 = 1.1.9 = 71 92 * Send whitelist IPs (user public IP + localhost) to the SIEM during pairing so the whitelist persists after synchronization. … … 122 143 123 144 == Upgrade Notice == 145 = 1.2.2 = 146 Fixes bidirectional auto-update sync with the SIEM: corrects authentication headers, dedup hashing, version tracking, and update trigger logic. 147 148 = 1.2.1 = 149 Maintenance release with Plugin Check compatibility fixes. 150 151 = 1.2.0 = 152 Fixes login URL validation and cron cleanup on disconnect. 153 124 154 = 1.1.9 = 125 155 Whitelist IPs are now sent to the SIEM during pairing to prevent them from being lost on sync. -
vulnity/trunk/uninstall.php
r3478185 r3490327 428 428 'vulnity_flush_alert_buffer', 429 429 'vulnity_check_heartbeat', 430 'vulnity_run_native_auto_updates', 430 431 'vulnity_sync_mitigation_config', 431 432 'vulnity_cleanup_flood_data', -
vulnity/trunk/views/admin-dashboard.php
r3478185 r3490327 110 110 'core_update_alert', 111 111 'suspicious_query_alert', 112 'plugin_deleted', 113 'theme_installed', 112 114 ), 113 115 'medium' => array( … … 118 120 'plugin.installed_or_activated', 119 121 'plugin_activated', 122 'theme_activated', 120 123 'file.editor_usage', 121 124 'file_editor_used', … … 124 127 'rest_access_no_auth', 125 128 'theme_changed', 129 'theme_deleted', 126 130 'user_registration_enabled', 127 131 'email_spike', … … 136 140 'low' => array( 137 141 'admin_user_exists', 142 'plugin_deactivated', 143 'theme_deactivated', 138 144 ), 139 145 'info' => array( … … 144 150 'site_paired', 145 151 'site_unpaired', 152 'plugin_installed', 153 'plugin_update', 154 'theme_update', 146 155 ), 147 156 ); … … 520 529 </div> 521 530 </div> 531 522 532 </div> 523 533 </div> … … 552 562 </div> 553 563 554 <div class="vulnity-sync-action-bar"> 555 <button type="button" class="button button-primary" id="sync-inventory-btn" onclick="vulnitySyncInventory()">Sync Now</button> 556 <span id="sync-result"></span> 557 </div> 564 558 565 559 566 <div class="vulnity-sync-subpanel"> … … 580 587 </section> 581 588 589 590 <?php 591 $au_plugins_on = get_option( 'vulnity_auto_update_plugins', '0' ) === '1'; 592 $au_themes_on = get_option( 'vulnity_auto_update_themes', '0' ) === '1'; 593 $au_any_on = $au_plugins_on || $au_themes_on; 594 ?> 595 <style> 596 .vulnity-autoupdate-status{display:flex;align-items:center;gap:10px;padding:12px 16px;border-radius:8px;margin-bottom:16px;font-size:14px;font-weight:500;flex-wrap:wrap;} 597 .vulnity-autoupdate-status.is-active{background:#d1fae5;color:#065f46;border:1px solid #6ee7b7;} 598 .vulnity-autoupdate-status.is-inactive{background:#f3f4f6;color:#6b7280;border:1px solid #e5e7eb;} 599 .vulnity-autoupdate-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;} 600 .is-active .vulnity-autoupdate-dot{background:#10b981;} 601 .is-inactive .vulnity-autoupdate-dot{background:#9ca3af;} 602 .vulnity-autoupdate-badge{background:rgba(0,0,0,.1);padding:2px 8px;border-radius:99px;font-size:12px;font-weight:600;} 603 </style> 582 604 <section class="card vulnity-sync-panel"> 583 605 <header class="vulnity-sync-header"> 584 606 <div> 585 <h2>Synchronization Policy</h2> 586 <p>What is synchronized and how often.</p> 587 </div> 588 <span class="vulnity-sync-pill is-neutral">Managed</span> 607 <h2>Actualizaciones Automáticas</h2> 608 <p>Estado de las actualizaciones automáticas gestionadas por el SIEM.</p> 609 </div> 610 <span id="vulnity-au-pill" class="vulnity-sync-pill <?php echo $au_any_on ? 'is-success' : 'is-neutral'; ?>"> 611 <?php echo $au_any_on ? 'Activas' : 'Inactivas'; ?> 612 </span> 589 613 </header> 590 614 591 < ul class="vulnity-sync-checklist">592 < li>WordPress core version and update status</li>593 < li>Plugins and themes with available updates</li>594 <li>Server runtime information (PHP and environment)</li>595 < li>User and administrator inventory</li>596 < li>Security configuration metadata</li>597 < li>Installed security tooling signals</li>598 </ ul>599 600 <div class="vulnity-sync- policy-grid">601 <div class="vulnity-sync- policy-item">602 <span class=" policy-label">Automatic sync</span>603 < span class="policy-value">Every 24 hours</span>604 </div>605 <div class="vulnity-sync-policy-item">606 < span class="policy-label">Manual sync</span>607 <span class="policy-value">On demand</span>608 < /div>609 <div class="vulnity-sync-policy-item">610 < span class="policy-label">Delivery</span>611 <span class="policy-value">SIEM API</span>612 </div>613 </div>614 615 < div class="vulnity-sync-subpanel">616 <h3>Recommended Workflow</h3> 617 <ul class="vulnity-sync-checklist">618 <li>Run manual sync after plugin, theme, or core changes.</li>619 <li>Review pending updates after each successful synchronization.</li>620 <li>Verify status is synchronized at least once per day.</li>621 </ ul>615 <div id="vulnity-autoupdate-status-banner" class="vulnity-autoupdate-status <?php echo $au_any_on ? 'is-active' : 'is-inactive'; ?>"> 616 <span class="vulnity-autoupdate-dot"></span> 617 <strong id="vulnity-autoupdate-status-text"> 618 <?php echo $au_any_on ? 'Actualizaciones automáticas ACTIVAS' : 'Actualizaciones automáticas INACTIVAS'; ?> 619 </strong> 620 <span id="vulnity-autoupdate-badge-plugins" class="vulnity-autoupdate-badge" style="<?php echo $au_plugins_on ? '' : 'display:none;'; ?>">Plugins</span> 621 <span id="vulnity-autoupdate-badge-themes" class="vulnity-autoupdate-badge" style="<?php echo $au_themes_on ? '' : 'display:none;'; ?>">Temas</span> 622 </div> 623 624 <div class="vulnity-sync-rows"> 625 <div class="vulnity-sync-row" style="align-items:center;"> 626 <span class="sync-label">Plugins</span> 627 <label class="vulnity-switch vulnity-switch-readonly" style="margin:0;pointer-events:none;opacity:0.6;"> 628 <input type="checkbox" id="vulnity-auto-update-plugins" <?php checked( $au_plugins_on ); ?> disabled /> 629 <span class="vulnity-slider"></span> 630 </label> 631 </div> 632 <div class="vulnity-sync-row" style="align-items:center;"> 633 <span class="sync-label">Temas</span> 634 <label class="vulnity-switch vulnity-switch-readonly" style="margin:0;pointer-events:none;opacity:0.6;"> 635 <input type="checkbox" id="vulnity-auto-update-themes" <?php checked( $au_themes_on ); ?> disabled /> 636 <span class="vulnity-slider"></span> 637 </label> 638 </div> 639 </div> 640 641 <div style="margin:14px 0 0;padding:10px 12px;background:#f0f6ff;border:1px solid #c3d9f7;border-radius:6px;display:flex;align-items:center;gap:8px;"> 642 <span style="font-size:15px;">ℹ️</span> 643 <span style="font-size:12px;color:#2563eb;line-height:1.5;"> 644 Para activar o desactivar las actualizaciones automáticas, accede al <strong>SIEM de Vulnity</strong> → apartado <strong>Sistema</strong>. 645 </span> 622 646 </div> 623 647 </section> -
vulnity/trunk/vulnity.php
r3478206 r3490327 3 3 * Plugin Name: Vulnity Security 4 4 * Description: Security monitoring and SIEM integration for WordPress 5 * Version: 1.2. 15 * Version: 1.2.2 6 6 * Author: Vulnity 7 7 * License: GPL-2.0-or-later … … 13 13 if (!defined('ABSPATH')) exit; 14 14 15 define('VULNITY_VERSION', '1.2. 1');15 define('VULNITY_VERSION', '1.2.2'); 16 16 17 17 $vulnity_plugin_dir = plugin_dir_path(__FILE__); … … 1497 1497 add_action('vulnity_check_heartbeat', array($this, 'check_heartbeat')); 1498 1498 1499 // Register custom cron interval 1499 // Add cron action for hourly auto-update check 1500 add_action('vulnity_run_native_auto_updates', array($this, 'run_native_auto_updates')); 1501 1502 // Register custom cron interval — must be before ensure_native_auto_update_schedule() 1503 // so that vulnity_1hour is available when wp_schedule_event() validates the interval. 1500 1504 add_filter('cron_schedules', array($this, 'add_cron_intervals')); 1505 1506 // Ensure the cron is scheduled whenever the plugin loads (idempotent check). 1507 // Runs after cron_schedules is registered to guarantee vulnity_1hour exists. 1508 $this->ensure_native_auto_update_schedule(); 1501 1509 1502 1510 // Add admin notices for critical failures … … 1511 1519 // Ensure heartbeat check is scheduled every six hours 1512 1520 $this->ensure_heartbeat_schedule(); 1513 1521 1514 1522 $this->core = Vulnity_Core::get_instance(); 1515 1523 } … … 1616 1624 'vulnity_flush_alert_buffer', 1617 1625 'vulnity_check_heartbeat', 1626 'vulnity_run_native_auto_updates', 1618 1627 'vulnity_sync_mitigation_config', 1619 1628 'vulnity_cleanup_flood_data', … … 1646 1655 'vulnity_flush_alert_buffer', 1647 1656 'vulnity_check_heartbeat', 1657 'vulnity_run_native_auto_updates', 1648 1658 'vulnity_sync_mitigation_config', 1649 1659 'vulnity_cleanup_flood_data', … … 1730 1740 wp_schedule_event(time(), 'vulnity_6hours', 'vulnity_check_heartbeat'); 1731 1741 } 1742 } 1743 1744 private function auto_updates_managed_by_vulnity_are_enabled() { 1745 return get_option('vulnity_auto_update_plugins', '0') === '1' 1746 || get_option('vulnity_auto_update_themes', '0') === '1'; 1747 } 1748 1749 public function handle_auto_update_option_change($old_value, $value, $option) { 1750 $this->ensure_native_auto_update_schedule(); 1751 } 1752 1753 private function ensure_native_auto_update_schedule() { 1754 $hook = 'vulnity_run_native_auto_updates'; 1755 $current_schedule = wp_get_schedule($hook); 1756 1757 if (!$this->auto_updates_managed_by_vulnity_are_enabled()) { 1758 if ($current_schedule) { 1759 wp_clear_scheduled_hook($hook); 1760 } 1761 return; 1762 } 1763 1764 if ($current_schedule && $current_schedule !== 'vulnity_1hour') { 1765 wp_clear_scheduled_hook($hook); 1766 } 1767 1768 if (!wp_next_scheduled($hook)) { 1769 wp_schedule_event(time() + MINUTE_IN_SECONDS, 'vulnity_1hour', $hook); 1770 } 1771 } 1772 1773 public function run_native_auto_updates() { 1774 if (!$this->auto_updates_managed_by_vulnity_are_enabled()) { 1775 return; 1776 } 1777 1778 require_once ABSPATH . 'wp-admin/includes/update.php'; 1779 require_once ABSPATH . 'wp-admin/includes/plugin.php'; 1780 require_once ABSPATH . 'wp-admin/includes/theme.php'; 1781 1782 $outdated = array(); 1783 1784 // Check plugins 1785 if (get_option('vulnity_auto_update_plugins', '0') === '1') { 1786 wp_update_plugins(); 1787 foreach (get_plugin_updates() as $plugin_file => $data) { 1788 $slug = dirname($plugin_file); 1789 if ($slug === '.') { 1790 $slug = basename($plugin_file, '.php'); 1791 } 1792 $plugin_data = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin_file, false, false); 1793 $outdated[] = array( 1794 'type' => 'plugin', 1795 'slug' => $slug, 1796 'file' => $plugin_file, 1797 'name' => isset($plugin_data['Name']) ? $plugin_data['Name'] : $slug, 1798 'version_old' => isset($plugin_data['Version']) ? $plugin_data['Version'] : 'unknown', 1799 'version_new' => $data->new_version, 1800 ); 1801 } 1802 } 1803 1804 // Check themes 1805 if (get_option('vulnity_auto_update_themes', '0') === '1') { 1806 wp_update_themes(); 1807 foreach (get_theme_updates() as $theme_slug => $data) { 1808 $theme = wp_get_theme($theme_slug); 1809 $outdated[] = array( 1810 'type' => 'theme', 1811 'slug' => $theme_slug, 1812 'name' => $theme->exists() ? $theme->get('Name') : $theme_slug, 1813 'version_old' => $theme->exists() ? $theme->get('Version') : 'unknown', 1814 'version_new' => $data['new_version'], 1815 ); 1816 } 1817 } 1818 1819 if (empty($outdated)) { 1820 vulnity_log('[Vulnity] Auto-update check: all components are up to date.'); 1821 return; 1822 } 1823 1824 // Report outdated components to SIEM — the SIEM will push back 1825 // a push_trigger_update command via /receive-mitigation to execute updates. 1826 $siem = Vulnity_SIEM_Connector::get_instance(); 1827 $config = get_option('vulnity_config', array()); 1828 $domain = (string) wp_parse_url(home_url(), PHP_URL_HOST); 1829 1830 $payload = array( 1831 'type' => 'outdated_components', 1832 'schema_version' => '1.0', 1833 'event_uuid' => wp_generate_uuid4(), 1834 'site_id' => isset($config['site_id']) ? $config['site_id'] : '', 1835 'domain' => $domain, 1836 'event_type' => 'outdated_components', 1837 'severity' => 'info', 1838 'occurred_at' => gmdate('c'), 1839 'detected_at' => gmdate('c'), 1840 'summary' => sprintf('%d component(s) with updates available on %s', count($outdated), $domain), 1841 'details' => array( 1842 'components' => $outdated, 1843 'auto_update_enabled' => true, 1844 ), 1845 'ui' => array('group' => 'Sistema', 'icon' => 'refresh-cw', 'color' => 'blue'), 1846 'trace' => array( 1847 'plugin_version' => defined('VULNITY_VERSION') ? VULNITY_VERSION : 'unknown', 1848 'wordpress_version' => get_bloginfo('version'), 1849 ), 1850 ); 1851 1852 $result = $siem->send_alert($payload); 1853 vulnity_log( 1854 '[Vulnity] Outdated components report sent to SIEM: ' 1855 . (empty($result['success']) ? 'FAILED — ' . (isset($result['error']) ? $result['error'] : 'unknown') : 'OK') 1856 . ' (' . count($outdated) . ' component(s))' 1857 ); 1732 1858 } 1733 1859 … … 2083 2209 } 2084 2210 2085 $heartbeat_state['last_status'] = $status_code ?? 'error'; 2211 $heartbeat_state['failures'] = isset($heartbeat_state['failures']) ? (int) $heartbeat_state['failures'] + 1 : 1; 2212 $heartbeat_state['last_status'] = $status_code ?? 'error'; 2086 2213 $heartbeat_state['last_latency'] = $latency_ms; 2087 2214 $heartbeat_state['last_latency_recorded_at'] = current_time('mysql');
Note: See TracChangeset
for help on using the changeset viewer.