Seguridad

Auditoría de seguridad web automatizada: 81 tests que deberías ejecutar en cada deploy

18 de marzo de 2026 13 min de lectura PWR.es

La seguridad web no es un estado: es un proceso continuo. Cada deploy puede introducir una regresión: una cabecera de seguridad que desaparece, un certificado TLS mal configurado, una cookie sin el flag Secure. Los pentest anuales no detectan estas regresiones. Lo que necesitas es una batería de tests automatizados que se ejecuten en cada despliegue y alerten cuando algo cambia.

Hemos compilado 81 verificaciones de seguridad web que pueden automatizarse completamente. No requieren acceso al código fuente ni credenciales: se ejecutan desde fuera, como lo haría un atacante. Están organizadas en 7 categorías, ordenadas por impacto.

Categoría 1: HTTP Security Headers (19 tests)

Las cabeceras HTTP de seguridad son la primera línea de defensa del navegador. Son gratuitas, fáciles de implementar, y sorprendentemente pocas webs las configuran correctamente. Cada cabecera ausente o mal configurada es una superficie de ataque abierta:

  • 1. Strict-Transport-Security (HSTS) presente con max-age >= 31536000
  • 2. HSTS incluye includeSubDomains
  • 3. HSTS incluye preload (y el dominio está en la lista de preload de Chrome)
  • 4. Content-Security-Policy (CSP) presente y sin 'unsafe-inline' ni 'unsafe-eval'
  • 5. CSP incluye directiva default-src restrictiva
  • 6. CSP incluye directiva script-src sin wildcards
  • 7. CSP incluye frame-ancestors para prevenir clickjacking
  • 8. X-Content-Type-Options: nosniff presente
  • 9. X-Frame-Options: DENY o SAMEORIGIN presente
  • 10. Referrer-Policy configurada (strict-origin-when-cross-origin o más restrictiva)
  • 11. Permissions-Policy presente (bloquea camera, microphone, geolocation si no se usan)
  • 12. Cross-Origin-Opener-Policy (COOP) configurada
  • 13. Cross-Origin-Resource-Policy (CORP) configurada
  • 14. Cross-Origin-Embedder-Policy (COEP) configurada si se requiere aislamiento
  • 15. Cache-Control: no-store en páginas con datos sensibles
  • 16. X-Powered-By ausente (no revelar tecnología del servidor)
  • 17. Server header minimalista o ausente (no revelar versión de Apache/Nginx)
  • 18. X-XSS-Protection: 0 (el filtro XSS del navegador está deprecado y puede causar más problemas de los que resuelve)
  • 19. No se exponen headers internos (X-Debug, X-Request-Id en producción)
PHP
<?php
// Test automatizado de headers de seguridad
function auditar_headers($url) {
    $resultados = [];
    $headers = get_headers($url, true);

    // HSTS
    $hsts = $headers['Strict-Transport-Security'] ?? null;
    $resultados[] = [
        'test' => 'HSTS presente',
        'ok' => $hsts !== null,
        'valor' => $hsts,
        'critico' => true,
    ];
    if ($hsts) {
        preg_match('/max-age=(\d+)/', $hsts, $m);
        $max_age = (int)($m[1] ?? 0);
        $resultados[] = [
            'test' => 'HSTS max-age >= 1 año',
            'ok' => $max_age >= 31536000,
            'valor' => $max_age . ' segundos',
        ];
    }

    // CSP
    $csp = $headers['Content-Security-Policy'] ?? null;
    $resultados[] = [
        'test' => 'CSP presente',
        'ok' => $csp !== null,
        'critico' => true,
    ];
    if ($csp) {
        $resultados[] = [
            'test' => 'CSP sin unsafe-inline',
            'ok' => stripos($csp, 'unsafe-inline') === false,
            'critico' => true,
        ];
        $resultados[] = [
            'test' => 'CSP sin unsafe-eval',
            'ok' => stripos($csp, 'unsafe-eval') === false,
        ];
    }

    // X-Content-Type-Options
    $xcto = $headers['X-Content-Type-Options'] ?? null;
    $resultados[] = [
        'test' => 'X-Content-Type-Options: nosniff',
        'ok' => strtolower($xcto ?? '') === 'nosniff',
    ];

    return $resultados;
}

Categoría 2: Configuración TLS (16 tests)

Un certificado TLS válido no significa que tu configuración sea segura. La mayoría de vulnerabilidades TLS vienen de protocolos obsoletos habilitados, cipher suites débiles, o configuraciones por defecto del servidor:

  • 20. TLS 1.3 soportado y preferido
  • 21. TLS 1.2 soportado (compatibilidad)
  • 22. TLS 1.0 deshabilitado
  • 23. TLS 1.1 deshabilitado
  • 24. SSL 3.0 deshabilitado
  • 25. SSL 2.0 deshabilitado
  • 26. Cipher suites con forward secrecy (ECDHE) priorizadas
  • 27. No hay cipher suites con RC4
  • 28. No hay cipher suites con 3DES
  • 29. No hay cipher suites con NULL encryption
  • 30. No hay cipher suites con export-grade (FREAK)
  • 31. Certificado válido y no expirado
  • 32. Cadena de certificación completa (no faltan intermedios)
  • 33. OCSP Stapling habilitado
  • 34. Certificado con RSA >= 2048 bits o ECDSA >= 256 bits
  • 35. Subject Alternative Name (SAN) cubre todos los subdominios necesarios
