← Documentação da API

Verificando a assinatura do webhook

Toda entrega de webhook da Guarita vai assinada. Este capítulo mostra como conferir essa assinatura antes de confiar no conteúdo — e por que cada passo existe.

Se você ler só um capítulo desta documentação, que seja este.

Por que verificar

Seu endpoint de webhook é uma URL pública que aceita POST sem login. Quem descobrir o endereço — num log de proxy, num commit, num print de tela, num scan de rota — pode mandar um corpo JSON com a forma certa e se passar pela Guarita.

O que isso permite depende do que seu handler faz. Na prática, é sempre ruim:

  • injetar um finding.opened falso e disparar um alerta no Slack do time às 3h da manhã;
  • injetar um scan.completed com risk_score: 0 e critical_count: 0, apagando de um painel interno a brecha que a ronda de verdade encontrou;
  • fazer seu pipeline liberar um deploy que deveria ter sido travado.

A assinatura resolve isso porque depende de um segredo (gwh_…) que só a Guarita e você conhecem. Sem ele, ninguém consegue produzir um header válido para um corpo escolhido.

Verificação não é opcional: é o que separa "recebi um evento da Guarita" de "recebi bytes de alguém na internet".

O que chega em cada entrega

A Guarita faz um POST com content-type: application/json e estes cabeçalhos:

user-agent: Guarita-Webhook/1
content-type: application/json
x-guarita-signature: t=1756483200,v1=6f2a...c4
x-guarita-delivery: dlv_2f0f6a1b-6b4e-4a58-9b2a-1f0f2c3d4e5f

O corpo é sempre este envelope:

json
{
  "id": "evt_9c3f1e2a-1c44-4b8e-9a1e-7d0b2f6a5c31",
  "event": "finding.opened",
  "createdAt": "2026-08-29T14:00:00.000Z",
  "data": {
    "scan_id": "scan_a1b2c3",
    "hostname": "app.exemplo.com.br",
    "verdict": "breach",
    "risk_score": 41,
    "previous_risk_score": 78,
    "opened": ["Painel de administração exposto sem autenticação"],
    "resolved": []
  }
}

Dois identificadores, com papéis diferentes:

  • `id` do corpo (evt_…) identifica o evento. Ele é estável entre reentregas: se a mesma entrega for tentada de novo, o corpo vem com o mesmo id. É por ele que se deduplica.
  • `x-guarita-delivery` (dlv_…) identifica a tentativa para aquele destino, e muda a cada retentativa do mesmo evento. Guarde-o nos seus logs: é o que distingue "recebi duas vezes o mesmo evento" (dois dlv_ diferentes, mesmo evt_) de "meu handler rodou duas vezes". O painel mostra só o resultado da última tentativa de cada destino, não a lista de entregas.

O formato da assinatura

O cabeçalho é x-guarita-signature e tem a forma:

t=<unix em segundos>,v1=<hmac-sha256 em hex minúsculo>

O que é assinado não é o corpo sozinho, é a concatenação do timestamp com o corpo cru, separados por um ponto:

<timestamp>.<corpo cru>

Sobre essa string, um HMAC-SHA256 com o segredo do destino (o gwh_… mostrado uma única vez quando você cadastrou o endpoint), em hexadecimal.

Em pseudocódigo:

assinado  = timestamp + "." + corpoCru
v1        = hex( hmac_sha256(segredo, assinado) )
cabeçalho = "t=" + timestamp + ",v1=" + v1

O esquema v1= existe para permitir trocar o algoritmo no futuro sem quebrar quem já integrou: uma versão nova entraria como v2= ao lado, e você continuaria lendo o v1 enquanto migra. Por isso leia o header por chave, não por posição — não presuma que v1 é sempre o segundo campo.

Por que o timestamp entra dentro da assinatura

A pergunta natural é: por que não mandar o timestamp num cabeçalho separado, e assinar só o corpo?

