'', // '' = sin decidir · 'on' · 'off'. 'id' => '', 'cola' => array(), 'marca_waf' => 0, 'enviados' => 0, 'ultimo_envio' => 0, ), $c ); } /** Guarda la configuración del sensor. */ function vsai_sensor_save( $c ) { update_option( 'vsai_sensor', $c, false ); } /** ¿El administrador aceptó compartir datos de ataques? */ function vsai_sensor_on() { $c = vsai_sensor_config(); return 'on' === $c['estado']; } /** Identificador anónimo del sensor (no identifica a ninguna persona). */ function vsai_sensor_id() { $c = vsai_sensor_config(); if ( '' === (string) $c['id'] ) { $c['id'] = 's_' . substr( hash( 'sha256', wp_generate_password( 32, true, true ) . home_url() ), 0, 20 ); vsai_sensor_save( $c ); } return (string) $c['id']; } /** * Encola un reporte de ataque, ya saneado. * * @param string $tipo Tipo de evento (waf, login-burst, admin-creado, opcion-critica). * @param string $ip IP del atacante (se valida; si no es IP, queda vacía). * @param string $ruta Ruta intentada; se le quitan parámetros y fragmento. * @param string $familia Familia/patrón (mini-WAF) o nombre de la opción tocada. * @param string $muestra Muestra útil; SOLO viaja su huella SHA-256. */ function vsai_sensor_report( $tipo, $ip = '', $ruta = '', $familia = '', $muestra = '' ) { if ( ! vsai_sensor_on() ) { return; } $ip = (string) $ip; if ( '' !== $ip && ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { $ip = ''; } $ruta = (string) $ruta; $qpos = strpos( $ruta, '?' ); if ( false !== $qpos ) { $ruta = substr( $ruta, 0, $qpos ); } $fpos = strpos( $ruta, '#' ); if ( false !== $fpos ) { $ruta = substr( $ruta, 0, $fpos ); } $c = vsai_sensor_config(); $c['cola'] = is_array( $c['cola'] ) ? $c['cola'] : array(); $c['cola'][] = array( 'ts' => time(), 'tipo' => sanitize_key( $tipo ), 'ip' => $ip, 'ruta' => substr( $ruta, 0, 120 ), 'familia' => substr( sanitize_key( $familia ), 0, 60 ), 'huella' => '' !== (string) $muestra ? hash( 'sha256', (string) $muestra ) : '', ); if ( count( $c['cola'] ) > VSAI_SENSOR_QUEUE_MAX ) { $c['cola'] = array_slice( $c['cola'], -VSAI_SENSOR_QUEUE_MAX ); } vsai_sensor_save( $c ); } /* --------------------------------------------------------------------- * Consentimiento: aviso en el admin + interruptor en el panel. * ------------------------------------------------------------------- */ /** Aviso de consentimiento mientras el administrador no elija. */ function vsai_sensor_consent_notice() { if ( ! current_user_can( 'manage_options' ) ) { return; } $c = vsai_sensor_config(); if ( '' !== $c['estado'] ) { return; } vsai_sensor_id(); // El identificador existe desde el primer aviso. ?>

🛰️

