O que vamos construir
O revisor é um pequeno projeto Python com algumas partes claras:| Parte | O que faz |
|---|---|
| Modelos Pydantic | Definem Evidence, Finding e Chain, e nos dão um limite duro de validação entre o LLM e o resto do programa |
| Cliente Venice | Envolve o SDK Python da OpenAI apontado para o endpoint compatível com OpenAI da Venice |
| Repo map AST | Caminha pela árvore alvo com o módulo ast do Python e constrói um mapa determinístico de cada símbolo público de módulo e arestas de import |
| Agente Scanner | Lê um arquivo Python por vez mais uma fatia de vizinhança do repo map por arquivo, e emite findings atômicos de vulnerabilidade com evidência file:line |
| Agente Chainer | Lê a união de findings mais um repo map completo condensado, e emite exploit chains que combinam dois ou mais findings |
| Validador de referência | Descarta qualquer chain que faça referência a um ID de finding que o Scanner não produziu, ou nomeie um arquivo do qual nenhum dos findings referenciados realmente veio |
| Relatório Markdown | Renderiza findings e chains em um relatório legível |
| CLI | Conecta tudo com Typer |
- Caminhar pelo diretório alvo procurando arquivos
.py. - Construir um repo map determinístico (imports, símbolos públicos, assinaturas).
- Para cada arquivo, enviar ao Scanner seu código-fonte mais uma fatia de vizinhança do mapa por arquivo e coletar findings atômicos.
- Enviar a união dos findings mais o repo map condensado ao Chainer e coletar exploit chains.
- Descartar qualquer chain que referencie um ID de finding que o Scanner não produziu, ou que nomeie um arquivo do qual nenhum dos findings referenciados realmente veio.
- Escrever um relatório Markdown.
ast do Python e construímos um mapa estrutural. O Scanner vê uma vizinhança por arquivo (quem importa deste arquivo, o que este arquivo importa, assinaturas desses símbolos externos). O Chainer vê um mapa completo condensado (cada módulo, cada símbolo público, cada aresta de import, sem código-fonte). Essa é a menor quantidade de engenharia de contexto que encontramos que permite ao Chainer construir chains cujo fluxo de dados atravessa limites de módulo, sem pagar o custo de tokens de empilhar a base de código inteira em cada prompt.
Pré-requisitos
- Python 3.12+
- Uma chave de API Venice de venice.ai
- Familiaridade básica com Pydantic, o módulo
astdo Python e o SDK Python da OpenAI
uv para gerenciamento de dependências, mas um ambiente virtual normal funciona igualmente bem.
Configurando o projeto
Crie um novo projeto e instale as dependências:pip, crie um ambiente virtual:
.env para desenvolvimento local:
src/venice_security_reviewer/ para mantê-lo importável como pacote, com os prompts em prompts/ na raiz do repo para que possam ser revisados e diferenciados como qualquer outro artefato de código:
Configurando o cliente Venice
A Venice é compatível com OpenAI, então podemos usar o SDK Python oficial da OpenAI e apenas apontar seubase_url para a Venice. Centralizar a construção do cliente em um arquivo significa que o resto do código nunca precisa saber com qual provedor está falando: trocar backends afetaria apenas este módulo.
Crie src/venice_security_reviewer/client.py:
- Por padrão usamos
zai-org-glm-5porque é um modelo Venice forte de uso geral, mas você pode sobrescrever com a variável de ambienteVENICE_MODEL. Para bases de código maiores ou mais nuançadas, trocar para um modelo mais forte pode tornar o Chainer notavelmente melhor em qualidade narrativa. build_clientretorna o cliente e o id do modelo, então os chamadores não precisam ler variáveis de ambiente eles mesmos e testes podem injetar uma configuração falsa sem monkeypatching.
Definindo os modelos de dados
O ponto de usar Pydantic aqui, em vez de passar dicts crus, é que temos um limite duro de validação entre o LLM e o resto do programa. Se o modelo retornar JSON malformado ou inventar um ID de finding que não existe, o parsing falha alto e nunca propagamos a alucinação para o relatório. Criesrc/venice_security_reviewer/models.py:
Finding.ideChain.idsão restritos a uma regex comoF-001,C-001. Se o modelo for criativo com o formato, a validação falha.Chain.findingsrequer pelo menos duas entradas: uma “chain” de um único finding é só um finding.Chain.severityé restrito ahighoucritical. Uma combinação de findings que não eleva o impacto acima da maior severidade individual não é uma chain que vale a pena reportar.Evidenceimpõe queend_line >= start_linepara que o modelo não retorne faixas de linha sem sentido.
models.py:
Construindo o repo map AST
O repo map é o esqueleto estrutural de uma base de código Python: a superfície pública de cada módulo, cada aresta de import e um índice reverso de “módulo M” para “módulos que importam de M”. Ele é construído uma vez por execução de scan com oast do Python, nunca via execução, então é seguro rodar em código adversarial: o parser não importa nem invoca nada da árvore escaneada.
Vamos consumir o mapa em duas formas. O Scanner recebe uma fatia de vizinhança por arquivo para que seus prompts permaneçam de tamanho limitado. O Chainer recebe um mapa completo condensado para que possa construir chains entre arquivos.
Crie src/venice_security_reviewer/repo_map.py e comece com os modelos Pydantic que descrevem o mapa:
__all__ explícita se uma estiver presente. Assinaturas de função e cabeçalhos de classe são renderizados como strings compactas que o LLM pode ler diretamente:
_SIGNATURE_CHAR_CAP de 200 preserva assinaturas reais típicas (incluindo type hints) e evita casos patológicos como uma união tipada de 200 linhas estourando o prompt.
A seguir, o extractor que puxa os dados estruturais de um módulo parseado. Lidamos com ast.FunctionDef, ast.ClassDef, ast.Assign top-level e ast.AnnAssign para constantes, e tanto ast.Import quanto ast.ImportFrom para as arestas de import. Imports relativos são resolvidos para sua forma pontuada absoluta para que o Chainer possa correspondê-los a nomes de módulo depois:
tree.body e emite entradas SymbolDef e ImportEdge para cada nó top-level. A função _extract no repo_map.py do repo de referência cobre a implementação completa. A forma que sai é uma lista de objetos ModuleEntry, um por arquivo.
A parte interessante é o que fazemos com essas entradas. Envolva-as em um RepoMap com dois métodos voltados ao consumidor:
neighborhood(path) é o que o Scanner chama para cada arquivo. Retorna um objeto ModuleNeighborhood contendo o próprio módulo, cada outro módulo que importa dele e cada símbolo do repo que ele importa de outro lugar (com suas assinaturas resolvidas). Isso dá ao Scanner contexto suficiente para sinalizar findings que só são óbvios em contexto cross-file, sem arrastar toda a base de código para o prompt.
condensed_dict() é o que o Chainer recebe. Snippets e assinaturas são descartados; só permanecem caminhos, nomes de módulo, exports públicos e arestas de import. Essa é a menor representação que ainda permite ao Chainer raciocinar sobre fluxo de dados cross-module.
Finalmente, o ponto de entrada que constrói tudo:
Escrevendo o agente Scanner
O Scanner caminha por um caminho alvo, pega arquivos de código-fonte Python e pede à Venice para identificar vulnerabilidades atômicas, um arquivo por vez. Scanning por arquivo mantém o prompt pequeno e torna as falhas isoladas: um arquivo ruim não mata a execução inteira. Manteremos o prompt em si em um arquivo separado para que possa ser revisado e diferenciado como qualquer outro artefato de código. Crieprompts/scanner.md:
- Dizemos ao modelo para emitir apenas JSON, sem prosa ou cercas. O SDK da OpenAI suporta um parâmetro
response_format={"type": "json_object"}que reforça isso no lado da API, mas reforçá-lo no prompt reduz casos extremos. - Explicitamente proibimos o Scanner de produzir chains cross-file. Chains são trabalho do Chainer, e pedir ao Scanner para fazer ambos turva a responsabilidade.
- Exigimos que o snippet seja copiado palavra por palavra. Isso significa que o relatório pode citar os bytes exatos que o modelo alega ter visto, e um revisor pode verificar pontualmente um finding comparando o snippet ao código-fonte.
src/venice_security_reviewer/scanner.py e comece com o caminhador de arquivos e o carregador de prompt:
MAX_FILE_BYTES é um teto de segurança. Acima de ~200 KB pulamos em vez de enviar um prompt enorme que provavelmente será caro e de baixa qualidade.
A próxima peça é o construtor de prompt. O template usa {filename}, {source} e {neighborhood} como placeholders; usamos str.replace em vez de .format() porque o template contém exemplos de JSON com chaves literais:
F-001 por arquivo, mas o Chainer precisa referenciar findings em toda a repo. Reemitimos os IDs contra um contador monotônico para que sejam globalmente únicos:
response_format={"type": "json_object"} e baixa temperatura, e parseamos o resultado:
- Ajustamos o caminho do arquivo de evidência para ser relativo a
repo_rootapós o parsing, já que o modelo ecoa de volta qualquer nome de arquivo que demos a ele, mas queremos uma única forma canônica em todo o relatório. temperature=0.1é intencionalmente baixa. Queremos que o Scanner seja conservador e consistente entre execuções; criatividade é trabalho do Chainer.
Escrevendo o agente Chainer
O Chainer toma a união de findings do Scanner mais o repo map condensado e pergunta à Venice se algum dos findings se combina em uma exploit chain real. Dois guardrails determinísticos ficam entre o LLM e o relatório:- Cada chain deve referenciar apenas IDs de finding que o Scanner produziu.
- Cada chain deve reivindicar apenas arquivos que a evidência de pelo menos um finding referenciado toca.
prompts/chainer.md. O núcleo dele parece com isto:
files_involved e, crucialmente, quando não encadear. Dizer ao modelo “it is correct and expected for many codebases to have findings that do not chain” é o que o impede de inventar chains para parecer produtivo.
Agora o código do agente. Crie src/venice_security_reviewer/chainer.py:
MAX_REPO_MAP_CHARS = 8000 é um teto suave para o bloco do repo map renderizado em JSON no prompt do Chainer. A aproximadamente 4 caracteres por token, isso são ~2000 tokens, o que cabe confortavelmente em qualquer janela de contexto de modelo Venice mesmo com findings e o orçamento da narrativa por cima.
Serializamos findings em um bloco JSON compacto. Note que removemos o snippet da evidência aqui de propósito: o Chainer não precisa dos bytes crus para decidir se dois findings combinam, e incluí-los aproximadamente dobra o custo de tokens em bases de código reais:
_pruned, _kept e _total, para que o prompt do Chainer possa avisar o modelo quando o mapa foi aparado.
Parsear a resposta tem a mesma forma do Scanner: desserializar, validar cada chain via Pydantic, descartar entradas malformadas:
- Saímos antes de chamar o modelo quando há menos de dois findings. Você não pode encadear um único finding, e pular a chamada significa que não queimamos tokens em um resultado garantido vazio.
temperature=0.2é um pouco maior que0.1do Scanner. O Chainer se beneficia de um toque mais de criatividade para detectar combinações não óbvias, mas ainda queremos que ele esteja ancorado nos findings e mapa que lhe foram dados.- Após parsear,
validate_chain_referencesexecuta a verificação determinística de referência cruzada que escrevemos antes. Qualquer coisa que sobrevive é segura de renderizar; qualquer coisa que não sobrevive é registrada para que o operador saiba que o modelo tentou inventar algo.
Renderizando o relatório Markdown
Manter a renderização separada da lógica do agente significa que os mesmos objetosFinding e Chain podem ser alimentados posteriormente em outros formatos (JSON, SARIF, HTML) sem tocar o Scanner ou Chainer.
Usaremos Jinja2 com um pequeno arquivo de template. Crie src/venice_security_reviewer/templates/report.md.j2:
src/venice_security_reviewer/report.py:
.html por extensão.
Conectando o CLI
O CLI é o orquestrador: constrói o repo map, escaneia, encadeia, renderiza. Usaremos Typer para tratar o parsing de argumentos e Rich para imprimir uma tabela de resumo bonita. Criesrc/venice_security_reviewer/cli.py:
pyproject.toml:
Testando os guardrails
Nos apoiamos firmemente em uma ideia ao longo desta construção: os guardrails determinísticos são o que separam uma ferramenta de segurança útil de uma confiantemente errada. Essa afirmação só vale ser feita se conseguirmos provar que os guardrails realmente seguram, então os testes mais valiosos neste projeto não chamam a Venice de jeito nenhum. Eles travam o limite Pydantic e a tubulação de montagem de prompt, o que significa que rodam offline, em milissegundos, sem chave de API e sem custo de tokens. Adicione as dependências dev primeiro:tests/test_models.py:
F-### e uma “chain” de um único finding. Se algum deles parar de levantar exceção, uma classe inteira de alucinação silenciosamente se torna possível novamente.
O teste mais importante cobre o validador de referência cruzada, já que essa é a função que de fato descarta chains inventadas:
F-999 nunca foi produzido pelo Scanner, então a chain que o referencia cai em dropped e nunca chega ao relatório. O teste companheiro no repo de referência, test_validate_chain_references_drops_unknown_files, faz o mesmo para uma chain que reivindica um arquivo do qual nenhum de seus findings veio.
A segunda coisa que vale testar é a tubulação que alimenta o Chainer. É fácil refatorar a montagem do prompt e silenciosamente parar de passar contexto cross-file, momento em que o Chainer continuaria funcionando, mas silenciosamente ficaria pior. Este teste constrói um fixture de dois módulos, renderiza o prompt e afirma que a informação cross-file de fato está presente, novamente sem um round-trip à Venice. Crie tests/test_cross_file_chain.py:
tests/test_scanner_parse.py, tests/test_chainer_parse.py e tests/test_repo_map.py, que cobrem casos extremos de parsing JSON (entradas malformadas sendo descartadas em vez de derrubar a execução) e o construtor do repo map AST.
Executando o projeto
Para experimentá-lo em uma base de código real, aponte o CLI para um diretório de código-fonte Python:pip install -e . e execute venice-security-reviewer scan path/to/your/code.
A saída parece aproximadamente com isto:
examples/vulnerable_app— um app Flask multi-arquivo com três findings “low”, dois dos quais combinam em uma chain crítica de escalada de privilégio entre arquivos. Testa se o Chainer é seletivo sobre o que combina.examples/url_preview— um fetcher de URL multi-arquivo com uma allowlist defensiva que não se aplica por iteração. Testa raciocínio de fluxo de dados cross-file combinado com topologia de deployment (IPs link-local são gateways de credenciais cloud).examples/csv_query— um filtro CSV single-file com uma sandbox deevalque escapa via__class__.__base__.__subclasses__(). Testa raciocínio em nível de linguagem em vez de fluxo HTTP.examples/webhook_handler— um verificador HMAC single-file com uma vulnerabilidade diferencial de parser JSON. Testa raciocínio entre múltiplas especificações.
chainer referenced N unknown finding id(s) or file(s); chains dropped, esse é o validador de referência cruzada pegando o modelo no ato de inventar uma chain. As chains descartadas nunca chegam ao relatório; você só recebe um aviso que pode usar para ajustar o prompt ou amostrar execuções adicionais do Chainer.
Estendendo este exemplo
A forma de dois agentes generaliza bem. Algumas direções que vale explorar:- Mais linguagens. O Scanner é agnóstico de linguagem no nível do prompt; o construtor AST é o que é específico de Python. Troque por
tree-sittere você pode construir as mesmas formas vizinhança/mapa condensado para TypeScript, Go ou Rust. - Um terceiro agente para correções. Quando você tem uma chain, pedir a um agente Patcher para esboçar um diff unificado que neutraliza um dos findings constituintes é um passo pequeno. Validar o diff via Pydantic contra o mesmo conjunto de arquivos de evidência e você obtém a mesma proteção contra alucinação de graça.
- Formatos de saída.
render_reporté o único lugar que sabe de Markdown. Adicione um renderizador SARIF e os mesmos findings podem cair no code scanning do GitHub. Adicione um renderizador JSON e você pode encaminhar resultados para um sistema downstream. - Cache por hash de arquivo. As chamadas por arquivo do Scanner são independentes e idempotentes. Caching por
(file_hash, prompt_hash, model)significa que rescanear uma repo onde um arquivo mudou só re-executa o Scanner naquele arquivo. - Sampling para o Chainer. Para execuções de alto risco, chame o Chainer N vezes com temperatura ligeiramente mais alta e cruze os resultados. Chains que o modelo encontra consistentemente são mais prováveis de serem reais; chains que ele encontra uma vez e nunca mais provavelmente são ruído.
- Modelos mais fortes.
zai-org-glm-5é o padrão porque atinge um bom equilíbrio entre custo e qualidade para raciocínio combinatório, mas para bases de código mais difíceis trocar por um modelo Venice mais forte (definido viaVENICE_MODEL) pode tornar as narrativas do Chainer perceptivelmente mais apertadas.