Plugin Directory

Changeset 3490327


Ignore:
Timestamp:
03/24/2026 07:39:27 PM (10 days ago)
Author:
manuelgalan
Message:

Nueva version 1.2.2, actualizaciones automaticas de plugins y temas

Location:
vulnity/trunk
Files:
1 added
15 edited

Legend:

Unmodified
Added
Removed
  • vulnity/trunk/CHANGELOG.md

    r3478206 r3490327  
    11# 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---
    228
    329## [1.2.1] - 2026-03-09
  • vulnity/trunk/includes/alerts/class-alert-base.php

    r3478185 r3490327  
    7373        do_action('vulnity_alert_created', $alert['id'], $alert);
    7474       
    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
    7888        // If alert type triggers inventory scan, schedule it
    7989        if ($send_result && in_array($this->alert_type, $this->inventory_trigger_types)) {
     
    391401   
    392402    /**
    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.
    394406     */
    395407    public static function process_retry_queue() {
    396408        $queue = get_option('vulnity_retry_queue', array());
    397        
     409
    398410        if (empty($queue)) {
    399411            return;
    400412        }
    401        
     413
    402414        $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
    407429        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;
    413438                    break;
    414439                }
    415440            }
    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).
    435497        update_option('vulnity_alerts', $alerts);
    436        
    437         // Remove processed items from queue
     498
     499        // Remove processed entries (success + exhausted + orphans + stale).
    438500        if (!empty($processed)) {
    439501            $queue = array_filter($queue, function($item) use ($processed) {
    440                 return !in_array($item['id'], $processed);
     502                return !in_array($item['id'], $processed, true);
    441503            });
    442504            update_option('vulnity_retry_queue', array_values($queue));
  • vulnity/trunk/includes/alerts/class-plugin-change-alert.php

    r3478185 r3490327  
    33
    44class 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
    620    public function __construct() {
    721        $this->alert_type = 'plugin_change';
    822        parent::__construct();
    923    }
    10    
     24
    1125    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
    1847    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        }
    1952        $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,
    2356        ));
    2457    }
    25    
     58
    2659    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        }
    2764        $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,
    3168        ));
    3269    }
    33    
     70
    3471    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' ) {
    4083            $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(),
    4386            ));
    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
    5292    public function on_plugin_deleted($plugin_file, $deleted) {
    5393        if ($deleted) {
    5494            $this->evaluate(array(
    5595                'action' => 'deleted',
    56                 'plugin' => $plugin_file
     96                'plugin' => $plugin_file,
    5797            ));
    5898        }
    5999    }
    60    
     100
    61101    protected function evaluate($data) {
    62102        $current_user_info = $this->get_current_user_info();
    63        
    64         $severity = 'medium';
    65         $title = '';
    66         $message = '';
     103
     104        $severity    = 'medium';
     105        $title       = '';
     106        $message     = '';
    67107        $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'] );
    71111        } else {
    72112            $plugin_data = array();
    73113        }
    74        
    75         if (!empty($plugin_data)) {
     114
     115        if ( ! empty( $plugin_data ) ) {
    76116            $plugin_info = array(
    77                 'name' => $plugin_data['Name'],
     117                'name'    => $plugin_data['Name'],
    78118                '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'] : '',
    81121            );
    82122        }
    83        
    84         switch ($data['action']) {
     123
     124        switch ( $data['action'] ) {
    85125            case 'activated':
    86126                $severity = 'medium';
    87                 $title = 'Plugin Activated';
    88                 $message = sprintf(
     127                $title    = 'Plugin Activated';
     128                $message  = sprintf(
    89129                    'Plugin "%s" was activated by user "%s" from IP %s',
    90130                    $plugin_info['name'],
     
    93133                );
    94134                break;
    95                
     135
    96136            case 'deactivated':
    97137                $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
    99177                $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
    127184            case 'deleted':
    128185                $severity = 'high';
    129                 $title = 'Plugin Deleted';
    130                 $message = sprintf(
     186                $title    = 'Plugin Deleted';
     187                $message  = sprintf(
    131188                    '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
    143200        $this->create_alert(array(
    144201            '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            ),
    157215        ));
    158216    }
  • vulnity/trunk/includes/alerts/class-suspicious-query-alert.php

    r3478185 r3490327  
    632632   
    633633    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
    634640        // Check SQL injection patterns
    635641        foreach ($this->sql_injection_patterns as $pattern) {
  • vulnity/trunk/includes/alerts/class-theme-change-alert.php

    r3448563 r3490327  
    33
    44class 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
    617    public function __construct() {
    718        $this->alert_type = 'theme_change';
     
    1627   
    1728    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
    1848        $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,
    2253        ));
    2354    }
    2455   
    2556    public function on_theme_updated($upgrader, $hook_extra) {
    26         if ($hook_extra['type'] !== 'theme') {
     57        if ( empty( $hook_extra['type'] ) || $hook_extra['type'] !== 'theme' ) {
    2758            return;
    2859        }
    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' ) {
    3168            $this->evaluate(array(
    3269                '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',
    3870                'themes' => isset($hook_extra['themes']) ? $hook_extra['themes'] : array()
    3971            ));
     
    5890       
    5991        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
    60114            case 'switched':
    61115                $severity = 'high';
     
    81135               
    82136            case 'updated':
    83                 $severity = 'low';
     137                $severity = 'info';
    84138                $title = 'Theme Updated';
    85139                $message = sprintf(
  • vulnity/trunk/includes/class-alert-manager.php

    r3478185 r3490327  
    3636            'class-core-update-alert.php',
    3737            'class-suspicious-query-alert.php',
    38             'class-scanner-detection-alert.php'
     38            'class-scanner-detection-alert.php',
     39            'class-auto-update-alert.php',
    3940        );
    4041       
     
    5758            'Vulnity_Core_Update_Alert',
    5859            'Vulnity_Suspicious_Query_Alert',
    59             'Vulnity_Scanner_Detection_Alert'
     60            'Vulnity_Scanner_Detection_Alert',
     61            'Vulnity_Auto_Update_Alert',
    6062        );
    6163       
  • vulnity/trunk/includes/class-anti-collapse.php

    r3460003 r3490327  
    121121    private function generate_hash($alert) {
    122122        $key = $alert['type'] . ':';
    123        
     123
    124124        if (isset($alert['details']['ip'])) {
    125125            $key .= $alert['details']['ip'] . ':';
     
    131131            $key .= $alert['details']['action'];
    132132        }
    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
    134141        return md5($key);
    135142    }
  • vulnity/trunk/includes/class-core.php

    r3478185 r3490327  
    141141        add_action('wp_ajax_vulnity_block_ip', array($this->mitigation_manager, 'ajax_block_ip'));
    142142        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.
    143146    }
    144147   
     
    447450    }
    448451   
     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
    449985    private function pair_plugin($site_id, $pair_code, $public_ip_whitelist = '') {
    450986        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)) {
     
    6281164    }
    6291165   
    630     // =====================================================
    631     // AQUÍ VA EL MÉTODO unpair_plugin
    632     // =====================================================
    6331166    public function unpair_plugin($reason = 'manual') {
    6341167        $config = get_option('vulnity_config');
     
    8721405        $config = get_option('vulnity_config');
    8731406
    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)');
    8761409            return false;
    8771410        }
     
    10041537                break;
    10051538
     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
    10061608            case 'remote_disconnect':
    10071609                $this->perform_remote_disconnect($body);
     
    10791681
    10801682        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
    10811690            vulnity_log( '[Vulnity] Hardening settings updated by SIEM: ' . wp_json_encode( $applied ) );
    10821691            return true;
  • vulnity/trunk/includes/class-inventory-sync.php

    r3460003 r3490327  
    1919        add_action('vulnity_sync_inventory', array($this, 'perform_sync'));
    2020        add_action(self::DEFERRED_SYNC_HOOK, array($this, 'perform_deferred_update_sync'));
     21        add_action('vulnity_inventory_sync_needed', array($this, 'perform_sync'));
    2122       
    2223        if (!wp_next_scheduled('vulnity_sync_inventory')) {
     
    329330    public function handle_updates($upgrader, $hook_extra) {
    330331        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            }
    331337            $this->queue_update_sync();
    332338        }
  • vulnity/trunk/includes/class-mitigation-manager.php

    r3478185 r3490327  
    12301230                'suspicious_query_alert',
    12311231                'suspicious_query',
     1232                'plugin_deleted',
     1233                'theme_installed',
    12321234            ),
    12331235            'medium' => array(
     
    12381240                'plugin.installed_or_activated',
    12391241                'plugin_activated',
     1242                'theme_activated',
    12401243                'plugin_change',
    12411244                'plugin_change_alert',
     
    12461249                'rest_access_no_auth',
    12471250                'theme_changed',
     1251                'theme_deleted',
    12481252                'theme_change',
    12491253                'theme_change_alert',
     
    12591263            'low' => array(
    12601264                'admin_user_exists',
     1265                'plugin_deactivated',
     1266                'theme_deactivated',
    12611267            ),
    12621268            'info' => array(
     
    12671273                'site_paired',
    12681274                'site_unpaired',
     1275                'plugin_update',
     1276                'theme_update',
     1277                'plugin_installed',
    12691278            ),
    12701279        );
  • vulnity/trunk/includes/class-siem-connector.php

    r3478185 r3490327  
    2828        $config = get_option('vulnity_config');
    2929       
    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)');
    3232            return array('success' => false, 'error' => 'Missing configuration');
    3333        }
     
    5050
    5151        $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
    6072        $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,
    6678            'redirection' => 5,
    6779            'httpversion' => '1.1',
    68             'blocking' => true
     80            'blocking'    => true,
    6981        );
    7082       
     
    96108       
    97109        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' ) . ')');
    99111            return array(
    100112                'success' => true,
     
    136148        }
    137149       
     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
    138173        // Special handling for system alerts (panic mode, recovery)
    139174        if (in_array($alert['type'], array('system_panic', 'system_recovery'))) {
     
    149184        if ($endpoint === '/plugin-change-alert') {
    150185            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);
    151191        }
    152192       
     
    303343            'installed' => 'plugin_installed',
    304344            'deleted' => 'plugin_deleted',
    305             'updated' => 'plugin_updated'
     345            'updated' => 'plugin_update'
    306346        );
    307347       
    308348        $action = isset($details['action']) ? $details['action'] : 'updated';
    309         $mapped_type = isset($action_map[$action]) ? $action_map[$action] : 'plugin_updated';
     349        $mapped_type = isset($action_map[$action]) ? $action_map[$action] : 'plugin_update';
    310350       
    311351        return array(
    312352            '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,
    313382            'severity' => $alert['severity'],
    314383            'title' => $alert['title'],
     
    422491        return isset($generic_map[$alert_type]) ? $generic_map[$alert_type] : 'custom_alert';
    423492    }
    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
    425505    /**
    426506     * Get the appropriate endpoint for each alert type
     
    444524            'plugin_change' => '/plugin-change-alert',
    445525            'theme_change' => '/theme-change-alert',
     526            'plugin_update' => '/real-time-alerts',
     527            'theme_update' => '/real-time-alerts',
    446528            'core_updated' => '/core-update-alert',
    447529            'suspicious_query' => '/suspicious-query-alert',
     
    449531            'scanner_detected' => '/scanner-detected-alert',
    450532            '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
    454544        return isset($endpoints[$alert_type]) ? $endpoints[$alert_type] : '/generic-alert';
    455545    }
     
    633723    }
    634724}
    635 
  • vulnity/trunk/readme.txt

    r3478206 r3490327  
    44Tested up to: 6.9
    55Requires PHP: 7.4
    6 Stable tag: 1.2.1
     6Stable tag: 1.2.2
    77License: GPLv2 or later
    88License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    6868
    6969== 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
    7091= 1.1.9 =
    7192* Send whitelist IPs (user public IP + localhost) to the SIEM during pairing so the whitelist persists after synchronization.
     
    122143
    123144== Upgrade Notice ==
     145= 1.2.2 =
     146Fixes bidirectional auto-update sync with the SIEM: corrects authentication headers, dedup hashing, version tracking, and update trigger logic.
     147
     148= 1.2.1 =
     149Maintenance release with Plugin Check compatibility fixes.
     150
     151= 1.2.0 =
     152Fixes login URL validation and cron cleanup on disconnect.
     153
    124154= 1.1.9 =
    125155Whitelist IPs are now sent to the SIEM during pairing to prevent them from being lost on sync.
  • vulnity/trunk/uninstall.php

    r3478185 r3490327  
    428428    'vulnity_flush_alert_buffer',
    429429    'vulnity_check_heartbeat',
     430    'vulnity_run_native_auto_updates',
    430431    'vulnity_sync_mitigation_config',
    431432    'vulnity_cleanup_flood_data',
  • vulnity/trunk/views/admin-dashboard.php

    r3478185 r3490327  
    110110            'core_update_alert',
    111111            'suspicious_query_alert',
     112            'plugin_deleted',
     113            'theme_installed',
    112114        ),
    113115        'medium' => array(
     
    118120            'plugin.installed_or_activated',
    119121            'plugin_activated',
     122            'theme_activated',
    120123            'file.editor_usage',
    121124            'file_editor_used',
     
    124127            'rest_access_no_auth',
    125128            'theme_changed',
     129            'theme_deleted',
    126130            'user_registration_enabled',
    127131            'email_spike',
     
    136140        'low' => array(
    137141            'admin_user_exists',
     142            'plugin_deactivated',
     143            'theme_deactivated',
    138144        ),
    139145        'info' => array(
     
    144150            'site_paired',
    145151            'site_unpaired',
     152            'plugin_installed',
     153            'plugin_update',
     154            'theme_update',
    146155        ),
    147156    );
     
    520529                </div>
    521530            </div>
     531
    522532        </div>
    523533    </div>
     
    552562                </div>
    553563
    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
    558565
    559566                <div class="vulnity-sync-subpanel">
     
    580587            </section>
    581588
     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>
    582604            <section class="card vulnity-sync-panel">
    583605                <header class="vulnity-sync-header">
    584606                    <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>
    589613                </header>
    590614
    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>
    622646                </div>
    623647            </section>
  • vulnity/trunk/vulnity.php

    r3478206 r3490327  
    33 * Plugin Name: Vulnity Security
    44 * Description: Security monitoring and SIEM integration for WordPress
    5  * Version: 1.2.1
     5 * Version: 1.2.2
    66 * Author: Vulnity
    77 * License: GPL-2.0-or-later
     
    1313if (!defined('ABSPATH')) exit;
    1414
    15 define('VULNITY_VERSION', '1.2.1');
     15define('VULNITY_VERSION', '1.2.2');
    1616
    1717$vulnity_plugin_dir = plugin_dir_path(__FILE__);
     
    14971497        add_action('vulnity_check_heartbeat', array($this, 'check_heartbeat'));
    14981498
    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.
    15001504        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();
    15011509
    15021510        // Add admin notices for critical failures
     
    15111519        // Ensure heartbeat check is scheduled every six hours
    15121520        $this->ensure_heartbeat_schedule();
    1513        
     1521
    15141522        $this->core = Vulnity_Core::get_instance();
    15151523    }
     
    16161624            'vulnity_flush_alert_buffer',
    16171625            'vulnity_check_heartbeat',
     1626            'vulnity_run_native_auto_updates',
    16181627            'vulnity_sync_mitigation_config',
    16191628            'vulnity_cleanup_flood_data',
     
    16461655            'vulnity_flush_alert_buffer',
    16471656            'vulnity_check_heartbeat',
     1657            'vulnity_run_native_auto_updates',
    16481658            'vulnity_sync_mitigation_config',
    16491659            'vulnity_cleanup_flood_data',
     
    17301740            wp_schedule_event(time(), 'vulnity_6hours', 'vulnity_check_heartbeat');
    17311741        }
     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        );
    17321858    }
    17331859
     
    20832209        }
    20842210
    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';
    20862213        $heartbeat_state['last_latency'] = $latency_ms;
    20872214        $heartbeat_state['last_latency_recorded_at'] = current_time('mysql');
Note: See TracChangeset for help on using the changeset viewer.