Saltar al contenido principal
La mayoría de las herramientas de seguridad estáticas encuentran bugs de forma aislada. Escanean un archivo, listan los problemas y siguen adelante. El problema es que las vulnerabilidades más dañinas en las bases de código modernas rara vez son un único bug. Son una cadena: una clave de firma codificada en duro más una falta de comprobación de autorización más una inyección SQL que, por separado, parecen manejables. Juntas son una ruta de toma de control de cuenta. Este es exactamente el tipo de razonamiento transversal en el que los LLM son buenos, si les das la estructura adecuada. En este artículo, construiremos un revisor de seguridad de código de dos agentes usando Python y la API de Venice AI. Al final tendrás una CLI que podrás apuntar a cualquier base de código en Python para producir un informe Markdown con hallazgos atómicos y cadenas de explotación. ¿Te interesa la implementación completa del código? Échale un vistazo al repositorio de GitHub. Antes de continuar, necesitarás una clave API de Venice. Expórtala como una variable de entorno:

Qué vamos a construir

El revisor es un pequeño proyecto en Python con unas pocas partes claras: El flujo es así:
  1. Recorrer el directorio objetivo en busca de archivos .py.
  2. Construir un mapa determinista del repo (importaciones, símbolos públicos, firmas).
  3. Para cada archivo, enviar al Scanner su código fuente junto con un corte de vecindad del mapa por archivo y recopilar los hallazgos atómicos.
  4. Enviar la unión de los hallazgos junto con el mapa condensado del repo al Chainer y recopilar las cadenas de explotación.
  5. Descartar cualquier cadena que referencie un ID de hallazgo que el Scanner no produjo, o que nombre un archivo del que no provenga ninguno de sus hallazgos referenciados.
  6. Escribir un informe Markdown.
Dos decisiones de diseño merecen señalarse antes de empezar a escribir código. La primera es por qué dos agentes en vez de uno. Un escáner de un solo agente que intente hacerlo todo en un único prompt tiene que equilibrar ser minucioso con los bugs por archivo y ser hábil con el razonamiento combinatorio. Dividir el trabajo permite al Scanner ser implacable y ruidoso, y al Chainer ser selectivo y silencioso. Añadir una llamada adicional al LLM dedicada a combinar hallazgos desbloquea toda una clase de bug por muy poco código extra. La segunda es por qué un mapa del repo. Las bases de código reales viven en muchos archivos. Un bug que consiste en “el validador se ejecuta pero no se aplica por iteración en el fetcher, y la respuesta del fetcher acaba en el renderer” es invisible para un escáner por archivo. Antes de cualquier llamada al LLM, recorremos el árbol objetivo con el ast de Python y construimos un mapa estructural. El Scanner ve una vecindad por archivo (qué otros módulos importan desde este archivo, qué importa este archivo, firmas de esos símbolos externos). El Chainer ve un mapa condensado completo (cada módulo, cada símbolo público, cada arista de importación, sin código fuente). Esa es la mínima cantidad de ingeniería de contexto que hemos encontrado que permite al Chainer construir cadenas cuyo flujo de datos cruza los límites entre módulos, sin pagar el coste en tokens de meter toda la base de código en cada prompt.

Requisitos previos

  • Python 3.12+
  • Una clave API de Venice de venice.ai
  • Familiaridad básica con Pydantic, el módulo ast de Python y el SDK de Python de OpenAI
El repositorio de referencia usa uv para la gestión de dependencias, pero un entorno virtual normal funciona igualmente bien.

Configurando el proyecto

Crea un nuevo proyecto e instala las dependencias:
Si prefieres pip, crea un entorno virtual en su lugar:
Crea un archivo .env para el desarrollo local:
Pondremos el código en src/venice_security_reviewer/ para mantenerlo importable como paquete, con los prompts en prompts/ en la raíz del repo para que se puedan revisar y diffear como cualquier otro artefacto de código:

Configurando el cliente de Venice

