Vai al contenuto principale
La maggior parte dei tool di sicurezza statici trova i bug in isolamento. Scansionano un file, elencano i problemi e proseguono. Il problema è che le vulnerabilità più dannose nelle codebase moderne raramente sono un singolo bug. Sono una catena: una chiave di firma hardcoded più un controllo di autorizzazione mancante più una SQL injection che, presi singolarmente, sembrano tutti gestibili. Insieme costituiscono un percorso di account-takeover. È esattamente il tipo di ragionamento trasversale in cui gli LLM eccellono, se gli dai la giusta struttura. In questo articolo costruiremo un revisore di sicurezza a due agenti usando Python e l’API di Venice AI. Alla fine avrai una CLI da puntare su qualsiasi codebase Python per produrre un report Markdown con findings atomici e catene di exploit. Ti interessa l’implementazione completa? Dai un’occhiata al repository GitHub. Prima di continuare, ti servirà una API key Venice. Esportala come variabile d’ambiente:

Cosa costruiremo

Il revisore è un piccolo progetto Python con poche parti ben definite: Il flusso è il seguente:
  1. Attraversa la directory target alla ricerca di file .py.
  2. Costruisce una mappa deterministica del repo (import, simboli pubblici, firme).
  3. Per ogni file, invia allo Scanner il suo sorgente più una porzione di vicinato della mappa e raccoglie i findings atomici.
  4. Invia l’unione dei findings più la mappa condensata del repo al Chainer e raccoglie le catene di exploit.
  5. Scarta qualsiasi catena che faccia riferimento a un ID finding non prodotto dallo Scanner, o che nomini un file da cui nessuno dei findings referenziati proviene davvero.
  6. Scrive un report Markdown.
Due scelte di design vanno evidenziate prima di scrivere il codice. La prima è perché due agenti invece di uno. Uno scanner single-agent che prova a fare tutto in un solo prompt deve bilanciare l’essere meticoloso sui bug per-file con l’essere brillante nel ragionamento combinatorio. Dividere il lavoro consente allo Scanner di essere implacabile e rumoroso, e al Chainer di essere selettivo e silenzioso. Aggiungere una sola chiamata LLM in più dedicata alla combinazione dei findings sblocca un’intera classe di bug con pochissimo codice extra. La seconda è perché una mappa del repo. Le codebase reali si distribuiscono su molti file. Un bug che consiste in “il validator viene eseguito ma non è applicato per iterazione nel fetcher, e la risposta del fetcher finisce nel renderer” è invisibile a uno scanner per-file. Prima di qualsiasi chiamata LLM, attraversiamo l’albero target con ast di Python e costruiamo una mappa strutturale. Lo Scanner vede un vicinato per file (chi importa da questo file, cosa importa questo file, firme di quei simboli esterni). Il Chainer vede una mappa condensata completa (ogni modulo, ogni simbolo pubblico, ogni arco di import, nessun sorgente). È la minima quantità di context engineering che abbiamo trovato e che permette al Chainer di costruire catene il cui flusso di dati attraversa i confini dei moduli, senza pagare il costo in token di infilare l’intera codebase in ogni prompt.

Prerequisiti

  • Python 3.12+
  • Una API key Venice da venice.ai
  • Familiarità di base con Pydantic, il modulo ast di Python e l’SDK Python di OpenAI
Il repo di riferimento usa uv per la gestione delle dipendenze, ma un normale virtual environment funziona altrettanto bene.

Configurare il progetto

Crea un nuovo progetto e installa le dipendenze:
Se preferisci pip, crea invece un virtual environment:
Crea un file .env per lo sviluppo locale:
Distribuiremo i sorgenti sotto src/venice_security_reviewer/ per mantenerli importabili come package, con i prompt sotto prompts/ nella root del repo in modo che possano essere revisionati e diff-ati come qualsiasi altro artefatto sorgente:

Configurare il client Venice