PHP
<?php
// Test automatizado de configuración TLS
function auditar_tls($dominio) {
    $resultados = [];

    // Verificar certificado y protocolo
    $contexto = stream_context_create([
        'ssl' => [
            'capture_peer_cert' => true,
            'capture_peer_cert_chain' => true,
            'verify_peer' => true,
        ]
    ]);

    $socket = @stream_socket_client(
        'ssl://' . $dominio . ':443', $errno, $error, 10,
        STREAM_CLIENT_CONNECT, $contexto
    );

    if (!$socket) {
        return [['test' => 'Conexión TLS', 'ok' => false, 'error' => $error]];
    }

    $params = stream_context_get_params($socket);
    $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']);

    // Verificar expiración
    $expira = $cert['validTo_time_t'];
    $dias_restantes = ($expira - time()) / 86400;
    $resultados[] = [
        'test' => 'Certificado no expirado',
        'ok' => $dias_restantes > 0,
        'valor' => (int)$dias_restantes . ' días restantes',
        'critico' => true,
    ];
    $resultados[] = [
        'test' => 'Certificado no expira en 30 días',
        'ok' => $dias_restantes > 30,
        'valor' => (int)$dias_restantes . ' días restantes',
    ];

    // Verificar protocolos obsoletos
    foreach (['ssl://3', 'tls://1.0', 'tls://1.1'] as $proto => $nombre) {
        $viejo = @stream_socket_client(
            $nombre . '://' . $dominio . ':443',
            $e, $em, 5
        );
        $resultados[] = [
            'test' => 'Protocolo ' . $nombre . ' deshabilitado',
            'ok' => $viejo === false,
            'critico' => true,
        ];
        if ($viejo) fclose($viejo);
    }

    fclose($socket);
    return $resultados;
}

Categoría 3: Cookies de seguridad (10 tests)

Las cookies son uno de los vectores de ataque más explotados. Una cookie de sesión sin los flags adecuados puede ser robada vía XSS, interceptada en conexiones HTTP, o enviada en peticiones cross-site (CSRF):

  • 36. Cookie de sesión con flag Secure (solo se envía por HTTPS)
  • 37. Cookie de sesión con flag HttpOnly (inaccesible desde JavaScript)
  • 38. Cookie de sesión con SameSite=Strict o SameSite=Lax
  • 39. Cookie de sesión con Path=/ restrictivo
  • 40. Nombre de cookie de sesión no revela tecnología (no PHPSESSID ni JSESSIONID)
  • 41. No hay cookies sin flag Secure en sitio HTTPS
  • 42. No hay cookies con SameSite=None sin flag Secure
  • 43. Cookies de terceros ausentes o minimizadas
  • 44. Cookie de sesión con entropy suficiente (>= 128 bits)
  • 45. Tiempo de expiración de sesión razonable (no años)

Categoría 4: Seguridad DNS (12 tests)

La configuración DNS es la base de la seguridad de tu dominio. Un DNS mal configurado permite spoofing de email (phishing con tu dominio), secuestro de subdominios, y ataques de caché poisoning:

  • 46. DNSSEC habilitado y firmas válidas
  • 47. Registro CAA presente (especifica qué CAs pueden emitir certificados)
  • 48. Registro SPF presente y configurado (previene email spoofing)
  • 49. SPF termina en -all (rechazar, no ~all que es softfail)
  • 50. Registro DMARC presente con política p=reject o p=quarantine
  • 51. DMARC con rua= configurado (recibir informes de abuso)
  • 52. Registro DKIM publicado y válido
  • 53. No hay registros DNS wildcard innecesarios (*.dominio.es)
  • 54. No hay subdominios apuntando a servicios desactivados (subdomain takeover)
  • 55. TTL de registros críticos no excesivamente alto (para respuesta ante incidentes)
  • 56. Registros MX solo apuntan a servidores de correo controlados
  • 57. No se exponen registros TXT con información sensible
