Volver a todos los posts
Investigación

Por qué tu escáner reporta 1.200 vulnerabilidades y solo 12 son reales

Abre cualquier informe SAST y verás cientos de banderas rojas. Guía de campo sobre alcanzabilidad, explotabilidad y lógica de negocio, y por qué se confunden.

En esta página
  1. El 1.200 no es un bug de SAST. Es el diseño.
  2. Filtro uno: alcanzabilidad
  3. Filtro dos: explotabilidad
  4. Filtro tres: lógica de negocio
  5. Entonces, ¿cómo se pasa de 1.200 a 12?
  6. Por qué esto importa más en 2026 que en 2022
  7. Una pequeña nota al pie

De 1.200 hallazgos SAST, solo 12 llegan a un sink explotable: el análisis de flujo de datos separa la señal del ruido.

Abre cualquier informe SAST sobre una base de código en producción. Verás números de cuatro cifras junto a la palabra «crítico». Abre el mismo escaneo una semana después: los mismos números, arriba o abajo. Al final un ingeniero de seguridad se ahoga en el triaje y el equipo despliega igual. Este artículo trata de lo que está pasando de verdad en esos informes, y de por qué la mayor parte de lo que ves no es lo que dice ser.

La primera vez que ves a una herramienta SAST decir «1.247 vulnerabilidades», una vocecita hace la pregunta correcta: ¿de verdad hay mil doscientos cuarenta y siete bugs explotables en este repositorio? Ya sabes que la respuesta es no. Lo que quizá no sepas es exactamente por qué la respuesta es no, y qué tendría que ser cierto para que el número significara algo.

Este texto va de tres filtros: alcanzabilidad, explotabilidad y lógica de negocio. Cualquier herramienta de análisis estático que merezca la pena tiene que aplicarlos, en algún orden. Casi todas hacen trampas en al menos uno. Aquí está lo que hace cada filtro, con código concreto, y por qué la coincidencia de patrones a secas sigue produciendo informes de cuatro cifras un año después de que un informe de cuatro cifras haga a cualquier equipo tirar la toalla.

El 1.200 no es un bug de SAST. Es el diseño.

Un escáner SAST de patrones hace, más o menos, esto: parsea el fuente, recorre el AST y, en cada nodo, comprueba si coincide con una lista de formas peligrosas. eval(x). exec(x). Una cadena SQL con + entre literales. Una asignación a innerHTML. Cada coincidencia se convierte en un hallazgo.

Es rápido y portable entre lenguajes. También es completamente ajeno a si alguien puede llegar a disparar esa forma peligrosa. Cuando el escáner reporta 1.247 hallazgos, lo que en realidad te está diciendo es: «he encontrado 1.247 patrones sintácticos que podrían, en principio, ser peligrosos en algún contexto de llamada que no me he molestado en mirar».

Esa última frase carga con todo el peso. Vamos a desmontarla.

Filtro uno: alcanzabilidad

Un hallazgo es alcanzable cuando un atacante puede realmente ejecutar el código que lo contiene.

Mira este handler de Express.

app.post("/admin/migrate", requireAdmin, async (req, res) => {
  const { sql } = req.body;
  const result = await db.raw(sql);   // SAST flags this
  res.json(result);
});

Un escáner de patrones marcará la línea db.raw(sql). Inyección SQL desde input de usuario. Severidad: crítica. El escáner no se equivoca sobre la forma; esa es la forma de una inyección SQL. Pero para explotarla, la petición tiene que pasar por requireAdmin. Lo que significa que el atacante ya tiene que ser admin. Lo que significa que aquí no hay inyección SQL, hay un problema de «confiamos en que los admins no rompan la base de datos», que es otra conversación.

La alcanzabilidad pregunta: desde cualquier punto de entrada controlable desde fuera (una petición, un mensaje de cola, un payload deserializado), ¿pueden los datos llegar a esa forma peligrosa? Si la respuesta es no, el hallazgo no es real, aunque el patrón sintáctico sí lo sea.

