'', // '' = 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' );