5, 'limit_response_size' => 262144, 'redirection' => 0, 'sslverify' => true, // Es una autocomprobación del propio sitio. 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', ); $data = array( 'readme_code' => 0, 'readme_is_wp' => false, 'author_code' => 0, 'home_code' => 0, 'home_headers' => array(), ); /* * readme: GET siguiendo redirecciones y verificando el CONTENIDO. * Un HEAD sin seguir puede dar un código que no refleja lo que ve un * visitante (falso «correcto» detectado en el piloto de Fresh Flowers: * por fuera era 200 y el chequeo viejo lo daba por bloqueado). */ $r = wp_remote_get( home_url( '/readme.html' ) . '?vsai_sc=' . time(), array( 'timeout' => 5, 'limit_response_size' => 262144, 'redirection' => 0, 'sslverify' => true, // Es una autocomprobación del propio sitio. 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', ) ); if ( ! is_wp_error( $r ) ) { $data['readme_code'] = (int) wp_remote_retrieve_response_code( $r ); $body = (string) wp_remote_retrieve_body( $r ); $data['readme_is_wp'] = ( false !== stripos( $body, 'WordPress' ) ); } $r = wp_remote_head( home_url( '/?author=1' ) . '&vsai_sc=' . time(), $args ); if ( ! is_wp_error( $r ) ) { $data['author_code'] = (int) wp_remote_retrieve_response_code( $r ); } $r = wp_remote_get( home_url( '/' ) . '?vsai_sc=' . time(), array( 'timeout' => 5, 'limit_response_size' => 262144, 'redirection' => 0, 'sslverify' => true, 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', ) ); if ( ! is_wp_error( $r ) ) { $data['home_code'] = (int) wp_remote_retrieve_response_code( $r ); $headers = wp_remote_retrieve_headers( $r ); $data['home_headers'] = is_object( $headers ) && method_exists( $headers, 'getAll' ) ? $headers->getAll() : (array) $headers; } // Si nada respondió, el loopback está bloqueado en este hosting. if ( 0 === $data['readme_code'] && 0 === $data['author_code'] && 0 === $data['home_code'] ) { return null; } set_transient( 'vsai_loopback_data', $data, HOUR_IN_SECONDS ); return $data; } /** * Constructor de un resultado de chequeo. */ function vsai_check( $id, $icon, $title, $status, $detail, $weight ) { return array( 'id' => $id, 'icon' => $icon, 'title' => $title, 'status' => $status, // ok | warn | fail 'detail' => $detail, 'weight' => $weight, ); } /** * Ejecuta todos los chequeos y devuelve la lista. * * @return array */ function vsai_scanner_run_checks() { $checks = array(); $settings = vsai_get_settings(); $loopback = vsai_scanner_loopback(); /* 1. WordPress actualizado ------------------------------------------ */ if ( ! function_exists( 'get_core_updates' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } $core_updates = get_core_updates(); if ( is_array( $core_updates ) && isset( $core_updates[0]->response ) && 'upgrade' === $core_updates[0]->response ) { $checks[] = vsai_check( 'wp_updates', '🔄', __( 'WordPress is up to date', 'vidal-security-ai' ), 'fail', sprintf( /* translators: %s: versión disponible */ __( 'An update is available (%s). Updating is defence #1.', 'vidal-security-ai' ), esc_html( $core_updates[0]->current ) ), 12 ); } elseif ( is_array( $core_updates ) && isset( $core_updates[0]->response ) ) { $checks[] = vsai_check( 'wp_updates', '🔄', __( 'WordPress is up to date', 'vidal-security-ai' ), 'ok', __( 'Your WordPress is on the latest version.', 'vidal-security-ai' ), 12 ); } else { $checks[] = vsai_check( 'wp_updates', '🔄', __( 'WordPress is up to date', 'vidal-security-ai' ), 'warn', __( 'Pending updates could not be checked.', 'vidal-security-ai' ), 12 ); } /* 2. Lista de usuarios en el REST ----------------------------------- */ // Si nuestro endurecimiento está activo, para los visitantes ya está cerrada // (el escaneo lo hace un admin conectado, que sí conserva el acceso). if ( ! empty( $settings['rest_users'] ) ) { $checks[] = vsai_check( 'rest_users', '👥', __( 'User list hidden', 'vidal-security-ai' ), 'ok', __( 'Visitors cannot see your user list through the API (hardening active).', 'vidal-security-ai' ), 10 ); } elseif ( function_exists( 'rest_get_server' ) ) { $routes = rest_get_server()->get_routes(); $exposed = isset( $routes['/wp/v2/users'] ) || isset( $routes['/wp/v2/users/(?P[\d]+)'] ); $checks[] = $exposed ? vsai_check( 'rest_users', '👥', __( 'User list hidden', 'vidal-security-ai' ), 'fail', __( 'Anyone can see your users (names and usernames) through the public API: it is the first step of an attack. Turn on “Hide the user list” below.', 'vidal-security-ai' ), 10 ) : vsai_check( 'rest_users', '👥', __( 'User list hidden', 'vidal-security-ai' ), 'ok', __( 'The API users route is not registered.', 'vidal-security-ai' ), 10 ); } else { $checks[] = vsai_check( 'rest_users', '👥', __( 'User list hidden', 'vidal-security-ai' ), 'warn', __( 'The API could not be checked right now.', 'vidal-security-ai' ), 10 ); } /* 3. XML-RPC ---------------------------------------------------------- */ $xmlrpc_file = file_exists( ABSPATH . 'xmlrpc.php' ); $xmlrpc_on = $xmlrpc_file && (bool) apply_filters( 'xmlrpc_enabled', true ); if ( ! $xmlrpc_file ) { $checks[] = vsai_check( 'xmlrpc', '📡', __( 'XML-RPC disabled', 'vidal-security-ai' ), 'ok', __( 'The xmlrpc.php file does not exist in this installation.', 'vidal-security-ai' ), 8 ); } elseif ( $xmlrpc_on ) { $checks[] = vsai_check( 'xmlrpc', '📡', __( 'XML-RPC disabled', 'vidal-security-ai' ), 'fail', __( 'XML-RPC is on: it is a door widely used to guess passwords. If you do not use the WordPress mobile app or Jetpack, disable it below.', 'vidal-security-ai' ), 8 ); } else { $checks[] = vsai_check( 'xmlrpc', '📡', __( 'XML-RPC disabled', 'vidal-security-ai' ), 'ok', __( 'XML-RPC is disabled.', 'vidal-security-ai' ), 8 ); } /* 4. Enumeración por autor (loopback a /?author=1) -------------------- */ if ( null === $loopback || 0 === $loopback['author_code'] ) { $checks[] = vsai_check( 'author_enum', '🕵️', __( 'Author enumeration blocked', 'vidal-security-ai' ), 'warn', __( 'Could not check: the site did not answer the self-check.', 'vidal-security-ai' ), 9 ); } elseif ( 404 === $loopback['author_code'] ) { $checks[] = vsai_check( 'author_enum', '🕵️', __( 'Author enumeration blocked', 'vidal-security-ai' ), 'ok', __( 'Trying “?author=1” returns 404: nobody can discover your users this way.', 'vidal-security-ai' ), 9 ); } else { $checks[] = vsai_check( 'author_enum', '🕵️', __( 'Author enumeration blocked', 'vidal-security-ai' ), 'fail', __( 'With “?author=1” anyone can discover your accounts’ real usernames. Turn on the enumeration block below.', 'vidal-security-ai' ), 9 ); } /* 5. Meta generator / versión pública -------------------------------- */ $generator_on = false !== has_action( 'wp_head', 'wp_generator' ); $checks[] = $generator_on ? vsai_check( 'generator', '🏷️', __( 'WordPress version hidden', 'vidal-security-ai' ), 'warn', __( 'Your site advertises its WordPress version in the code: it saves the attacker work. Hide it below.', 'vidal-security-ai' ), 4 ) : vsai_check( 'generator', '🏷️', __( 'WordPress version hidden', 'vidal-security-ai' ), 'ok', __( 'The “generator” tag with the version is not being published.', 'vidal-security-ai' ), 4 ); /* 6. readme.html accesible (loopback) --------------------------------- */ if ( null === $loopback || 0 === $loopback['readme_code'] ) { $checks[] = vsai_check( 'readme', '📄', __( 'readme.html not accessible', 'vidal-security-ai' ), 'warn', __( 'Could not check: the site did not answer the self-check.', 'vidal-security-ai' ), 5 ); } elseif ( 200 === $loopback['readme_code'] && ! empty( $loopback['readme_is_wp'] ) ) { $checks[] = vsai_check( 'readme', '📄', __( 'readme.html not accessible', 'vidal-security-ai' ), 'fail', __( 'The readme.html file is public and reveals your exact version. Turn on the .htaccess block below.', 'vidal-security-ai' ), 5 ); } elseif ( ( 300 <= $loopback['readme_code'] && 400 > $loopback['readme_code'] ) || 500 <= $loopback['readme_code'] ) { $checks[] = vsai_check( 'readme', '📄', __( 'readme.html not accessible', 'vidal-security-ai' ), 'warn', __( 'The self-check was redirected or the server returned an error. Access could not be confirmed; review this URL manually.', 'vidal-security-ai' ), 5 ); } elseif ( 200 === $loopback['readme_code'] ) { $checks[] = vsai_check( 'readme', '📄', __( 'readme.html not accessible', 'vidal-security-ai' ), 'warn', __( 'The /readme.html path answers 200, but the content does not look like the WordPress readme. Check it by hand just in case.', 'vidal-security-ai' ), 5 ); } else { $checks[] = vsai_check( 'readme', '📄', __( 'readme.html not accessible', 'vidal-security-ai' ), 'ok', __( 'readme.html cannot be opened from outside.', 'vidal-security-ai' ), 5 ); } /* 7. Modo debug -------------------------------------------------------- */ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { $display = defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY; $checks[] = vsai_check( 'debug', '🐞', __( 'Debug mode off', 'vidal-security-ai' ), $display ? 'fail' : 'warn', $display ? __( 'WP_DEBUG is on AND displays errors on screen: it can leak paths and internal data. Turn it off in wp-config.php.', 'vidal-security-ai' ) : __( 'WP_DEBUG is on. On a production site it should be off (wp-config.php).', 'vidal-security-ai' ), 6 ); } else { $checks[] = vsai_check( 'debug', '🐞', __( 'Debug mode off', 'vidal-security-ai' ), 'ok', __( 'Debug mode is off.', 'vidal-security-ai' ), 6 ); } /* 8. Prefijo de tablas -------------------------------------------------- */ global $wpdb; $checks[] = ( 'wp_' === $wpdb->prefix ) ? vsai_check( 'db_prefix', '🗄️', __( 'Non-default database prefix', 'vidal-security-ai' ), 'warn', __( 'Your tables use the default “wp_” prefix, the first one any automated attack tries. Changing it requires a migration: do it with help.', 'vidal-security-ai' ), 5 ) : vsai_check( 'db_prefix', '🗄️', __( 'Non-default database prefix', 'vidal-security-ai' ), 'ok', __( 'Your table prefix is not the default one.', 'vidal-security-ai' ), 5 ); /* 9. SSL ---------------------------------------------------------------- */ $is_https = 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME ); $checks[] = $is_https ? vsai_check( 'ssl', '🔒', __( 'Secure connection (HTTPS)', 'vidal-security-ai' ), 'ok', __( 'Your site uses HTTPS: passwords travel encrypted.', 'vidal-security-ai' ), 10 ) : vsai_check( 'ssl', '🔒', __( 'Secure connection (HTTPS)', 'vidal-security-ai' ), 'fail', __( 'Your site does NOT use HTTPS: passwords can be intercepted. Ask your hosting for the free certificate (Let’s Encrypt).', 'vidal-security-ai' ), 10 ); /* 10. Cabeceras de seguridad (loopback a la home) ----------------------- */ if ( null === $loopback || 0 === $loopback['home_code'] ) { $checks[] = vsai_check( 'headers', '🧱', __( 'Security headers present', 'vidal-security-ai' ), 'warn', __( 'Could not check: the site did not answer the self-check.', 'vidal-security-ai' ), 9 ); } else { $h = array_change_key_case( $loopback['home_headers'], CASE_LOWER ); $expected = array( 'x-frame-options', 'x-content-type-options', 'referrer-policy' ); if ( $is_https ) { $expected[] = 'strict-transport-security'; } $present = 0; foreach ( $expected as $name ) { if ( ! empty( $h[ $name ] ) ) { $present++; } } if ( $present === count( $expected ) ) { $checks[] = vsai_check( 'headers', '🧱', __( 'Security headers present', 'vidal-security-ai' ), 'ok', __( 'Your site sends the headers that stop clickjacking and other browser abuse.', 'vidal-security-ai' ), 9 ); } elseif ( $present > 0 ) { $checks[] = vsai_check( 'headers', '🧱', __( 'Security headers present', 'vidal-security-ai' ), 'warn', sprintf( /* translators: 1: presentes 2: esperadas */ __( 'Only %1$d of %2$d security headers are present. Turn on the setting below to complete them.', 'vidal-security-ai' ), $present, count( $expected ) ), 9 ); } else { $checks[] = vsai_check( 'headers', '🧱', __( 'Security headers present', 'vidal-security-ai' ), 'fail', __( 'Your site sends no security headers. Turn on the setting below: it is one click.', 'vidal-security-ai' ), 9 ); } } /* 11. Versión de PHP ----------------------------------------------------- */ $php = phpversion(); if ( version_compare( $php, '7.4', '<' ) ) { $checks[] = vsai_check( 'php_version', '🐘', __( 'Modern PHP', 'vidal-security-ai' ), 'fail', sprintf( /* translators: %s: versión actual */ __( 'You run PHP %s, without security patches. Ask your hosting to move to PHP 8.1 or higher.', 'vidal-security-ai' ), $php ), 8 ); } elseif ( version_compare( $php, '8.1', '<' ) ) { $checks[] = vsai_check( 'php_version', '🐘', __( 'Modern PHP', 'vidal-security-ai' ), 'warn', sprintf( /* translators: %s: versión actual */ __( 'You run PHP %s. It works, but the current recommendation is 8.1 or higher.', 'vidal-security-ai' ), $php ), 8 ); } else { $checks[] = vsai_check( 'php_version', '🐘', __( 'Modern PHP', 'vidal-security-ai' ), 'ok', sprintf( /* translators: %s: versión actual */ __( 'PHP %s: a modern, supported version.', 'vidal-security-ai' ), $php ), 8 ); } /* 12. Usuario llamado "admin" -------------------------------------------- */ $admin_user = get_user_by( 'login', 'admin' ); if ( $admin_user && user_can( $admin_user, 'manage_options' ) ) { $checks[] = vsai_check( 'admin_user', '👤', __( 'No account named “admin”', 'vidal-security-ai' ), 'fail', __( 'There is an administrator account named “admin”: it is the first name bots try. Create another admin account with a different name and delete this one.', 'vidal-security-ai' ), 7 ); } else { $checks[] = vsai_check( 'admin_user', '👤', __( 'No account named “admin”', 'vidal-security-ai' ), 'ok', __( 'There is no administrator account named “admin”.', 'vidal-security-ai' ), 7 ); } /* 13. Actualizaciones automáticas del núcleo ------------------------------ */ $auto_disabled = ( defined( 'WP_AUTO_UPDATE_CORE' ) && false === WP_AUTO_UPDATE_CORE ) || ( defined( 'AUTOMATIC_UPDATER_DISABLED' ) && AUTOMATIC_UPDATER_DISABLED ) || (bool) apply_filters( 'automatic_updater_disabled', false ); $checks[] = $auto_disabled ? vsai_check( 'auto_updates', '♻️', __( 'Automatic updates on', 'vidal-security-ai' ), 'warn', __( 'Automatic updates are disabled: security patches will not arrive on their own. Turn them on in Dashboard → Updates.', 'vidal-security-ai' ), 7 ) : vsai_check( 'auto_updates', '♻️', __( 'Automatic updates on', 'vidal-security-ai' ), 'ok', __( 'Automatic updates are not blocked.', 'vidal-security-ai' ), 7 ); /* 14. Actualizaciones pendientes (el 91% de los fallos está en plugins) ------- */ $upd_plugins = get_site_transient( 'update_plugins' ); $pl_pending = ( $upd_plugins && ! empty( $upd_plugins->response ) && is_array( $upd_plugins->response ) ) ? count( $upd_plugins->response ) : 0; $upd_themes = get_site_transient( 'update_themes' ); $th_pending = ( $upd_themes && ! empty( $upd_themes->response ) && is_array( $upd_themes->response ) ) ? count( $upd_themes->response ) : 0; $upd_core = get_site_transient( 'update_core' ); $core_pending = false; if ( $upd_core && ! empty( $upd_core->updates ) && is_array( $upd_core->updates ) ) { foreach ( $upd_core->updates as $u ) { if ( isset( $u->response ) && 'upgrade' === $u->response ) { $core_pending = true; } } } if ( $core_pending ) { $checks[] = vsai_check( 'pending_updates', '🔄', __( 'Everything up to date', 'vidal-security-ai' ), 'fail', __( 'A WordPress update is waiting. An outdated core is a known door: update as soon as possible in Dashboard → Updates.', 'vidal-security-ai' ), 10 ); } elseif ( $pl_pending + $th_pending > 0 ) { $checks[] = vsai_check( 'pending_updates', '🔄', __( 'Everything up to date', 'vidal-security-ai' ), 'warn', sprintf( /* translators: %d: number of pending updates among plugins and themes. */ __( 'There are %d pending update(s) among plugins and themes. 91%% of WordPress vulnerabilities live in plugins: do not leave them waiting.', 'vidal-security-ai' ), $pl_pending + $th_pending ), 8 ); } else { $checks[] = vsai_check( 'pending_updates', '🔄', __( 'Everything up to date', 'vidal-security-ai' ), 'ok', __( 'Core, plugins and themes are up to date.', 'vidal-security-ai' ), 8 ); } /* 15. Administradores con verificación en dos pasos ------------------------- */ $adm_ids = get_users( array( 'role' => 'administrator', 'fields' => 'ID', ) ); $adm_total = count( (array) $adm_ids ); $adm_2fa = 0; if ( function_exists( 'vsai_2fa_enabled_for' ) ) { foreach ( (array) $adm_ids as $uid ) { if ( vsai_2fa_enabled_for( (int) $uid ) ) { $adm_2fa++; } } } if ( $adm_total > 0 && 0 === $adm_2fa ) { $checks[] = vsai_check( 'twofa_admins', '🔐', __( 'Administrators with 2FA', 'vidal-security-ai' ), 'warn', __( 'No administrator uses two-factor authentication. Most site takeovers start with a leaked password: enable it in your profile and the password alone will no longer be enough.', 'vidal-security-ai' ), 8 ); } else { $checks[] = vsai_check( 'twofa_admins', '🔐', __( 'Administrators with 2FA', 'vidal-security-ai' ), 'ok', sprintf( /* translators: 1: administrators who use two-factor authentication. 2: total number of administrators. */ __( '%1$d of %2$d administrators use two-factor authentication.', 'vidal-security-ai' ), $adm_2fa, $adm_total ), 8 ); } /* * Honestidad de medición (0.1.3): dos chequeos se miden con el * autochequeo interno (loopback), y en hostings con caché de página * completa o firewall, la petición del propio servidor NO ve lo mismo * que un visitante (detectado en el piloto de Fresh Flowers: por fuera * las cabeceras llegaban y ?author=1 daba 404, y el loopback decía lo * contrario). Si NUESTRO ajuste está activo y aun así la medición da * «fail», no gritamos un Riesgo falso: queda en «Atención — no * confirmado desde dentro», que es lo único que sabemos con certeza. */ $vsai_settings = function_exists( 'vsai_get_settings' ) ? vsai_get_settings() : array(); $vsai_unconfirmed = array( 'headers' => array( 'setting' => 'security_headers', 'detail' => __( 'The setting is on, but the internal self-check could not confirm the headers: full-page cache or some hosts’ firewalls skew this measurement. Your visitors are probably receiving them anyway.', 'vidal-security-ai' ), ), 'author_enum' => array( 'setting' => 'author_enum', 'detail' => __( 'The setting is on, but the internal self-check could not confirm the block: full-page cache or some hosts’ firewalls skew this measurement.', 'vidal-security-ai' ), ), ); foreach ( $checks as $i => $c ) { if ( isset( $vsai_unconfirmed[ $c['id'] ] ) && 'fail' === $c['status'] ) { $rule = $vsai_unconfirmed[ $c['id'] ]; if ( ! empty( $vsai_settings[ $rule['setting'] ] ) ) { $checks[ $i ]['status'] = 'warn'; $checks[ $i ]['detail'] = $rule['detail']; } } } return $checks; } /** * Calcula la puntuación 0–100 de un escaneo guardado. * ok = peso completo · warn = mitad · fail = 0. * * @param array $scan Datos de la opción vsai_scan. * @return int|null Null si aún no hay escaneo. */ function vsai_scanner_score( $scan ) { if ( ! is_array( $scan ) || empty( $scan['checks'] ) ) { return null; } $total = 0; $earned = 0.0; foreach ( $scan['checks'] as $c ) { $w = isset( $c['weight'] ) ? (int) $c['weight'] : 1; $total += $w; $earned += $w * ( 'ok' === $c['status'] ? 1 : ( 'warn' === $c['status'] ? 0.5 : 0 ) ); } if ( 0 === $total ) { return null; } return (int) round( 100 * $earned / $total ); } /** * Ejecuta el escaneo y guarda el resultado. * * @return array El escaneo guardado. */ function vsai_scanner_do_scan() { // Un escaneo nuevo no debe reutilizar el loopback cacheado. delete_transient( 'vsai_loopback_data' ); $scan = array( 'time' => time(), 'checks' => vsai_scanner_run_checks(), ); update_option( 'vsai_scan', $scan, false ); return $scan; } /** * Handler del botón «Escanear ahora» (admin-post). */ function vsai_handle_run_scan() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'vidal-security-ai' ) ); } check_admin_referer( 'vsai_run_scan' ); // Escaneo manual = foto FRESCA: sin la caché loopback de 1 h, que en el // piloto hizo que un arreglo recién activado siguiera saliendo en Riesgo. delete_transient( 'vsai_loopback_data' ); vsai_scanner_do_scan(); wp_safe_redirect( admin_url( 'admin.php?page=vidal-security-ai&vsai_notice=scan_done' ) ); exit; } add_action( 'admin_post_vsai_run_scan', 'vsai_handle_run_scan' );