registra admin-post 'aracne_rescan' * ARACNE_Scanner::run(bool $force=false) -> ['score','counts','checks'] */ if (!defined('ABSPATH')) exit; class ARACNE_Scanner { /** Clave del transient de caché. */ const CACHE_KEY = 'aracne_scan_cache'; /** TTL de la caché (15 minutos). */ const CACHE_TTL = 900; /** Cabecera con la que el escáner se identifica ante los filtros del add-on Pro. */ const SELF_HEADER = 'X-ARACNE-Scan'; /** Ventana de validez del token de auto-escaneo, en segundos. */ const SELF_WINDOW = 300; /** Riesgos que corrige el endurecimiento básico incluido en esta versión. */ const FREE_FIX = ['xmlrpc', 'user_enum_author', 'user_enum_rest', 'user_enum_sitemap', 'version_exposed', 'readme_files', 'file_edit']; /** Señales de intrusión: ARACNE no las corrige, las limpia una persona. */ const COMPROMISE = ['hidden_admin', 'admin_email_wporg', 'mu_plugin_no_header', 'fake_core_plugins']; /** Autores que usan los plugins falsos para pasar por parte de WordPress (en minúsculas). */ const FAKE_CORE_AUTHORS = ['wordpress', 'wordpress.org', 'wordpress core', 'wordpress team', 'wp core contributors', 'wordpress core contributors']; /** * Registra la acción de "Volver a escanear": borra la caché y vuelve a la * página del escáner. manage_options + nonce. */ public static function boot() { add_action('admin_post_aracne_rescan', [__CLASS__, 'handle_rescan']); } /** * Handler de admin-post: fuerza un nuevo escaneo y redirige de vuelta. */ public static function handle_rescan() { if (!current_user_can('manage_options')) { wp_die(esc_html__('No tienes permisos para esta acción.', 'aracne-security'), 403); } check_admin_referer('aracne_rescan'); delete_transient(self::CACHE_KEY); $back = wp_get_referer(); if (!$back) { $back = admin_url('admin.php?page=aracne-escaner'); } // Marca para que la vista pueda informar "escaneo actualizado". $back = add_query_arg('aracne_rescan', '1', $back); wp_safe_redirect($back); exit; } /** * Ejecuta (o recupera de caché) el escaneo completo. * * @param bool $force Ignora la caché y vuelve a escanear. * @return array{score:int,counts:array,checks:array} */ public static function run(bool $force = false): array { if (!$force) { $cached = get_transient(self::CACHE_KEY); if (is_array($cached) && isset($cached['checks'])) { return $cached; } } $checks = []; // Cada check se envuelve en try/catch para garantizar que un fallo // puntual no rompa el escaneo completo. foreach ([ 'check_exposed_files', 'check_xmlrpc', 'check_user_enum', 'check_version_exposed', 'check_security_headers', 'check_dir_listing', 'check_readme', 'check_file_edit', 'check_default_admin', 'check_table_prefix', 'check_php_version', 'check_wp_version', 'check_debug_mode', 'check_admins', 'check_mu_plugins', 'check_fake_core_plugins', ] as $method) { try { $items = self::$method(); foreach ($items as $item) { if (is_array($item) && !empty($item['id'])) { $checks[] = self::normalize($item); } } } catch (\Throwable $e) { // Nunca fatal: ignoramos el check que falle. } } // Post-proceso: marca en verde lo que ARACNE mitiga y señala como "recomendación manual" // lo que ARACNE no puede cambiar. foreach ($checks as &$c) { if ($c['status'] === 'riesgo' && self::mitigates($c['id'])) { $c['status'] = 'ok'; $c['by_aracne'] = true; $c['detail'] = 'ARACNE mitiga este riesgo con la configuración actual.'; } if (in_array($c['id'], ['default_admin', 'table_prefix'], true)) { $c['manual'] = true; $c['severity'] = 'baja'; } if (in_array($c['id'], self::COMPROMISE, true)) { $c['manual'] = true; } $c['free_fix'] = in_array($c['id'], self::FREE_FIX, true); } unset($c); $result = [ 'checks' => $checks, 'counts' => self::tally($checks), 'score' => self::score($checks), 'time' => time(), ]; set_transient(self::CACHE_KEY, $result, self::CACHE_TTL); return $result; } /* ------------------------------------------------------------------ * * Utilidades internas * ------------------------------------------------------------------ */ /** Rellena claves opcionales del item de check. */ private static function normalize(array $item): array { return [ 'id' => (string) $item['id'], 'title' => (string) ($item['title'] ?? $item['id']), 'severity' => in_array(($item['severity'] ?? 'baja'), ['alta','media','baja'], true) ? $item['severity'] : 'baja', 'status' => ($item['status'] ?? 'ok') === 'riesgo' ? 'riesgo' : 'ok', 'what' => (string) ($item['what'] ?? ''), 'why' => (string) ($item['why'] ?? ''), 'detail' => (string) ($item['detail'] ?? ''), 'pro' => (string) ($item['pro'] ?? ''), 'by_aracne' => false, // lo pone ARACNE activamente (verde) 'manual' => false, // ARACNE no puede cambiarlo por ti (recomendación) ]; } private static function hard(string $k): bool { $m = get_option('aracne_simple_modules', []); if (is_array($m) && isset($m['hardening']) && empty($m['hardening'])) return false; $d = ARACNE_Hardening::defaults(); return (bool) ARACNE_Hardening::get_key($k, $d[$k] ?? 0); } /** Mapea cada riesgo a la condición de ARACNE que lo mitiga. */ private static function mitigates(string $id): bool { switch ($id) { case 'xmlrpc': $free = self::hard('xmlrpc_full_disable'); break; case 'user_enum_author': case 'user_enum_rest': case 'user_enum_sitemap': $free = self::hard('block_user_enum'); break; case 'version_exposed': $free = self::hard('hide_version'); break; case 'readme_files': $free = self::hard('block_readme'); break; case 'file_edit': $free = self::hard('disable_file_edit'); break; default: $free = false; } return $free || (bool) apply_filters('aracne_scanner_mitigates', false, $id); } /** * Token HMAC del auto-escaneo para una ventana de tiempo y una URI exacta (ruta + query); la * clave nunca sale del sitio. Atado a la URI, quien lo capture en tránsito solo puede repetir * ese mismo GET del escáner, no usarlo para mandar otra petición sin filtros. */ public static function self_token(int $window, string $uri): string { return hash_hmac('sha256', 'aracne-scan|' . $window . '|' . $uri, wp_salt('nonce')); } /** * ¿La cabecera recibida es un token de auto-escaneo vigente (ventana actual o anterior) para este * GET? Sin método ni URI se toman de la petición en curso: así lo llaman las versiones de Pro * que solo pasan el token. */ public static function verify_self_token(string $given, ?string $method = null, ?string $uri = null): bool { $method = $method ?? (isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : 'GET'); $uri = $uri ?? (isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '/'); if ($given === '' || !in_array(strtoupper($method), ['GET', 'HEAD'], true)) return false; $w = intdiv(time(), self::SELF_WINDOW); return hash_equals(self::self_token($w, $uri), $given) || hash_equals(self::self_token($w - 1, $uri), $given); } /** Ruta + query tal como llegará en REQUEST_URI. */ private static function request_uri(string $url): string { $path = (string) (wp_parse_url($url, PHP_URL_PATH) ?: '/'); $query = (string) wp_parse_url($url, PHP_URL_QUERY); return $query === '' ? $path : $path . '?' . $query; } /** Cuenta riesgos por severidad + los que están OK. */ private static function tally(array $checks): array { $counts = ['alta' => 0, 'media' => 0, 'baja' => 0, 'ok' => 0]; foreach ($checks as $c) { if ($c['status'] === 'ok') { $counts['ok']++; } else { $counts[$c['severity']]++; } } return $counts; } /** Puntuación 0..100: -15 alta, -8 media, -3 baja, suelo 0. */ private static function score(array $checks): int { $score = 100; $w = ['alta' => 15, 'media' => 8, 'baja' => 3]; foreach ($checks as $c) { if ($c['status'] === 'riesgo') { $score -= ($w[$c['severity']] ?? 3); } } return max(0, min(100, $score)); } /** GET remoto seguro y corto; devuelve la respuesta WP_HTTP o null. */ private static function fetch(string $url, array $args = []) { $defaults = [ 'timeout' => 5, 'redirection' => 0, // Mismo filtro que usa el núcleo en sus peticiones a sí mismo: un staging con // certificado propio puede desactivarlo sin que el escáner lo haga para todos. 'sslverify' => apply_filters('https_local_ssl_verify', true), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- hook del núcleo 'headers' => [ 'Accept' => '*/*', self::SELF_HEADER => self::self_token(intdiv(time(), self::SELF_WINDOW), self::request_uri($url)), ], 'user-agent' => 'ARACNE-Scanner/' . ARACNE_VERSION, ]; $resp = wp_remote_get($url, array_merge($defaults, $args)); if (is_wp_error($resp)) return null; return $resp; } /* ------------------------------------------------------------------ * * Checks (cada uno devuelve array de items) * ------------------------------------------------------------------ */ /** Ficheros sensibles accesibles por HTTP (200 con cuerpo). */ private static function check_exposed_files(): array { $paths = ['/.env', '/.git/config', '/.git/HEAD', '/wp-config.php.bak', '/debug.log']; $found = []; $checked = 0; foreach ($paths as $p) { $resp = self::fetch(home_url($p)); if ($resp === null) continue; // petición fallida: no concluyente $checked++; $code = (int) wp_remote_retrieve_response_code($resp); $body = (string) wp_remote_retrieve_body($resp); if ($code === 200 && trim($body) !== '') { $found[] = $p; } } $riesgo = !empty($found); return [[ 'id' => 'exposed_files', 'title' => 'Ficheros sensibles accesibles desde Internet', 'severity' => 'alta', 'status' => $riesgo ? 'riesgo' : 'ok', 'what' => 'Algunos ficheros internos (configuración, copias de seguridad, registros o el repositorio Git) pueden descargarse escribiendo su nombre en el navegador.', 'why' => 'Estos ficheros suelen contener contraseñas de la base de datos, claves o el código fuente completo. Si están abiertos, cualquiera puede robarlos y tomar control de tu web.', 'detail' => $riesgo ? 'Accesibles públicamente: ' . implode(', ', $found) . '.' : ($checked > 0 ? 'No se encontró ninguno de los ficheros sensibles comprobados.' : 'No se pudo comprobar (la web no respondió a las peticiones).'), 'pro' => 'Bloquea automáticamente el acceso por HTTP a .env, .git, copias .bak, debug.log y otros ficheros sensibles.', ]]; } /** XML-RPC habilitado. */ private static function check_xmlrpc(): array { $resp = self::fetch(home_url('/xmlrpc.php'), ['timeout' => 4]); // xmlrpc.php a un GET responde 405 ("only POST"); 404/403 = bloqueado. $enabled = null; if ($resp !== null) { $code = (int) wp_remote_retrieve_response_code($resp); $body = (string) wp_remote_retrieve_body($resp); if ($code === 405 || stripos($body, 'XML-RPC server accepts POST') !== false) { $enabled = true; } elseif (in_array($code, [403, 404, 0], true)) { $enabled = false; } else { $enabled = ($code === 200); } } // Si no concluyente, miramos el filtro de opción de WP. if ($enabled === null) { $enabled = (bool) apply_filters('xmlrpc_enabled', true); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- hook del núcleo } return [[ 'id' => 'xmlrpc', 'title' => 'XML-RPC activado', 'severity' => 'media', 'status' => $enabled ? 'riesgo' : 'ok', 'what' => 'XML-RPC es una puerta antigua de WordPress (el fichero xmlrpc.php) que permite publicar y conectar apps desde fuera.', 'why' => 'Casi nadie la usa hoy, pero los atacantes la aprovechan para probar miles de contraseñas a la vez y para amplificar ataques contra otras webs.', 'detail' => $enabled ? 'xmlrpc.php está activo y acepta peticiones.' : 'xmlrpc.php parece estar bloqueado.', 'pro' => 'Desactiva o protege xmlrpc.php para frenar los ataques de fuerza bruta y amplificación.', ]]; } /** Enumeración de usuarios (?author=1, REST /users y sitemap de usuarios). */ private static function check_user_enum(): array { $items = []; // 1) ?author=1 redirige a /author/ $resp = self::fetch(home_url('/?author=1'), ['redirection' => 0]); $author_leak = false; $login = ''; if ($resp !== null) { $code = (int) wp_remote_retrieve_response_code($resp); $loc = (string) wp_remote_retrieve_header($resp, 'location'); if (in_array($code, [301, 302], true) && $loc && preg_match('~/author/([^/?#]+)~i', $loc, $m)) { $author_leak = true; $login = $m[1]; } } $items[] = [ 'id' => 'user_enum_author', 'title' => 'Nombres de usuario visibles (parámetro author)', 'severity' => 'media', 'status' => $author_leak ? 'riesgo' : 'ok', 'what' => 'Al pedir una dirección como tu-web.com/?author=1, WordPress puede revelar el nombre real con el que un usuario inicia sesión.', 'why' => 'Conocer el nombre de usuario es la mitad del trabajo para un atacante: solo le falta adivinar la contraseña para entrar.', 'detail' => $author_leak ? ('Se ha filtrado el usuario: "' . $login . '".') : 'No se detectó filtración por el parámetro author.', 'pro' => 'Bloquea el truco ?author= para que nadie pueda descubrir tus nombres de usuario.', ]; // 2) REST /wp-json/wp/v2/users sin autenticar $resp2 = self::fetch(rest_url('wp/v2/users'), ['timeout' => 4]); $rest_leak = false; $n = 0; if ($resp2 !== null) { $code = (int) wp_remote_retrieve_response_code($resp2); $body = (string) wp_remote_retrieve_body($resp2); $data = json_decode($body, true); if ($code === 200 && is_array($data) && isset($data[0]) && (isset($data[0]['slug']) || isset($data[0]['name']))) { $rest_leak = true; $n = count($data); } } $items[] = [ 'id' => 'user_enum_rest', 'title' => 'Lista de usuarios expuesta por la API REST', 'severity' => 'media', 'status' => $rest_leak ? 'riesgo' : 'ok', 'what' => 'La API de WordPress (/wp-json/wp/v2/users) puede devolver la lista de autores sin pedir contraseña.', 'why' => 'Es una forma cómoda para que un atacante recopile todos tus nombres de usuario de golpe y luego intente entrar con ellos.', 'detail' => $rest_leak ? ('La API devolvió ' . (int) $n . ' usuario(s) sin autenticación.') : 'La API no expone la lista de usuarios a visitantes anónimos.', 'pro' => 'Cierra el endpoint público de usuarios de la API REST a quienes no han iniciado sesión.', ]; // 3) Sitemap de usuarios del núcleo (WordPress 5.5+), con /author/ por cada autor. $resp3 = self::fetch(home_url('/wp-sitemap-users-1.xml'), ['timeout' => 4]); $sitemap_leak = false; if ($resp3 !== null) { $code = (int) wp_remote_retrieve_response_code($resp3); $body = (string) wp_remote_retrieve_body($resp3); $sitemap_leak = $code === 200 && stripos($body, '') !== false && stripos($body, '/author/') !== false; } $items[] = [ 'id' => 'user_enum_sitemap', 'title' => 'Usuarios listados en el sitemap', 'severity' => 'baja', 'status' => $sitemap_leak ? 'riesgo' : 'ok', 'what' => 'El sitemap de WordPress (wp-sitemap-users-1.xml) publica la dirección de la página de cada autor.', 'why' => 'La dirección del autor incluye su identificador, que a menudo coincide con el nombre de usuario de acceso.', 'detail' => $sitemap_leak ? 'El sitemap de usuarios está publicado.' : 'El sitemap no publica la lista de autores.', 'pro' => 'Retira la lista de autores del sitemap del sitio.', ]; return $items; } /** Versión de WordPress expuesta (meta generator). */ private static function check_version_exposed(): array { $exposed = has_action('wp_head', 'wp_generator') !== false; // Confirmación best-effort: buscar la etiqueta generator en la portada. if (!$exposed) { $resp = self::fetch(home_url('/'), ['timeout' => 4]); if ($resp !== null) { $body = (string) wp_remote_retrieve_body($resp); if (preg_match('~]+name=["\']generator["\'][^>]*WordPress~i', $body)) { $exposed = true; } } } return [[ 'id' => 'version_exposed', 'title' => 'Versión de WordPress a la vista', 'severity' => 'baja', 'status' => $exposed ? 'riesgo' : 'ok', 'what' => 'WordPress añade en el código de tus páginas una etiqueta que indica qué versión exacta estás usando.', 'why' => 'Si un atacante sabe tu versión, puede buscar fallos conocidos de esa versión concreta y atacarte con ellos directamente.', 'detail' => $exposed ? 'La etiqueta "generator" con la versión está presente.' : 'La versión no se muestra en el código de las páginas.', 'pro' => 'Oculta la versión de WordPress para no dar pistas sobre qué fallos podrían afectarte.', ]]; } /** Cabeceras de seguridad ausentes en la respuesta propia. */ private static function check_security_headers(): array { $resp = self::fetch(home_url('/'), ['timeout' => 5, 'redirection' => 1]); if ($resp === null) { return [[ 'id' => 'security_headers', 'title' => 'Cabeceras de seguridad', 'severity' => 'baja', 'status' => 'ok', 'what' => 'Las cabeceras de seguridad son instrucciones que tu web envía al navegador para protegerlo.', 'why' => 'Sin ellas, tu web es más vulnerable a robos de sesión, suplantación y carga en marcos ocultos.', 'detail' => 'No se pudo comprobar (la web no respondió).', 'pro' => 'Añade automáticamente las cabeceras de seguridad recomendadas.', ]]; } $wanted = [ 'x-frame-options' => 'X-Frame-Options', 'x-content-type-options' => 'X-Content-Type-Options', 'referrer-policy' => 'Referrer-Policy', ]; if (is_ssl()) { $wanted['strict-transport-security'] = 'Strict-Transport-Security'; } $missing = []; foreach ($wanted as $key => $label) { $val = wp_remote_retrieve_header($resp, $key); if ($val === '' || $val === null || (is_array($val) && empty($val))) { $missing[] = $label; } } $riesgo = !empty($missing); return [[ 'id' => 'security_headers', 'title' => 'Cabeceras de seguridad ausentes', 'severity' => 'baja', 'status' => $riesgo ? 'riesgo' : 'ok', 'what' => 'Las cabeceras de seguridad son instrucciones invisibles que tu web envía al navegador para reforzar la protección del visitante.', 'why' => 'Sin ellas tu web es más fácil de incrustar en páginas falsas, de interpretar de forma engañosa o de espiar entre tu servidor y el visitante.', 'detail' => $riesgo ? ('Faltan: ' . implode(', ', $missing) . '.') : 'Todas las cabeceras de seguridad recomendadas están presentes.', 'pro' => 'Configura X-Frame-Options, X-Content-Type-Options, Referrer-Policy y HSTS por ti.', ]]; } /** Listado de directorios en wp-content/uploads. */ private static function check_dir_listing(): array { $url = content_url('/uploads/'); $resp = self::fetch($url, ['timeout' => 4]); $listing = false; $concluyente = false; if ($resp !== null) { $code = (int) wp_remote_retrieve_response_code($resp); $body = (string) wp_remote_retrieve_body($resp); $concluyente = true; if ($code === 200 && (stripos($body, 'Index of') !== false || stripos($body, 'Index of') !== false)) { $listing = true; } } return [[ 'id' => 'dir_listing', 'title' => 'Listado de archivos de la carpeta de subidas', 'severity' => 'media', 'status' => $listing ? 'riesgo' : 'ok', 'what' => 'Tu carpeta de subidas (wp-content/uploads) muestra la lista completa de archivos cuando alguien la visita directamente.', 'why' => 'Permite a cualquiera curiosear todos tus documentos, copias y archivos privados que hayas subido, aunque no estén enlazados en ninguna página.', 'detail' => $listing ? 'La carpeta de subidas muestra un "Index of" con sus archivos.' : ($concluyente ? 'La carpeta de subidas no muestra su contenido.' : 'No se pudo comprobar la carpeta de subidas.'), 'pro' => 'Desactiva el listado de directorios para que nadie pueda explorar tus carpetas.', ]]; } /** readme.html / license.txt accesibles. */ private static function check_readme(): array { $found = []; $checked = 0; foreach (['/readme.html', '/license.txt'] as $p) { $resp = self::fetch(home_url($p), ['timeout' => 4]); if ($resp === null) continue; $checked++; $code = (int) wp_remote_retrieve_response_code($resp); $body = (string) wp_remote_retrieve_body($resp); if ($code === 200 && trim($body) !== '') { $found[] = $p; } } $riesgo = !empty($found); return [[ 'id' => 'readme_files', 'title' => 'Ficheros readme.html y license.txt accesibles', 'severity' => 'baja', 'status' => $riesgo ? 'riesgo' : 'ok', 'what' => 'WordPress incluye los ficheros readme.html y license.txt, que cualquiera puede abrir desde el navegador.', 'why' => 'El readme.html delata tu versión de WordPress, dando pistas a los atacantes sobre qué fallos conocidos probar.', 'detail' => $riesgo ? ('Accesibles: ' . implode(', ', $found) . '.') : ($checked > 0 ? 'readme.html y license.txt no son accesibles.' : 'No se pudo comprobar.'), 'pro' => 'Bloquea el acceso a readme.html y license.txt para no revelar tu versión.', ]]; } /** Edición de ficheros desde el admin habilitada. */ private static function check_file_edit(): array { $editable = !defined('DISALLOW_FILE_EDIT') || !DISALLOW_FILE_EDIT; return [[ 'id' => 'file_edit', 'title' => 'Editor de archivos del panel activado', 'severity' => 'media', 'status' => $editable ? 'riesgo' : 'ok', 'what' => 'El panel de WordPress incluye un editor que permite modificar el código de temas y plugins desde el navegador.', 'why' => 'Si un atacante consigue entrar como administrador, ese editor le permite inyectar código malicioso en tu web en segundos, sin necesidad de acceso al servidor.', 'detail' => $editable ? 'El editor de temas/plugins está disponible (DISALLOW_FILE_EDIT no está activo).' : 'El editor de archivos está desactivado.', 'pro' => 'Desactiva el editor de archivos del panel para cortar esa vía de ataque.', ]]; } /** Usuario 'admin' por defecto. */ private static function check_default_admin(): array { $exists = function_exists('get_user_by') ? (bool) get_user_by('login', 'admin') : false; return [[ 'id' => 'default_admin', 'title' => 'Existe el usuario "admin"', 'severity' => 'media', 'status' => $exists ? 'riesgo' : 'ok', 'what' => 'Hay una cuenta cuyo nombre de usuario es exactamente "admin", el valor por defecto que todos conocen.', 'why' => 'Los atacantes prueban "admin" lo primero: si esa cuenta existe, solo necesitan acertar la contraseña, así que les ahorras la mitad del trabajo.', 'detail' => $exists ? 'Existe una cuenta con el nombre de usuario "admin".' : 'No existe ninguna cuenta llamada "admin".', 'pro' => 'Te guía para renombrar la cuenta y refuerza el acceso frente a fuerza bruta.', ]]; } /** Prefijo de tabla 'wp_'. */ private static function check_table_prefix(): array { $prefix = isset($GLOBALS['wpdb']) ? (string) $GLOBALS['wpdb']->prefix : ''; $default = ($prefix === 'wp_'); return [[ 'id' => 'table_prefix', 'title' => 'Prefijo de base de datos por defecto (wp_)', 'severity' => 'baja', 'status' => $default ? 'riesgo' : 'ok', 'what' => 'Las tablas de tu base de datos usan el prefijo "wp_", que es el que trae WordPress de fábrica.', 'why' => 'Al ser predecible, facilita ciertos ataques automáticos contra la base de datos que dan por hecho ese nombre.', 'detail' => $default ? 'El prefijo de las tablas es "wp_".' : ('El prefijo es "' . $prefix . '" (personalizado).'), 'pro' => 'Te orienta para usar un prefijo personalizado y endurecer la base de datos.', ]]; } /** PHP desactualizado (< 8.0). */ private static function check_php_version(): array { $old = version_compare(PHP_VERSION, '8.0', '<'); return [[ 'id' => 'php_version', 'title' => 'Versión de PHP antigua', 'severity' => 'media', 'status' => $old ? 'riesgo' : 'ok', 'what' => 'PHP es el motor que hace funcionar WordPress. Tu servidor usa la versión ' . PHP_VERSION . '.', 'why' => 'Las versiones antiguas de PHP dejan de recibir parches de seguridad, así que los fallos descubiertos ya no se corrigen y tu web queda expuesta.', 'detail' => $old ? ('PHP ' . PHP_VERSION . ' está por debajo de 8.0 y puede no tener soporte de seguridad.') : ('PHP ' . PHP_VERSION . ' está al día.'), 'pro' => 'Te avisa y orienta sobre cómo actualizar PHP con tu proveedor de hosting.', ]]; } /** Núcleo de WordPress desactualizado (best-effort). */ private static function check_wp_version(): array { $current = get_bloginfo('version'); $latest = ''; $outdated = false; if (function_exists('get_preferred_from_update_core')) { $upd = get_preferred_from_update_core(); if (is_object($upd) && !empty($upd->current)) { $latest = (string) $upd->current; if (isset($upd->response) && $upd->response === 'upgrade') { $outdated = true; } elseif ($latest && version_compare($current, $latest, '<')) { $outdated = true; } } } return [[ 'id' => 'wp_version', 'title' => 'WordPress desactualizado', 'severity' => 'media', 'status' => $outdated ? 'riesgo' : 'ok', 'what' => 'WordPress publica actualizaciones que corrigen fallos de seguridad. Tu web usa la versión ' . $current . '.', 'why' => 'Cada actualización tapa agujeros que ya son públicos; si no la instalas, los atacantes pueden usar esos agujeros conocidos contra ti.', 'detail' => $outdated ? ('Hay una versión más reciente disponible' . ($latest ? ' (' . $latest . ')' : '') . '.') : ('WordPress ' . $current . ' parece estar al día.'), 'pro' => 'Vigila las actualizaciones pendientes y te avisa cuando tu WordPress se queda atrás.', ]]; } /** Modo depuración activo en producción. */ private static function check_debug_mode(): array { $debug = defined('WP_DEBUG') && WP_DEBUG; return [[ 'id' => 'debug_mode', 'title' => 'Modo depuración (WP_DEBUG) activado', 'severity' => 'baja', 'status' => $debug ? 'riesgo' : 'ok', 'what' => 'El modo depuración de WordPress (WP_DEBUG) está encendido. Sirve para desarrollar, no para una web en producción.', 'why' => 'Puede mostrar mensajes de error técnicos con rutas internas del servidor, revelando información útil para un atacante.', 'detail' => $debug ? 'WP_DEBUG está activado.' : 'WP_DEBUG está desactivado.', 'pro' => 'Te ayuda a desactivar el modo depuración y a evitar fugas de información por errores.', ]]; } /** Administradores ocultos en la lista de usuarios y cuentas que se hacen pasar por WordPress.org. */ private static function check_admins(): array { global $wpdb; // Lectura directa de la base de datos: el malware oculta sus cuentas filtrando las consultas // de usuarios del núcleo, no la tabla. $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- debe saltarse los filtros de WP_User_Query "SELECT u.ID, u.user_login, u.user_email FROM {$wpdb->users} u JOIN {$wpdb->usermeta} m ON m.user_id = u.ID WHERE m.meta_key = %s AND m.meta_value LIKE %s", $wpdb->get_blog_prefix() . 'capabilities', '%' . $wpdb->esc_like('"administrator"') . '%' )); // Misma consulta que la pantalla Usuarios, con sus filtros: lo que falte aquí está oculto. $args = (array) apply_filters('users_list_table_query_args', ['role' => 'administrator']); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- hook del núcleo $visible = array_map('intval', get_users(array_merge($args, ['role' => 'administrator', 'fields' => 'ID', 'number' => -1]))); $hidden = []; $wporg = []; foreach ((array) $rows as $r) { if (!in_array((int) $r->ID, $visible, true)) $hidden[] = $r->user_login; if (preg_match('~@wordpress\.org$~i', trim((string) $r->user_email))) $wporg[] = $r->user_login . ' (' . $r->user_email . ')'; } return [ [ 'id' => 'hidden_admin', 'title' => 'Administradores ocultos en la lista de usuarios', 'severity' => 'alta', 'status' => $hidden ? 'riesgo' : 'ok', 'what' => 'Hay cuentas con rol de administrador que no aparecen en la pantalla Usuarios del panel.', 'why' => 'Ocultar una cuenta de administrador es una técnica habitual tras una intrusión: permite volver a entrar aunque se cambien las contraseñas del resto.', 'detail' => $hidden ? ('Cuentas ocultas: ' . implode(', ', $hidden) . '.') : 'Todos los administradores aparecen en la lista de usuarios.', 'pro' => 'Revisa quién creó estas cuentas. Si nadie las reconoce, trata la web como comprometida: elimínalas, cambia todas las contraseñas y restaura desde una copia limpia.', ], [ 'id' => 'admin_email_wporg', 'title' => 'Administradores con correo de wordpress.org', 'severity' => 'alta', 'status' => $wporg ? 'riesgo' : 'ok', 'what' => 'Hay cuentas de administrador cuyo correo termina en @wordpress.org.', 'why' => 'WordPress.org no crea cuentas en las webs de sus usuarios. Una cuenta así la ha creado un tercero para pasar desapercibida.', 'detail' => $wporg ? ('Cuentas: ' . implode(', ', $wporg) . '.') : 'Ningún administrador usa un correo de wordpress.org.', 'pro' => 'Elimina estas cuentas, cambia todas las contraseñas y revisa la web en busca de otros cambios no autorizados.', ], ]; } /** Ficheros de mu-plugins sin cabecera de plugin. */ private static function check_mu_plugins(): array { $bare = []; // WordPress solo carga los .php de primer nivel de la carpeta. foreach ((array) glob(WPMU_PLUGIN_DIR . '/*.php') as $file) { $head = get_file_data($file, ['name' => 'Plugin Name']); if (trim((string) $head['name']) === '') $bare[] = basename($file); } return [[ 'id' => 'mu_plugin_no_header', 'title' => 'Plugins obligatorios sin identificar', 'severity' => 'media', 'status' => $bare ? 'riesgo' : 'ok', 'what' => 'Los plugins obligatorios (carpeta mu-plugins) se ejecutan siempre y no se pueden desactivar desde el panel. Alguno no declara nombre ni autor.', 'why' => 'Es un lugar habitual para esconder código malicioso, por ejemplo para ocultar otros plugins o cuentas del panel.', 'detail' => $bare ? ('Ficheros sin cabecera: ' . implode(', ', $bare) . '.') : 'Todos los plugins obligatorios están identificados.', 'pro' => 'Comprueba con tu proveedor de hosting o tu desarrollador si reconocen estos ficheros. Si nadie los reconoce, elimínalos y revisa la web.', ]]; } /** Plugins que dicen ser de WordPress y que WordPress.org no conoce. */ private static function check_fake_core_plugins(): array { if (!function_exists('get_plugins')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } // Plugins que el directorio de WordPress.org reconoce en la última comprobación de actualizaciones. $upd = get_site_transient('update_plugins'); $known = is_object($upd) ? array_merge((array) ($upd->response ?? []), (array) ($upd->no_update ?? [])) : []; $fake = []; foreach (get_plugins() as $file => $p) { $author = strtolower(trim(wp_strip_all_tags((string) ($p['Author'] ?? '')))); if (in_array($author, self::FAKE_CORE_AUTHORS, true) && !isset($known[$file])) { $fake[] = ($p['Name'] ?: $file) . ' (' . $file . ')'; } } return [[ 'id' => 'fake_core_plugins', 'title' => 'Plugins que se presentan como parte de WordPress', 'severity' => 'alta', 'status' => $fake ? 'riesgo' : 'ok', 'what' => 'Hay plugins instalados que declaran como autor a WordPress o a su equipo de desarrollo, pero no proceden del directorio oficial.', 'why' => 'WordPress no distribuye plugins con ese autor fuera de su directorio. Es una forma habitual de disfrazar código malicioso.', 'detail' => $fake ? ('Plugins: ' . implode(', ', $fake) . '.') : 'Ningún plugin se presenta como parte de WordPress.', 'pro' => 'Comprueba si alguien de tu equipo instaló estos plugins. Si nadie los reconoce, elimínalos y trata la web como comprometida.', ]]; } }