Venice es compatible con OpenAI, así que podemos usar el SDK oficial de Python de OpenAI y simplemente apuntar su base_url a Venice. Centralizar la construcción del cliente en un único archivo significa que el resto del código nunca tiene que saber con qué proveedor está hablando: cambiar de backend solo tocaría este módulo. Crea src/venice_security_reviewer/client.py:
Algunos puntos a destacar:
  • Usamos zai-org-glm-5 por defecto porque es un modelo de Venice potente y de uso general, pero puedes anularlo con la variable de entorno VENICE_MODEL. Para bases de código más grandes o con más matices, cambiar a un modelo más potente puede hacer al Chainer notablemente mejor en la calidad narrativa.
  • build_client devuelve el cliente y el id del modelo, de modo que los llamadores no tienen que leer las variables de entorno por su cuenta y los tests pueden inyectar una configuración falsa sin monkeypatching.

Definiendo los modelos de datos

El punto de usar Pydantic aquí, en lugar de pasar dicts crudos, es que obtenemos un límite de validación estricto entre el LLM y el resto del programa. Si el modelo devuelve JSON mal formado o inventa un ID de hallazgo que no existe, el parseo falla ruidosamente y nunca propagamos la alucinación al informe. Crea src/venice_security_reviewer/models.py:
Las restricciones están haciendo un trabajo real aquí:
  • Finding.id y Chain.id están restringidos a una regex como F-001, C-001. Si el modelo se pone creativo con el formato, la validación falla.
  • Chain.findings requiere al menos dos entradas: una “cadena” de un único hallazgo es simplemente un hallazgo.
  • Chain.severity está restringido a high o critical. Una combinación de hallazgos que no eleve el impacto por encima de la mayor severidad individual no es una cadena que merezca la pena reportar.
  • Evidence impone que end_line >= start_line para que el modelo no pueda devolver rangos de línea sin sentido.
Esa es la validación de forma. También necesitamos validación de referencias cruzadas: una cadena que referencia un ID de hallazgo que el Scanner nunca produjo no tiene sentido. Añade esta función a models.py:
Este es el guardrail determinista que mantiene honesto al Chainer. Solo puede referenciar hallazgos que el Scanner realmente produjo, y solo puede reclamar como archivos involucrados en la cadena aquellos de los que uno de esos hallazgos realmente provino. Devolver las cadenas descartadas en lugar de filtrarlas silenciosamente permite a la CLI mostrar una advertencia cuando el modelo intenta inventar algo.

Construyendo el mapa AST del repo

El mapa del repo es el esqueleto estructural de una base de código Python: la superficie pública de cada módulo, cada arista de importación y un índice inverso desde “el módulo M” a “los módulos que importan desde M”. Se construye una vez por ejecución de scan con el ast de Python, nunca mediante ejecución, así que es seguro ejecutarlo sobre código adversario: el parser no importa ni invoca nada del árbol escaneado. Consumiremos el mapa en dos formas. El Scanner obtiene un corte de vecindad por archivo para que sus prompts mantengan un tamaño acotado. El Chainer obtiene un mapa condensado completo para que pueda construir cadenas entre archivos. Crea src/venice_security_reviewer/repo_map.py y comienza con los modelos Pydantic que describen el mapa:
Ahora el helper que recorre el árbol y omite los directorios que no debemos indexar:
Para cada archivo queremos tres cosas del AST: los símbolos de nivel superior que define, las aristas de importación y una lista __all__ explícita si está presente. Las firmas de funciones y las cabeceras de clase se renderizan como cadenas compactas que el LLM puede leer directamente:
El _SIGNATURE_CHAR_CAP de 200 preserva las firmas reales típicas (incluidos los type hints) a la vez que evita casos patológicos como una unión tipada de 200 líneas que haría explotar el prompt. A continuación, el extractor que saca los datos estructurales de un módulo parseado. Manejamos ast.FunctionDef, ast.ClassDef, ast.Assign y ast.AnnAssign de nivel superior para las constantes, y tanto ast.Import como ast.ImportFrom para las aristas de importación. Las importaciones relativas se resuelven en su forma punteada absoluta para que el Chainer pueda compararlas con los nombres de módulo más adelante:
La lógica completa de extracción recorre tree.body y emite entradas SymbolDef e ImportEdge por cada nodo de nivel superior. La función _extract del repositorio de referencia en repo_map.py cubre la implementación completa. La forma que sale es una lista de objetos ModuleEntry, uno por archivo. La parte interesante es lo que hacemos con esas entradas. Las envolvemos en un RepoMap con dos métodos orientados al consumidor:
neighborhood(path) es lo que el Scanner llama por cada archivo. Devuelve un objeto ModuleNeighborhood que contiene el propio módulo, cada otro módulo que importa desde él, y cada símbolo dentro del repo que importa de otros lugares (con sus firmas resueltas). Eso le da al Scanner suficiente contexto para señalar hallazgos que solo son evidentes en contexto entre archivos, sin arrastrar toda la base de código al prompt. condensed_dict() es lo que recibe el Chainer. Los snippets y las firmas se descartan; solo quedan rutas, nombres de módulo, exportaciones públicas y aristas de importación. Esa es la representación más pequeña que aún permite al Chainer razonar sobre el flujo de datos entre módulos. Finalmente, el punto de entrada que construye todo:
Los archivos que no podemos leer o que no parsean correctamente quedan registrados y se omiten. Devolvemos un mapa parcial en lugar de hacer que falle toda la ejecución; en el peor caso, una llamada al Scanner no verá vecindad para un archivo, lo que sigue siendo un scan que funciona.

