Cosa costruiremo
Il revisore è un piccolo progetto Python con poche parti ben definite:| Parte | Cosa fa |
|---|---|
| Modelli Pydantic | Definiscono Evidence, Finding e Chain e ci forniscono un confine di validazione rigido tra l’LLM e il resto del programma |
| Client Venice | Avvolge l’SDK Python di OpenAI puntato all’endpoint compatibile con OpenAI di Venice |
| Mappa AST del repo | Attraversa l’albero del target con il modulo ast di Python e costruisce una mappa deterministica dei simboli pubblici e degli archi di import di ogni modulo |
| Scanner agent | Legge un file Python alla volta più una “vicinato” per-file della mappa del repo ed emette findings atomici di vulnerabilità con evidenza file:riga |
| Chainer agent | Legge l’unione dei findings più una mappa completa condensata del repo ed emette catene di exploit che combinano due o più findings |
| Validatore di riferimenti | Scarta qualsiasi catena che faccia riferimento a un ID finding che lo Scanner non ha prodotto, o che nomini un file da cui nessuno dei findings referenziati proviene |
| Report Markdown | Renderizza findings e catene in un report leggibile |
| CLI | Mette tutto insieme con Typer |
- Attraversa la directory target alla ricerca di file
.py. - Costruisce una mappa deterministica del repo (import, simboli pubblici, firme).
- Per ogni file, invia allo Scanner il suo sorgente più una porzione di vicinato della mappa e raccoglie i findings atomici.
- Invia l’unione dei findings più la mappa condensata del repo al Chainer e raccoglie le catene di exploit.
- 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.
- Scrive un report Markdown.
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
astdi Python e l’SDK Python di OpenAI
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:pip, crea invece un virtual environment:
.env per lo sviluppo locale:
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 suobase_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:
- Usiamo
zai-org-glm-5come default perché è un solido modello Venice general-purpose, ma puoi sovrascriverlo con la variabile d’ambienteVENICE_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_clientrestituisce 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. Creasrc/venice_security_reviewer/models.py:
Finding.ideChain.idsono vincolati a un regex tipoF-001,C-001. Se il modello diventa creativo sul formato, la validazione fallisce.Chain.findingsrichiede almeno due voci: una “catena” di un solo finding è solo un finding.Chain.severityè limitato ahighocritical. Una combinazione di findings che non eleva l’impatto sopra la severità individuale più alta non è una catena degna di essere segnalata.Evidenceimpone cheend_line >= start_linecosì il modello non può restituire intervalli di riga senza senso.
models.py:
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 conast 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:
__all__ esplicita se presente. Le firme delle funzioni e gli header delle classi vengono renderizzati come stringhe compatte che l’LLM può leggere direttamente:
_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:
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:
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. Creaprompts/scanner.md:
- 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.
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:
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:
response_format={"type": "json_object"} e una temperatura bassa, e parsiamo il risultato:
- Patchamo il path del file in evidence per essere relativo a
repo_rootdopo 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.
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:- Ogni catena deve fare riferimento solo a ID finding prodotti dallo Scanner.
- Ogni catena deve rivendicare solo file che almeno una evidence dei findings referenziati tocca.
prompts/chainer.md. Il suo nucleo è il seguente:
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:
_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:
- 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 di0.1dello 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_referencesesegue 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.
Renderizzare il report Markdown
Tenere il rendering separato dalla logica degli agenti significa che gli stessi oggettiFinding 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:
src/venice_security_reviewer/report.py:
.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. Creasrc/venice_security_reviewer/cli.py:
pyproject.toml:
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:tests/test_models.py:
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:
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:pip install -e . ed esegui venice-security-reviewer scan path/to/your/code.
L’output ha più o meno questo aspetto:
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 viaevalattraverso__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.
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-sittere 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 viaVENICE_MODEL) può rendere le narrazioni del Chainer notevolmente più strette.