Qué vamos a construir
El revisor es un pequeño proyecto en Python con unas pocas partes claras:| Parte | Qué hace |
|---|---|
| Modelos Pydantic | Definen Evidence, Finding y Chain, y nos dan un límite de validación estricto entre el LLM y el resto del programa |
| Cliente de Venice | Envuelve el SDK de Python de OpenAI apuntado al endpoint de Venice compatible con OpenAI |
| Mapa AST del repo | Recorre el árbol objetivo con el módulo ast de Python y construye un mapa determinista de los símbolos públicos e importaciones de cada módulo |
| Agente Scanner | Lee un archivo Python a la vez junto con un corte de vecindad por archivo del mapa del repo, y emite hallazgos atómicos de vulnerabilidades con evidencia archivo:línea |
| Agente Chainer | Lee la unión de los hallazgos junto con un mapa condensado completo del repo, y emite cadenas de explotación que combinan dos o más hallazgos |
| Validador de referencias | Descarta cualquier cadena que referencie un ID de hallazgo que el Scanner no produjo, o que nombre un archivo del que no provino ninguno de sus hallazgos referenciados |
| Informe Markdown | Renderiza los hallazgos y las cadenas en un informe legible |
| CLI | Conecta todo con Typer |
- Recorrer el directorio objetivo en busca de archivos
.py. - Construir un mapa determinista del repo (importaciones, símbolos públicos, firmas).
- 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.
- Enviar la unión de los hallazgos junto con el mapa condensado del repo al Chainer y recopilar las cadenas de explotación.
- 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.
- Escribir un informe Markdown.
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
astde Python y el SDK de Python de OpenAI
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:pip, crea un entorno virtual en su lugar:
.env para el desarrollo local:
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 subase_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:
- Usamos
zai-org-glm-5por defecto porque es un modelo de Venice potente y de uso general, pero puedes anularlo con la variable de entornoVENICE_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_clientdevuelve 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. Creasrc/venice_security_reviewer/models.py:
Finding.idyChain.idestán restringidos a una regex comoF-001,C-001. Si el modelo se pone creativo con el formato, la validación falla.Chain.findingsrequiere al menos dos entradas: una “cadena” de un único hallazgo es simplemente un hallazgo.Chain.severityestá restringido ahighocritical. 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.Evidenceimpone queend_line >= start_linepara que el modelo no pueda devolver rangos de línea sin sentido.
models.py:
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 elast 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:
__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:
_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:
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:
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. Creaprompts/scanner.md:
- 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.
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:
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:
response_format={"type": "json_object"} y una temperature baja, y parseamos el resultado:
- Parcheamos la ruta de archivo de la evidencia para que sea relativa a
repo_rootdespué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.1es intencionadamente bajo. Queremos que el Scanner sea conservador y consistente entre ejecuciones; la creatividad es trabajo del Chainer.
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:- Cada cadena debe referenciar solo IDs de hallazgo que el Scanner haya producido.
- Cada cadena debe reclamar solo archivos que la evidencia de al menos uno de los hallazgos referenciados toque.
prompts/chainer.md. El núcleo se ve así:
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:
_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:
- 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.2es ligeramente más alta que el0.1del 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_referencesejecuta 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.
Renderizando el informe Markdown
Mantener el renderizado separado de la lógica del agente significa que los mismos objetosFinding 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:
src/venice_security_reviewer/report.py:
.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. Creasrc/venice_security_reviewer/cli.py:
pyproject.toml:
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:tests/test_models.py:
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:
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:
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:pip install -e . y ejecuta venice-security-reviewer scan path/to/your/code.
La salida se ve más o menos así:
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 deevalmediante__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.
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-sittery 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_reportes 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-5es 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 medianteVENICE_MODEL) puede hacer que las narrativas del Chainer sean notablemente más ajustadas.