Pular para o conteúdo principal
A maioria das ferramentas de segurança estática encontra bugs isoladamente. Elas escaneiam um arquivo, listam os problemas e seguem em frente. O problema é que as vulnerabilidades mais danosas em bases de código modernas raramente são um único bug. Elas são uma cadeia: uma chave de assinatura hardcoded mais uma verificação de autorização ausente mais uma SQL injection que, sozinhas, parecem todas gerenciáveis. Juntas, são um caminho de tomada de conta. Esse é exatamente o tipo de raciocínio cruzado em que LLMs são bons, se você der a estrutura certa. Neste artigo, construiremos um revisor de segurança de código com dois agentes usando Python e a API Venice AI. Ao final, você terá um CLI que pode apontar para qualquer base de código Python para produzir um relatório Markdown com findings atômicos e exploit chains. Interessado na implementação completa do código? Confira o repositório no GitHub. Antes de continuarmos, você precisará de uma chave de API Venice. Exporte-a como variável de ambiente:

O que vamos construir

O revisor é um pequeno projeto Python com algumas partes claras: O fluxo é assim:
  1. Caminhar pelo diretório alvo procurando arquivos .py.
  2. Construir um repo map determinístico (imports, símbolos públicos, assinaturas).
  3. 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.
  4. Enviar a união dos findings mais o repo map condensado ao Chainer e coletar exploit chains.
  5. 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.
  6. Escrever um relatório Markdown.
Duas decisões de design valem destacar antes de começarmos a escrever código. A primeira é por que dois agentes em vez de um. Um scanner de agente único que tenta fazer tudo em um prompt tem que equilibrar ser minucioso sobre bugs por arquivo e ser inteligente sobre raciocínio combinatório. Dividir o trabalho significa que o Scanner pode ser implacável e barulhento, e o Chainer pode ser seletivo e silencioso. Adicionar uma chamada LLM extra dedicada a combinar findings desbloqueia uma classe inteira de bugs por muito pouco código extra. A segunda é por que um repo map. Bases de código reais vivem em muitos arquivos. Um bug que consiste em “o validator roda mas não aplica por iteração no fetcher, e a resposta do fetcher acaba no renderer” é invisível para um scanner por arquivo. Antes de qualquer chamada LLM, caminhamos pela árvore alvo com o 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 ast do Python e o SDK Python da OpenAI
O repo de referência usa 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:
Se preferir pip, crie um ambiente virtual:
Crie um arquivo .env para desenvolvimento local:
Vamos colocar o código-fonte em 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 seu base_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:
Algumas coisas valem nota:
  • Por padrão usamos zai-org-glm-5 porque é um modelo Venice forte de uso geral, mas você pode sobrescrever com a variável de ambiente VENICE_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_client retorna 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. Crie src/venice_security_reviewer/models.py:
As restrições estão fazendo trabalho real aqui:
  • Finding.id e Chain.id são restritos a uma regex como F-001, C-001. Se o modelo for criativo com o formato, a validação falha.
  • Chain.findings requer pelo menos duas entradas: uma “chain” de um único finding é só um finding.
  • Chain.severity é restrito a high ou critical. 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.
  • Evidence impõe que end_line >= start_line para que o modelo não retorne faixas de linha sem sentido.
Essa é a validação de forma. Também precisamos de validação de referência cruzada: uma chain que referencia um ID de finding que o Scanner nunca produziu é sem sentido. Adicione esta função ao models.py:
Este é o guardrail determinístico que mantém o Chainer honesto. Ele só pode referenciar findings que o Scanner de fato produziu, e só pode reivindicar arquivos envolvidos na chain dos quais um dos findings de fato veio. Retornar as chains descartadas em vez de filtrá-las silenciosamente permite ao CLI sinalizar um aviso quando o modelo tenta inventar algo.

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 o ast 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:
Agora o helper que caminha pela árvore e pula diretórios que não devemos indexar:
Para cada arquivo queremos três coisas do AST: os símbolos top-level que ele define, as arestas de import e uma lista __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:
O _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:
A lógica completa de extração caminha por 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:
Arquivos que não conseguimos ler ou que falham no parse são registrados e pulados. Retornamos um mapa parcial em vez de falhar a execução inteira; o pior caso é que uma chamada do Scanner veja nenhuma vizinhança para um arquivo, o que ainda é um scan funcional.

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. Crie prompts/scanner.md:
O prompt completo no repo de referência também contém uma seção “What to look for” listando classes comuns de vulnerabilidade (segredos hardcoded, SQL injection, command injection, SSRF, deserialização insegura, etc.) e uma seção “How to use the neighborhood” explicando como o modelo deve consumir o contexto cross-file. Algumas notas de design de prompt:
  • 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.