Hay tres formas habituales de que el código sea inalcanzable en este sentido:

  1. Puertas de autenticación o autorización se interponen entre el punto de entrada y el sink. El middleware corta el camino peligroso.
  2. Saneamiento transforma el input del atacante antes de que llegue al sink. Un parseInt que lanza excepción con lo que no sea dígito. Un WHERE construido con consulta parametrizada. Un framework que escapa HTML automáticamente.
  3. La función nunca se llama desde ningún camino alcanzable. El código muerto es una categoría real y las bases de código modernas están llenas. Herramientas de admin viejas. Un handler registrado en una ruta que ya no existe. Una función de librería que solo usan los tests.

La versión honesta de «1.247 hallazgos» se parece más a «de los cuales 380 son alcanzables desde al menos un punto de entrada controlable desde fuera». Ya has recortado la cola en dos tercios y todavía no has empezado a mirar la explotabilidad.

Filtro dos: explotabilidad

Alcanzable no significa explotable. Solo significa que el camino de datos existe.

Coge el mismo ejemplo de db.raw, sin el middleware de auth:

app.post("/api/search", async (req, res) => {
  const { q } = req.body;
  const result = await db.raw(
    "SELECT id, title FROM articles WHERE title LIKE ?",
    [`%${q}%`]
  );
  res.json(result);
});

El patrón sigue ahí. El camino sigue siendo alcanzable desde cualquier cliente sin autenticar. El hallazgo sigue diciendo «inyección SQL». Pero el parámetro ligado hace que la base de datos trate q como un valor, no como SQL. No hay inyección, da igual lo que contenga q. El hallazgo no es explotable.

La explotabilidad va de cómo se moldean los datos entre la fuente y el sink. Aquí importan tres cosas:

  • Fronteras de codificación. Un WHERE construido con parámetros ligados es a prueba de exploits en la capa SQL. Un motor de plantillas con auto-escape lo es en la capa HTML. Los escáneres de patrones no siempre ven esas fronteras porque viven en llamadas del framework (db.raw(template, params)) y no en patrones sintácticos.
  • Estrechamiento de tipos. Si un valor pasa por un parseInt, un esquema Joi o un type guard de TypeScript antes del sink, su forma queda restringida. La inyección SQL exige control sobre la cadena bruta. Un entero tipado no inyecta SQL.
  • Semántica del sink. child_process.exec("ls") no es una inyección si la cadena es un literal. eval(JSON.stringify(x)) es inofensivo aunque contenga la palabra eval. El sink es peligroso; la llamada no.

Después de este filtro el número vuelve a caer. De 380 hallazgos alcanzables puedes quedarte en 60 realmente explotables. La mayoría de los equipos se detiene aquí, declara esos 60 el backlog «real» y empieza a triar.

Es un error. Los 60 que te quedan son bugs con CWE. Los bugs que duelen no siempre tienen CWE.

Filtro tres: lógica de negocio

Esta es una vulnerabilidad que ningún escáner SAST puede marcar por patrones, por listo que sea:

app.post("/checkout", requireAuth, async (req, res) => {
  const { items } = req.body;
  let total = 0;
  for (const it of items) {
    const product = await db.products.findById(it.productId);
    total += product.price * it.quantity;   // it.quantity can be -1
  }
  await charge(req.user, total);
  await fulfilOrder(req.user, items);
});

Pon quantity a -1 y el precio se resta. Apila artículos de cantidad negativa contra uno positivo y el total llega a cero. El charge(0) funciona. El pedido sale.

Esto no está en el OWASP Top 10. No es un patrón CWE. No existe ninguna forma sintáctica que diga «las cantidades negativas se pueden explotar»; la forma total += a * b es aritmética normal. La vulnerabilidad es que el negocio permite cantidades negativas en el input mientras el negocio pretende que toda cantidad sea un entero positivo. Esa intención vive en tu base de código, en especificaciones de producto, en tests que nadie escribió. No vive en una base de datos de CWE.

Los fallos de lógica de negocio están detrás de casi la mitad de las brechas que producen pérdidas financieras reales. Acumulación de cupones. Escalada de privilegios abusando de un flujo. Condiciones de carrera en transiciones de estado de cuenta. El escáner que encontró 1.247 hallazgos sintácticos se perdió todos estos porque estaba mirando la forma del código, no su intención.