Porque, solto, ele seria trocável. Um atacante que capturasse uma entrega legítima poderia mudar a data no cabeçalho ao lado e reenviar o mesmo corpo com a mesma assinatura — que continuaria válida, porque a assinatura não teria nada a ver com a data. A verificação de frescor viraria teatro: você conferiria um número que qualquer um pode reescrever.

Colocando o timestamp dentro do conteúdo assinado, mudar a data invalida a assinatura. Data e corpo passam a ser uma coisa só, e quem não tem o segredo não consegue separar as duas.

A janela de tolerância

A Guarita aceita — e você deve aceitar — assinaturas com no máximo 300 segundos (5 minutos) de diferença do relógio atual.

Isso é o que impede replay: sem janela, uma entrega capturada uma vez poderia ser reenviada para sempre, sempre com assinatura perfeitamente válida. Cinco minutos absorvem relógio dessincronizado entre servidores sem deixar a janela de reuso grande.

Um detalhe que evita confusão nas retentativas: o `t` é o momento do envio daquela tentativa, não o momento do evento. Cada tentativa é assinada de novo com o relógio daquele instante, sobre exatamente os mesmos bytes de corpo. Uma retentativa que sai 8 minutos depois chega com t novo e passa na janela normalmente. O momento em que o evento aconteceu está no corpo, em createdAt.

Os passos da verificação

  1. Pegue o corpo cru da requisição, em bytes, antes de qualquer parse.
  2. Leia x-guarita-signature. Se faltar ou não tiver t e v1, rejeite.
  3. Confira que agora - t está dentro de 300 segundos (em módulo — um t no futuro também é suspeito).
  4. Monte "<t>." + corpoCru e calcule o HMAC-SHA256 com o segredo do destino, em hex.
  5. Compare com o v1 recebido em tempo constante.
  6. Só depois disso faça o JSON.parse e trate o evento.

Ordem importa: nada de parsear, consultar banco ou disparar notificação antes do passo 5.

Código completo em Node.js e Express

Este arquivo roda como está. Coloque o segredo do destino em GUARITA_WEBHOOK_SECRET.

js
import express from "express";
import crypto from "node:crypto";

const app = express();

const SECRET = process.env.GUARITA_WEBHOOK_SECRET;
if (!SECRET) throw new Error("Defina GUARITA_WEBHOOK_SECRET");

const SIGNATURE_HEADER = "x-guarita-signature";
const TOLERANCE_SECONDS = 300;

/** Lê `t=…,v1=…` por chave, sem depender da ordem dos campos. */
function parseSignature(header) {
  if (typeof header !== "string") return null;
  let timestamp = null;
  let signature = null;
  for (const part of header.split(",")) {
    const [k, v] = part.trim().split("=");
    if (k === "t" && /^\d+$/.test(v ?? "")) timestamp = Number(v);
    if (k === "v1" && /^[0-9a-f]+$/i.test(v ?? "")) signature = v.toLowerCase();
  }
  return timestamp !== null && signature ? { timestamp, signature } : null;
}

/** Compara dois hex de mesmo tamanho sem vazar quanto deles bateu. */
function safeEqualHex(a, b) {
  const bufA = Buffer.from(a, "hex");
  const bufB = Buffer.from(b, "hex");
  // timingSafeEqual LANÇA se os tamanhos diferem — a checagem vem antes, sempre.
  if (bufA.length !== bufB.length || bufA.length === 0) return false;
  return crypto.timingSafeEqual(bufA, bufB);
}

