Ce que nous allons construire
Le réviseur est un petit projet Python comportant quelques parties bien distinctes :| Partie | Ce qu’elle fait |
|---|---|
| Modèles Pydantic | Définissent Evidence, Finding et Chain, et nous donnent une frontière de validation stricte entre le LLM et le reste du programme |
| Client Venice | Encapsule le SDK Python d’OpenAI pointé vers le point de terminaison compatible OpenAI de Venice |
| Carte de dépôt AST | Parcourt l’arborescence cible avec le module ast de Python et construit une carte déterministe des symboles publics et des arêtes d’import de chaque module |
| Agent Scanner | Lit un fichier Python à la fois plus une tranche de voisinage par fichier de la carte du dépôt, et émet des résultats de vulnérabilités atomiques avec des preuves file:line |
| Agent Chainer | Lit l’union des résultats plus une carte complète condensée du dépôt, et émet des chaînes d’exploitation qui combinent deux ou plusieurs résultats |
| Validateur de références | Supprime toute chaîne qui référence un ID de résultat que le Scanner n’a pas produit, ou qui nomme un fichier dont aucun des résultats référencés ne provient réellement |
| Rapport Markdown | Restitue les résultats et les chaînes dans un rapport lisible par un humain |
| CLI | Connecte tout avec Typer |
- Parcourir le répertoire cible à la recherche de fichiers
.py. - Construire une carte de dépôt déterministe (imports, symboles publics, signatures).
- Pour chaque fichier, envoyer au Scanner sa source plus une tranche de voisinage par fichier de la carte et collecter les résultats atomiques.
- Envoyer l’union des résultats plus la carte de dépôt condensée au Chainer et collecter les chaînes d’exploitation.
- Supprimer toute chaîne qui référence un ID de résultat que le Scanner n’a pas produit, ou qui nomme un fichier dont aucun des résultats référencés ne provient réellement.
- Écrire un rapport Markdown.
ast de Python et construisons une carte structurelle. Le Scanner voit un voisinage par fichier (qui importe depuis ce fichier, ce que ce fichier importe, les signatures de ces symboles externes). Le Chainer voit une carte complète condensée (chaque module, chaque symbole public, chaque arête d’import, sans source). C’est la plus petite quantité d’ingénierie de contexte que nous ayons trouvée qui permet au Chainer de construire des chaînes dont le flux de données traverse les frontières de modules, sans payer le coût en tokens consistant à insérer la totalité de la base de code dans chaque prompt.
Prérequis
- Python 3.12+
- Une clé API Venice depuis venice.ai
- Une familiarité de base avec Pydantic, le module
astde Python et le SDK Python d’OpenAI
uv pour la gestion des dépendances, mais un environnement virtuel classique fonctionne tout aussi bien.
Mise en place du projet
Créez un nouveau projet et installez les dépendances :pip, créez plutôt un environnement virtuel :
.env pour le développement local :
src/venice_security_reviewer/ pour qu’il puisse être importé comme un package, avec les prompts sous prompts/ à la racine du dépôt afin qu’ils puissent être révisés et comparés comme tout autre artefact source :
Mise en place du client Venice
Venice est compatible OpenAI, nous pouvons donc utiliser le SDK Python officiel d’OpenAI et pointer sonbase_url vers Venice. Centraliser la construction du client dans un seul fichier signifie que le reste du code n’a jamais à savoir avec quel fournisseur il communique : changer de backend ne toucherait que ce module.
Créez src/venice_security_reviewer/client.py :
- Nous utilisons par défaut
zai-org-glm-5parce que c’est un modèle Venice polyvalent et performant, mais vous pouvez le remplacer via la variable d’environnementVENICE_MODEL. Pour des bases de code plus volumineuses ou plus nuancées, choisir un modèle plus puissant peut rendre le Chainer nettement meilleur en qualité narrative. build_clientretourne le client et l’ID du modèle, afin que les appelants n’aient pas à lire les variables d’environnement eux-mêmes et que les tests puissent injecter une fausse configuration sans monkeypatching.
Définition des modèles de données
Tout l’intérêt d’utiliser Pydantic ici, plutôt que de faire circuler des dicts bruts, est que nous obtenons une frontière de validation stricte entre le LLM et le reste du programme. Si le modèle renvoie du JSON malformé ou invente un ID de résultat qui n’existe pas, le parsing échoue bruyamment et nous ne propageons jamais l’hallucination dans le rapport. Créezsrc/venice_security_reviewer/models.py :
Finding.idetChain.idsont contraints à une regex du typeF-001,C-001. Si le modèle fait preuve de créativité sur le format, la validation échoue.Chain.findingsexige au moins deux entrées : une « chaîne » d’un seul résultat n’est qu’un résultat.Chain.severityest restreint àhighoucritical. Une combinaison de résultats qui n’élève pas l’impact au-dessus de la sévérité individuelle la plus élevée n’est pas une chaîne digne d’être rapportée.Evidenceimpose queend_line >= start_lineafin que le modèle ne puisse pas retourner des plages de lignes absurdes.
models.py :
Construction de la carte de dépôt AST
La carte de dépôt est le squelette structurel d’une base de code Python : la surface publique de chaque module, chaque arête d’import et un index inverse de « module M » vers « modules qui importent depuis M ». Elle est construite une fois par exécution de scan avecast de Python, jamais par exécution, donc il est sûr de l’exécuter sur du code adversarial : le parseur n’importe ni n’invoque rien depuis l’arbre analysé.
Nous consommerons la carte sous deux formes. Le Scanner reçoit une tranche de voisinage par fichier afin que ses prompts restent de taille limitée. Le Chainer reçoit une carte complète condensée afin qu’il puisse construire des chaînes à travers les fichiers.
Créez src/venice_security_reviewer/repo_map.py et commencez par les modèles Pydantic qui décrivent la carte :
__all__ explicite si elle est présente. Les signatures de fonctions et les en-têtes de classes sont rendus sous forme de chaînes compactes que le LLM peut lire directement :
_SIGNATURE_CHAR_CAP de 200 préserve les signatures réelles typiques (y compris les hints de type) tout en empêchant les cas pathologiques comme une union typée de 200 lignes qui ferait exploser le prompt.
Ensuite, l’extracteur qui tire les données structurelles d’un module parsé. Nous gérons ast.FunctionDef, ast.ClassDef, les ast.Assign et ast.AnnAssign de niveau supérieur pour les constantes, et à la fois ast.Import et ast.ImportFrom pour les arêtes d’import. Les imports relatifs sont résolus dans leur forme pointée absolue afin que le Chainer puisse les apparier ultérieurement aux noms de modules :
tree.body et émet des entrées SymbolDef et ImportEdge pour chaque nœud de niveau supérieur. La fonction _extract du dépôt de référence dans repo_map.py couvre l’implémentation complète. La forme qui en ressort est une liste d’objets ModuleEntry, un par fichier.
La partie intéressante est ce que nous faisons avec ces entrées. Encapsulez-les dans une RepoMap avec deux méthodes orientées consommateur :
neighborhood(path) est ce que le Scanner appelle pour chaque fichier. Cela retourne un objet ModuleNeighborhood contenant le module lui-même, tout autre module qui importe depuis lui, et chaque symbole intra-dépôt qu’il importe d’ailleurs (avec leurs signatures résolues). Cela donne au Scanner suffisamment de contexte pour signaler des résultats qui ne sont évidents que dans un contexte transversal aux fichiers, sans traîner toute la base de code dans le prompt.
condensed_dict() est ce que le Chainer reçoit. Les extraits et les signatures sont supprimés ; seuls les chemins, les noms de modules, les exports publics et les arêtes d’import demeurent. C’est la plus petite représentation qui permette encore au Chainer de raisonner sur le flux de données entre modules.
Enfin, le point d’entrée qui construit le tout :
Écriture de l’agent Scanner
Le Scanner parcourt un chemin cible, récupère les fichiers source Python et demande à Venice d’identifier les vulnérabilités atomiques un fichier à la fois. L’analyse par fichier maintient le prompt à une taille réduite et isole les défaillances : un mauvais fichier ne fait pas tomber toute l’exécution. Nous garderons le prompt lui-même dans un fichier séparé afin qu’il puisse être révisé et comparé comme tout autre artefact source. Créezprompts/scanner.md :
- Nous indiquons au modèle d’émettre uniquement du JSON, sans prose ni clôtures. Le SDK d’OpenAI prend en charge un paramètre
response_format={"type": "json_object"}qui impose cela côté API, mais le renforcer dans le prompt réduit les cas limites. - Nous interdisons explicitement au Scanner de produire des chaînes transversales aux fichiers. Les chaînes sont la mission du Chainer, et demander au Scanner de faire les deux brouille la responsabilité.
- Nous exigeons que l’extrait soit copié textuellement. Cela signifie que le rapport peut citer les octets exacts que le modèle prétend avoir vus, et qu’un réviseur peut vérifier ponctuellement un résultat en comparant l’extrait à la source.
src/venice_security_reviewer/scanner.py et commencez par le parcoureur de fichiers et le chargeur de prompt :
MAX_FILE_BYTES est un plafond de sécurité. Au-delà d’environ 200 Ko, nous sautons plutôt que d’envoyer un prompt énorme qui sera probablement à la fois coûteux et de faible qualité.
Le morceau suivant est le constructeur de prompt. Le modèle utilise {filename}, {source} et {neighborhood} comme espaces réservés ; nous utilisons str.replace plutôt que .format() parce que le modèle contient des exemples JSON avec des accolades littérales :
F-001 par fichier, mais le Chainer a besoin de référencer les résultats à travers tout le dépôt. Nous réattribuons les IDs par rapport à un compteur monotone afin qu’ils soient globalement uniques :
response_format={"type": "json_object"} et une température basse, et parsons le résultat :
- Nous corrigeons le chemin du fichier de preuve pour qu’il soit relatif à
repo_rootaprès le parsing, puisque le modèle renvoie en écho le nom de fichier que nous lui avons donné, mais nous voulons une forme canonique unique dans tout le rapport. temperature=0.1est intentionnellement basse. Nous voulons que le Scanner soit conservateur et cohérent entre les exécutions ; la créativité est la mission du Chainer.
Écriture de l’agent Chainer
Le Chainer prend l’union des résultats du Scanner plus la carte de dépôt condensée et demande à Venice si l’un des sous-ensembles de résultats se combine en une véritable chaîne d’exploitation. Deux garde-fous déterministes se trouvent entre le LLM et le rapport :- Chaque chaîne doit référencer uniquement des IDs de résultats que le Scanner a produits.
- Chaque chaîne doit revendiquer uniquement les fichiers que la preuve d’au moins un résultat référencé touche.
prompts/chainer.md. Son cœur ressemble à ceci :
files_involved, et surtout, quand ne pas enchaîner. Dire au modèle « il est correct et attendu pour de nombreuses bases de code d’avoir des résultats qui ne s’enchaînent pas » est ce qui l’empêche d’inventer des chaînes pour paraître productif.
Maintenant le code de l’agent. Créez src/venice_security_reviewer/chainer.py :
MAX_REPO_MAP_CHARS = 8000 est un plafond souple pour le bloc de carte de dépôt rendu en JSON dans le prompt du Chainer. À environ 4 caractères par token, cela représente environ 2 000 tokens, ce qui tient confortablement dans la fenêtre de contexte de n’importe quel modèle Venice, même avec les résultats et le budget narratif en sus.
Nous sérialisons les résultats dans un bloc JSON compact. Notez que nous supprimons délibérément le snippet des preuves ici : le Chainer n’a pas besoin des octets bruts pour décider si deux résultats se combinent, et les inclure double approximativement le coût en tokens sur de vraies bases de code :
_pruned, _kept et _total, afin que le prompt du Chainer puisse avertir le modèle quand la carte a été réduite.
Le parsing de la réponse a la même forme que celui du Scanner : désérialisation, validation de chaque chaîne via Pydantic, suppression des entrées malformées :
- Nous abandonnons avant d’appeler le modèle lorsqu’il y a moins de deux résultats. On ne peut pas enchaîner un seul résultat, et éviter l’appel signifie que nous ne brûlons pas de tokens sur un résultat garanti vide.
temperature=0.2est légèrement plus élevée que les0.1du Scanner. Le Chainer bénéficie d’un peu plus de créativité pour repérer des combinaisons non évidentes, mais nous voulons toujours qu’il soit ancré dans les résultats et la carte qui lui ont été donnés.- Après le parsing,
validate_chain_referencesexécute la vérification déterministe des références croisées que nous avons écrite plus tôt. Tout ce qui survit peut être rendu en toute sécurité ; tout ce qui ne survit pas est journalisé afin que l’opérateur sache que le modèle a essayé d’inventer quelque chose.
Rendu du rapport Markdown
Garder le rendu séparé de la logique de l’agent signifie que les mêmes objetsFinding et Chain peuvent ensuite être alimentés dans d’autres formats (JSON, SARIF, HTML) sans toucher au Scanner ou au Chainer.
Nous utiliserons Jinja2 avec un petit fichier de modèle. Créez src/venice_security_reviewer/templates/report.md.j2 :
src/venice_security_reviewer/report.py :
.html par extension.
Câblage de la CLI
La CLI est l’orchestrateur : construire la carte du dépôt, scanner, enchaîner, restituer. Nous utiliserons Typer pour gérer le parsing des arguments et Rich pour afficher un joli tableau de résumé. Créezsrc/venice_security_reviewer/cli.py :
pyproject.toml :
Tester les garde-fous
Nous avons largement insisté sur une idée tout au long de cette construction : les garde-fous déterministes sont ce qui distingue un outil de sécurité utile d’un outil confiant à tort. Cette affirmation ne vaut la peine d’être faite que si nous pouvons prouver que les garde-fous tiennent réellement, donc les tests les plus précieux dans ce projet n’appellent pas Venice du tout. Ils verrouillent la frontière Pydantic et la plomberie d’assemblage du prompt, ce qui signifie qu’ils s’exécutent hors ligne, en quelques millisecondes, sans clé API et sans coût en tokens. Ajoutez d’abord les dépendances de développement :tests/test_models.py :
F-### et une « chaîne » d’un seul résultat. Si l’un d’eux cesse de lever une exception, toute une catégorie d’hallucinations est silencieusement redevenue possible.
Le test le plus important couvre le validateur de références croisées, puisque c’est la fonction qui supprime réellement les chaînes inventées :
F-999 n’a jamais été produit par le Scanner, donc la chaîne qui le référence se retrouve dans dropped et n’atteint jamais le rapport. Le test compagnon dans le dépôt de référence, test_validate_chain_references_drops_unknown_files, fait la même chose pour une chaîne qui prétend qu’un fichier ne provient d’aucun de ses résultats.
La deuxième chose qu’il vaut la peine de tester est la plomberie qui alimente le Chainer. Il est facile de refactoriser l’assemblage du prompt et d’arrêter silencieusement de passer le contexte transversal aux fichiers, après quoi le Chainer continuerait de fonctionner mais s’aggraverait silencieusement. Ce test construit un fixture à deux modules, rend le prompt et affirme que les informations transversales aux fichiers sont effectivement présentes, là encore sans aller-retour Venice. Créez tests/test_cross_file_chain.py :
tests/test_scanner_parse.py, tests/test_chainer_parse.py et tests/test_repo_map.py, qui couvrent les cas limites de parsing JSON (les entrées malformées sont écartées plutôt que de faire planter l’exécution) et le constructeur de carte de dépôt AST.
Exécution du projet
Pour l’essayer sur une vraie base de code, pointez la CLI sur un répertoire de code source Python :pip install -e . et exécutez venice-security-reviewer scan path/to/your/code.
La sortie ressemble approximativement à ceci :
examples/vulnerable_app— une application Flask multi-fichiers avec trois résultats « low », dont deux se combinent en une chaîne critique d’élévation de privilèges à travers les fichiers. Teste si le Chainer est sélectif sur ce qu’il combine.examples/url_preview— un récupérateur d’URL multi-fichiers avec une liste blanche défensive qui ne s’applique pas par itération. Teste le raisonnement sur le flux de données transversal aux fichiers combiné à la topologie de déploiement (les IP link-local sont des passerelles vers les identifiants cloud).examples/csv_query— un filtre CSV à fichier unique avec une évasion de bac à sableevalvia__class__.__base__.__subclasses__(). Teste le raisonnement au niveau du langage plutôt que le flux HTTP.examples/webhook_handler— un vérificateur HMAC à fichier unique avec une vulnérabilité de différentielle d’analyseur JSON. Teste le raisonnement sur plusieurs spécifications.
chainer referenced N unknown finding id(s) or file(s); chains dropped, c’est le validateur de références croisées qui prend le modèle en flagrant délit d’invention d’une chaîne. Les chaînes supprimées n’arrivent jamais dans le rapport ; vous obtenez simplement un avertissement que vous pouvez utiliser pour ajuster le prompt ou échantillonner des exécutions supplémentaires du Chainer.
Étendre cet exemple
La forme à deux agents se généralise bien. Quelques directions qu’il vaut la peine d’explorer :- Plus de langages. Le Scanner est indépendant du langage au niveau du prompt ; c’est le constructeur d’AST qui est spécifique à Python. Remplacez-le par
tree-sitteret vous pouvez construire les mêmes formes de voisinage/carte condensée pour TypeScript, Go ou Rust. - Un troisième agent pour les correctifs. Une fois que vous avez une chaîne, demander à un agent Patcher de rédiger un diff unifié qui désamorce l’un des résultats constitutifs est un petit pas. Validez le diff via Pydantic par rapport au même ensemble de fichiers de preuve et vous obtenez gratuitement la même protection contre les hallucinations.
- Formats de sortie.
render_reportest le seul endroit qui connaît Markdown. Ajoutez un renderer SARIF et les mêmes résultats peuvent être déversés dans le code scanning de GitHub. Ajoutez un renderer JSON et vous pouvez acheminer les résultats dans un système en aval. - Mise en cache par hachage de fichier. Les appels par fichier du Scanner sont indépendants et idempotents. Mettre en cache par
(file_hash, prompt_hash, model)signifie que re-scanner un dépôt dans lequel un fichier a changé ne réexécute le Scanner que sur ce seul fichier. - Échantillonnage pour le Chainer. Pour les exécutions à enjeux élevés, appelez le Chainer N fois à une température légèrement plus élevée et croisez les résultats. Les chaînes que le modèle trouve de manière cohérente sont plus susceptibles d’être réelles ; les chaînes qu’il trouve une fois et jamais plus sont probablement du bruit.
- Modèles plus puissants.
zai-org-glm-5est la valeur par défaut parce qu’il offre un bon équilibre entre coût et qualité pour le raisonnement combinatoire, mais pour des bases de code plus difficiles, choisir un modèle Venice plus puissant (défini viaVENICE_MODEL) peut rendre les récits du Chainer notablement plus serrés.