Escribiendo el agente Scanner

El Scanner recorre una ruta objetivo, recoge archivos de código fuente Python y pide a Venice que identifique vulnerabilidades atómicas un archivo a la vez. Escanear por archivo mantiene el prompt pequeño y hace que los fallos queden aislados: un archivo malo no mata toda la ejecución. Mantendremos el prompt en sí en un archivo aparte para que pueda revisarse y diffearse como cualquier otro artefacto de código. Crea prompts/scanner.md:
El prompt completo en el repositorio de referencia también contiene una sección “What to look for” que enumera clases comunes de vulnerabilidades (secretos codificados en duro, inyección SQL, inyección de comandos, SSRF, deserialización insegura, etc.) y una sección “How to use the neighborhood” que explica cómo el modelo debería consumir el contexto entre archivos. Algunas notas sobre el diseño del prompt:
  • Le decimos al modelo que emita solo JSON, sin prosa ni vallas. El SDK de OpenAI admite un parámetro response_format={"type": "json_object"} que impone esto del lado de la API, pero reforzarlo en el prompt reduce los casos límite.
  • Prohibimos explícitamente al Scanner producir cadenas entre archivos. Las cadenas son trabajo del Chainer, y pedir al Scanner que haga ambas cosas difumina la responsabilidad.
  • Exigimos que el snippet se copie palabra por palabra. Esto significa que el informe puede citar los bytes exactos que el modelo afirma haber visto, y un revisor puede verificar un hallazgo comparando el snippet con el código fuente.
Ahora el código del agente. Crea src/venice_security_reviewer/scanner.py y empieza con el recorredor de archivos y el cargador de prompts:
MAX_FILE_BYTES es un límite de seguridad. Por encima de ~200 KB omitimos en lugar de enviar un prompt enorme que probablemente sea caro y de baja calidad. La siguiente pieza es el constructor del prompt. La plantilla usa {filename}, {source} y {neighborhood} como marcadores de posición; usamos str.replace en lugar de .format() porque la plantilla contiene ejemplos JSON con llaves literales:
Ahora el parser. Deserializamos el JSON, validamos cada hallazgo mediante Pydantic y descartamos los hallazgos mal formados individualmente en lugar de hacer que falle todo el archivo. Un hallazgo malo no debería hacernos perder los buenos:
El Scanner emite IDs como F-001 por archivo, pero el Chainer necesita referenciar hallazgos en todo el repo. Reasignamos los IDs frente a un contador monótono para que sean únicos globalmente:
La llamada de scan de un solo archivo combina todo esto. Leemos el archivo, construimos el prompt, lo enviamos a Venice con response_format={"type": "json_object"} y una temperature baja, y parseamos el resultado:
Dos detalles que merece la pena destacar:
  • Parcheamos la ruta de archivo de la evidencia para que sea relativa a repo_root después del parseo, ya que el modelo nos devuelve el nombre de archivo que le pasamos, pero queremos una única forma canónica en todo el informe.
  • temperature=0.1 es intencionadamente bajo. Queremos que el Scanner sea conservador y consistente entre ejecuciones; la creatividad es trabajo del Chainer.