Agora o código do agente. Crie 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:
Agora o parser. Desserializamos o JSON, validamos cada finding através do Pydantic e descartamos findings individuais malformados em vez de falhar o arquivo inteiro. Um finding ruim não deve nos custar os bons:
O Scanner emite IDs como 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:
A chamada de scan de arquivo único combina tudo isso. Lemos o arquivo, construímos o prompt, enviamos à Venice com response_format={"type": "json_object"} e baixa temperatura, e parseamos o resultado:
Dois detalhes vale destacar:
  • Ajustamos o caminho do arquivo de evidência para ser relativo a repo_root apó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.
Finalmente, o orquestrador que escaneia cada arquivo elegível sob o root:
O repo map é construído uma vez pelo chamador e reutilizado para cada arquivo, então o Scanner vê uma estrutura global consistente mesmo quando arquivos individuais falham no parse ou são pulados.

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:
  1. Cada chain deve referenciar apenas IDs de finding que o Scanner produziu.
  2. Cada chain deve reivindicar apenas arquivos que a evidência de pelo menos um finding referenciado toca.
Chains que violam qualquer regra são descartadas no momento do parse. Isso impede o modelo de alucinar chains “para garantir” e de reivindicar que uma chain se estende por arquivos para os quais não tem evidência. O prompt do Chainer vive em prompts/chainer.md. O núcleo dele parece com isto:
O prompt completo no repo de referência também explica como ler o repo map, como decidir o que vai em 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:
Para bases de código maiores, o repo map completo condensado pode estourar nosso orçamento de caracteres. Quando isso acontece, podamos para módulos que carregam findings mais seus vizinhos diretos. Isso preserva estrutura suficiente para o Chainer raciocinar sobre chains para as quais temos evidência, e descarta o resto:
A estratégia de poda é intencionalmente simples: mantenha os módulos em que nossos findings vivem e mantenha seus vizinhos diretos do grafo de imports. Qualquer coisa mais distante não tem papel plausível em uma chain para a qual temos evidência, então pode ser descartada sem perder poder de raciocínio. Também anotamos o payload com marcadores _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:
Depois o próprio agente:
Algumas coisas vale apontar:
  • 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 que 0.1 do 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_references executa 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.
Essa verificação de referência cruzada é a peça mais importante do projeto inteiro. É o limite entre “ferramenta de segurança útil” e “relatório de IA ocasionalmente confiantemente errado”. Com isso no lugar, mesmo se o modelo alucina, a chain errada nunca alcança o relatório.

Renderizando o relatório Markdown

Manter a renderização separada da lógica do agente significa que os mesmos objetos Finding 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:
Depois o renderizador em src/venice_security_reviewer/report.py:
Autoescape fica desligado para o template Markdown (Markdown não é HTML), mas o deixamos habilitado para quaisquer futuros templates .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. Crie src/venice_security_reviewer/cli.py:
Adicione o ponto de entrada do script ao pyproject.toml:
Esse é o pipeline inteiro conectado.

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:
A primeira coisa que vale testar é o próprio limite do modelo. Esses testes afirmam que findings e chains malformados são rejeitados em tempo de construção, antes que possam alcançar um relatório. Crie tests/test_models.py:
Cada um deles espelha uma restrição que colocamos nos modelos anteriormente: uma faixa de linha invertida, um ID que não corresponde ao padrão 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:
Se este teste passa, o Chainer está recebendo um prompt que contém ambos os findings, ambos os caminhos de arquivo e a aresta de import entre eles. Se o modelo usa essa informação bem é uma avaliação separada, fora de banda; este teste só guarda a tubulação que coloca a informação no prompt em primeiro lugar. Execute a suíte inteira, mais o linter e o type checker, com:
Como nenhum desses testes toca a rede, é seguro rodá-los em cada commit e no CI sem queimar tokens ou precisar de chave Venice. O repo de referência também inclui 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:
Ou instale-o no seu virtualenv com pip install -e . e execute venice-security-reviewer scan path/to/your/code. A saída parece aproximadamente com isto:
O relatório Markdown mostra cada chain no topo com sua narrativa, depois cada finding individual abaixo com severidade, CWE, localização do arquivo, descrição e o snippet palavra por palavra que o modelo alega ter lido. O repo de referência também vem com quatro alvos demo empacotados que cada um exercita uma forma diferente de raciocínio que o Chainer tem que fazer:
  • 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 de eval que 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.
Experimente-os com:
Se você ver o CLI registrar 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-sitter e 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 via VENICE_MODEL) pode tornar as narrativas do Chainer perceptivelmente mais apertadas.

Encerrando

Obrigado por ler! Espero que isso tenha ajudado você a entender como estruturar uma ferramenta de segurança de IA que é de fato confiável. O padrão que usamos aqui generaliza para além da segurança também: sempre que você quiser que um LLM raciocine entre arquivos de uma forma que precisa se ancorar em evidência real, a receita é a mesma. Construa um mapa estrutural determinístico, entregue ao modelo uma fatia dele que cabe no contexto, valide as referências do modelo de volta contra a estrutura e descarte qualquer coisa que ele não consiga ancorar. Usando Python com a API Venice AI, podemos construir agentes que combinam raciocínio LLM com limites duros de validação e entregar algo que dá uma resposta útil em vez de uma que apenas soa confiante.