GitHub Actions
Rode uma ronda a cada deploy e falhe o pipeline quando aparecer um crítico.
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.
scans:read e scans:write, e guarde no repositório como GUARITA_API_KEY (Settings → Secrets and variables → Actions). Secret vazado em log de CI é chave pra revogar na hora.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.
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 }}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 — 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 > 0por["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: falseroda 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
ifjá 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.completede dispense o laço — veja os webhooks.
Resolvendo problemas
| Sintoma | Causa | O que fazer |
|---|---|---|
401 unauthorized | Secret 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_unverified | O 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_exceeded | Cota 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_limited | Vá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: true | WAF 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. |
x-request-id, se for sobre uma resposta.