/** `rawBody` é um Buffer com os bytes exatos que chegaram. */
function verifyGuaritaSignature(rawBody, header, secret) {
  const parsed = parseSignature(header);
  if (!parsed) return { ok: false, reason: "cabeçalho ausente ou malformado" };

  const agora = Math.floor(Date.now() / 1000);
  if (Math.abs(agora - parsed.timestamp) > TOLERANCE_SECONDS) {
    return { ok: false, reason: "fora da janela de tolerância" };
  }

  // Conteúdo assinado: `<timestamp>.<corpo cru>`. Dois `update` em vez de montar uma
  // string: assim os bytes do corpo entram como vieram, sem passar por conversão.
  const esperado = crypto
    .createHmac("sha256", secret)
    .update(`${parsed.timestamp}.`)
    .update(rawBody)
    .digest("hex");

  if (!safeEqualHex(esperado, parsed.signature)) {
    return { ok: false, reason: "assinatura não confere" };
  }
  return { ok: true };
}

// express.raw entrega req.body como Buffer — os bytes originais, sem parse.
app.post(
  "/webhooks/guarita",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const check = verifyGuaritaSignature(
      req.body,
      req.get(SIGNATURE_HEADER),
      SECRET,
    );

    if (!check.ok) {
      console.warn("[guarita] entrega recusada:", check.reason);
      // 4xx: é definitivo, não adianta reenviar o mesmo corpo.
      return res.status(400).send("assinatura inválida");
    }

    const evento = JSON.parse(req.body.toString("utf8"));

    // Responda rápido e processe depois: cada tentativa tem 10s de timeout.
    res.status(204).end();

    // Deduplique por evento.id — a mesma entrega pode chegar mais de uma vez.
    processarEmBackground(evento).catch((err) => {
      console.error("[guarita] falha ao processar", evento.id, err);
    });
  },
);

async function processarEmBackground(evento) {
  switch (evento.event) {
    case "scan.completed":
      console.log("ronda concluída", evento.data.hostname, evento.data.risk_score);
      break;
    case "finding.opened":
      console.log("brecha nova em", evento.data.hostname, evento.data.opened);
      break;
    case "scan.failed":
      console.log("ronda falhou", evento.data);
      break;
  }
}

app.listen(3000);

Se o resto da sua aplicação usa express.json(), registre a rota do webhook antes do middleware global, ou monte o express.json() com { type: ... } que exclua esse caminho. Um express.json() global aplicado antes consome o corpo e você fica sem os bytes.

Os dois pontos onde quase todo mundo erra

1. Precisa ser o corpo cru, não o objeto parseado

A assinatura cobre bytes, não a estrutura de dados. Depois de JSON.parse, os bytes originais deixaram de existir — e JSON.stringify não os reconstrói.

js
// ERRADO: a assinatura nunca vai bater
app.use(express.json());
app.post("/webhooks/guarita", (req, res) => {
  const corpo = JSON.stringify(req.body); // bytes DIFERENTES dos que chegaram
  // ...
});

JSON.stringify produz uma serialização canônica do seu runtime, e ela quase certamente difere da que a Guarita enviou: espaços em branco, quebras de linha, ordem das chaves, como caracteres não-ASCII foram escapados ("ç" versus "ç"), como números foram formatados. Um único byte diferente muda o HMAC inteiro.

O sintoma é cruel porque é silencioso e absoluto: toda entrega falha na verificação, e o corpo parece idêntico quando você imprime os dois lados no console. Se a sua verificação nunca passa e o segredo está certo, é quase sempre isto.

A forma correta é guardar os bytes:

js
// CERTO: req.body é um Buffer com os bytes que chegaram
app.post("/webhooks/guarita", express.raw({ type: "application/json" }), handler);

Em outros frameworks o nome muda, a ideia não: Fastify tem addContentTypeParser com parseAs: "buffer"; no Next.js App Router use await request.text() antes de qualquer request.json(); em serverless há normalmente um event.body cru (cuidado com isBase64Encoded). O parse só acontece depois que a assinatura passou.

2. A comparação precisa ser em tempo constante

js
// ERRADO
if (esperado === recebida) { /* ... */ }

=== em strings para na primeira diferença. Isso faz o tempo de resposta depender de quantos caracteres iniciais o atacante acertou — uma assinatura que começa certa demora um pouquinho mais para ser rejeitada que uma que erra no primeiro caractere.

