← 목록

O prompt de AppSec que eu criei achou 1 gap em 174 rotas.

devto 2026-09-02 원문 보기 ↗


Pessoal, eu quase abri uma issue com uma lista.

O modelo tinha devolvido XSS, CORS, CSP, um SVG suspeito. Eu sentia que tinha trabalhado. Aí parei e perguntei a pergunta chata: disto aqui, o que eu mandaria pra um mantenedor sem vergonha?

A conta virou quando eu parei de pedir "audita meu código" e passei a escrever o contrato antes de abrir o repo. Padrão externo, invariante falsificável, arquivo:linha ou não é finding, denominador em toda afirmação, sem PoC, sem tráfego em ambiente alheio. Isso é o prompt que eu construí. Não deixa o modelo mais inteligente. Controla o comportamento.

Em dois dias, rodei em seis repositórios open source. O caso que melhor explica: um SaaS multi-tenant. Semgrep com p/owasp-top-ten no mesmo commit, 25 segundos: 23 findings, zero de autorização. O meu procedimento, numa invariante só (toda rota autenticada que lê um recurso de um time por ID verifica se aquele recurso pertence a um time do usuário): 173 de 174 rotas seguravam. Uma não.

Nenhum dos dois errou. Cada um viu a sua metade.

O que você leva daqui: o ponto cego do scanner (com o comando), o esqueleto que faz o modelo virar auditor, dois snippets de código que viraram issue, e o contrato de três portas.

Tabela de Conteúdo

1. Seis repos em dois dias

Tudo estático. Li fonte. Não mandei request contra ambiente de ninguém. Não montei PoC.

Alvo Invariante / escopo Resultado Porta
Formbricks headers, CORS, sanitização de HTML CORS contraditório; XSS não confirmado issue #9081
axios Authorization sobrevive a redirect cross-origin? 3/3 caminhos cobertos, 0 gap nada
gin a lib entrega o controle de CORS/origin? entrega; compliance é da app nada
Guzzle strip de credencial em cross-origin invariante mantida nada
SaaS multi-tenant (não nomeado) isolamento entre times, 174 rotas 1 gap confirmado canal privado
Dub webhook de entrada verifica o remetente? 7/7 verificam; 2 MAC em !== issue #4415

Se o seu critério de sucesso for volume, metade dessa tabela parece desperdício. Pra mim as linhas com "nada" são as que sustentam as outras.

A formbricks#9081 ganhou as labels security e agent-ready. No dia seguinte um contribuidor comentou que o fix dos dois pontos já estava em branch. A dubinc/dub#4415 entrou no Linear deles como ENG-1732. As duas continuam abertas. Isso importa: impacto verificável não é o mesmo que "issue aceita".

2. Scanner casa presença. Hunt sente ausência

Scanner estático (Semgrep, CodeQL, Bandit) casa padrão que existe. Chamada perigosa, query concatenada, header ausente. É rápido, determinístico, barato. Roda a cada push. Eu não troco isso por LLM.

O que ele estruturalmente não vê é um controle ausente. "Esta rota deveria checar o dono do recurso antes de retornar, e não checa" não é regex. É propriedade semântica: o que é um tenant, que recurso pertence a quem, qual rota é pública de propósito.

No SaaS do começo, o baseline foi este:

semgrep --config p/default --config p/typescript --config p/react \
        --config p/nextjs --config p/owasp-top-ten \
        --config p/javascript --config p/secrets