roles, true ) ) { return; } // En el admin, la IP sería la del propio administrador: no se envía. $ip = is_admin() ? '' : ( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- saneado con sanitize_text_field y validado en vsai_sensor_report(). vsai_sensor_report( 'admin-creado', $ip, '/wp-login.php?action=register', 'usuarios' ); } add_action( 'user_register', 'vsai_sensor_user_register' ); /** Opciones críticas tocadas (lista cerrada; solo viaja el NOMBRE). */ function vsai_sensor_option_watch( $option ) { if ( ! vsai_sensor_on() ) { return; } $criticas = array( 'siteurl', 'home', 'admin_email', 'users_can_register', 'default_role', 'permalink_structure' ); if ( ! in_array( $option, $criticas, true ) ) { return; } vsai_sensor_report( 'opcion-critica', '', '/wp-admin/options.php', (string) $option ); } add_action( 'updated_option', 'vsai_sensor_option_watch' ); /** Ráfagas de login fallido por IP (5 en 10 min; un aviso por IP y 6 h). */ function vsai_sensor_login_failed( $username ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- la firma del hook exige el parámetro; el nombre NUNCA se envía. if ( ! vsai_sensor_on() ) { return; } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- saneado y validado en el reporte. if ( '' === $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return; } $clave = 'vsai_sensor_login_' . md5( $ip ); $cuenta = (int) get_transient( $clave ); $cuenta++; set_transient( $clave, $cuenta, 10 * MINUTE_IN_SECONDS ); if ( $cuenta < 5 ) { return; } $freno = 'vsai_sensor_login_avisado_' . md5( $ip ); if ( get_transient( $freno ) ) { return; } set_transient( $freno, 1, 6 * HOUR_IN_SECONDS ); vsai_sensor_report( 'login-burst', $ip, '/wp-login.php', 'login' ); } add_action( 'wp_login_failed', 'vsai_sensor_login_failed' ); /** * Recoge los hallazgos NUEVOS del mini-WAF desde la marca de agua. * Salta el «ruido probable» (contenido legítimo) y los usuarios con * sesión: solo ataques anónimos o de cuentas sin privilegios claros. */ function vsai_sensor_collect_waf() { $c = vsai_sensor_config(); $log = (array) get_option( 'vsai_waf_log', array() ); $marca = (int) $c['marca_waf']; $nueva = $marca; $tope = 0; foreach ( $log as $e ) { if ( ! is_array( $e ) || ! isset( $e['ts'] ) ) { continue; } if ( (int) $e['ts'] <= $marca ) { continue; } $nueva = max( $nueva, (int) $e['ts'] ); if ( ! empty( $e['noise'] ) || $tope >= 50 ) { continue; } $tope++; // La ruta sale del registro pero sin parámetros, y la muestra // viaja solo como huella (el colector central ya lo garantiza). vsai_sensor_report( 'waf', isset( $e['ip'] ) ? (string) $e['ip'] : '', isset( $e['path'] ) ? (string) $e['path'] : '', ( isset( $e['family'] ) ? (string) $e['family'] : '' ) . ( isset( $e['pattern'] ) ? '/' . (string) $e['pattern'] : '' ), isset( $e['snippet'] ) ? (string) $e['snippet'] : '' ); } if ( $nueva !== $marca ) { $c = vsai_sensor_config(); $c['marca_waf'] = $nueva; vsai_sensor_save( $c ); } } /* --------------------------------------------------------------------- * Envío por horas. Si el servidor no responde, la cola espera. * ------------------------------------------------------------------- */ function vsai_sensor_send_run() { if ( ! vsai_sensor_on() ) { return; } vsai_sensor_collect_waf(); $c = vsai_sensor_config(); if ( empty( $c['cola'] ) ) { return; } global $wp_version; $payload = array( 'sensor' => vsai_sensor_id(), 'sitio' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'wp' => (string) $wp_version, 'plugin' => defined( 'VSAI_VERSION' ) ? VSAI_VERSION : '', 'php' => PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, 'reportes' => array_values( (array) $c['cola'] ), ); $response = wp_safe_remote_post( VSAI_SENSOR_ENDPOINT, array( 'timeout' => 12, 'redirection' => 0, 'limit_response_size' => 1048576, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $payload ), ) ); if ( is_wp_error( $response ) ) { return; // La cola espera a la siguiente hora. } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code >= 200 && $code < 300 ) { vsai_ack_queue( 'vsai_sensor', (array) $c['cola'], 'cola' ); } } add_action( 'vsai_sensor_send', 'vsai_sensor_send_run' ); /** Si el sensor está encendido, el cron existe aunque WP-Cron se recree. */ function vsai_sensor_cron_guard() { if ( vsai_sensor_on() && ! wp_next_scheduled( 'vsai_sensor_send' ) ) { wp_schedule_event( time() + 300, 'hourly', 'vsai_sensor_send' ); } } add_action( 'admin_init', 'vsai_sensor_cron_guard' );