▸ API · Receitas

GitHub Actions

Rode uma ronda a cada deploy e falhe o pipeline quando aparecer um crítico.

atualizado em 2 set 2026versão v1

O que a receita faz

A cada deploy em produção, o workflow dispara uma ronda no app publicado e espera ela terminar. Se aparecer um achado crítico, o job falha e lista os títulos. Ronda que a Guarita não conseguiu ver só avisa — não pune o time pelo WAF.

Dois arquivos: o workflow e um script Node sem dependências. Copie os dois, crie a variável GUARITA_TARGET com a URL de produção e pronto.

O workflow

Roda no evento deployment_status (o que a Vercel, o Render e a maioria dos provedores emitem quando o deploy termina) filtrando sucesso em production. O workflow_dispatch deixa você disparar à mão pra testar.

.github/workflows/guarita.yml
name: Guarita — ronda no deploy

on:
  deployment_status:
  workflow_dispatch:

# Uma ronda por vez por branch. Rajada vira 429: o balde é de 10 rondas por CONTA.
concurrency:
  group: guarita-${{ github.ref }}
  cancel-in-progress: false

jobs:
  ronda:
    # Só quando o deploy de produção terminou com sucesso — ou no disparo manual.
    if: >-
      github.event_name == 'workflow_dispatch' ||
      (github.event.deployment_status.state == 'success' &&
       github.event.deployment.environment == 'production')
    runs-on: ubuntu-latest
    # Uma ronda tem teto de 30 min; o script espera até 35. Folga pro checkout.
    timeout-minutes: 40
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Ronda da Guarita
        run: node .github/scripts/guarita-ronda.mjs
        env:
          GUARITA_API_KEY: ${{ secrets.GUARITA_API_KEY }}
          # Prefira a variável com a URL de produção (Settings → Variables).
          # O target_url do deploy pode ser um hostname único de deploy —
          # pra Guarita, isso é OUTRO app (não verificado, e ocupa vaga).
          GUARITA_TARGET: ${{ vars.GUARITA_TARGET || github.event.deployment_status.target_url }}
Por que a variável vem antes do target_url.
O target_url de um deploy costuma ser um hostname único daquele deploy. Pra Guarita, um hostname novo é outro app: ocupa vaga do plano e não tem prova de propriedade. Aponte GUARITA_TARGET pra URL de produção de verdade.

O script

Dispara com IA; se o app não tem prova de propriedade vigente, refaz sem IA e avisa. Em 429 espera o Retry-After uma vez. Depois consulta a ronda a cada 10 s até terminar e decide.

.github/scripts/guarita-ronda.mjs
// .github/scripts/guarita-ronda.mjs — Node 20+, sem dependências.
// Dispara uma ronda, espera terminar e falha o job se houver crítico.
import { appendFileSync } from "node:fs";

const BASE = "https://api.guarita.dev/v1/public";
const KEY = process.env.GUARITA_API_KEY;
const TARGET = process.env.GUARITA_TARGET;
if (!KEY || !TARGET) {
  console.error("::error::Defina GUARITA_API_KEY (secret) e GUARITA_TARGET (variável).");
  process.exit(1);
}

const auth = { Authorization: `Bearer ${KEY}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const link = (id) => `https://www.guarita.dev/scans/${id}`;

function resumo(md) {
  // Aparece na aba Summary do job.
  if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + "\n");
}

async function disparar({ ai, retried = false }) {
  const res = await fetch(`${BASE}/scans`, {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({ target: TARGET, ai }),
  });
  const body = await res.json();
  if (res.status === 202) return body.id;

  // App sem prova de propriedade vigente: roda a básica (sem IA, sem cota) e avisa.
  if (res.status === 403 && body.error === "domain_unverified" && ai) {
    console.log(`::warning::${body.hostname} sem prova de propriedade vigente — rodando a ronda básica. Verifique o app em Apps.`);
    return disparar({ ai: false, retried });
  }

  // Muitas rondas em pouco tempo: espera o Retry-After UMA vez.
  if (res.status === 429 && !retried) {
    const wait = Number(res.headers.get("retry-after") ?? body.retryAfterSec ?? 60);
    console.log(`::notice::Limite de rondas — esperando ${wait}s.`);
    await sleep(wait * 1000);
    return disparar({ ai, retried: true });
  }

  throw new Error(`Guarita recusou a ronda: ${res.status} ${body.error}${body.message}`);
}