Vinte e três hits. Dezessete eram unsafe-formatstring em template. Zero de autorização. Isso não é falha do Semgrep. Autorização quebrada (BOLA, OWASP API #1) não tem regra sintática estável: o padrão certo e o errado usam o mesmo findUnique.

O hunt que eu uso começa invertido. Você escreve a invariante antes de abrir o arquivo, numa frase falsificável:

Toda rota autenticada /api/.../[id] que lê ou muta um recurso scoped a time primeiro verifica membership e amarra o teamId no where da query.

Aí você enumera os caminhos (não "olha o repo"). Classifica cada um: enforced-explicitly, inherited, gap, out-of-scope. No fim o relatório tem denominador. "Achei um IDOR" sem "1 de 174" não é hunt. É vibe.

Scanner Hunt dirigido
Enxerga padrão presente controle ausente
Bom em injeção, XSS, secret, header, crypto fraca IDOR cross-tenant, o 1 caminho em 100 que pula o guard
Custo centavos, a cada PR dólares, periódico
Falso positivo ruído sintático alucinação de finding, se você soltar o denominador
Ponto cego lógica de negócio cobertura exaustiva do trivial

Duas cadências, duas perguntas. Verde no CI nunca respondeu "a invariante ainda vale".

3. Direção e disciplina não deixam o modelo mais inteligente

Esse é o ponto que eu demorei pra aceitar.

O procedimento não ensina ASVS ao modelo. Ele já sabe. O que muda é o que ele tem permissão de afirmar.

Direção amarra três coisas antes da leitura:

  1. Padrão externo. OWASP ASVS 5.0, com o ID do requisito. "Boas práticas" não conta. Se o modelo parafraseia o requisito como se fosse o texto oficial, a sessão já quebrou.
  2. Invariante escrita em uma frase, no passado, falsificável. "Webhooks deveriam ser autenticados" é opinião. "Todo handler de webhook de entrada verifica autenticidade do remetente antes de mutar estado" é hunt.
  3. Formato único de relatório. Finding, observação e hipótese não misturam.

Disciplina é o que corta o teatro:

Esse último parece burocracia até você ver o modelo achar um gap e, empolgado, reescrever o where do Prisma no mesmo fôlego, sem teste, misturando finding e patch no mesmo diff. Finding de um lado. Fix de outro.

Eu construí esse contrato ao longo de estudo longo e rodadas reais. Hoje ele é um pack privado: prompt, skill do harness, metodologia, templates de invariante, cases fixados em commit. Não vou colar o texto aqui. Trinta mil caracteres de contrato não é o que você usa amanhã. O esqueleto acima é.

Um limite honesto: isso só rende em modelo de raciocínio alto. Num modelo fraco, prompt bom organiza melhor o erro.

4. Resultado negativo é resultado

A falha mais comum de IA em segurança é fabricar achado para não devolver silêncio.

Axios: o threat model já estava no repo

A pergunta não foi "axios é inseguro?". Foi a invariante T-R2 que os próprios mantenedores documentam no THREATMODEL.md: credential leakage on cross-origin redirect.

No adaptador Node (http.js), follow-redirects stripa Authorization em todo redirect. O axios só restaura Basic se a origem for a mesma. Origem malformada: fail-safe, deixa stripado. Fetch e XHR não implementam strip próprio: redirect: 'follow' e o browser decide. Isso é bypass-by-design da plataforma, não gap da lib.

Denominador: 3 de 3 caminhos cobertos. Gaps: 0. Residual consciente: header secreto customizado só sai se o caller preencher sensitiveHeaders. Abrir "axios vaza Authorization" seria falso. Nenhuma issue.

Gin: a lib certa, a app que erra

Gin entrega o controle. Quem esquece de usar é a aplicação. Tratar "lib insegura" e "uso inseguro" como o mesmo finding é o tipo de preguiça que um procedimento curto não deixa passar.

Dub: 7 de 7 autenticam. O scanner viu zero. Ainda havia hardening

Enumerei os handlers de webhook de entrada. Stripe com constructEvent. PayPal com certificado e allowlist de host (anti-SSRF). Intercom com HMAC e timingSafeEqual. Shopify e HubSpot com HMAC comparado em !==. Singular e AppsFlyer com allowlist de IP.

Sete de sete verificam o remetente antes de mutar estado. Semgrep no escopo webhook/**: 0 findings. Autenticidade de mensagem não é padrão sintático.

Ainda assim havia coisa acionável. O próprio repo já fazia o certo no Intercom:

crypto.timingSafeEqual(received, expected)

Shopify e HubSpot comparavam assim:

generatedSignature !== signature

Isso é CWE-208. Severidade prática baixa: timing over HMAC via rede sofre jitter. Não classifiquei como gap da invariante. A assinatura é verificada. Classifiquei como observação, e virei dubinc/dub#4415 porque o fix já existia no mesmo monorepo.

Formbricks: seguir até o controle

Busca ingênua por dangerouslySetInnerHTML: oito hits, XSS crítico fantasma. Seguindo o dado até a função, é DOMPurify (isomorphic-dompurify) com allowlist, duas passagens, rel="noopener noreferrer" forçado em target=_blank. Em SSR o conteúdo cai em texto e o React escapa. V3.2.2 = PASS. Zero finding.

O que virou issue foi configuração, não exploit. Nas rotas de client e capture:

{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Origin", value: "*" },

Pela spec de CORS, * + credentials o navegador rejeita. Não é "pegue a sessão do usuário". É contradição: ou a intenção era credencial (aí precisa refletir origin), ou Allow-Credentials é inócuo e pode sair. formbricks#9081.

Tinha uma terceira coisa: dangerouslyAllowSVG: true no otimizador de imagem. Vetor conhecido se o arquivo vem de fonte não confiável. Não fechei estático: faltava default efetivo do Next, se o upload aceita image/svg+xml, e o Content-Disposition real. Rotulei Needs-runtime. Não virou issue. Prompt solto teria aberto dois cards. O dirigido abriu um.

5. As três portas

Voltando ao SaaS do começo. O gap de autorização era real, confirmado, em código de outra pessoa. Não foi para o GitHub Issues. Foi para o canal que o projeto pede. Sem path neste artigo. Sem nome do projeto enquanto o mantenedor não coordenar.

Issue pública é para hardening. Bypass, injeção, secret, IDOR: privado primeiro. Sem SECURITY.md, é e-mail do mantenedor, não thread.

Copia. Cola no fim da sessão, antes de abrir o browser.

## Three doors (audit on third-party code)

Owner: is this my code, or someone else's?
Static: did I only read source, or did I touch their environment?
Evidence: is there file:line plus a real requirement, or just a vibe?

1. NOTHING
   Invariant held, or it is a Needs-runtime hypothesis.
   Do not occupy the tracker.

2. PUBLIC ISSUE
   Hardening: contradictory CORS, timing, lockfile,
   dependency policy, missing SECURITY.md.
   Not a bypass. Check for duplicates first.

3. PRIVATE CHANNEL
   Exploitable, or "when in doubt, exploitable".
   SECURITY.md / private reporting / maintainer email.
   No path in a post, no PoC, no project name until coordinated.

The session that finds it is not the session that ships the fix.

Least privilege aqui é de divulgação: o blast radius do texto. HITL é você escolhendo a porta, não o modelo. Audit trail é a issue ou o e-mail, nunca o transcript.

6. O que você monta amanhã

Pega o repo que você mantém. Escreve uma invariante falsificável antes de abrir arquivo. "Todo webhook de entrada verifica assinatura antes de mutar estado" serve.

Roda o scanner primeiro. Depois pede ao modelo o passe só naquela invariante, com denominador e arquivo:linha, sem PoC, sem fix na mesma sessão.

Se voltar "23 findings" sem X de Y e sem citação, você não tem auditoria. Tem lista.

No último output de "audit" que um modelo te deu: você teria aberto issue pública, mandado e-mail privado, ou deixado nada? Se a resposta honesta for "ia colar o relatório inteiro no tracker", a porta ainda está solta.