Venice è compatibile con OpenAI, quindi possiamo usare l’SDK Python ufficiale di OpenAI puntando semplicemente il suo base_url a Venice. Centralizzando la costruzione del client in un unico file, il resto del codice non deve mai sapere con quale provider sta parlando: sostituire il backend toccherebbe solo questo modulo. Crea src/venice_security_reviewer/client.py:
Alcuni punti da notare:
  • Usiamo zai-org-glm-5 come default perché è un solido modello Venice general-purpose, ma puoi sovrascriverlo con la variabile d’ambiente VENICE_MODEL. Per codebase più grandi o più sfumate, passare a un modello più potente può rendere il Chainer notevolmente migliore in termini di qualità narrativa.
  • build_client restituisce il client e il model id, così i chiamanti non devono leggere variabili d’ambiente e i test possono iniettare una config fake senza monkeypatching.

Definire i data model

Tutto il senso dell’usare Pydantic qui, anziché passare dict raw, è ottenere un confine di validazione rigido tra l’LLM e il resto del programma. Se il modello restituisce JSON malformato o inventa un ID finding inesistente, il parsing fallisce rumorosamente e non propaghiamo mai l’allucinazione nel report. Crea src/venice_security_reviewer/models.py:
I vincoli qui fanno un lavoro concreto:
  • Finding.id e Chain.id sono vincolati a un regex tipo F-001, C-001. Se il modello diventa creativo sul formato, la validazione fallisce.
  • Chain.findings richiede almeno due voci: una “catena” di un solo finding è solo un finding.
  • Chain.severity è limitato a high o critical. Una combinazione di findings che non eleva l’impatto sopra la severità individuale più alta non è una catena degna di essere segnalata.
  • Evidence impone che end_line >= start_line così il modello non può restituire intervalli di riga senza senso.
Questa è la validazione della forma. Ci serve anche la validazione di cross-reference: una catena che fa riferimento a un ID finding mai prodotto dallo Scanner è priva di significato. Aggiungi questa funzione a models.py:
Questo è il guardrail deterministico che mantiene il Chainer onesto. Può fare riferimento solo a findings effettivamente prodotti dallo Scanner e può rivendicare solo file coinvolti nella catena da cui uno di quei findings proviene davvero. Restituire le catene scartate invece di filtrarle silenziosamente permette alla CLI di emettere un warning quando il modello prova a inventare qualcosa.

Costruire la mappa AST del repo

La mappa del repo è lo scheletro strutturale di una codebase Python: la superficie pubblica di ogni modulo, ogni arco di import e un indice inverso da “modulo M” ai “moduli che importano da M”. Si costruisce una volta per run di scansione con ast di Python, mai tramite esecuzione, quindi è sicura da eseguire su codice avversariale: il parser non importa né invoca nulla dall’albero scansionato. Consumeremo la mappa in due forme. Lo Scanner riceve una slice di vicinato per file in modo che i suoi prompt restino di dimensioni limitate. Il Chainer riceve una mappa completa condensata per poter costruire catene attraverso i file. Crea src/venice_security_reviewer/repo_map.py e inizia con i modelli Pydantic che descrivono la mappa:
Ora l’helper che attraversa l’albero e salta le directory che non vogliamo indicizzare:
Per ogni file vogliamo tre cose dall’AST: i simboli di top-level che definisce, gli archi di import e una lista __all__ esplicita se presente. Le firme delle funzioni e gli header delle classi vengono renderizzati come stringhe compatte che l’LLM può leggere direttamente:
Il _SIGNATURE_CHAR_CAP di 200 preserva firme reali tipiche (inclusi i type hint) prevenendo casi patologici come un union typed da 200 righe che farebbe esplodere il prompt. Poi, l’extractor che tira fuori i dati strutturali da un modulo parsato. Gestiamo ast.FunctionDef, ast.ClassDef, top-level ast.Assign e ast.AnnAssign per le costanti, e sia ast.Import sia ast.ImportFrom per gli archi di import. Gli import relativi vengono risolti nella loro forma assoluta dotted in modo che il Chainer possa abbinarli ai nomi dei moduli più avanti:
La logica di estrazione completa attraversa tree.body ed emette voci SymbolDef e ImportEdge per ogni nodo di top-level. La funzione _extract del repo di riferimento in repo_map.py copre l’implementazione completa. La forma che ne esce è una lista di oggetti ModuleEntry, uno per file. La parte interessante è cosa facciamo con queste voci. Le avvolgiamo in una RepoMap con due metodi destinati ai consumer:
neighborhood(path) è ciò che lo Scanner chiama per ogni file. Restituisce un oggetto ModuleNeighborhood contenente il modulo stesso, ogni altro modulo che importa da esso e ogni simbolo in-repo che importa da altrove (con le loro firme risolte). Questo dà allo Scanner abbastanza contesto per segnalare findings che sono evidenti solo in contesto cross-file, senza trascinare l’intera codebase nel prompt. condensed_dict() è ciò che riceve il Chainer. Snippet e firme vengono eliminati; restano solo path, nomi dei moduli, export pubblici e archi di import. È la rappresentazione più piccola che consenta comunque al Chainer di ragionare sul flusso di dati cross-module. Infine, l’entry point che costruisce il tutto:
I file che non possiamo leggere o che non si parsano vengono loggati e saltati. Restituiamo una mappa parziale invece di far fallire l’intero run; il caso peggiore è che una chiamata Scanner non veda alcun vicinato per un file, il che è comunque una scansione funzionante.