Por último, el orquestador que escanea cada archivo elegible bajo la raíz:
El mapa del repo lo construye el llamador una sola vez y se reutiliza para cada archivo, así que el Scanner ve una estructura global consistente incluso cuando archivos individuales fallan al parsear o se omiten.

Escribiendo el agente Chainer

El Chainer toma la unión de los hallazgos del Scanner más el mapa condensado del repo y le pregunta a Venice si alguno de los hallazgos se combina en una cadena de explotación real. Hay dos guardrails deterministas entre el LLM y el informe:
  1. Cada cadena debe referenciar solo IDs de hallazgo que el Scanner haya producido.
  2. Cada cadena debe reclamar solo archivos que la evidencia de al menos uno de los hallazgos referenciados toque.
Las cadenas que violan cualquiera de las dos reglas se descartan en el momento del parseo. Esto evita que el modelo alucine cadenas “por si acaso” y que reclame que una cadena se extiende por archivos para los que no tiene evidencia. El prompt del Chainer vive en prompts/chainer.md. El núcleo se ve así:
El prompt completo en el repositorio de referencia también explica cómo leer el mapa del repo, cómo decidir qué entra en files_involved y, lo más importante, cuándo no encadenar. Decirle al modelo “es correcto y esperable que muchas bases de código tengan hallazgos que no encadenen” es lo que evita que invente cadenas para parecer productivo. Ahora el código del agente. Crea src/venice_security_reviewer/chainer.py:
MAX_REPO_MAP_CHARS = 8000 es un techo suave para el bloque del mapa del repo renderizado en JSON en el prompt del Chainer. A aproximadamente 4 caracteres por token, eso son unos ~2000 tokens, lo que cabe cómodamente dentro de la ventana de contexto de cualquier modelo de Venice, incluso con los hallazgos y el presupuesto para la narrativa por encima. Serializamos los hallazgos en un bloque JSON compacto. Ten en cuenta que aquí quitamos el snippet de la evidencia a propósito: el Chainer no necesita los bytes en bruto para decidir si dos hallazgos se combinan, e incluirlos aproximadamente duplica el coste de tokens en bases de código reales:
Para bases de código más grandes, el mapa condensado completo del repo puede pasar muy por encima de nuestro presupuesto de caracteres. Cuando eso sucede, podamos hasta los módulos que contienen hallazgos más sus vecinos directos. Eso conserva suficiente estructura para que el Chainer razone sobre cadenas para las que tenemos evidencia, y descarta el resto:
La estrategia de poda es intencionadamente simple: mantener los módulos en los que viven nuestros hallazgos y mantener sus vecinos directos en el grafo de importaciones. Cualquier cosa más allá no tiene un papel plausible en una cadena para la que tengamos evidencia actualmente, así que se puede descartar sin perder capacidad de razonamiento. También anotamos el payload con marcadores _pruned, _kept y _total, para que el prompt del Chainer pueda advertir al modelo cuando el mapa ha sido recortado. Parsear la respuesta tiene la misma forma que el Scanner: deserializar, validar cada cadena mediante Pydantic, descartar las entradas mal formadas:
Luego el agente en sí:
Un par de cosas dignas de mención:
  • Salimos antes de llamar al modelo cuando hay menos de dos hallazgos. No puedes encadenar un solo hallazgo, y saltarse la llamada significa que no quemamos tokens en un resultado garantizado vacío.
  • temperature=0.2 es ligeramente más alta que el 0.1 del Scanner. El Chainer se beneficia de un poco más de creatividad para detectar combinaciones no obvias, pero aún queremos que esté anclado en los hallazgos y el mapa que se le ha dado.
  • Tras el parseo, validate_chain_references ejecuta la comprobación determinista de referencias cruzadas que escribimos antes. Cualquier cosa que sobreviva es segura para renderizar; cualquier cosa que no, se registra para que el operador sepa que el modelo intentó inventar algo.
Esa comprobación de referencias cruzadas es la pieza más importante de todo el proyecto. Es la frontera entre “herramienta de seguridad útil” e “informe de IA ocasionalmente equivocado con confianza”. Con ella en su lugar, incluso si el modelo alucina, la cadena incorrecta nunca llega al informe.