PHP
<?php
// Test automatizado de seguridad DNS
function auditar_dns($dominio) {
    $resultados = [];

    // CAA — ¿Quién puede emitir certificados?
    $caa = dns_get_record($dominio, DNS_CAA);
    $resultados[] = [
        'test' => 'Registro CAA presente',
        'ok' => !empty($caa),
        'valor' => $caa ? implode(', ', array_column($caa, 'value')) : 'ausente',
    ];

    // SPF
    $txt = dns_get_record($dominio, DNS_TXT);
    $spf = array_filter($txt, fn($r) => str_starts_with($r['txt'], 'v=spf1'));
    $spf_record = reset($spf);
    $resultados[] = [
        'test' => 'Registro SPF presente',
        'ok' => !empty($spf),
        'valor' => $spf_record['txt'] ?? 'ausente',
    ];
    if ($spf_record) {
        $resultados[] = [
            'test' => 'SPF con -all (hard fail)',
            'ok' => str_contains($spf_record['txt'], '-all'),
            'valor' => str_contains($spf_record['txt'], '~all') ? 'soft fail (~all)' : 'hard fail (-all)',
        ];
    }

    // DMARC
    $dmarc_records = dns_get_record('_dmarc.' . $dominio, DNS_TXT);
    $dmarc = array_filter($dmarc_records, fn($r) => str_starts_with($r['txt'], 'v=DMARC1'));
    $resultados[] = [
        'test' => 'Registro DMARC presente',
        'ok' => !empty($dmarc),
        'critico' => true,
    ];

    return $resultados;
}

Categoría 5: Exposición de información (11 tests)

Cada fragmento de información sobre tu stack tecnológico ayuda a un atacante a buscar exploits específicos. La regla es simple: no reveles nada que no sea estrictamente necesario para el funcionamiento de la aplicación:

  • 58. El header Server no revela versión exacta del servidor web
  • 59. El header X-Powered-By está ausente
  • 60. /server-status no es accesible públicamente (Apache)
  • 61. /server-info no es accesible públicamente (Apache)
  • 62. phpinfo() no es accesible públicamente
  • 63. Listado de directorios deshabilitado
  • 64. Archivos .git/ no son accesibles (exposición del código fuente)
  • 65. Archivos .env no son accesibles
  • 66. Archivos de backup (.bak, .old, .sql) no son accesibles
  • 67. robots.txt no revela rutas sensibles de administración
  • 68. Mensajes de error no exponen trazas de stack ni rutas del sistema

Categoría 6: Configuración de formularios y entrada (9 tests)

  • 69. Formularios de login no revelan si el usuario existe (mensajes genéricos)
  • 70. Rate limiting activo en endpoints de autenticación
  • 71. Campos de contraseña con autocomplete='new-password' o 'current-password'
  • 72. Formularios sirven token CSRF
  • 73. Uploads restringidos por tipo MIME y extensión
  • 74. Tamaño máximo de upload configurado (no el default de PHP: 128M)
  • 75. Inputs numéricos validan rango en servidor (no solo en cliente)
  • 76. Redirect después de login no permite open redirect
  • 77. Logout invalida la sesión en servidor (no solo borra cookie)

Categoría 7: Configuración del servidor (4 tests)

  • 78. HTTP redirige a HTTPS automáticamente (301, no 302)
  • 79. www redirige a non-www (o viceversa) con 301
  • 80. Respuesta a métodos HTTP no usados (TRACE, OPTIONS) es 405
  • 81. Tiempo de respuesta del servidor < 1 segundo (resiliencia a DoS)

Automatización: integrar en el pipeline de deploy

Los 81 tests se pueden ejecutar con un solo script PHP que recibe una URL y devuelve un informe estructurado. El script no necesita acceso al servidor: todo se verifica desde fuera, como lo haría un atacante o un auditor externo.

Vigía, el escáner de seguridad de PWR.es, implementa estos 81 tests y más. Se ejecuta como un script PHP que puedes invocar desde tu CI/CD, un cron, o manualmente. El resultado es un JSON estructurado con cada test, su resultado, el valor encontrado, y la recomendación de corrección:

PHP
<?php
// Auditoría completa con Vigía
require_once 'vigia.php';

// Ejecutar los 81 tests contra tu dominio
$informe = vigia_auditar('https://mi-web.es', [
    'categorias' => ['headers', 'tls', 'cookies', 'dns', 'exposicion', 'formularios', 'servidor'],
    'timeout' => 30,
]);

// Resumen
echo 'Tests ejecutados: ' . $informe['total'];
echo 'Aprobados: ' . $informe['ok'];
echo 'Fallidos: ' . $informe['fallos'];
echo 'Críticos: ' . $informe['criticos'];

// En CI/CD: fallo si hay tests críticos no superados
if ($informe['criticos'] > 0) {
    echo 'DEPLOY BLOQUEADO — ' . $informe['criticos'] . ' fallos críticos';
    exit(1);
}

// Guardar informe para histórico
file_put_contents(
    'informes/vigia_' . date('Y-m-d_His') . '.json',
    json_encode($informe, JSON_PRETTY_PRINT)
);

La clave no es ejecutar los 81 tests una vez y olvidarte. La clave es ejecutarlos en cada deploy, comparar con el informe anterior, y alertar si algún test que antes pasaba ahora falla. Las regresiones de seguridad son más frecuentes que los ataques: un deploy apresurado, una configuración que se sobreescribe, un certificado que caduca sin que nadie lo note.

La seguridad web no es un proyecto con fecha de fin. Es un proceso continuo que necesita automatización. Con 81 tests ejecutándose en cada deploy, las regresiones se detectan antes de que un atacante las encuentre. Y esa es la diferencia entre una web segura y una web que cree que es segura.