Las auditorías de seguridad son fáciles de saltear. No porque el desarrollador no le importe la seguridad — sino porque sin un sistema, siempre se revisa lo mismo y se omite lo demás. OWASP Top 10 es el estándar, pero revisarlo de memoria no escala.

Este artículo muestra cómo aplicar cada punto con código PHP real: el patrón vulnerable, el fix, y cómo Claude Code ejecuta el checklist automáticamente cuando tiene Cerebro MCP disponible.

El problema con las auditorías ad-hoc

Cuando le pedís a Claude "revisá la seguridad de este archivo" sin contexto específico, el resultado es predecible: SQL injection, XSS y validación de entrada. Los mismos tres puntos de siempre, expresados en términos genéricos que aplican a cualquier proyecto PHP del universo.

Los puntos que más se omiten en proyectos PHP vanilla en hosting compartido:

  • CSRF en paneles administrativos (especialmente formularios de gestión interna)
  • Validación real de uploads: extensión + MIME type + nombre único en disco
  • Rate limiting en login (el más implementado en teoría, el menos en práctica)
  • Headers de seguridad en .htaccess
  • Exposición de información en mensajes de error en producción
  • Configuración de sesiones: httponly, samesite, secure

Los 10 puntos con código real

1. SQL Injection — prepared statements PDO

Vulnerable:

// ❌ Concatenación directa — inyectable
$user = $pdo->query("SELECT * FROM users WHERE email = '$email'")->fetch();

Fix:

// ✅ Prepared statement con parámetro nombrado
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
$stmt->execute([':email' => $email]);
$user = $stmt->fetch();

2. CSRF — tokens de sesión en formularios POST

Vulnerable:

// ❌ Formulario sin CSRF token — cualquier sitio externo puede enviar POST
<form method="POST" action="/panel/clientes.php">
  <input name="nombre" value="">
  <button>Guardar</button>
</form>

Fix:

// Generar token al renderizar el form
function csrf_token(): string {
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

// Validar en el handler POST
function csrf_validate(): void {
    $token = $_POST['csrf_token'] ?? '';
    if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
        http_response_code(403);
        exit('Token CSRF inválido');
    }
}

// ✅ Form con token
<form method="POST">
  <input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
  <input name="nombre" value="">
  <button>Guardar</button>
</form>

3. Contraseñas — bcrypt obligatorio

// ❌ MD5 o SHA1 — reversibles con rainbow tables
$hash = md5($password);

// ✅ bcrypt con cost factor 12
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

// Verificar
if (!password_verify($input, $hash)) {
    // login fallido
}

4. Headers de seguridad — .htaccess

<IfModule mod_headers.c>
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
    # CSP básico — ajustar según los recursos del sitio
    Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline';"
</IfModule>

5. Uploads — triple validación

function validar_upload(array $file): string {
    $permitidos = ['image/jpeg', 'image/png', 'image/webp'];
    $ext_ok     = ['jpg', 'jpeg', 'png', 'webp'];

    // 1. Extensión whitelist (no blacklist)
    $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    if (!in_array($ext, $ext_ok, true)) {
        throw new RuntimeException('Extensión no permitida');
    }

    // 2. MIME type real con finfo (no confiar en $_FILES['type'])
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime  = $finfo->file($file['tmp_name']);
    if (!in_array($mime, $permitidos, true)) {
        throw new RuntimeException('Tipo de archivo no permitido');
    }

    // 3. Verificar que es imagen real
    if (!getimagesize($file['tmp_name'])) {
        throw new RuntimeException('El archivo no es una imagen válida');
    }

    // 4. Nombre único en disco — nunca usar el nombre original
    return bin2hex(random_bytes(16)) . '.' . $ext;
}

6. Rate limiting en login

function check_rate_limit(string $email, PDO $pdo): void {
    $stmt = $pdo->prepare(
        'SELECT COUNT(*) FROM login_attempts
         WHERE email = ? AND created_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)'
    );
    $stmt->execute([$email]);
    if ($stmt->fetchColumn() >= 5) {
        http_response_code(429);
        exit('Demasiados intentos. Intentá de nuevo en 15 minutos.');
    }
}

function log_attempt(string $email, PDO $pdo): void {
    $stmt = $pdo->prepare('INSERT INTO login_attempts (email, ip, created_at) VALUES (?, ?, NOW())');
    $stmt->execute([$email, $_SERVER['REMOTE_ADDR'] ?? '']);
}

7. Sesiones seguras

// Configurar antes de session_start()
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'domain'   => '',
    'secure'   => true,   // solo HTTPS
    'httponly' => true,   // no accesible desde JS
    'samesite' => 'Lax',  // protección CSRF adicional
]);
session_start();

// Regenerar ID tras login exitoso
session_regenerate_id(true);

8. Output escaping — todo lo que se renderiza

// Una función helper que debería estar en bootstrap.php
function e(string $str): string {
    return htmlspecialchars($str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

// Uso en las vistas — sin excepciones
<p><?= e($user['nombre']) ?></p>
<input value="<?= e($post['titulo']) ?>">

9. Errores silenciados en producción

// .env o bootstrap.php
if (getenv('APP_ENV') === 'production') {
    error_reporting(0);
    ini_set('display_errors', '0');
    ini_set('log_errors', '1');
    ini_set('error_log', '/ruta/segura/fuera/de/public_html/error.log');
}

10. Paths sensibles en robots.txt y .htaccess

# robots.txt
Disallow: /app/
Disallow: /.env
Disallow: /*.sql
Disallow: /database/
Disallow: /scripts/

# .htaccess — denegar acceso directo a app/
<FilesMatch "\.(env|sql|log|sh|json)$">
    Require all denied
</FilesMatch>

Cómo Claude ejecuta esto con Cerebro MCP

Con Cerebro MCP activo, cuando escribís "auditá la seguridad de este proyecto", Claude llama automáticamente:

  1. get_context("security-audit") — retorna el prompt especializado + chunks de conocimiento sobre el stack
  2. get_checklist("security") — retorna el checklist completo con cada ítem numerado

Claude aplica cada ítem al código real del proyecto, no a un ejemplo genérico. Si tu proyecto usa PDO con parámetros nombrados en algunos lugares y concatenación en otros, Claude lo detecta y señala las inconsistencias específicas. Si ya tenés rate limiting implementado, lo valida en lugar de sugerirte que lo implementes.

Una auditoría completa con este checklist toma entre 30 y 60 minutos dependiendo del tamaño del proyecto. Sin el checklist, podés tardar lo mismo y cubrir la mitad de los puntos.