wizz-method 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/modules/wizz/agents/wizz-qa/SKILL.md +1 -0
- package/src/skills-lib/auth-and-secrets/SKILL.md +1 -0
- package/src/skills-lib/security-audit-pentest/SKILL.md +53 -0
- package/src/skills-lib/security-audit-pentest/references/stage-1-validacao-entrada.md +50 -0
- package/src/skills-lib/security-audit-pentest/references/stage-2-autorizacao.md +61 -0
- package/src/skills-lib/security-audit-pentest/references/stage-3-abuso-volume.md +49 -0
- package/src/skills-lib/security-audit-pentest/references/stage-4-vazamento-resposta.md +52 -0
- package/src/skills-lib/security-audit-pentest/references/stage-5-fusao-plano.md +27 -0
- package/src/skills-lib/web-security/SKILL.md +15 -8
- package/src/skills-lib/web-security/references/headers-rate-limit-cors.md +11 -0
- package/src/skills-lib/web-security/references/owasp-top5-detalhado.md +32 -0
- package/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
- package/tools/installer/modules/env-vars.js +174 -16
- package/tools/installer/modules/mcp-config.js +44 -0
- package/tools/installer/ui.js +14 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "wizz-method",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.0",
|
|
5
5
|
"description": "Wizz Method — método de agência orientado por IA em PT-BR (fork independente do BMad Method)",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agile",
|
|
@@ -25,6 +25,7 @@ Você é o QA do Wizz. Entra **depois do wizz-agent-dev**: pega o código pronto
|
|
|
25
25
|
- Gerar testes E2E e rodar fluxos críticos → `wizz-qa-generate-e2e-tests`; para browser real, use `agent-browser`.
|
|
26
26
|
- Revisão adversarial caçando bugs (assumir que tem bug) → `adversarial-reviewer`.
|
|
27
27
|
- Revisão de qualidade/segurança do código → `wizz-code-review`; para segurança web profunda, use `web-security`.
|
|
28
|
+
- Auditoria/pentest de segurança do app inteiro (varredura adversarial antes de release, "auditar segurança") → `security-audit-pentest` (caça com prova de exploração + plano priorizado). Para corrigir uma falha isolada, use `web-security`/`auth-and-secrets`.
|
|
28
29
|
- Conferir se entrega o que foi pedido → comparo com o que o wizz-pm/usuário definiu.
|
|
29
30
|
|
|
30
31
|
Sempre reporte achados em ordem de gravidade (crítico primeiro). Se passou em tudo, diga claramente que está pronto pra entregar.
|
|
@@ -42,6 +42,7 @@ Evita que um atacante descubra quais e-mails existem na base (login, signup, res
|
|
|
42
42
|
- Auth gerenciada pelo Clerk: nunca reimplementar flows de auth manualmente
|
|
43
43
|
- Toda rota protegida, Server Action e route handler chama `auth()` do Clerk no servidor. Nunca confiar em estado de sessão vindo do cliente
|
|
44
44
|
- Clerk webhook (`svix`) verificado por assinatura antes de processar: não confiar no body sem verificar
|
|
45
|
+
- Webhook de pagamento/provedor externo: além de verificar a assinatura, **nunca confie em valor de status, dinheiro ou permissão que veio no corpo**. Confirme com o provedor de origem (ex: re-buscar a sessão no Stripe pelo id) antes de liberar acesso ou creditar saldo. Assinatura válida só prova que o evento veio do provedor, não que o payload não foi remontado num replay.
|
|
45
46
|
- Supabase RLS: toda tabela de domínio deve ter RLS ativo; queries devem filtrar por `workspace_id`/`user_id`
|
|
46
47
|
- `SUPABASE_SERVICE_ROLE_KEY` bypassa RLS: só em server-side (API routes, jobs), nunca exposta no frontend
|
|
47
48
|
- `NEXT_PUBLIC_*` = seguro expor no cliente; tudo sem `NEXT_PUBLIC_` = server-only
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security-audit-pentest
|
|
3
|
+
description: >
|
|
4
|
+
Auditoria de segurança adversarial em 5 estágios com saída padronizada (tabela markdown, régua de corte,
|
|
5
|
+
payload concreto por achado). Use quando o pedido for auditar/pentestar um app inteiro, revisar segurança
|
|
6
|
+
antes de release, "auditoria de segurança", "pentest", "encontrar vulnerabilidades", ou rodar uma varredura
|
|
7
|
+
completa de mass assignment, IDOR/RLS, injeção, rate limit/abuso por volume, e vazamento por resposta.
|
|
8
|
+
Diferente de web-security/auth-and-secrets (que corrigem): esta CAÇA, com prova de exploração, e no fim funde
|
|
9
|
+
os achados num plano priorizado ("as três de hoje"). Use para revisão de correção de uma falha isolada as skills
|
|
10
|
+
web-security e auth-and-secrets.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Security Audit (Pentest adversarial)
|
|
14
|
+
|
|
15
|
+
Cinco estágios independentes de caça a vulnerabilidade, cada um com **prova de exploração concreta** e **régua de corte** (sem payload real, a linha não entra). No fim, um sexto passo funde os cinco relatórios num plano único priorizado.
|
|
16
|
+
|
|
17
|
+
Cada estágio é um agente com um prompt fechado em `references/`. Rode-os em paralelo (subagentes), um por estágio, e depois o passo de fusão sobre os cinco resultados.
|
|
18
|
+
|
|
19
|
+
## Premissa comum a todos os estágios
|
|
20
|
+
|
|
21
|
+
Validação no cliente não conta. O atacante fala direto com a API. Considere apenas verificação no servidor. O campo "Como se explora" de cada achado tem que conter o **payload concreto** que o atacante enviaria, não a categoria da vulnerabilidade. "Poderia ser melhor" e "considere adicionar" não são achados. Prefira dez linhas reais a cinquenta de boa prática. Sem achado que passe na régua num estágio: responda só `NENHUM ACHADO NESTA ETAPA`.
|
|
22
|
+
|
|
23
|
+
Formato de saída de cada estágio, tabela markdown, sem texto antes ou depois:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
| # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Gravidade: CRÍTICO, ALTO, MÉDIO.
|
|
30
|
+
|
|
31
|
+
## Os 5 estágios (rodar em paralelo)
|
|
32
|
+
|
|
33
|
+
| Estágio | Caça | Prompt |
|
|
34
|
+
|---|------|--------|
|
|
35
|
+
| 1 | **Validação de entrada** — mass assignment (prioridade máxima), injeção SQL/NoSQL/shell, renderização insegura, schema ausente / texto sem limite | `references/stage-1-validacao-entrada.md` |
|
|
36
|
+
| 2 | **Falha de autorização** — 3 camadas: app (identidade do cliente vs token; posse na query), banco (RLS, policy permissiva `USING(true)`, tabela nova sem policy), máquina (webhook sem assinatura, confia em status/dinheiro do body) | `references/stage-2-autorizacao.md` |
|
|
37
|
+
| 3 | **Abuso por volume** — rate limit em auth (por IP **e** por conta), custo por chamada (IA/SMS/e-mail × 10k), extração por paginação, origem do IP atrás de proxy/CDN | `references/stage-3-abuso-volume.md` |
|
|
38
|
+
| 4 | **Vazamento por resposta** — detalhe técnico em prod, divergência na auth (mensagem/status/tempo), existência de recurso (404 vs 403), secret em log | `references/stage-4-vazamento-resposta.md` |
|
|
39
|
+
| 5 | **Fusão** — funde os 4 relatórios: dedup por origem cruzada, ordena por esforço÷gravidade, identifica cadeias, fecha com "as três de hoje" | `references/stage-5-fusao-plano.md` |
|
|
40
|
+
|
|
41
|
+
> Estágios 1-4 são de caça e independentes. O estágio 5 recebe a saída dos outros quatro. (O prompt original tinha o estágio de validação duplicado; aqui é um só.)
|
|
42
|
+
|
|
43
|
+
## Como rodar
|
|
44
|
+
|
|
45
|
+
1. Dispare os estágios 1-4 como quatro subagentes em paralelo, cada um carregando seu prompt de `references/`.
|
|
46
|
+
2. Colete as quatro tabelas.
|
|
47
|
+
3. Rode o estágio 5 (fusão) colando as quatro tabelas onde o prompt indica.
|
|
48
|
+
4. Entregue o plano de fusão como resultado. As tabelas por estágio ficam como anexo.
|
|
49
|
+
|
|
50
|
+
## Relação com as outras skills de segurança
|
|
51
|
+
|
|
52
|
+
- **Esta skill CAÇA** (encontra + prova). Saída = lista de achados + plano.
|
|
53
|
+
- **`web-security` e `auth-and-secrets` CORRIGEM** (padrão certo + exemplo de código). Ao fechar um achado desta auditoria, abra a skill de correção correspondente citada na coluna "O que impede".
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Estágio 1 — Validação de entrada
|
|
2
|
+
|
|
3
|
+
Você é um analista de validação de entrada.
|
|
4
|
+
|
|
5
|
+
Premissa: validação no cliente não conta. Um atacante fala
|
|
6
|
+
direto com a API. Considere apenas verificação no servidor.
|
|
7
|
+
|
|
8
|
+
Procure, nesta ordem de prioridade:
|
|
9
|
+
|
|
10
|
+
1. ATRIBUIÇÃO EM MASSA — prioridade máxima
|
|
11
|
+
Todo endpoint de escrita onde o corpo da requisição é
|
|
12
|
+
repassado inteiro para o banco (spread do objeto, update
|
|
13
|
+
com o payload completo, create com todos os campos).
|
|
14
|
+
Para cada um: liste os campos da tabela que um atacante
|
|
15
|
+
conseguiria setar e que não estão no formulário.
|
|
16
|
+
Dê atenção especial a campos de papel, permissão, plano,
|
|
17
|
+
saldo, status de pagamento e id de dono.
|
|
18
|
+
|
|
19
|
+
2. INJEÇÃO
|
|
20
|
+
Consultas montadas por concatenação ou template string
|
|
21
|
+
com valor vindo do usuário. Inclua SQL, NoSQL e comandos
|
|
22
|
+
de shell.
|
|
23
|
+
|
|
24
|
+
3. RENDERIZAÇÃO INSEGURA
|
|
25
|
+
Conteúdo de usuário chegando em innerHTML,
|
|
26
|
+
dangerouslySetInnerHTML, v-html ou equivalente
|
|
27
|
+
sem sanitização no caminho.
|
|
28
|
+
|
|
29
|
+
4. SCHEMA AUSENTE
|
|
30
|
+
Endpoints sem validação de tipo, formato ou tamanho.
|
|
31
|
+
Sinalize campos de texto sem limite máximo — são vetor
|
|
32
|
+
de exaustão de recurso.
|
|
33
|
+
|
|
34
|
+
Para cada achado, o campo "Como se explora" deve conter
|
|
35
|
+
o payload concreto que um atacante enviaria, não a categoria
|
|
36
|
+
da vulnerabilidade.
|
|
37
|
+
|
|
38
|
+
Formato de saída — tabela markdown, sem texto antes ou depois:
|
|
39
|
+
|
|
40
|
+
| # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
|
|
41
|
+
|
|
42
|
+
Gravidade: CRÍTICO, ALTO, MÉDIO.
|
|
43
|
+
|
|
44
|
+
REGRA DE CORTE: se você não conseguir preencher "Como se explora"
|
|
45
|
+
com uma ação concreta e específica, NÃO inclua a linha.
|
|
46
|
+
"Poderia ser melhor" não é achado. "Considere adicionar" não é achado.
|
|
47
|
+
Prefiro dez linhas reais a cinquenta linhas de boa prática.
|
|
48
|
+
|
|
49
|
+
Se não houver nenhum achado que passe nessa régua, responda
|
|
50
|
+
apenas: NENHUM ACHADO NESTA ETAPA.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Estágio 2 — Falha de autorização
|
|
2
|
+
|
|
3
|
+
Você é um pentester especializado em falha de autorização.
|
|
4
|
+
|
|
5
|
+
Distinção que governa toda essa análise:
|
|
6
|
+
AUTENTICAÇÃO responde "quem é você". AUTORIZAÇÃO responde
|
|
7
|
+
"você pode acessar ISSO". Estar logado não é permissão.
|
|
8
|
+
|
|
9
|
+
Analise as três camadas.
|
|
10
|
+
|
|
11
|
+
CAMADA 1 — APLICAÇÃO
|
|
12
|
+
Para cada endpoint que lê ou escreve um recurso pertencente
|
|
13
|
+
a um usuário, responda:
|
|
14
|
+
a) De onde vem a identidade de QUEM está pedindo?
|
|
15
|
+
Do token/sessão no servidor, ou de algo enviado pelo cliente
|
|
16
|
+
(body, query, header, param)?
|
|
17
|
+
b) A consulta ao banco amarra o id do recurso E o dono
|
|
18
|
+
na mesma condição?
|
|
19
|
+
Marque CRÍTICO todo lugar onde um user_id, org_id, tenant_id
|
|
20
|
+
ou equivalente é lido do que o cliente enviou e usado para
|
|
21
|
+
determinar propriedade.
|
|
22
|
+
|
|
23
|
+
CAMADA 2 — BANCO
|
|
24
|
+
Leia os arquivos de migration, schema e policy.
|
|
25
|
+
a) Liste as tabelas e diga, para cada uma, se há RLS
|
|
26
|
+
habilitada nos arquivos versionados.
|
|
27
|
+
b) Marque CRÍTICO toda policy permissiva —
|
|
28
|
+
USING (true), WITH CHECK (true) ou equivalente.
|
|
29
|
+
Uma policy permissiva é PIOR que policy nenhuma,
|
|
30
|
+
porque o painel mostra a trava como ativa.
|
|
31
|
+
c) Sinalize qualquer tabela criada em migration recente
|
|
32
|
+
que não tenha policy correspondente.
|
|
33
|
+
|
|
34
|
+
CAMADA 3 — MÁQUINA
|
|
35
|
+
Para cada webhook e endpoint que recebe evento externo:
|
|
36
|
+
a) Há validação de assinatura ou token compartilhado?
|
|
37
|
+
b) O fluxo confia em valor de status, dinheiro ou permissão
|
|
38
|
+
vindo no corpo, sem confirmar com o provedor de origem?
|
|
39
|
+
|
|
40
|
+
LIMITE DA SUA ANÁLISE — obrigatório:
|
|
41
|
+
Você está lendo arquivos. RLS de verdade é estado do banco,
|
|
42
|
+
não arquivo. Termine com uma seção "NÃO VERIFICÁVEL AQUI"
|
|
43
|
+
listando tudo que precisa ser confirmado no banco ao vivo
|
|
44
|
+
ou no painel do provedor. Não afirme que está seguro
|
|
45
|
+
o que você não conseguiu ver.
|
|
46
|
+
|
|
47
|
+
Formato de saída — tabela markdown, sem texto antes ou depois:
|
|
48
|
+
|
|
49
|
+
| # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
|
|
50
|
+
|
|
51
|
+
Gravidade: CRÍTICO, ALTO, MÉDIO.
|
|
52
|
+
|
|
53
|
+
REGRA DE CORTE: se você não conseguir preencher "Como se explora"
|
|
54
|
+
com uma ação concreta e específica, NÃO inclua a linha.
|
|
55
|
+
"Poderia ser melhor" não é achado. "Considere adicionar" não é achado.
|
|
56
|
+
Prefiro dez linhas reais a cinquenta linhas de boa prática.
|
|
57
|
+
|
|
58
|
+
Se não houver nenhum achado que passe nessa régua, responda
|
|
59
|
+
apenas: NENHUM ACHADO NESTA ETAPA.
|
|
60
|
+
|
|
61
|
+
(Após a tabela, inclua a seção obrigatória "NÃO VERIFICÁVEL AQUI".)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Estágio 3 — Abuso por volume
|
|
2
|
+
|
|
3
|
+
Você é um analista de abuso por volume.
|
|
4
|
+
|
|
5
|
+
Enumere TODA rota de entrada do sistema e classifique cada uma:
|
|
6
|
+
TEM LIMITADOR / NÃO TEM LIMITADOR / NÃO DETERMINADO.
|
|
7
|
+
|
|
8
|
+
Depois, marque as rotas nestas categorias:
|
|
9
|
+
|
|
10
|
+
A) AUTENTICAÇÃO
|
|
11
|
+
Login, cadastro, recuperação de senha, verificação de código.
|
|
12
|
+
Sem limitador aqui significa força bruta e teste de
|
|
13
|
+
credenciais vazadas. Marque CRÍTICO.
|
|
14
|
+
Verifique também: o limitador conta por IP apenas, ou também
|
|
15
|
+
por conta alvo? Contagem só por IP é contornada distribuindo
|
|
16
|
+
o ataque.
|
|
17
|
+
|
|
18
|
+
B) CUSTO POR CHAMADA
|
|
19
|
+
Rotas que gastam dinheiro real a cada execução:
|
|
20
|
+
chamada a modelo de linguagem, disparo de SMS, envio de e-mail,
|
|
21
|
+
processamento de arquivo, chamada a API paga de terceiro.
|
|
22
|
+
Para cada uma estime o custo unitário e o custo de
|
|
23
|
+
dez mil chamadas em loop.
|
|
24
|
+
Rota de agente de IA exposta sem limitador é CRÍTICO,
|
|
25
|
+
mesmo que não vaze nenhum dado.
|
|
26
|
+
|
|
27
|
+
C) EXTRAÇÃO
|
|
28
|
+
Rotas que devolvem um registro por vez e que, chamadas em
|
|
29
|
+
sequência, entregam a base inteira.
|
|
30
|
+
|
|
31
|
+
D) ORIGEM DO IP
|
|
32
|
+
Se o sistema roda atrás de proxy ou CDN, verifique de qual
|
|
33
|
+
cabeçalho o IP do cliente é lido. Se estiver lendo do
|
|
34
|
+
socket direto, o mundo inteiro conta como um IP só
|
|
35
|
+
e o limitador não existe na prática.
|
|
36
|
+
|
|
37
|
+
Formato de saída — tabela markdown, sem texto antes ou depois:
|
|
38
|
+
|
|
39
|
+
| # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
|
|
40
|
+
|
|
41
|
+
Gravidade: CRÍTICO, ALTO, MÉDIO.
|
|
42
|
+
|
|
43
|
+
REGRA DE CORTE: se você não conseguir preencher "Como se explora"
|
|
44
|
+
com uma ação concreta e específica, NÃO inclua a linha.
|
|
45
|
+
"Poderia ser melhor" não é achado. "Considere adicionar" não é achado.
|
|
46
|
+
Prefiro dez linhas reais a cinquenta linhas de boa prática.
|
|
47
|
+
|
|
48
|
+
Se não houver nenhum achado que passe nessa régua, responda
|
|
49
|
+
apenas: NENHUM ACHADO NESTA ETAPA.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Estágio 4 — Vazamento por resposta
|
|
2
|
+
|
|
3
|
+
Você é um analista de vazamento por resposta.
|
|
4
|
+
|
|
5
|
+
Premissa: toda resposta de erro é informação. Você está
|
|
6
|
+
procurando o que o sistema conta a quem falhou.
|
|
7
|
+
|
|
8
|
+
1. DETALHE TÉCNICO EM PRODUÇÃO
|
|
9
|
+
Stack trace, caminho de arquivo do servidor, nome e versão
|
|
10
|
+
de framework ou biblioteca, fragmento de consulta SQL,
|
|
11
|
+
nome de tabela ou coluna chegando na resposta ao cliente.
|
|
12
|
+
Confirme se existe separação real entre comportamento de
|
|
13
|
+
desenvolvimento e de produção, ou se é o mesmo caminho.
|
|
14
|
+
|
|
15
|
+
2. DIVERGÊNCIA NA AUTENTICAÇÃO — prioridade máxima
|
|
16
|
+
Compare as respostas de: e-mail inexistente, e-mail existente
|
|
17
|
+
com senha errada, e conta bloqueada.
|
|
18
|
+
Divergem em mensagem? Em código de status? Em campo do corpo?
|
|
19
|
+
E divergem em TEMPO de resposta, porque o caminho do e-mail
|
|
20
|
+
existente executa a verificação de hash e o outro não?
|
|
21
|
+
Qualquer divergência transforma o login numa API de consulta
|
|
22
|
+
da base de clientes. Marque CRÍTICO.
|
|
23
|
+
O mesmo vale para cadastro ("e-mail já em uso") e recuperação
|
|
24
|
+
de senha.
|
|
25
|
+
|
|
26
|
+
3. EXISTÊNCIA DE RECURSO
|
|
27
|
+
Quando o usuário pede um recurso que existe mas não é dele,
|
|
28
|
+
a resposta é diferente de quando o recurso não existe?
|
|
29
|
+
Se for, dá para mapear os ids válidos contando respostas.
|
|
30
|
+
O correto é responder "não encontrado" nos dois casos.
|
|
31
|
+
|
|
32
|
+
4. LOG
|
|
33
|
+
Senha, token, cabeçalho de autorização, dado de cartão ou
|
|
34
|
+
documento sendo gravado em log.
|
|
35
|
+
|
|
36
|
+
Para cada achado, o campo "Como se explora" deve conter
|
|
37
|
+
o payload concreto que um atacante enviaria, não a categoria
|
|
38
|
+
da vulnerabilidade.
|
|
39
|
+
|
|
40
|
+
Formato de saída — tabela markdown, sem texto antes ou depois:
|
|
41
|
+
|
|
42
|
+
| # | Risco | Onde (arquivo:linha) | Como se explora | O que impede | Gravidade |
|
|
43
|
+
|
|
44
|
+
Gravidade: CRÍTICO, ALTO, MÉDIO.
|
|
45
|
+
|
|
46
|
+
REGRA DE CORTE: se você não conseguir preencher "Como se explora"
|
|
47
|
+
com uma ação concreta e específica, NÃO inclua a linha.
|
|
48
|
+
"Poderia ser melhor" não é achado. "Considere adicionar" não é achado.
|
|
49
|
+
Prefiro dez linhas reais a cinquenta linhas de boa prática.
|
|
50
|
+
|
|
51
|
+
Se não houver nenhum achado que passe nessa régua, responda
|
|
52
|
+
apenas: NENHUM ACHADO NESTA ETAPA.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Estágio 5 — Fusão num plano único
|
|
2
|
+
|
|
3
|
+
Vou colar abaixo os relatórios das quatro etapas de auditoria
|
|
4
|
+
(validação de entrada, autorização, abuso por volume, vazamento
|
|
5
|
+
por resposta).
|
|
6
|
+
|
|
7
|
+
Funda os quatro num plano único de correção:
|
|
8
|
+
|
|
9
|
+
1. Remova duplicatas. O mesmo problema aparece em mais de uma
|
|
10
|
+
etapa com nomes diferentes — por exemplo, uma chave vazada
|
|
11
|
+
aparece no MAPA e o efeito dela aparece no ACESSO.
|
|
12
|
+
Trate como um item só, citando as duas origens.
|
|
13
|
+
|
|
14
|
+
2. Reordene por ESFORÇO DE CORREÇÃO dividido por GRAVIDADE.
|
|
15
|
+
Primeiro o que é crítico e se resolve em minutos.
|
|
16
|
+
Por último o que é médio e exige refatoração.
|
|
17
|
+
|
|
18
|
+
3. Identifique CADEIAS: onde dois achados de gravidade média
|
|
19
|
+
se combinam num caminho crítico. Exemplo: enumeração de
|
|
20
|
+
usuário no login mais ausência de limitador na mesma rota
|
|
21
|
+
é uma cadeia, e vale mais que a soma das partes.
|
|
22
|
+
|
|
23
|
+
4. Termine com "AS TRÊS DE HOJE": os três itens que devem ser
|
|
24
|
+
corrigidos antes de qualquer outra coisa, com a justificativa
|
|
25
|
+
em uma linha cada.
|
|
26
|
+
|
|
27
|
+
[colar os 4 relatórios abaixo]
|
|
@@ -17,14 +17,21 @@ As que mais aparecem numa auditoria, com a correção direta. Use como triagem r
|
|
|
17
17
|
|---|-------|-----------|----------|------|
|
|
18
18
|
| 1 | SQL Injection | 🔴 Crítica (parada de linha) | Query parametrizada / query-builder, nunca interpolar string | `references/owasp-top5-detalhado.md` §3 |
|
|
19
19
|
| 2 | IDOR (recurso por ID sem checar dono) | 🔴 Crítica (parada de linha) | Validar posse do recurso no servidor a cada request | `references/owasp-top5-detalhado.md` §1 |
|
|
20
|
-
| 3 |
|
|
21
|
-
| 4 |
|
|
22
|
-
| 5 |
|
|
23
|
-
| 6 |
|
|
24
|
-
| 7 |
|
|
25
|
-
| 8 |
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
| 3 | Mass assignment (body inteiro vai pro banco) | 🔴 Crítica (parada de linha) | Allowlist de campos: só grave os campos do formulário, nunca `spread` do body | `references/owasp-top5-detalhado.md` §6 |
|
|
21
|
+
| 4 | RLS permissiva (`USING(true)`) ou tabela sem policy | 🔴 Crítica | Policy que amarra `auth.uid()` ao dono; tabela nova sempre com policy | `references/owasp-top5-detalhado.md` §1 |
|
|
22
|
+
| 5 | Rate limit ausente (brute force grátis) | 🟠 Alta | Limite por IP **e** por conta + lockout/CAPTCHA no login | `references/headers-rate-limit-cors.md` |
|
|
23
|
+
| 6 | Origem do IP lida do socket atrás de CDN | 🟠 Alta | Ler IP do header certo (`x-forwarded-for` confiável do proxy), senão o rate limit não existe | `references/headers-rate-limit-cors.md` |
|
|
24
|
+
| 7 | Custo por chamada sem limitador (IA/SMS/e-mail) | 🟠 Alta | Rate limit + quota por conta em toda rota que gasta dinheiro real | `references/headers-rate-limit-cors.md` |
|
|
25
|
+
| 8 | Extração por paginação (base inteira registro a registro) | 🟠 Alta | Filtrar por dono na query + limite de página + detecção de varredura | `references/headers-rate-limit-cors.md` |
|
|
26
|
+
| 9 | CORS refletindo o `Origin` | 🟠 Alta | Allowlist de origens, nunca ecoar o `Origin` recebido | `references/headers-rate-limit-cors.md` |
|
|
27
|
+
| 10 | PII/dado demais na resposta (até hash de senha) | 🟠 Alta | Retornar só os campos necessários (allowlist de saída) | `references/headers-rate-limit-cors.md` |
|
|
28
|
+
| 11 | JWT na URL / token vivo pós-logout | 🟠 Alta | Token no header `Authorization` + revogar no logout | skill `auth-and-secrets` |
|
|
29
|
+
| 12 | Enumeração de usuário (erro revela se e-mail existe) | 🟡 Média | Mensagem genérica + resposta em tempo constante | skill `auth-and-secrets` |
|
|
30
|
+
| 13 | Clickjacking (sem header) | 🟡 Média (1 min) | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` | `references/headers-rate-limit-cors.md` |
|
|
31
|
+
|
|
32
|
+
> As quatro primeiras (SQLi, IDOR, mass assignment e RLS permissiva) são **parada de linha**: apareceu, corrige antes de qualquer coisa.
|
|
33
|
+
>
|
|
34
|
+
> Para uma auditoria adversarial completa (caça com prova de exploração + plano priorizado), use a skill `security-audit-pentest`. Esta skill aqui é para **corrigir** uma falha específica da tabela.
|
|
28
35
|
|
|
29
36
|
## References (load on demand)
|
|
30
37
|
|
|
@@ -56,6 +56,17 @@ async headers() {
|
|
|
56
56
|
- Comparações de token/secret: `crypto.timingSafeEqual`, nunca `!==` (detalhe em auth-and-secrets)
|
|
57
57
|
- Sem rate limit = brute force de graça. Somar lockout/CAPTCHA após N falhas no login
|
|
58
58
|
|
|
59
|
+
### Origem do IP atrás de proxy/CDN
|
|
60
|
+
Se o app roda atrás de Vercel/Cloudflare/qualquer proxy, o IP do socket é o do proxy, não o do cliente. Ler o socket direto faz **o mundo inteiro contar como um IP só** e o rate limit deixa de existir na prática.
|
|
61
|
+
- Vercel: use `x-forwarded-for` (ou `req.ip` no runtime que já resolve). Pegue o **primeiro** IP da lista, mas só confie na cadeia que vem do seu proxy.
|
|
62
|
+
- Nunca confie em `x-forwarded-for` cru vindo de origem não-proxy (o cliente falsifica o header). Configure a lib de rate limit para o número certo de proxies à frente.
|
|
63
|
+
|
|
64
|
+
### Custo por chamada (rota que gasta dinheiro real)
|
|
65
|
+
Toda rota que chama modelo de linguagem, dispara SMS, envia e-mail, processa arquivo ou bate em API paga de terceiro precisa de rate limit **e** quota por conta. Rota de agente de IA exposta sem limitador é crítica mesmo sem vazar dado nenhum: 10.000 chamadas em loop viram conta de centenas a milhares de reais/dólares. Estime o custo unitário e multiplique por 10k ao avaliar a rota.
|
|
66
|
+
|
|
67
|
+
### Extração por paginação
|
|
68
|
+
Rota que devolve um registro por vez e, chamada em sequência, entrega a base inteira. Barre com: filtro por dono na query (o mesmo do IDOR), limite máximo de página, e detecção de varredura (muitos ids sequenciais do mesmo cliente).
|
|
69
|
+
|
|
59
70
|
## CORS
|
|
60
71
|
- **Nunca reflita o header `Origin` recebido** de volta em `Access-Control-Allow-Origin` (isso libera qualquer site a chamar sua API com credenciais)
|
|
61
72
|
- Use uma **allowlist explícita** de origens: compare o `Origin` recebido contra a lista e só então ecoe o valor exato daquela origem
|
|
@@ -28,6 +28,38 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
28
28
|
}
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
### RLS: policy permissiva é pior que policy nenhuma
|
|
32
|
+
Ler os arquivos de migration/policy versionados e checar:
|
|
33
|
+
- Toda tabela de domínio tem `ENABLE ROW LEVEL SECURITY`?
|
|
34
|
+
- Nenhuma policy é permissiva: `USING (true)` ou `WITH CHECK (true)` libera geral, mas o painel do Supabase mostra a trava como "ativa". Amarre sempre ao dono: `USING (auth.uid() = user_id)`.
|
|
35
|
+
- Tabela criada em migration recente **sem** policy correspondente = exposta.
|
|
36
|
+
|
|
37
|
+
```sql
|
|
38
|
+
-- ERRADO: mostra RLS ativo no painel, mas libera qualquer um
|
|
39
|
+
create policy "read" on projects for select using (true);
|
|
40
|
+
|
|
41
|
+
-- CERTO: amarra o dono
|
|
42
|
+
alter table projects enable row level security;
|
|
43
|
+
create policy "own rows" on projects
|
|
44
|
+
for select using (auth.uid() = user_id);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> RLS de verdade é estado do banco, não arquivo. O que está no repo é ponto de partida: confirme o estado real no dashboard/`\d+` do Postgres.
|
|
48
|
+
|
|
49
|
+
## 6. Mass Assignment (atribuição em massa)
|
|
50
|
+
O corpo da requisição inteiro repassado para o banco (`spread` do objeto, `update`/`create` com o payload completo) deixa o atacante setar qualquer coluna, mesmo as que não estão no formulário. Alvos clássicos: `role`, `is_admin`, `plan`, `balance`, `payment_status`, `user_id`/`owner_id`.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// ERRADO: o cliente manda { name: "x", role: "admin" } e vira admin
|
|
54
|
+
await supabase.from("users").update({ ...body }).eq("id", userId)
|
|
55
|
+
|
|
56
|
+
// CERTO: allowlist explícita, só os campos do formulário
|
|
57
|
+
const { name, bio } = updateSchema.parse(body) // Zod com só os campos permitidos
|
|
58
|
+
await supabase.from("users").update({ name, bio }).eq("id", userId)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Regra: **nunca** `...body` / `...req.body` num write. Sempre desestruture (ou valide com um schema que só contém os campos editáveis). Campos de papel, permissão, plano, saldo, status de pagamento e id de dono nunca vêm do cliente.
|
|
62
|
+
|
|
31
63
|
## 2. Cryptographic Failures
|
|
32
64
|
- HTTPS em tudo, sem exceção
|
|
33
65
|
- HSTS header com includeSubDomains
|
|
@@ -15,6 +15,7 @@ Use esta tabela **só fora de projeto Wizz** (sem `_wizz/`), quando o router map
|
|
|
15
15
|
| Auth, secrets, tokens, OAuth, JWT, Clerk, permissões | `auth-and-secrets` + `web-security` | 1 |
|
|
16
16
|
| Dependências, packages, vulnerabilidades, npm audit | `database-and-deps` | 2 |
|
|
17
17
|
| Segurança, XSS, CSRF, SQLi, IDOR, OWASP, rate limit, CORS, clickjacking, PII na resposta, enumeração de usuário, headers | `web-security` + `auth-and-secrets` | 1 |
|
|
18
|
+
| Auditoria/pentest de segurança do app inteiro, "auditar segurança", "pentest", "encontrar vulnerabilidades", varredura antes de release | `security-audit-pentest` | 1 |
|
|
18
19
|
| Desktop, Electron, contextIsolation, code signing | `desktop-security` | 2 |
|
|
19
20
|
|
|
20
21
|
## Área Técnica — Código e Qualidade
|
|
@@ -29,17 +29,36 @@
|
|
|
29
29
|
// placeholder stays in `.mcp.json`, and the summary explains how to
|
|
30
30
|
// configure it later.
|
|
31
31
|
//
|
|
32
|
+
// GLOBAL KEY STORE (`~/.claude/wizz-env.json`): a key the user already typed
|
|
33
|
+
// in ANY project is reused silently in every new install — the store is read
|
|
34
|
+
// by the installer only (C7 still holds: the runtime never reads it), and a
|
|
35
|
+
// hit is copied into the new project's `settings.local.json`. A key typed at
|
|
36
|
+
// the prompt is saved to the store too, so it is only ever asked once.
|
|
37
|
+
//
|
|
32
38
|
// API (decomposed per E3 so each piece is unit-testable without a TTY):
|
|
33
39
|
// extractEnvPlaceholders(mcps) — pure
|
|
34
40
|
// resolveEnvVars(vars, opts) — I/O read (providers + prompt)
|
|
35
41
|
// persistEnvValues(toPersist, opts) — I/O write (settings.local.json)
|
|
36
42
|
// persistProjectEnv(projectDir, envRecord) — the actual writer, reusable
|
|
43
|
+
// persistGlobalEnv(storePath, envRecord) — writer do store global
|
|
37
44
|
// promptMissingEnvVars(mcps, opts) — thin orchestrator of the above
|
|
38
45
|
|
|
39
46
|
const path = require('node:path');
|
|
47
|
+
const os = require('node:os');
|
|
40
48
|
const fs = require('../fs-native');
|
|
41
49
|
const prompts = require('../prompts');
|
|
42
50
|
|
|
51
|
+
// Global key store, read by the INSTALLER only (never by the Claude Code
|
|
52
|
+
// runtime — C7 still holds). A value found here is copied into the project's
|
|
53
|
+
// `.claude/settings.local.json` at install time, which IS what reaches the
|
|
54
|
+
// MCP subprocess. This is what makes a key typed once in project A resolve
|
|
55
|
+
// silently in projects B, C, D... without ever living in the global
|
|
56
|
+
// settings.json `env` (which would expose it to every session of every
|
|
57
|
+
// project — the exact pattern the 360° audit flagged as a security critical).
|
|
58
|
+
function defaultGlobalEnvPath() {
|
|
59
|
+
return path.join(os.homedir(), '.claude', 'wizz-env.json');
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
// Deliberately POSIX-strict (uppercase + underscore only): this both matches
|
|
44
63
|
// standard env var naming and doubles as a defensive filter against false
|
|
45
64
|
// positives like `{bin}` (no `$` prefix at all, so it never matches) or a
|
|
@@ -168,12 +187,51 @@ function createDotenvFileProvider(dotenvPath) {
|
|
|
168
187
|
}
|
|
169
188
|
|
|
170
189
|
/**
|
|
171
|
-
*
|
|
190
|
+
* The global key store (`~/.claude/wizz-env.json`, flat `{ "VAR": "value" }`
|
|
191
|
+
* map, chmod 600) as a provider. Read-only here — `persistGlobalEnv` is the
|
|
192
|
+
* writer. Marked `persistToProject: true`: unlike `process.env`, a value from
|
|
193
|
+
* this store is NOT in the runtime's environment, so the resolver must copy
|
|
194
|
+
* it into the project's `settings.local.json` for it to actually reach the
|
|
195
|
+
* MCP subprocess (C7).
|
|
196
|
+
* @param {string} storePath - Absolute path to the global store file
|
|
197
|
+
* @returns {{name: string, persistToProject: boolean, available: () => Promise<boolean>, get: (name: string) => Promise<string|undefined>}}
|
|
198
|
+
*/
|
|
199
|
+
function createGlobalStoreProvider(storePath) {
|
|
200
|
+
let cache = null;
|
|
201
|
+
|
|
202
|
+
async function load() {
|
|
203
|
+
if (cache) return cache;
|
|
204
|
+
cache = {};
|
|
205
|
+
if (!storePath || !(await fs.pathExists(storePath))) return cache;
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(await fs.readFile(storePath, 'utf8'));
|
|
208
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) cache = parsed;
|
|
209
|
+
} catch {
|
|
210
|
+
// Malformed/unreadable store — treat as empty, never throw.
|
|
211
|
+
cache = {};
|
|
212
|
+
}
|
|
213
|
+
return cache;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
name: 'global-store',
|
|
218
|
+
persistToProject: true,
|
|
219
|
+
available: async () => !!storePath && (await fs.pathExists(storePath)),
|
|
220
|
+
get: async (name) => {
|
|
221
|
+
const value = (await load())[name];
|
|
222
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Try each provider in order, returning the first non-empty value found and
|
|
229
|
+
* the provider that had it (so the caller can honor `persistToProject`).
|
|
172
230
|
* A provider throwing or being unavailable is skipped, never fatal — one
|
|
173
231
|
* broken provider must not block the chain (e.g. an unreadable `.env`).
|
|
174
232
|
* @param {string} name - Var name to look up
|
|
175
233
|
* @param {Array<Object>} providers
|
|
176
|
-
* @returns {Promise<string|undefined>}
|
|
234
|
+
* @returns {Promise<{value: string, provider: Object}|undefined>}
|
|
177
235
|
*/
|
|
178
236
|
async function findInProviders(name, providers) {
|
|
179
237
|
for (const provider of providers || []) {
|
|
@@ -182,7 +240,7 @@ async function findInProviders(name, providers) {
|
|
|
182
240
|
const isAvailable = typeof provider.available === 'function' ? await provider.available() : true;
|
|
183
241
|
if (!isAvailable) continue;
|
|
184
242
|
const value = await provider.get(name);
|
|
185
|
-
if (value !== undefined && value !== null && value !== '') return value;
|
|
243
|
+
if (value !== undefined && value !== null && value !== '') return { value, provider };
|
|
186
244
|
} catch {
|
|
187
245
|
continue;
|
|
188
246
|
}
|
|
@@ -221,7 +279,14 @@ function createDefaultPrompter() {
|
|
|
221
279
|
* @param {boolean} [opts.interactive=false] - Whether to prompt for missing vars
|
|
222
280
|
* @param {Array<Object>} [opts.providers] - Provider chain, tried in order
|
|
223
281
|
* @param {(entry) => Promise<string|undefined>} [opts.prompter] - Injectable prompt fn
|
|
224
|
-
* @returns {Promise<{filled: Array, skipped: Array, existing: Array,
|
|
282
|
+
* @returns {Promise<{filled: Array, skipped: Array, existing: Array, imported: Array,
|
|
283
|
+
* toPersist: Record<string,string>, toPersistGlobal: Record<string,string>}>}
|
|
284
|
+
* `existing` = found in a runtime-visible source (process.env/.env), nothing
|
|
285
|
+
* to write. `imported` = found in a `persistToProject` provider (the global
|
|
286
|
+
* store): resolved without prompting, but must be written to the project's
|
|
287
|
+
* settings.local.json (included in `toPersist`). `toPersistGlobal` = the
|
|
288
|
+
* subset of typed answers that should ALSO be saved to the global store so
|
|
289
|
+
* the next project never asks.
|
|
225
290
|
*/
|
|
226
291
|
async function resolveEnvVars(vars, opts = {}) {
|
|
227
292
|
const { interactive = false, providers = [createProcessEnvProvider()], prompter = null } = opts;
|
|
@@ -229,7 +294,9 @@ async function resolveEnvVars(vars, opts = {}) {
|
|
|
229
294
|
const filled = [];
|
|
230
295
|
const skipped = [];
|
|
231
296
|
const existing = [];
|
|
297
|
+
const imported = [];
|
|
232
298
|
const toPersist = {};
|
|
299
|
+
const toPersistGlobal = {};
|
|
233
300
|
|
|
234
301
|
for (const entry of vars || []) {
|
|
235
302
|
// A default is resolved by the Claude Code runtime itself; asking would
|
|
@@ -242,7 +309,12 @@ async function resolveEnvVars(vars, opts = {}) {
|
|
|
242
309
|
|
|
243
310
|
const found = await findInProviders(entry.name, providers);
|
|
244
311
|
if (found !== undefined) {
|
|
245
|
-
|
|
312
|
+
if (found.provider && found.provider.persistToProject) {
|
|
313
|
+
imported.push(entry);
|
|
314
|
+
toPersist[entry.name] = found.value;
|
|
315
|
+
} else {
|
|
316
|
+
existing.push(entry);
|
|
317
|
+
}
|
|
246
318
|
continue;
|
|
247
319
|
}
|
|
248
320
|
|
|
@@ -259,9 +331,10 @@ async function resolveEnvVars(vars, opts = {}) {
|
|
|
259
331
|
|
|
260
332
|
filled.push(entry);
|
|
261
333
|
toPersist[entry.name] = answer;
|
|
334
|
+
toPersistGlobal[entry.name] = answer;
|
|
262
335
|
}
|
|
263
336
|
|
|
264
|
-
return { filled, skipped, existing, toPersist };
|
|
337
|
+
return { filled, skipped, existing, imported, toPersist, toPersistGlobal };
|
|
265
338
|
}
|
|
266
339
|
|
|
267
340
|
/**
|
|
@@ -352,23 +425,80 @@ async function persistEnvValues(toPersist, opts = {}) {
|
|
|
352
425
|
return persistProjectEnv(projectDir, toPersist);
|
|
353
426
|
}
|
|
354
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Merge `envRecord` into the flat global store (`~/.claude/wizz-env.json`).
|
|
430
|
+
* Additive only — a key already present is never overwritten (same rule as
|
|
431
|
+
* `persistProjectEnv`; a prompt only ever fires for a var no provider had,
|
|
432
|
+
* so an overwrite here would always mean clobbering something newer). File
|
|
433
|
+
* is chmod 600 after any write that changed it, same best-effort semantics
|
|
434
|
+
* as the project writer.
|
|
435
|
+
*
|
|
436
|
+
* @param {string} storePath - Absolute path to the global store file
|
|
437
|
+
* @param {Record<string,string>} envRecord - Vars to merge
|
|
438
|
+
* @returns {Promise<string|null>} Path written/merged, or null when empty
|
|
439
|
+
*/
|
|
440
|
+
async function persistGlobalEnv(storePath, envRecord) {
|
|
441
|
+
if (!storePath || !envRecord || Object.keys(envRecord).length === 0) return null;
|
|
442
|
+
|
|
443
|
+
await fs.ensureDir(path.dirname(storePath));
|
|
444
|
+
|
|
445
|
+
let store = {};
|
|
446
|
+
if (await fs.pathExists(storePath)) {
|
|
447
|
+
try {
|
|
448
|
+
const parsed = JSON.parse(await fs.readFile(storePath, 'utf8'));
|
|
449
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) store = parsed;
|
|
450
|
+
} catch {
|
|
451
|
+
// A corrupt store must never eat keys the user just typed: keep the
|
|
452
|
+
// broken file aside and start a fresh store with the new values.
|
|
453
|
+
try {
|
|
454
|
+
await fs.rename(storePath, `${storePath}.bak`);
|
|
455
|
+
} catch {
|
|
456
|
+
// Even the rename failing must not block the install.
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let changed = false;
|
|
462
|
+
for (const [key, value] of Object.entries(envRecord)) {
|
|
463
|
+
if (Object.prototype.hasOwnProperty.call(store, key)) continue;
|
|
464
|
+
store[key] = value;
|
|
465
|
+
changed = true;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (changed) {
|
|
469
|
+
await fs.writeJson(storePath, store, { spaces: 2 });
|
|
470
|
+
if (process.platform !== 'win32') {
|
|
471
|
+
try {
|
|
472
|
+
await fs.chmod(storePath, 0o600);
|
|
473
|
+
} catch {
|
|
474
|
+
// Best-effort, same as persistProjectEnv.
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return storePath;
|
|
480
|
+
}
|
|
481
|
+
|
|
355
482
|
/**
|
|
356
483
|
* Render the DX summary (E6) — the highest-value part of this feature: turn
|
|
357
484
|
* a skipped var from a dead end into a 30-second fix. One line per var,
|
|
358
485
|
* grouped by outcome. Never includes a raw secret value, only names/status.
|
|
359
|
-
* @param {{filled: Array, skipped: Array, existing: Array}} resolved
|
|
486
|
+
* @param {{filled: Array, skipped: Array, existing: Array, imported: Array}} resolved
|
|
360
487
|
* @returns {string|null} Formatted block, or null when there is nothing to say
|
|
361
488
|
*/
|
|
362
|
-
function formatSummary({ filled, skipped, existing }) {
|
|
363
|
-
const total = (filled?.length || 0) + (skipped?.length || 0) + (existing?.length || 0);
|
|
489
|
+
function formatSummary({ filled, skipped, existing, imported }) {
|
|
490
|
+
const total = (filled?.length || 0) + (skipped?.length || 0) + (existing?.length || 0) + (imported?.length || 0);
|
|
364
491
|
if (total === 0) return null;
|
|
365
492
|
|
|
366
493
|
const lines = [];
|
|
367
494
|
for (const entry of existing || []) {
|
|
368
495
|
lines.push(` ✓ ${entry.name.padEnd(28)} já existia no ambiente`);
|
|
369
496
|
}
|
|
497
|
+
for (const entry of imported || []) {
|
|
498
|
+
lines.push(` ✓ ${entry.name.padEnd(28)} importada do global (~/.claude/wizz-env.json)`);
|
|
499
|
+
}
|
|
370
500
|
for (const entry of filled || []) {
|
|
371
|
-
lines.push(` ✓ ${entry.name.padEnd(28)} configurada agora (
|
|
501
|
+
lines.push(` ✓ ${entry.name.padEnd(28)} configurada agora (+ salva no global p/ próximos projetos)`);
|
|
372
502
|
}
|
|
373
503
|
for (const entry of skipped || []) {
|
|
374
504
|
if (entry.hasDefault) {
|
|
@@ -403,18 +533,26 @@ function formatSummary({ filled, skipped, existing }) {
|
|
|
403
533
|
* @param {string} opts.projectDir - Project root (for persistence + the
|
|
404
534
|
* default `.env` provider)
|
|
405
535
|
* @param {boolean} [opts.interactive=false]
|
|
406
|
-
* @param {Array<Object>} [opts.providers] - Defaults to `[processEnv,
|
|
536
|
+
* @param {Array<Object>} [opts.providers] - Defaults to `[processEnv,
|
|
537
|
+
* dotenvFile(<projectDir>/.env), globalStore(~/.claude/wizz-env.json)]`
|
|
538
|
+
* @param {string} [opts.globalEnvPath] - Global store path (default
|
|
539
|
+
* `~/.claude/wizz-env.json`); used for both the default provider chain and
|
|
540
|
+
* the save-on-prompt write. Injectable so tests never touch the real home.
|
|
407
541
|
* @param {(entry) => Promise<string|undefined>} [opts.prompter] - Defaults to
|
|
408
542
|
* the masked `password()` prompter
|
|
409
|
-
* @returns {Promise<{filled: Array, skipped: Array, existing: Array, envFile: string|null}>}
|
|
543
|
+
* @returns {Promise<{filled: Array, skipped: Array, existing: Array, imported: Array, envFile: string|null}>}
|
|
410
544
|
*/
|
|
411
545
|
async function promptMissingEnvVars(mcps, opts = {}) {
|
|
412
|
-
const { projectDir, interactive = false, providers, prompter } = opts;
|
|
546
|
+
const { projectDir, interactive = false, providers, prompter, globalEnvPath = defaultGlobalEnvPath() } = opts;
|
|
413
547
|
|
|
414
548
|
const vars = extractEnvPlaceholders(mcps);
|
|
415
|
-
if (vars.length === 0) return { filled: [], skipped: [], existing: [], envFile: null };
|
|
549
|
+
if (vars.length === 0) return { filled: [], skipped: [], existing: [], imported: [], envFile: null };
|
|
416
550
|
|
|
417
|
-
const resolvedProviders = providers || [
|
|
551
|
+
const resolvedProviders = providers || [
|
|
552
|
+
createProcessEnvProvider(),
|
|
553
|
+
createDotenvFileProvider(path.join(projectDir, '.env')),
|
|
554
|
+
createGlobalStoreProvider(globalEnvPath),
|
|
555
|
+
];
|
|
418
556
|
const resolvedPrompter = interactive ? prompter || createDefaultPrompter() : null;
|
|
419
557
|
|
|
420
558
|
const resolved = await resolveEnvVars(vars, {
|
|
@@ -428,10 +566,27 @@ async function promptMissingEnvVars(mcps, opts = {}) {
|
|
|
428
566
|
envFile = await persistEnvValues(resolved.toPersist, { projectDir, target: 'settings-local' });
|
|
429
567
|
}
|
|
430
568
|
|
|
569
|
+
// Typed answers also go to the global store so the NEXT project resolves
|
|
570
|
+
// them silently. Failure here must never block the install — the project
|
|
571
|
+
// write above already succeeded, which is what this install needs.
|
|
572
|
+
if (Object.keys(resolved.toPersistGlobal).length > 0) {
|
|
573
|
+
try {
|
|
574
|
+
await persistGlobalEnv(globalEnvPath, resolved.toPersistGlobal);
|
|
575
|
+
} catch {
|
|
576
|
+
// Global save is a convenience for future installs, never a blocker.
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
431
580
|
const summary = formatSummary(resolved);
|
|
432
581
|
if (summary) await prompts.log.info(summary);
|
|
433
582
|
|
|
434
|
-
return {
|
|
583
|
+
return {
|
|
584
|
+
filled: resolved.filled,
|
|
585
|
+
skipped: resolved.skipped,
|
|
586
|
+
existing: resolved.existing,
|
|
587
|
+
imported: resolved.imported,
|
|
588
|
+
envFile,
|
|
589
|
+
};
|
|
435
590
|
}
|
|
436
591
|
|
|
437
592
|
module.exports = {
|
|
@@ -439,8 +594,11 @@ module.exports = {
|
|
|
439
594
|
resolveEnvVars,
|
|
440
595
|
persistEnvValues,
|
|
441
596
|
persistProjectEnv,
|
|
597
|
+
persistGlobalEnv,
|
|
442
598
|
promptMissingEnvVars,
|
|
443
599
|
createProcessEnvProvider,
|
|
444
600
|
createDotenvFileProvider,
|
|
601
|
+
createGlobalStoreProvider,
|
|
602
|
+
defaultGlobalEnvPath,
|
|
445
603
|
formatSummary,
|
|
446
604
|
};
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// a `claude mcp add` command renderer for the recommend path.
|
|
20
20
|
|
|
21
21
|
const path = require('node:path');
|
|
22
|
+
const os = require('node:os');
|
|
22
23
|
const crypto = require('node:crypto');
|
|
23
24
|
const fs = require('../fs-native');
|
|
24
25
|
const { defaultExec } = require('./cli-config');
|
|
@@ -325,6 +326,48 @@ async function partitionAlreadyConfigured({ projectDir, mcps }) {
|
|
|
325
326
|
return { toPrepare, alreadyConfigured };
|
|
326
327
|
}
|
|
327
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Split resolved MCP entries into those still worth offering/installing in
|
|
331
|
+
* the project and those the user already configured GLOBALLY (user scope, the
|
|
332
|
+
* `mcpServers` key of `~/.claude.json`). A server configured there is live in
|
|
333
|
+
* every project already — usually with the real key embedded — so writing the
|
|
334
|
+
* registry's `${VAR}`-placeholder copy into the project `.mcp.json` would at
|
|
335
|
+
* best duplicate it and at worst shadow a working global config with a broken
|
|
336
|
+
* placeholder one, then prompt the user for a key they already provided.
|
|
337
|
+
*
|
|
338
|
+
* Read failures (missing/malformed `~/.claude.json`) fall back to "nothing is
|
|
339
|
+
* global", the safe default: worst case the user sees the old behavior.
|
|
340
|
+
*
|
|
341
|
+
* @param {Object} args
|
|
342
|
+
* @param {Array<{id: string}>} args.mcps - Resolved MCP entries to partition
|
|
343
|
+
* @param {string} [args.claudeJsonPath] - Override of `~/.claude.json` (tests)
|
|
344
|
+
* @returns {Promise<{toInstall: Array<Object>, globallyConfigured: string[]}>}
|
|
345
|
+
*/
|
|
346
|
+
async function partitionGloballyConfigured({ mcps, claudeJsonPath }) {
|
|
347
|
+
if (!mcps || mcps.length === 0) return { toInstall: [], globallyConfigured: [] };
|
|
348
|
+
|
|
349
|
+
const file = claudeJsonPath || path.join(os.homedir(), '.claude.json');
|
|
350
|
+
let globalIds = new Set();
|
|
351
|
+
if (await fs.pathExists(file)) {
|
|
352
|
+
try {
|
|
353
|
+
const config = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
354
|
+
const servers =
|
|
355
|
+
config && typeof config === 'object' && config.mcpServers && !Array.isArray(config.mcpServers) ? config.mcpServers : null;
|
|
356
|
+
if (servers) globalIds = new Set(Object.keys(servers));
|
|
357
|
+
} catch {
|
|
358
|
+
globalIds = new Set();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const toInstall = [];
|
|
363
|
+
const globallyConfigured = [];
|
|
364
|
+
for (const mcp of mcps) {
|
|
365
|
+
if (mcp && mcp.id && globalIds.has(mcp.id)) globallyConfigured.push(mcp.id);
|
|
366
|
+
else toInstall.push(mcp);
|
|
367
|
+
}
|
|
368
|
+
return { toInstall, globallyConfigured };
|
|
369
|
+
}
|
|
370
|
+
|
|
328
371
|
/**
|
|
329
372
|
* Merge the chosen MCP entries into `<projectDir>/.mcp.json`, additively.
|
|
330
373
|
* Reads any existing file (preserving unknown keys and existing servers),
|
|
@@ -442,6 +485,7 @@ module.exports = {
|
|
|
442
485
|
prepareMcp,
|
|
443
486
|
prepareMcps,
|
|
444
487
|
partitionAlreadyConfigured,
|
|
488
|
+
partitionGloballyConfigured,
|
|
445
489
|
resolveBinPath,
|
|
446
490
|
shellQuote,
|
|
447
491
|
substituteBin,
|
package/tools/installer/ui.js
CHANGED
|
@@ -17,7 +17,7 @@ const {
|
|
|
17
17
|
bundledTargetWarnings,
|
|
18
18
|
} = require('./modules/channel-plan');
|
|
19
19
|
const channelResolver = require('./modules/channel-resolver');
|
|
20
|
-
const { resolveMcps } = require('./modules/mcp-config');
|
|
20
|
+
const { resolveMcps, partitionGloballyConfigured } = require('./modules/mcp-config');
|
|
21
21
|
const { resolveClis, detectClis } = require('./modules/cli-config');
|
|
22
22
|
const prompts = require('./prompts');
|
|
23
23
|
const { parseSetEntries } = require('./set-overrides');
|
|
@@ -571,9 +571,21 @@ class UI {
|
|
|
571
571
|
if (!selectedModules.includes('bmm')) return { toWrite: [], toRecommend: [] };
|
|
572
572
|
|
|
573
573
|
const registry = this._loadSkillsRegistry();
|
|
574
|
-
|
|
574
|
+
let resolved = resolveMcps(registry, selectedAreas);
|
|
575
575
|
if (resolved.length === 0) return { toWrite: [], toRecommend: [] };
|
|
576
576
|
|
|
577
|
+
// A server the user already configured globally (user scope, `mcpServers`
|
|
578
|
+
// in ~/.claude.json) is live in every project — offering it again would
|
|
579
|
+
// duplicate config and re-prompt for a key that already works. Filter it
|
|
580
|
+
// out of every path below (multiselect, --mcps, --yes) with an info line
|
|
581
|
+
// so nothing disappears silently.
|
|
582
|
+
const { toInstall, globallyConfigured } = await partitionGloballyConfigured({ mcps: resolved });
|
|
583
|
+
if (globallyConfigured.length > 0) {
|
|
584
|
+
await prompts.log.info(`MCPs já configurados no seu Claude global (~/.claude.json), pulados: ${globallyConfigured.join(', ')}`);
|
|
585
|
+
resolved = toInstall;
|
|
586
|
+
if (resolved.length === 0) return { toWrite: [], toRecommend: [] };
|
|
587
|
+
}
|
|
588
|
+
|
|
577
589
|
const byId = new Map(resolved.map((m) => [m.id, m]));
|
|
578
590
|
const split = (writeIds) => {
|
|
579
591
|
const writeSet = new Set(writeIds);
|