Relatório
GET /scans/{id}/report e /report.pdf — o relatório completo, o PDF e como o plano afeta o conserto.
Relatório completo de uma ronda
/v1/public/scans/{id}/reportescopo: scans:readAchados, evidências, camada leiga, conserto pronto, análise da IA, inventário de infra e o que o app já acerta. É o mesmo documento que o painel mostra e o PDF imprime — passa pelo mesmo preparo: o que o seu plano não destrava na tela também não sai por aqui.
O relatório só existe quando a ronda produziu resultado. Antes disso (e numa ronda que falhou sem gerar nada) a resposta é 404 — estado normal de uma ronda em andamento, não erro de integração. Consulte GET /scans/{id} pra saber quando pedir.
fix ausente com fixWithheld: true. Hoje todo plano com API também destrava o conserto, mas o gate é avaliado a cada requisição a partir do plano naquele instante — uma integração que assume fix sempre presente quebra no dia em que o plano muda.Parâmetros de rota
| Parâmetro | Descrição |
|---|---|
idstringobrigatório | Id da ronda (scan_…), devolvido por POST /scans ou por GET /scans. |
Requisição
curl https://api.guarita.dev/v1/public/scans/scan_7b3e9a12/report \
-H "Authorization: Bearer $GUARITA_API_KEY"Resposta 200
O relatório (recortado — um relatório real tem dezenas de achados). Objeto Report.
{
"schemaVersion": "1.0",
"scan": {
"id": "scan_7b3e9a12",
"target": "https://app.exemplo.com.br",
"hostname": "app.exemplo.com.br",
"stack": [
"Lovable",
"Supabase",
"Vercel"
],
"status": "done",
"startedAt": "2026-08-27T14:02:57.010Z",
"finishedAt": "2026-08-27T14:09:24.106Z",
"durationMs": 387096,
"modulesRun": 37
},
"findings": [
{
"id": "SECRETS-001",
"module": "secrets",
"severity": "critical",
"cvss": 9.8,
"title": "Supabase service_role key exposta no bundle JS",
"description": "Chave de serviço (bypassa RLS) encontrada em index-CBWCsxTw.js. Concede acesso administrativo total à API de dados.",
"evidence": "eyJhbGciOiJIUzI1Ni… · role: \"service_role\" · fonte: app.exemplo.com.br/assets/index-CBWCsxTw.js",
"recommendation": "Rotacione a service_role e use apenas a anon key no client.",
"confidence": "firm",
"friendly": {
"headline": "Sua \"senha mestra\" do banco está exposta no site",
"whatItIs": "A service_role é a chave de administrador do seu Supabase. Ela está escrita no JavaScript que qualquer visitante baixa só de abrir seu site.",
"whyItMatters": "Com ela, qualquer pessoa pode ler, alterar ou apagar todos os dados do seu app.",
"urgency": "now"
},
"fix": {
"kind": "code",
"targets": [
"lovable",
"cursor",
"v0",
"bolt"
],
"prompt": "Remova a chave service_role do Supabase de todo o código do front-end…",
"code": "const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)"
}
},
{
"id": "CORS-001",
"module": "cors",
"severity": "high",
"cvss": 7.5,
"title": "CORS reflete qualquer Origin com credenciais",
"description": "A API devolve Access-Control-Allow-Origin igual ao Origin recebido, com credentials.",
"evidence": "Origin: https://evil.test → Access-Control-Allow-Origin: https://evil.test",
"recommendation": "Restrinja as origens permitidas ao seu domínio.",
"confidence": "confirmed",
"fixWithheld": true
}
],
"ai": {
"executive_summary": "O app expõe uma credencial administrativa do banco e opera com RLS desligado…",
"risk_score": {
"score": 78,
"label": "HIGH_RISK",
"justification": "Credencial privilegiada exposta + RLS desabilitado permitem takeover total do banco."
},
"risk_scenarios": [],
"owasp_mapping": [
{
"category": "A02:2021-Cryptographic Failures",
"finding_ids": [
"SECRETS-001"
],
"status": "vulnerable"
}
],
"remediation_plan": [
{
"priority": 1,
"finding_ids": [
"SECRETS-001"
],
"action": "Esconder a chave de administrador do Supabase",
"effort": "quick_win",
"impact_if_not_fixed": "Acesso total ao banco por qualquer visitante."
}
],
"additional_insights": [],
"infra_inventory": [
{
"provider": "supabase",
"asset": "plsvzxvydhkvlxbtxgrs.supabase.co",
"kind": "baas_project",
"exposure": "credential_leaked",
"notes": "service_role exposta no bundle."
}
],
"cross_infra_chains": [],
"discovered_vulnerabilities": []
},
"infra": {
"assets": [],
"credentials": [],
"providers": [
"supabase",
"gcp",
"vercel"
],
"scopeHosts": [
"app.exemplo.com.br",
"plsvzxvydhkvlxbtxgrs.supabase.co"
],
"notes": []
},
"strengths": [
"HTTPS ativo e certificado válido",
"Nenhuma chave do Stripe exposta"
]
}Campos da resposta
| Campo | Descrição |
|---|---|
schemaVersionstringsempre | Versão do formato do relatório. |
scanReportScansempre | Metadados da execução. |
findingsFinding[]sempre | Os achados, do mais grave pro menos grave. |
aiAiAssessmentsempre | Análise da IA. Numa ronda básica (ai: false), bloqueada ou parcial, vem com o risk_score calculado e os demais blocos vazios. |
infraInfraInventorysempre | Inventário de infraestrutura observado (provedores, ativos, credenciais). |
strengthsstring[] | O que o app já acerta — boas práticas comprovadas na ronda. |
lgpd | Pré-diagnóstico LGPD. Só quando a ronda incluiu os módulos LGPD (lgpd: true em POST /scans, ou a opção da tela). |
Erros
- 404not_foundRonda inexistente, de outra conta, ou ainda sem relatório.
- 401unauthorizedChave ausente, fora do formato
Bearer gsk_…, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o headerWWW-Authenticate. - 403insufficient_scopeA chave é válida, mas não tem o escopo da rota.
- 400bad_requestCorpo malformado (JSON inválido). O status acompanha o motivo:
413com corpo acima de 1 MB,415comContent-Typeque não seja JSON — o códigoerrorcontinuabad_request. - 500internalErro nosso.
Relatório de uma ronda em PDF
/v1/public/scans/{id}/report.pdfescopo: scans:readO mesmo documento que o botão "Salvar PDF" do painel gera — pra anexar no chamado, arquivar no CI ou mandar pro cliente. Passa pelo MESMO preparo e pela mesma trava de plano do relatório em JSON: o que não sai na tela não sai aqui. No Business, vem com a marca da conta.
Só existe quando a ronda produziu resultado; antes disso a resposta é 404, como em GET /scans/{id}/report.
Content-Disposition: guarita-<hostname>.pdf — ou <marca>-<hostname>.pdf quando a conta tem marca própria (Business). Vem com Cache-Control: no-store: o conteúdo depende do plano e das marcações do momento, não guarde em cache intermediário.GET /scans/{id}/report continua trazendo todos.Parâmetros de rota
| Parâmetro | Descrição |
|---|---|
idstringobrigatório | Id da ronda (scan_…), devolvido por POST /scans ou por GET /scans. |
Requisição
curl https://api.guarita.dev/v1/public/scans/scan_7b3e9a12/report.pdf \
-H "Authorization: Bearer $GUARITA_API_KEY" \
-o relatorio.pdfResposta 200
O PDF, como anexo (`Content-Disposition: attachment`). O corpo é o arquivo, não JSON: salve em disco em vez de chamar res.json(). Os erros continuam em JSON, na forma de sempre.
| Header | Valor |
|---|---|
Content-Type | application/pdf |
Content-Disposition | attachment; filename="guarita-app.exemplo.com.br.pdf" — no Business com marca própria, o slug da marca no lugar de guarita. |
Cache-Control | no-store |
Erros
- 404not_foundRonda inexistente, de outra conta, ou ainda sem relatório.
- 401unauthorizedChave ausente, fora do formato
Bearer gsk_…, inexistente, revogada — ou o plano da conta deixou de incluir a API. O corpo é o MESMO em todos os casos, de propósito. Vem com o headerWWW-Authenticate. - 403insufficient_scopeA chave é válida, mas não tem o escopo da rota.
- 400bad_requestCorpo malformado (JSON inválido). O status acompanha o motivo:
413com corpo acima de 1 MB,415comContent-Typeque não seja JSON — o códigoerrorcontinuabad_request. - 500internalErro nosso.
A estrutura
Cinco blocos no topo. Os objetos aninhados estão em Objetos.
| Campo | Descrição |
|---|---|
schemaVersionstringsempre | Versão do formato do relatório. |
scanReportScansempre | Metadados da execução. |
findingsFinding[]sempre | Os achados, do mais grave pro menos grave. |
aiAiAssessmentsempre | Análise da IA. Numa ronda básica (ai: false), bloqueada ou parcial, vem com o risk_score calculado e os demais blocos vazios. |
infraInfraInventorysempre | Inventário de infraestrutura observado (provedores, ativos, credenciais). |
strengthsstring[] | O que o app já acerta — boas práticas comprovadas na ronda. |
lgpd | Pré-diagnóstico LGPD. Só quando a ronda incluiu os módulos LGPD (lgpd: true em POST /scans, ou a opção da tela). |
Um achado
Cada item de findings[] traz o achado técnico, a camada leiga (friendly) e, quando o plano destrava, o conserto (fix). A ordem é do mais grave pro menos grave.
| Campo | Descrição |
|---|---|
idstringsempre | Id do achado DENTRO desta ronda. Renumera entre rondas — pra casar achados entre rondas use module + title. |
modulestringsempre | Verificação que produziu o achado (secrets, cors, headers, supabase…). |
titlestringsempre | Título técnico. Estável entre rondas (é a chave de identidade do achado). |
severitystringsempre | Gravidade.criticalhighmediumlowinfo |
cvssnumbersempre | Pontuação CVSS. |
descriptionstringsempre | O que foi encontrado, em detalhe. |
evidencestringsempre | A prova observada: requisição, resposta, trecho do bundle. Valores sensíveis vêm encurtados com … — a ronda não guarda o segredo inteiro. |
recommendationstringsempre | Recomendação curta do scanner (não é o conserto pronto). |
confidencestring | confirmed = provado por observação ativa · firm = detecção determinística · tentative = heurística que pede confirmação. Ausente = firm.confirmedfirmtentative |
goodboolean | Achado POSITIVO: uma boa prática comprovada, não um problema. |
linksstring[] | Ids de achados relacionados — hoje, os elos de uma cadeia de ataque (module: "chain"). |
friendly | A versão em português de gente. |
fix | O conserto pronto. Ausente quando retido pelo plano — aí vem fixWithheld: true. |
fixWithheldboolean | O conserto existe, mas não está nesta resposta (plano sem análise com IA). Diz "foi retido", pra não parecer que a ronda não analisou. |
fixSampleboolean | Este é o achado-AMOSTRA da conta: o conserto veio inteiro mesmo sem plano. Uma por conta, não por ronda. |
fixFromCatalogboolean | O conserto veio do catálogo determinístico, não da IA (ronda básica). |
| severity | Quando |
|---|---|
critical | Invasão ou vazamento provável agora (credencial exposta, dados abertos). |
high | Brecha séria que pede correção esta semana. |
medium | Trava de proteção faltando; abre caminho combinada com outras. |
low | Reforço. |
info | Observação — ou um acerto, quando `good: true`. |
O conserto e o plano
O relatório — em JSON e em PDF — passa pelo mesmo preparo do painel. Qualquer plano com cota de IA vê o conserto inteiro; conta sem cota fica sem ele. A regra é da conta, vale retroativamente pra todas as rondas, e é avaliada a cada requisição. No PDF, os achados que você marcou como alarme falso no painel ficam de fora, e a marca da conta (Business) vai no documento e no nome do arquivo.
Quando o conserto é retido, fix não vem e no lugar aparece fixWithheld: true:
{
"id": "CORS-001",
"module": "cors",
"severity": "high",
"cvss": 7.5,
"title": "CORS reflete qualquer Origin com credenciais",
"description": "A API devolve Access-Control-Allow-Origin igual ao Origin recebido, com credentials.",
"evidence": "Origin: https://evil.test → Access-Control-Allow-Origin: https://evil.test",
"recommendation": "Restrinja as origens permitidas ao seu domínio.",
"confidence": "confirmed",
"fixWithheld": true
}| Marcador | Significa |
|---|---|
fixWithheld: true | O conserto existe, mas não está nesta resposta. Sem ele, um `fix` ausente pareceria "a ronda não analisou". |
fixSample: true | Este é o achado-amostra da conta: o conserto veio inteiro mesmo sem plano. Uma por conta, não por ronda. |
fixFromCatalog: true | O conserto veio do catálogo determinístico, não da IA (ronda básica). Não anuncie "análise com IA" onde ela não rodou. |
fix. Não trate isso como garantia: escreva o consumidor tolerando fix ausente com fixWithheld: true.Casar achados entre rondas
id (SECRETS-001) renumera a cada ronda. Pra saber se o achado de hoje é o mesmo de ontem, use module + title — é a mesma chave que o plantão usa no diff e que o webhook finding.opened carrega em opened e resolved.
const chave = (f) => `${f.module}::${f.title}`;
const antes = new Set(anterior.findings.map(chave));
const novos = atual.findings.filter((f) => !antes.has(chave(f)) && !f.good);x-request-id, se for sobre uma resposta.