async function esperar(id) {
  const limite = Date.now() + 35 * 60_000;
  for (;;) {
    if (Date.now() > limite) throw new Error(`Ronda ${id} não terminou em 35 min.`);
    await sleep(10_000);
    const res = await fetch(`${BASE}/scans/${id}`, { headers: auth });
    const ronda = await res.json();
    if (!res.ok) throw new Error(`Guarita ${res.status} ${ronda.error}: ${ronda.message}`);
    if (ronda.status === "done") return ronda;
    if (ronda.status === "failed" || ronda.status === "canceled") {
      throw new Error(`Ronda ${id} terminou como ${ronda.status}.`);
    }
  }
}

async function titulosCriticos(id) {
  const res = await fetch(`${BASE}/scans/${id}/report`, { headers: auth });
  if (!res.ok) return [];
  const report = await res.json();
  return report.findings.filter((f) => f.severity === "critical").map((f) => f.title);
}

const id = await disparar({ ai: true });
console.log(`Ronda ${id} em andamento: ${link(id)}`);
const { summary } = await esperar(id);

// A Guarita não conseguiu ver o app: a nota não vale. Avisa, não falha.
if (summary.blocked) {
  console.log(`::warning::A ronda não conseguiu ver ${TARGET} (${summary.blockedReason}). O pipeline segue.`);
  resumo(`## Guarita — ronda inconclusiva\n\nA ronda não conseguiu ver ${TARGET} (\`${summary.blockedReason}\`). [Ver a ronda](${link(id)})`);
  process.exit(0);
}

resumo(`## Guarita — exposição ${summary.score}/100 (${summary.riskLabel})\n\n${summary.criticalCount} crítico(s). [Ver o relatório](${link(id)})`);

if (summary.criticalCount > 0) {
  console.log(`::error::${summary.criticalCount} achado(s) crítico(s) em ${TARGET}:`);
  for (const t of await titulosCriticos(id)) console.log(`  - ${t}`);
  console.log(`Relatório: ${link(id)}`);
  process.exit(1);
}

console.log(`OK — exposição ${summary.score}/100 (${summary.riskLabel}), nenhum crítico. ${link(id)}`);

O resumo com score, faixa e link do relatório vai pra aba Summary do job via GITHUB_STEP_SUMMARY. Os ::error:: e ::warning:: viram anotações no PR.

Ajustes

  • Limiar por faixa, não por crítico. Troque summary.criticalCount > 0 por ["HIGH_RISK", "CRITICAL_RISK"].includes(summary.riskLabel) se quiser falhar também por exposição alta sem crítico.
  • Sem IA em deploy de PR. ai: false roda a ronda básica sem consumir a cota do mês — só não traz o conserto pronto. Reserve a IA pra produção.
  • Só em produção. O filtro do if já faz isso. Preview costuma ter proteção de senha, e a ronda vê só a tela de login.
  • Webhook no lugar do polling. Se o seu pipeline tem um receptor, assine scan.completed e dispense o laço — veja os webhooks.

Resolvendo problemas

SintomaCausaO que fazer
401 unauthorizedSecret errado, chave revogada, ou o plano da conta deixou de incluir a API.Confira o secret no repositório e o plano em GET /me. Não repita a chamada.
403 domain_unverifiedO app não tem prova de propriedade vigente pra rodar com IA.O script já cai pra ai: false. Pra voltar à IA, verifique o app em Apps no painel.
402 quota_exceededCota de análises com IA do mês esgotada.Use ai: false até virar o mês, ou avise quem cuida da assinatura.
429 rate_limitedVários jobs disparando ronda ao mesmo tempo — o balde é por conta.Mantenha o concurrency do workflow. O script espera o Retry-After uma vez.
blocked: trueWAF ou anti-bot barrou a ronda; ela não viu o app.Libere a Guarita no seu provedor — na Cloudflare, a integração faz isso sozinha. O job não falha por isso.
Achou algo errado ou faltando? help@guarita.dev — com o x-request-id, se for sobre uma resposta.