Scrivere lo Scanner agent

Lo Scanner attraversa un path target, raccoglie i file sorgente Python e chiede a Venice di identificare vulnerabilità atomiche un file alla volta. La scansione per-file mantiene il prompt piccolo e isola i fallimenti: un file errato non distrugge l’intero run. Manterremo il prompt in un file separato così può essere revisionato e diff-ato come qualsiasi altro artefatto sorgente. Crea prompts/scanner.md:
Il prompt completo nel repo di riferimento contiene anche una sezione “What to look for” che elenca le classi di vulnerabilità comuni (secret hardcoded, SQL injection, command injection, SSRF, deserializzazione insicura, ecc.) e una sezione “How to use the neighborhood” che spiega come il modello deve consumare il contesto cross-file. Alcune note sul design del prompt:
  • Diciamo al modello di emettere solo JSON, senza prose o code fence. L’SDK OpenAI supporta un parametro response_format={"type": "json_object"} che lo impone lato API, ma rinforzarlo nel prompt riduce i casi limite.
  • Vietiamo esplicitamente allo Scanner di produrre catene cross-file. Le catene sono lavoro del Chainer e chiedere allo Scanner di fare entrambe le cose offusca le responsabilità.
  • Richiediamo che lo snippet sia copiato verbatim. Significa che il report può citare gli esatti byte che il modello dichiara di aver visto, e un revisore può verificare a campione un finding confrontando lo snippet con il sorgente.
Ora il codice dell’agente. Crea src/venice_security_reviewer/scanner.py e inizia con il walker dei file e il prompt loader:
MAX_FILE_BYTES è un tetto di sicurezza. Oltre ~200 KB saltiamo invece di inviare un prompt enorme che probabilmente sarebbe sia costoso sia di bassa qualità. Il pezzo successivo è il builder del prompt. Il template usa {filename}, {source} e {neighborhood} come segnaposto; usiamo str.replace invece di .format() perché il template contiene esempi JSON con graffe letterali:
Ora il parser. Deserializziamo il JSON, validiamo ogni finding attraverso Pydantic e scartiamo i singoli findings malformati anziché far fallire l’intero file. Un finding errato non deve farci perdere quelli buoni:
Lo Scanner emette ID come F-001 per file, ma il Chainer deve poter fare riferimento ai findings nell’intero repo. Rinumeriamo gli ID con un contatore monotono in modo che siano globalmente unici:
La chiamata di scansione del singolo file combina tutto questo. Leggiamo il file, costruiamo il prompt, lo inviamo a Venice con response_format={"type": "json_object"} e una temperatura bassa, e parsiamo il risultato:
Due dettagli da evidenziare:
  • Patchamo il path del file in evidence per essere relativo a repo_root dopo il parsing, perché il modello rispedisce il nome del file che gli abbiamo dato ma vogliamo una sola forma canonica in tutto il report.
  • temperature=0.1 è intenzionalmente basso. Vogliamo che lo Scanner sia conservativo e coerente tra run; la creatività è lavoro del Chainer.
