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.
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".
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 oteamIdnowhereda 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".
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:
Disciplina é o que corta o teatro:
arquivo:linha sustentando o requisito, não é finding;Confirmed-static e Needs-runtime são rótulos separados. O segundo não vira card;X de Y) não vale;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.
A falha mais comum de IA em segurança é fabricar achado para não devolver silêncio.
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 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.
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.
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.
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.
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.