docs(seguranca): registra a razao do alerta descartado e o que a classe achou

O `dismiss` do painel do GitHub aceita 280 caracteres. A razao de descartar o
alerta #5 (js/clear-text-logging) nao cabe la, e razao que nao cabe vira "alguem
decidiu isso em algum momento" — o proximo a triar re-deriva tudo do zero ou,
pior, desfaz. O comentario do dismiss agora e um ponteiro para este arquivo.

O registro guarda o que so aparece medindo: o alerta #5 tem DOIS fluxos para o
mesmo sink (o de NEXT_PUBLIC_APP_URL e falso positivo; o de OWNER_EMAIL e PII
verdadeira), e por isso a saida obvia — "tira o e-mail do log" — removeria a PII
e deixaria o alerta ABERTO. Guarda tambem os 5 cenarios de erro em que o catch
final foi testado com canario e nao vazou a service role key, para ninguem ter de
refazer.

E guarda o que a varredura por CLASSE achou e nenhum alerta tinha: 2 advisories
auto-descartados pelo Dependabot, 1 HIGH que o `pnpm audit` mascara por colapsar
versoes irmas, 1 credencial em log invisivel ao scanner, e 6 ReDoS polinomiais
NAO consertados — com o motivo de nao terem entrado nesta rodada (quatro sao
regex de deteccao de PII ou de autenticacao: perder um match la vaza dado, nao
degrada tempo) e com as 4 refutadas por medicao, para ninguem consertar fantasma.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RfAjBEKuCkL6tpUo77kUe
This commit is contained in:
Rafael Melgaço
2026-08-12 17:23:01 -03:00
co-authored by Claude Opus 5
parent 78ae7aaa4c
commit ce9b30a4e2
2 changed files with 177 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
# Alertas de segurança triados — registro de decisão
> Onde mora a razão de cada alerta **descartado** no painel do GitHub, e o que a varredura
> por CLASSE achou que o scanner não vê. Alerta consertado não entra aqui: ele vive no
> commit que o consertou.
>
> Um `dismiss` no painel aceita 280 caracteres de comentário. A razão de verdade não cabe
> lá, e razão que não cabe vira "alguém decidiu isso em algum momento". Este arquivo é o
> destino do ponteiro que o comentário do dismiss carrega.
Complementa [`threat-model.md`](threat-model.md) (superfície de ataque do self-host) e
[`harness-audit.md`](harness-audit.md) (onde a verificação tem buraco).
---
## Rodada de 2026-08-12 — 13 alertas (6 Dependabot + 7 CodeQL)
Estado inicial medido: 6 alertas abertos do Dependabot e 7 do CodeQL. Doze foram
consertados; **um** foi descartado, e é o assunto principal deste arquivo. A varredura por
classe achou **quatro** coisas que não estavam em alerta nenhum.
### Consertados
| Alertas | O quê | Commit |
|---|---|---|
| #73, #76, #77, #78, #79, #80 (Dependabot) | 5 transitivas com advisory elevadas por `pnpm.overrides` | `fix(deps): eleva 5 transitivas com advisory` |
| #6, #7 (CodeQL `js/polynomial-redos`) | `parseChatId` deixa de fazer backtracking quadrático sobre `payload.from` | `fix(waha): parseChatId deixa de ser ReDoS polinomial` |
| #1, #2, #4, #9 (CodeQL `actions/missing-workflow-permissions`) | `permissions: contents: read` nos 3 workflows + gate no `verify` | `fix(ci): jobs declaram o privilegio do GITHUB_TOKEN` |
### Descartado — #5, `js/clear-text-logging` em `scripts/bootstrap-owner.ts:215`
**Razão registrada: `won't fix`.** Não `false positive` — metade do alerta é verdadeira, e
gravar "falso positivo" seria escrever uma afirmação falsa no registro auditável, que
envenena a régua na próxima vez que esta regra disparar de verdade.
**O alerta tem DOIS fluxos para o mesmo sink, não um.** A tela do GitHub mostra um rótulo
de origem ("process environment") e isso engana. O SARIF da análise em `f9abedd0`
(`gh api -H "Accept: application/sarif+json" .../code-scanning/analyses/<id>`) traz
`codeFlows: 2`:
- **Fluxo A — `env.NEXT_PUBLIC_APP_URL`** (coluna 101). **Falso positivo puro:** o prefixo
`NEXT_PUBLIC_` significa, por contrato do Next.js, que o valor vai para o browser. Ele é
público por construção.
- **Fluxo B — `OWNER_EMAIL`** (coluna 51). PII real indo para um sink de log. Verdadeiro no
sentido estrito do CWE-532.
A fonte de taint não é o nome da variável: é o `{ ...process.env }` da linha 24. O CodeQL
marca o objeto de ambiente inteiro como sensível e o spread propaga para tudo que se lê de
`env`. É por isso que a linha 158 — que imprime `process.env.AI_PROVIDER` lido direto — não
é flagrada, e esta é: leitura direta é classificada pelo nome da chave; leitura da cópia
herda a classificação do objeto todo.
**Por que não vale patch:**
1. **O conserto não removeria um byte de exposição do produto.** O `install.sh` — o caminho
que todo self-hoster roda — imprime o mesmo `${OWNER_EMAIL}` três vezes (linhas 1415,
1552, 1562), e escolheu deliberadamente não imprimir a senha (`senha: (a que você
definiu)`, linha 1553). O CodeQL não analisa shell: o `default-setup` cobre
`actions`/`javascript-typescript`, e não há analisador de bash. **O scanner flagrou a
cópia que ninguém executa e é cego para a original que todo mundo executa** —
`grep bootstrap-owner hostgator-setup-kit/` devolve zero ocorrências; o kit reimplementa
o bootstrap inline com `curl` + `psql`.
2. **A linha tem valor de diagnóstico real.** O script é create-**or-update**: ele
sobrescreve a senha de um usuário existente. O eco do e-mail é o único sinal de que se
acertou a conta — e mostra o valor DEPOIS do parse, que é o que pega o `.env` com aspas
(defeito que este projeto já teve).
3. **Fechar o alerta exigiria matar os dois fluxos.** Remover só o e-mail (a saída
"óbvia") tira a PII e **deixa o alerta aberto**, porque o fluxo A entra pela URL que essa
variante preserva. Trocar o e-mail pelo valor devolvido pelo Supabase mata o fluxo B e
deixa o A. Fechar de verdade custa mais mudança do que o achado justifica — e a variante
com fallback `?? OWNER_EMAIL` reintroduz o taint em silêncio, com typecheck verde.
**O que foi medido antes de decidir**, para o próximo leitor não ter de re-derivar:
- Os 12 `console.*` do arquivo foram enumerados um a um. `OWNER_PASSWORD` e
`SUPABASE_SERVICE_ROLE_KEY` não alcançam nenhum deles.
- O `catch` final (`console.error("❌ Bootstrap falhou:", err)`) foi testado com um canário
na service role key contra `@supabase/supabase-js` 2.112.1, em 5 cenários de erro (401 do
auth, 401 do postgrest, host inexistente, conexão derrubada, `fetch` cru com o header
`Authorization`). **O canário não apareceu em nenhum.** O pior que ele imprime é o
hostname do Supabase, que já é público.
**Achado lateral, não consertado** (é robustez, não segurança, e está fora do escopo desta
rodada): `scripts/bootstrap-owner.ts:67` descarta o `error` de `admin.auth.admin.listUsers`.
Sob falha de rede, `data` volta `{users: []}` e o script segue — a idempotência prometida no
cabeçalho não vale quando o `listUsers` falha por rede, e ele cria um segundo dono.
---
## O que a varredura por CLASSE achou e o scanner não tinha
Triar a instância que o scanner aponta deixa as irmãs com álibi. As quatro abaixo saíram de
varrer a classe de cada alerta com um instrumento independente.
### 1. Dois advisories que o Dependabot auto-descartou — consertados
`brace-expansion@1.1.16` carregava `GHSA-mh99-v99m-4gvg` e `GHSA-rgw5-rvv9-x895` (dois
HIGH). O Dependabot auto-descartou os alertas #72/#75 e marcou #74 como *fixed* — porque a
**outra** árvore (`5.0.9`, via `@sentry/nextjs`) foi mesmo corrigida. A árvore `1.x`
continuava vulnerável e sem dono.
**A lição de método:** a lista de alertas do GitHub é uma superfície **já filtrada por
política de auto-dismiss**. Tratá-la como a superfície completa é medir contra a régua
errada.
### 2. Um HIGH que nem o `pnpm audit` enxerga — consertado
`path-to-regexp@6.1.0` (`GHSA-9wv6-86v2-598j`, CVSS 7.5) estava no lock e o `pnpm audit`
dizia "No known vulnerabilities found".
**Causa medida:** `pnpm audit` (9.15.9) **colapsa pacotes de mesmo nome e audita só o
último na ordem do lockfile**. A árvore `8.4.2`, corrigida, mascarava a `6.1.0`. Provado
com experimento controlado: num repro mínimo, variando só o nome da chave do alias (o que
muda a ordem alfabética), o alerta aparece ou some — 3 de 3 estável nos dois sentidos.
**Consequência para quem for construir um gate:** `pnpm audit` cobre as instâncias que ele
enxerga, **não a classe**. O ponto cego dele é exatamente a forma multi-árvore, que é a
forma dos dois achados acima. A medição que sustenta esta rodada é OSV.dev sobre o lock
inteiro, por `name@version`:
```
lock em f9abedd0 (controle positivo) : 5 pacotes / 9 advisories
lock depois dos overrides : 0
```
### 3. Uma credencial viva em log — consertada
`scripts/observar-escalacao-turno-real.ts:227` imprimia `webhook_path_token` em claro.
É a credencial que autentica o webhook do WAHA e resolve o tenant. Estritamente pior que a
PII do alerta #5, e **invisível ao scanner**: entra como propriedade de objeto dentro de
`JSON.stringify`, não como identificador interpolado em template.
### 4. Seis ReDoS polinomiais que o CodeQL não flagrou — NÃO consertados
Varredura dinâmica de 8.404 literais de regex em 1.909 arquivos (controle positivo: o
detector flagra `lib/waha/ingest.ts`), filtrada por crescimento quadrático confirmado em 3
pontos **e** entrada externa verificada lendo o call site:
| Local | Custo em 64 KB | Entrada |
|---|---|---|
| `lib/ai/anonymize/index.ts:26,59` | ~3,3 s | **A pior.** `anonymize(transcript)` roda sobre a concatenação dos `messages.body` da conversa — corpo de mensagem de WhatsApp, sem teto de tamanho antes |
| `lib/sentry/scrub.ts:56` | ~3,2 s | Indireta (dado de evento Sentry) |
| `lib/agent-engine/guardrails/vazamento-interno.ts:398` | ~4,5 s | Parcial — por token da saída do LLM; teto real de `max_tokens` **não medido** |
| `lib/ai/skills/package.ts:84,85` | ~2,9 s | Autenticada (`requireRole("manager")`), mas o SKILL.md vem descompactado de zip — zip-bomb de espaços dá N grande com upload pequeno |
| `lib/mcp/auth.ts:72` + `app/api/internal/agents/run/route.ts:53` | ~160 ms | **Não autenticada** — é o próprio check de auth, e `/api/mcp` é rota pública. Limitada pelo teto de 16 KB de header do Node, mas ~6 req/s saturam um core |
| `app/api/v1/channels/partner/route.ts:78` | ~166 ms | Header `origin`, controlado pelo cliente; atrás de `adminGate` |
**Por que ficaram de fora desta rodada, e não é por serem menores:** quatro das seis são
regex de **detecção de PII** ou de **autenticação**. Perder um match ali não degrada
performance — vaza dado pessoal ou aceita credencial malformada. A equivalência precisa ser
provada caso a caso, como foi para `parseChatId` (corpus exaustivo de 37.449 strings, com
controles negativos), e isso é trabalho próprio, não um apêndice.
**Refutadas por medição** (registradas para ninguém "consertar" fantasma):
`app/api/v1/channel-sessions/[id]/route.ts:171` — `/@.*/ ` **sem `$`** é linear (0,05 ms em
64 KB contra 4.481 ms da versão com âncora; o `$` é o defeito inteiro);
`lib/auth/provision.ts:11` e `lib/leads/stage-editing.ts:132` — a linha anterior colapsa a
corrida ou corta a entrada, então a regex nunca vê o caso ruim;
`lib/ai/rag/ingest/policy.ts:26` — o flag `/m` faz o match resolver cedo (0,25 ms em 200 KB).
---
## Não medido nesta rodada
- **Que os 12 alertas fecham de fato.** Isso exige uma varredura nova do CodeQL e uma do
Dependabot depois do merge. O que está medido é a *causa* de cada um removida: o
predicado `jobHasPermissions` satisfeito, a regex quadrática substituída por varredura
linear equivalente, e o OSV a zero sobre o lock.
- **`allow: [{dependency-type: "all"}]` em `.github/dependabot.yml`.** O mecanismo que
produziu o buraco continua de pé: o Dependabot não abriu PR para nenhuma das 6 transitivas
em 4 a 8 dias, com `automated-security-fixes` ligado. Se essa opção mudaria o
comportamento para pnpm é hipótese não verificável por CLI.
- **Um gate de auditoria de dependência no CI.** `pnpm audit` é barato mas comprovadamente
cego (item 2 acima); `osv-scanner` cobre a classe mas põe uma action de terceiro num check
obrigatório, e ambos ficam vermelhos quando sai GHSA novo sem mudança de código. É decisão
de política de CI, não de patch.
+1
View File
@@ -88,6 +88,7 @@ Detalham schema SQL e payloads exatos. **Consulte antes de modelar qualquer cois
| [`research/reference-synthesis.md`](research/reference-synthesis.md) | Arquitetura herdada da referência WAHA |
| [`research/followup-reference-mining.md`](research/followup-reference-mining.md) | Pesquisa do motor de follow-up |
| [`threat-model.md`](threat-model.md) | **Superfície de ataque real do self-host** |
| [`alertas-de-seguranca-triados.md`](alertas-de-seguranca-triados.md) | Razão de cada alerta **descartado** no painel do GitHub, e o que a varredura por classe achou que o scanner não vê |
## 5. Design system