Infine, l’orchestratore che scansiona ogni file idoneo sotto la root:
La mappa del repo viene costruita una sola volta dal chiamante e riutilizzata per ogni file, così lo Scanner vede una struttura globale coerente anche quando singoli file non si parsano o vengono saltati.

Scrivere il Chainer agent

Il Chainer prende l’unione dei findings dello Scanner più la mappa condensata del repo e chiede a Venice se uno qualsiasi dei findings si combini in una catena di exploit reale. Due guardrail deterministici si trovano tra l’LLM e il report:
  1. Ogni catena deve fare riferimento solo a ID finding prodotti dallo Scanner.
  2. Ogni catena deve rivendicare solo file che almeno una evidence dei findings referenziati tocca.
Le catene che violano una delle due regole vengono scartate in fase di parsing. Questo impedisce al modello di allucinare catene “tanto per” e di rivendicare che una catena copra file per i quali non ha alcuna evidenza. Il prompt del Chainer risiede in prompts/chainer.md. Il suo nucleo è il seguente:
Il prompt completo nel repo di riferimento spiega anche come leggere la mappa del repo, come decidere cosa va in files_involved e, soprattutto, quando non concatenare. Dire al modello “è corretto e atteso che molte codebase abbiano findings che non si concatenano” è ciò che gli impedisce di inventare catene per sembrare produttivo. Ora il codice dell’agente. Crea src/venice_security_reviewer/chainer.py:
MAX_REPO_MAP_CHARS = 8000 è un tetto morbido per il blocco di mappa repo renderizzata in JSON nel prompt del Chainer. A circa 4 caratteri per token, sono ~2000 token, che rientrano comodamente in qualsiasi finestra di contesto dei modelli Venice anche con findings e budget narrativo in cima. Serializziamo i findings in un blocco JSON compatto. Nota che togliamo apposta lo snippet dall’evidence qui: il Chainer non ha bisogno dei byte raw per decidere se due findings si combinano, e includerli circa raddoppia il costo in token su codebase reali:
Per codebase più grandi la mappa condensata completa del repo può sforare il nostro budget di caratteri. Quando succede, sfrondiamo limitandoci ai moduli che contengono findings più i loro vicini diretti. Questo preserva struttura sufficiente per consentire al Chainer di ragionare su catene per cui abbiamo evidenza, e scarta il resto:
La strategia di pruning è volutamente semplice: tieni i moduli in cui vivono i nostri findings e tieni i loro vicini diretti nel grafo di import. Qualsiasi cosa più lontana non ha un ruolo plausibile in una catena per la quale attualmente abbiamo evidenza, quindi può essere scartata senza perdere potere di ragionamento. Annotiamo anche il payload con i marker _pruned, _kept e _total, così il prompt del Chainer può avvisare il modello quando la mappa è stata sfrondata. Il parsing della risposta ha la stessa forma di quello dello Scanner: deserializza, valida ogni catena tramite Pydantic, scarta le voci malformate:
Poi l’agente stesso:
Un paio di cose da segnalare:
  • Usciamo prima di chiamare il modello quando ci sono meno di due findings. Non puoi concatenare un singolo finding, e saltare la chiamata significa non bruciare token su un risultato garantito-vuoto.
  • temperature=0.2 è leggermente più alta di 0.1 dello Scanner. Il Chainer beneficia di un tocco di creatività in più per individuare combinazioni non ovvie, ma vogliamo comunque che resti ancorato ai findings e alla mappa che gli sono stati forniti.
  • Dopo il parsing, validate_chain_references esegue il controllo cross-reference deterministico che abbiamo scritto prima. Tutto ciò che sopravvive è sicuro da renderizzare; tutto ciò che non sopravvive viene loggato così l’operatore sa che il modello ha provato a inventarsi qualcosa.
Quel controllo di cross-reference è il pezzo più importante dell’intero progetto. È il confine tra “tool di sicurezza utile” e “report AI occasionalmente convintamente sbagliato”. Con esso a posto, anche se il modello allucina, la catena sbagliata non raggiunge mai il report.