Com esse retorno, o ataque deixa de ser "adivinhar 64 caracteres hexadecimais de uma vez" (inviável) e passa a ser "descobrir um caractere de cada vez, medindo o tempo" — 64 rodadas de 16 tentativas. A diferença é de nanossegundos, mas é estatisticamente mensurável com volume suficiente de requisições, e seu endpoint aceita quantas requisições o atacante quiser mandar.

crypto.timingSafeEqual compara os buffers inteiros sempre, independentemente de onde está a primeira diferença. Duas exigências ao usá-lo:

  • Cheque o tamanho antes. timingSafeEqual lança RangeError se os buffers têm tamanhos diferentes, e uma exceção não tratada aqui vira 500 — que a Guarita interpreta como falha temporária e continua reenviando. Vazar o tamanho não é problema: o tamanho de um HMAC-SHA256 é fixo e público.
  • Compare os bytes, não o hex como texto. Buffer.from(x, "hex") ignora caracteres fora do alfabeto hexadecimal em vez de falhar, o que pode produzir dois buffers curtos de mesmo tamanho a partir de lixo. Por isso o parseSignature acima só aceita v1 que case com /^[0-9a-f]+$/i, e a comparação rejeita buffer vazio.

O que responder, e o efeito de cada resposta

A resposta do seu endpoint controla a retentativa:

RespostaO que a Guarita faz
2xxConsidera entregue e para.
4xx (exceto 429)Trata como definitivo e não tenta de novo.
429, 5xx, timeout, erro de redeReagenda e tenta de novo.

As retentativas são até 6 tentativas no total, com esperas crescentes entre elas (15s, 30s, 1min, 2min, 4min). Cada tentativa tem 10 segundos de timeout — por isso o exemplo responde antes de processar.

Consequência prática de devolver 400 em assinatura inválida: se você errar a configuração do segredo, os eventos daquele período são descartados sem retentativa. É a escolha certa mesmo assim — reenviar um corpo cujo remetente não foi comprovado só gasta tentativa. Confira no painel, em Configurações → Desenvolvedores, o status e o erro da última entrega de cada destino antes de considerar a integração pronta.

Depurando pela linha de comando

Se a verificação não passa, recalcule a assinatura na mão a partir de uma entrega real. Salve o corpo exatamente como chegou, sem reformatar, e use o t do header daquela entrega:

bash
T=1756483200
SECRET='gwh_...'

printf '%s.%s' "$T" "$(cat corpo-cru.json)" \
  | openssl dgst -sha256 -hmac "$SECRET" \
  | awk '{print $NF}'

O resultado tem que ser idêntico ao v1 do header. O prefixo que o openssl imprime antes do hash varia com a versão, por isso o awk pega o último campo.

Se não bater, o problema quase sempre está no arquivo: um editor que salvou com quebra de linha no final, ou um "formatar JSON" que reindentou o conteúdo. Salve com curl --data-binary, ou registre o corpo cru direto do seu handler antes de qualquer tratamento.

Checklist de produção

  • [ ] O segredo (gwh_…) vem de variável de ambiente ou gerenciador de segredos — nunca do código nem de log.
  • [ ] O handler lê o corpo cru; nenhum parser de JSON roda antes dele.
  • [ ] A assinatura é verificada antes de qualquer parse, consulta ou notificação.
  • [ ] A janela de 300 segundos é conferida, nos dois sentidos (passado e futuro).
  • [ ] A comparação usa crypto.timingSafeEqual, com checagem de tamanho antes.
  • [ ] O endpoint é https:// e o certificado é válido.
  • [ ] Eventos são deduplicados por id do corpo (evt_…).
  • [ ] A resposta sai em menos de 10 segundos; o trabalho pesado vai para background.
  • [ ] Assinatura inválida devolve 4xx e registra um aviso — silêncio aqui esconde ataque.
  • [ ] Você tem um segredo por destino, e sabe qual revogar sem derrubar os outros.