Para pillarlos necesitas reglas extraídas de la propia base de código: «este campo siempre es un entero positivo aquí, aquí y aquí, pero el controlador no lo valida». Y eso exige leer más que el AST.

Entonces, ¿cómo se pasa de 1.200 a 12?

El pipeline que produce 12 a partir de 1.200 no es una regex más lista. Es otra forma de análisis por completo. A grandes rasgos:

Construye un grafo de la base de código

Símbolos, llamadas a funciones, flujos de tipos, puertas del framework, bindings de rutas. No un flujo plano de tokens. Un grafo que el analizador pueda recorrer.

Recórrelo buscando alcanzabilidad y explotabilidad

Desde cada punto de entrada, sigue los datos. Párate en saneadores, puertas y estrechamientos de tipo. Solo los caminos que sobreviven al recorrido son candidatos reales.

Saca las reglas de negocio del propio código

Extrae invariantes de la base de código: este campo siempre es positivo, esta transición siempre pasa por un pago, este flujo siempre acaba en un log de auditoría. Cada invariante se convierte en una restricción que el agente (o la persona) debe cumplir.

El primer paso es lo que la mayoría de los productos SAST modernos llama «grafo de propiedades del código» o grafo de conocimiento. Construirlo es difícil. Recorrerlo, más. Pero es la única manera honesta de responder «¿es real este hallazgo?» sin obligar a un ingeniero de seguridad a leerse tu base de código entera.

Los 12 que sobreviven a los tres filtros son los que merecen un ticket de Jira. Los 1.235 que no sobrevivieron no son «falsos positivos» en ningún sentido moral; son patrones sintácticos que el escáner no pudo descartar sin un grafo. Si tu escáner no tiene grafo, te llegan los 1.247 completos cada lunes.

Por qué esto importa más en 2026 que en 2022

En 2022 el informe de 1.247 hallazgos era molesto. Un ingeniero de seguridad lo triaba despacio, nadie disfrutaba, pero la base de código crecía a ritmo humano y la cola también.

En 2026 la base de código crece a ritmo de agente. Los agentes de código IA escriben ya una parte significativa de cada línea que llega a producción. Producen código con los patrones que reconoce el agente, no con los que reconoce el ingeniero de seguridad. El número 1.247 escala con la velocidad de escritura, no con la velocidad de triaje.

Tienes dos opciones. O el análisis se vuelve radicalmente más listo (basado en grafo, filtrado por alcanzabilidad, explotabilidad y lógica de negocio antes de emitir hallazgos), o la cola te come. No hay una tercera opción en la que una persona se lo lea todo.

Una pequeña nota al pie

Construimos CybeDefend alrededor de esta idea. La arquitectura es un grafo de conocimiento del código más una capa de minería de lógica de negocio (la llamamos BLSA) más una interfaz en línea para que el analizador corra mientras el agente escribe, no después del merge. Si has llegado hasta aquí y quieres ver los mismos tres filtros corriendo sobre tu propio repositorio, el camino sin instalación es la lectura más rápida de cómo es tu backlog de verdad: treinta minutos desde el clone hasta el primer veredicto, sin tarjeta.

El número del informe no es el número de bugs. Es el número de patrones que el escáner no pudo descartar sin hacer más trabajo. Los equipos que ganan son aquellos cuyo escáner hace ese trabajo.

En vivo · recién lanzado

Instala VibeDefend en 5 segundos.

Un comando conecta cada agente de coding de tu máquina a CybeDefend: tus reglas de negocio, tus frameworks de cumplimiento y guards que bloquean llamadas destructivas antes de que se disparen.

Instala en 5 segundosNode 18.17+
npx -y @cybedefend/vibedefend@latest install
Auto-detecta
  • Claude CodeClaude Code
  • CursorCursor
  • OpenAI CodexOpenAI Codex
  • WindsurfWindsurf
  • GitHub CopilotVS Code Copilot
Lee el README en npm