Renderizando el informe Markdown

Mantener el renderizado separado de la lógica del agente significa que los mismos objetos Finding y Chain pueden alimentarse luego a otros formatos (JSON, SARIF, HTML) sin tocar el Scanner ni el Chainer. Usaremos Jinja2 con un pequeño archivo de plantilla. Crea src/venice_security_reviewer/templates/report.md.j2:
Luego el renderer en src/venice_security_reviewer/report.py:
El autoescape se queda desactivado para la plantilla Markdown (Markdown no es HTML), pero lo dejamos habilitado para cualquier futura plantilla .html por extensión.

Conectando la CLI

La CLI es el orquestador: construye el mapa del repo, escanea, encadena, renderiza. Usaremos Typer para gestionar el parseo de argumentos y Rich para imprimir una tabla resumen agradable. Crea src/venice_security_reviewer/cli.py:
Añade el punto de entrada del script al pyproject.toml:
Eso es toda la tubería conectada.

Probando los guardrails

Hemos insistido mucho en una idea a lo largo de esta construcción: los guardrails deterministas son lo que separa una herramienta de seguridad útil de una equivocada con confianza. Esa afirmación solo merece la pena hacerla si podemos demostrar que los guardrails realmente se mantienen, así que las pruebas más valiosas de este proyecto no llaman a Venice en absoluto. Bloquean el límite de Pydantic y la fontanería de ensamblaje de prompts, lo que significa que se ejecutan offline, en milisegundos, sin clave API y sin coste de tokens. Añade primero las dependencias de desarrollo:
Lo primero que merece la pena probar es el propio límite de los modelos. Estas pruebas afirman que los hallazgos y cadenas mal formados se rechazan en el momento de la construcción, antes de que puedan llegar nunca a un informe. Crea tests/test_models.py:
Cada una de estas refleja una restricción que pusimos antes en los modelos: un rango de líneas invertido, un ID que no coincide con el patrón F-### y una “cadena” de un único hallazgo. Si alguna deja de lanzar excepción, toda una clase de alucinación se vuelve silenciosamente posible de nuevo. La prueba más importante cubre el validador de referencias cruzadas, ya que esa es la función que realmente descarta las cadenas inventadas:
El Scanner nunca produjo F-999, así que la cadena que lo referencia acaba en dropped y nunca llega al informe. La prueba complementaria en el repo de referencia, test_validate_chain_references_drops_unknown_files, hace lo mismo para una cadena que reclama un archivo del que no provino ninguno de sus hallazgos. La segunda cosa que merece la pena probar es la fontanería que alimenta al Chainer. Es fácil refactorizar el ensamblaje del prompt y dejar silenciosamente de pasar el contexto entre archivos, momento en el que el Chainer seguiría funcionando pero empeoraría silenciosamente. Esta prueba construye un fixture de dos módulos, renderiza el prompt y afirma que la información entre archivos realmente está presente, todo ello sin un viaje de ida y vuelta a Venice. Crea tests/test_cross_file_chain.py:
Si esta prueba pasa, al Chainer se le está entregando un prompt que contiene los dos hallazgos, las dos rutas de archivo y la arista de importación entre ellos. Si el modelo usa bien esa información es una evaluación separada y fuera de banda; esta prueba solo protege la fontanería que mete la información en el prompt en primer lugar. Ejecuta toda la suite, además del linter y el comprobador de tipos, con:
Como ninguna de estas pruebas toca la red, son seguras de ejecutar en cada commit y en CI sin quemar tokens ni necesitar una clave de Venice. El repo de referencia también incluye tests/test_scanner_parse.py, tests/test_chainer_parse.py y tests/test_repo_map.py, que cubren casos límite del parseo JSON (entradas mal formadas que se descartan en vez de hacer que la ejecución se rompa) y el constructor del mapa AST del repo.

Ejecutando el proyecto