Renderizzare il report Markdown

Tenere il rendering separato dalla logica degli agenti significa che gli stessi oggetti Finding e Chain potranno in seguito essere alimentati in altri formati (JSON, SARIF, HTML) senza toccare lo Scanner o il Chainer. Useremo Jinja2 con un piccolo file di template. Crea src/venice_security_reviewer/templates/report.md.j2:
Poi il renderer in src/venice_security_reviewer/report.py:
L’autoescape resta disattivato per il template Markdown (Markdown non è HTML), ma lo lasciamo abilitato per qualsiasi futuro template .html per estensione.

Cablare la CLI

La CLI è l’orchestratore: costruisce la mappa del repo, scansiona, concatena, renderizza. Useremo Typer per gestire il parsing degli argomenti e Rich per stampare una bella tabella di sintesi. Crea src/venice_security_reviewer/cli.py:
Aggiungi il script entry point al pyproject.toml:
Ecco l’intera pipeline cablata.

Testare i guardrail

Ci siamo appoggiati pesantemente a un’idea per tutto questo build: i guardrail deterministici sono ciò che separa un tool di sicurezza utile da uno convintamente sbagliato. Quell’affermazione vale la pena di farla solo se possiamo dimostrare che i guardrail tengono davvero, quindi i test più preziosi di questo progetto non chiamano affatto Venice. Bloccano il confine Pydantic e il plumbing di assemblaggio del prompt, il che significa che girano offline, in millisecondi, senza API key e senza costo in token. Aggiungi prima le dipendenze di sviluppo:
La prima cosa che vale la pena testare è il confine del modello stesso. Questi test verificano che findings e catene malformati vengano rifiutati al momento della costruzione, prima ancora di poter raggiungere un report. Crea tests/test_models.py:
Ognuno di questi rispecchia un vincolo che abbiamo messo prima sui modelli: un intervallo di righe invertito, un ID che non rispetta il pattern F-### e una “catena” di un singolo finding. Se uno di essi smettesse di sollevare, un’intera classe di allucinazione tornerebbe silenziosamente possibile. Il test più importante copre il validatore di cross-reference, perché è la funzione che effettivamente scarta le catene inventate:
F-999 non è mai stato prodotto dallo Scanner, quindi la catena che lo referenzia finisce in dropped e non raggiunge mai il report. Il test compagno nel repo di riferimento, test_validate_chain_references_drops_unknown_files, fa lo stesso per una catena che rivendica un file da cui nessuno dei suoi findings proviene. La seconda cosa che vale la pena testare è il plumbing che alimenta il Chainer. È facile fare il refactoring dell’assemblaggio del prompt e smettere silenziosamente di passare il contesto cross-file, momento in cui il Chainer continuerebbe a funzionare ma peggiorerebbe silenziosamente. Questo test costruisce una fixture a due moduli, renderizza il prompt e verifica che l’informazione cross-file sia effettivamente presente, di nuovo senza un round-trip su Venice. Crea tests/test_cross_file_chain.py:
Se questo test passa, al Chainer viene passato un prompt che contiene entrambi i findings, entrambi i path dei file e l’arco di import tra loro. Se il modello utilizza bene quell’informazione è una valutazione separata, fuori banda; questo test salvaguarda solo il plumbing che porta l’informazione nel prompt in primo luogo. Esegui l’intera suite, più il linter e il type checker, con:
Poiché nessuno di questi test tocca la rete, sono sicuri da eseguire a ogni commit e in CI senza bruciare token o aver bisogno di una key Venice. Il repo di riferimento include anche tests/test_scanner_parse.py, tests/test_chainer_parse.py e tests/test_repo_map.py, che coprono casi limite di parsing JSON (voci malformate che vengono scartate invece di far crashare il run) e il builder della mappa AST del repo.

Eseguire il progetto

