▸ API · Referência

Relatório

GET /scans/{id}/report e /report.pdf — o relatório completo, o PDF e como o plano afeta o conserto.

atualizado em 2 set 2026versão v1

Relatório completo de uma ronda

GET/v1/public/scans/{id}/reportescopo: scans:read

Achados, 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.

Parâmetros de rota

ParâmetroDescrição
id
stringobrigatório
Id da ronda (scan_…), devolvido por POST /scans ou por GET /scans.

Requisição

curl
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.

200 · application/json
{
  "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

CampoDescrição
schemaVersion
stringsempre
Versão do formato do relatório.
scanMetadados da execução.
findings
Finding[]sempre
Os achados, do mais grave pro menos grave.
aiAnálise da IA. Numa ronda básica (ai: false), bloqueada ou parcial, vem com o risk_score calculado e os demais blocos vazios.
infraInventário de infraestrutura observado (provedores, ativos, credenciais).
strengths
string[]
O que o app já acerta — boas práticas comprovadas na ronda.
lgpdPré-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 header WWW-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: 413 com corpo acima de 1 MB, 415 com Content-Type que não seja JSON — o código error continua bad_request.
  • 500internalErro nosso.

Relatório de uma ronda em PDF

GET/v1/public/scans/{id}/report.pdfescopo: scans:read

O 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.

Nome sugerido no 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.
Achados marcados como alarme falso no painel ficam de fora do PDF — o anexo que você arquiva não contradiz a tela. O JSON de GET /scans/{id}/report continua trazendo todos.

Parâmetros de rota

ParâmetroDescrição
id
stringobrigatório
Id da ronda (scan_…), devolvido por POST /scans ou por GET /scans.

Requisição

curl
curl https://api.guarita.dev/v1/public/scans/scan_7b3e9a12/report.pdf \
  -H "Authorization: Bearer $GUARITA_API_KEY" \
  -o relatorio.pdf

Resposta 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.

HeaderValor
Content-Typeapplication/pdf
Content-Dispositionattachment; filename="guarita-app.exemplo.com.br.pdf" — no Business com marca própria, o slug da marca no lugar de guarita.
Cache-Controlno-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 header WWW-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: 413 com corpo acima de 1 MB, 415 com Content-Type que não seja JSON — o código error continua bad_request.
  • 500internalErro nosso.

A estrutura

Cinco blocos no topo. Os objetos aninhados estão em Objetos.

CampoDescrição
schemaVersion
stringsempre
Versão do formato do relatório.
scanMetadados da execução.
findings
Finding[]sempre
Os achados, do mais grave pro menos grave.
aiAnálise da IA. Numa ronda básica (ai: false), bloqueada ou parcial, vem com o risk_score calculado e os demais blocos vazios.
infraInventário de infraestrutura observado (provedores, ativos, credenciais).
strengths
string[]
O que o app já acerta — boas práticas comprovadas na ronda.
lgpdPré-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.

CampoDescrição
id
stringsempre
Id do achado DENTRO desta ronda. Renumera entre rondas — pra casar achados entre rondas use module + title.
module
stringsempre
Verificação que produziu o achado (secrets, cors, headers, supabase…).
title
stringsempre
Título técnico. Estável entre rondas (é a chave de identidade do achado).
severity
stringsempre
Gravidade.
criticalhighmediumlowinfo
cvss
numbersempre
Pontuação CVSS.
description
stringsempre
O que foi encontrado, em detalhe.
evidence
stringsempre
A prova observada: requisição, resposta, trecho do bundle. Valores sensíveis vêm encurtados com — a ronda não guarda o segredo inteiro.
recommendation
stringsempre
Recomendação curta do scanner (não é o conserto pronto).
confidence
string
confirmed = provado por observação ativa · firm = detecção determinística · tentative = heurística que pede confirmação. Ausente = firm.
confirmedfirmtentative
good
boolean
Achado POSITIVO: uma boa prática comprovada, não um problema.
links
string[]
Ids de achados relacionados — hoje, os elos de uma cadeia de ataque (module: "chain").
friendlyA versão em português de gente.
fixO conserto pronto. Ausente quando retido pelo plano — aí vem fixWithheld: true.
fixWithheld
boolean
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.
fixSample
boolean
Este é o achado-AMOSTRA da conta: o conserto veio inteiro mesmo sem plano. Uma por conta, não por ronda.
fixFromCatalog
boolean
O conserto veio do catálogo determinístico, não da IA (ronda básica).
severityQuando
criticalInvasão ou vazamento provável agora (credencial exposta, dados abertos).
highBrecha séria que pede correção esta semana.
mediumTrava de proteção faltando; abre caminho combinada com outras.
lowReforço.
infoObservaçã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:

achado com o conserto retido
{
  "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
}
MarcadorSignifica
fixWithheld: trueO conserto existe, mas não está nesta resposta. Sem ele, um `fix` ausente pareceria "a ronda não analisou".
fixSample: trueEste é o achado-amostra da conta: o conserto veio inteiro mesmo sem plano. Uma por conta, não por ronda.
fixFromCatalog: trueO 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.

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.

diff.mjs
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);
Achou algo errado ou faltando? help@guarita.dev — com o x-request-id, se for sobre uma resposta.