Para probarlo en una base de código real, apunta la CLI a un directorio de código fuente Python:
O instálalo en tu virtualenv con pip install -e . y ejecuta venice-security-reviewer scan path/to/your/code. La salida se ve más o menos así:
El informe Markdown muestra cada cadena en la parte superior con su narrativa, luego cada hallazgo individual debajo con la severidad, CWE, ubicación del archivo, descripción y el snippet textual que el modelo afirma haber leído. El repo de referencia también incluye cuatro objetivos de demo empaquetados que ejercitan cada uno una forma distinta de razonamiento que el Chainer tiene que hacer:
  • examples/vulnerable_app — una app Flask multiarchivo con tres hallazgos “low”, dos de los cuales se combinan en una cadena crítica de escalada de privilegios entre archivos. Prueba si el Chainer es selectivo sobre qué combina.
  • examples/url_preview — un fetcher de URL multiarchivo con una allowlist defensiva que no se aplica por iteración. Prueba el razonamiento de flujo de datos entre archivos combinado con topología de despliegue (las IPs link-local son puertas a credenciales en la nube).
  • examples/csv_query — un filtro CSV de un solo archivo con un escape del sandbox de eval mediante __class__.__base__.__subclasses__(). Prueba el razonamiento a nivel de lenguaje en lugar de flujo HTTP.
  • examples/webhook_handler — un verificador HMAC de un solo archivo con una vulnerabilidad de diferencia entre parsers JSON. Prueba el razonamiento entre múltiples especificaciones.
Pruébalos con:
Si alguna vez ves que la CLI registra chainer referenced N unknown finding id(s) or file(s); chains dropped, ese es el validador de referencias cruzadas pillando al modelo en el acto de inventar una cadena. Las cadenas descartadas nunca llegan al informe; solo recibes una advertencia que puedes usar para ajustar el prompt o muestrear ejecuciones adicionales del Chainer.

Extendiendo este ejemplo

La forma de dos agentes se generaliza bien. Algunas direcciones que merece la pena explorar:
  • Más lenguajes. El Scanner es agnóstico al lenguaje a nivel de prompt; el constructor de AST es lo específico de Python. Cambia a tree-sitter y podrás construir las mismas formas de vecindad/mapa-condensado para TypeScript, Go o Rust.
  • Un tercer agente para arreglos. Una vez que tienes una cadena, pedir a un agente Patcher que redacte un diff unificado que neutralice uno de los hallazgos constituyentes es un pequeño paso. Validar el diff con Pydantic contra el mismo conjunto de archivos-evidencia te da gratis la misma protección frente a alucinaciones.
  • Formatos de salida. render_report es el único lugar que conoce el Markdown. Añade un renderer SARIF y los mismos hallazgos pueden caer en code scanning de GitHub. Añade un renderer JSON y podrás canalizar los resultados a un sistema descendente.
  • Caché por hash de archivo. Las llamadas por archivo del Scanner son independientes e idempotentes. Cachear por (file_hash, prompt_hash, model) significa que re-escanear un repo donde cambió un archivo solo vuelve a ejecutar el Scanner sobre ese archivo.
  • Muestreo para el Chainer. Para ejecuciones de alto riesgo, llama al Chainer N veces con una temperature ligeramente más alta e intersecta los resultados. Las cadenas que el modelo encuentra de forma consistente tienen más probabilidades de ser reales; las que encuentra una vez y nunca más son probablemente ruido.
  • Modelos más potentes. zai-org-glm-5 es el predeterminado porque logra un buen equilibrio entre coste y calidad para el razonamiento combinatorio, pero para bases de código más difíciles, cambiar a un modelo de Venice más potente (configurado mediante VENICE_MODEL) puede hacer que las narrativas del Chainer sean notablemente más ajustadas.

Para terminar

¡Gracias por leer! Esperamos que esto te haya ayudado a entender cómo estructurar una herramienta de seguridad con IA que sea realmente confiable. El patrón que hemos usado aquí se generaliza más allá de la seguridad: cada vez que quieras que un LLM razone entre archivos de un modo que tenga que aterrizar en evidencia real, la receta es la misma. Construye un mapa estructural determinista, entrega al modelo un corte del mismo que quepa en el contexto, valida las referencias del modelo contra la estructura y descarta cualquier cosa que no pueda aterrizar. Al utilizar Python con la API de Venice AI, podemos construir agentes que combinen el razonamiento del LLM con límites de validación estrictos, y entregar algo que dé una respuesta útil en lugar de una que suena confiada.