Per provarlo su una codebase reale, punta la CLI a una directory di sorgenti Python:
Oppure installalo nel tuo virtualenv con pip install -e . ed esegui venice-security-reviewer scan path/to/your/code. L’output ha più o meno questo aspetto:
Il report Markdown mostra ogni catena in cima con la sua narrazione, poi ogni singolo finding sotto con severità, CWE, posizione del file, descrizione e lo snippet verbatim che il modello dichiara di aver letto. Il repo di riferimento include anche quattro target demo bundled, ognuno dei quali mette alla prova una forma diversa di ragionamento che il Chainer deve fare:
  • examples/vulnerable_app — una app Flask multi-file con tre findings “low”, due dei quali si combinano in una catena critica di privilege-escalation cross-file. Verifica se il Chainer è selettivo su cosa combina.
  • examples/url_preview — un URL-fetcher multi-file con un’allowlist difensiva che non si applica per iterazione. Verifica il ragionamento sul flusso di dati cross-file combinato con la topologia di deployment (gli IP link-local sono gateway per le credenziali cloud).
  • examples/csv_query — un filtro CSV single-file con un sandbox escape via eval attraverso __class__.__base__.__subclasses__(). Verifica ragionamento a livello di linguaggio piuttosto che flusso HTTP.
  • examples/webhook_handler — un verificatore HMAC single-file con una vulnerabilità parser-differential JSON. Verifica il ragionamento su più specifiche.
Provali con:
Se vedi mai la CLI loggare chainer referenced N unknown finding id(s) or file(s); chains dropped, è il validatore di cross-reference che becca il modello sul fatto mentre inventa una catena. Le catene scartate non finiscono mai nel report; ottieni solo un warning che puoi usare per aggiustare il prompt o campionare run aggiuntivi del Chainer.

Estendere questo esempio

La forma a due agenti generalizza bene. Alcune direzioni che vale la pena esplorare:
  • Altri linguaggi. Lo Scanner è agnostico al linguaggio a livello di prompt; il builder dell’AST è ciò che è specifico per Python. Sostituiscilo con tree-sitter e puoi costruire le stesse forme di vicinato/mappa condensata per TypeScript, Go o Rust.
  • Un terzo agente per le fix. Una volta che hai una catena, chiedere a un agente Patcher di abbozzare una diff unificata che disinneschi uno dei findings costituenti è un piccolo passo. Pydantic-valida la diff contro lo stesso set di file di evidence e ottieni la stessa protezione contro le allucinazioni gratis.
  • Formati di output. render_report è l’unico posto che conosce Markdown. Aggiungi un renderer SARIF e gli stessi findings possono finire in GitHub code scanning. Aggiungi un renderer JSON e puoi convogliare i risultati in un sistema downstream.
  • Caching per hash del file. Le chiamate per-file dello Scanner sono indipendenti e idempotenti. Cachare per (file_hash, prompt_hash, model) significa che riscansionare un repo in cui è cambiato un file rieseguirà lo Scanner solo su quel singolo file.
  • Sampling per il Chainer. Per run ad alto rischio, chiama il Chainer N volte a temperatura leggermente più alta e interseca i risultati. Le catene che il modello trova in modo consistente sono più probabilmente reali; quelle che trova una volta e mai più sono probabilmente rumore.
  • Modelli più potenti. zai-org-glm-5 è il default perché raggiunge un buon equilibrio tra costo e qualità per il ragionamento combinatorio, ma per codebase più difficili passare a un modello Venice più potente (impostato via VENICE_MODEL) può rendere le narrazioni del Chainer notevolmente più strette.

Per concludere

Grazie per aver letto! Speriamo che ti abbia aiutato a capire come strutturare un tool di sicurezza AI di cui ci si possa effettivamente fidare. Il pattern che abbiamo usato qui generalizza oltre la sicurezza: ogni volta che vuoi un LLM che ragioni attraverso file in un modo che deve ancorarsi a evidenza reale, la ricetta è la stessa. Costruisci una mappa strutturale deterministica, dai al modello una slice che entri nel contesto, valida i riferimenti del modello contro la struttura e scarta tutto ciò che non riesce a fondare. Usando Python con l’API di Venice AI, possiamo costruire agenti che combinano il ragionamento LLM con confini di validazione rigidi, e rilasciare qualcosa che dia una risposta utile invece di una che suoni solo sicura.