O SunCVE é um dashboard para pesquisar e analisar CVEs. A parte interessante não é o front, é o problema de dados por trás dele: são mais de 300 mil CVEs, do CVE-1999-0001 até o registro mais recente, enriquecidas com repositórios, advisories, PoCs e metadados de pacotes. Isso vira um SQLite de cerca de 130 MB.
E o objetivo do projeto sempre foi radical: rodar 100% no navegador, publicado no GitHub Pages, sem um único servidor de backend para manter. Nada de banco gerenciado, nada de API, nada de fatura no fim do mês.
O problema técnico é direto: servir esses 130 MB de dados sem infra própria, usando o GitHub Releases como storage de objetos. E, de quebra, manter a história git limpa.
O problema original: versionar dados no git
A primeira versão do pipeline fazia a coisa mais óbvia possível: todo dado processado era escrito em JSON, zipado e commitado no repositório. O front baixava esses JSONs, descomprimia no navegador e rodava as queries ali mesmo, tudo local e rápido (o SQLite que roda hoje veio só depois; a ideia de processar tudo no cliente é anterior a ele). Cada rodada de processamento gerava arquivos novos, e eles iam para o git junto com o código.
Funcionou por umas semanas. Depois o problema apareceu:
Repositório de dados é uma armadilha
Dados versionados no git não são apagados quando você deleta o arquivo. Eles ficam para sempre na história git. Cada rodada de processamento adicionava megabytes, e como os JSONs mudavam a cada execução, o git não conseguia comprimir quase nada entre as versões. O .git cresceu para a casa das centenas de MB.
O efeito prático foi o pior possível para um projeto aberto: clonar o repositório ficou lento e pesado. Como exemplo concreto, o git clone demorava demais e, algumas vezes, chegava a travar a própria interface web do GitHub na hora de renderizar o repositório. Quem quisesse contribuir precisava puxar todo o lixo de dados histórico só para mexer em um botão da interface. A barreira para colaborar subiu, contra o ponto inteiro de ser open source.
Faxina: reescrevendo a história git
O primeiro passo foi aceitar uma coisa simples, que vale como regra geral: se você está usando o git como backup de dados, de informação processada, de arquivos gerados, provavelmente está fazendo errado e é hora de rever os conceitos, parça. Versionador de código não é storage de dados.
Então tiramos os arquivos de dados do versionamento, colocamos os diretórios no .gitignore e, mais importante, reescrevemos a história git para remover os blobs antigos. Deletar o arquivo no HEAD não resolve nada quando o peso está no passado: é preciso reescrever a história git para o repositório emagrecer de verdade.
Com a faxina feita, o repositório voltou a ser só código: scripts de processamento, componentes de UI, configuração. O git log voltou a registrar só o que importa, features e correções, e não despejos de dados.
Mas isso reabriu a pergunta que a gente tinha empurrado com a barriga: se os dados não moram no git, onde eles moram?
A pergunta certa: onde guardar os dados sem servidor?
As opções na mesa eram as de sempre:
- Um servidor com banco de dados. Resolve, mas cria infra para manter, custo mensal e um ponto único de falha. Contra tudo que o projeto queria ser.
- Um bucket de storage (S3, R2, etc). Mais barato, mas ainda é uma conta a mais, credenciais para gerenciar e mais uma dependência externa.
- Não ter servidor nenhum. O projeto já rodava direto no navegador desde os tempos dos JSONs zipados. Com o SQLite isso só ficou mais robusto: o banco vai inteiro para o cliente e as queries acontecem ali, via WebAssembly. Se os dados já viajavam para o browser, por que precisaríamos de um backend?
A terceira opção era claramente a certa em espírito. Faltava só um lugar para hospedar o arquivo de banco, um lugar que:
- Não fosse o git (para não sujar a história git de novo).
- Não fosse infra nova para manter.
- Fosse gratuito e já estivesse ali, junto do projeto.
Primeira tentativa: processar tudo no Actions
A ideia inicial foi mover o pipeline inteiro para o GitHub Actions. O Actions processaria os dados e cuspiria os arquivos estáticos que o GitHub Pages precisava, incluindo o SQLite que roda no browser. Elegante no papel: o mesmo lugar que hospeda o código também gera e publica os dados.
O problema apareceu na conta de tempo. Processar desde o primeiro CVE da história até hoje, com todo o enriquecimento (bater em advisory database, verificar repositórios, PoCs, metadados de npm e Packagist), leva quase uma semana de execução.
O teto do runner gratuito
Um job no GitHub Actions gratuito tem limite de tempo de execução (na casa das 6 horas por job). Um pipeline que precisa de dias para processar tudo do zero simplesmente estoura o timeout e é morto no meio do caminho. Reprocessar o mundo inteiro toda vez nunca ia funcionar.
A lição é a de sempre em processamento de dados: você não reprocessa tudo a cada execução. Você processa incrementalmente, do último ponto conhecido para frente. Só que, para partir do último ponto, você precisa ter guardado esse ponto em algum lugar. E aí voltamos para a pergunta do storage.
A sacada: GitHub Releases como bucket
A hipótese foi: e se o GitHub Releases for esse lugar? Releases aceitam assets binários arbitrários, de tamanho generoso, servidos por uma URL estável, sem custo, e sem tocar na história git. Isso é, na prática, um bucket de objetos grudado no repositório.
A ideia funcionou. O desenho final tem cinco etapas:
- Bootstrap local. A maior parte dos dados é processada na máquina local, onde não existe teto de 6h. O snapshot final vira um único arquivo e sobe para o release.
- Snapshot no release. Um release de tag fixa
db-snapshotsguarda o SQLite comprimido como asset. Esse é o bucket. - Job incremental diário. Um schedule no Actions baixa o último snapshot, processa só as CVEs novas a partir dali e sobe um snapshot atualizado.
- Retenção. Mantemos os últimos snapshots como backup e apagamos os antigos, para o release não crescer sem controle.
- Deploy no Pages. O deploy puxa o snapshot mais recente do release, embute no build estático e publica no GitHub Pages.
O ponto que amarra tudo: o job do Actions nunca começa do zero. Ele começa do último snapshot que ele mesmo publicou. O trabalho de uma execução deixa de ser “processar 27 anos de CVEs” e passa a ser “processar o que apareceu desde ontem”. Isso cabe folgado dentro do limite do runner.
Um release, vários snapshots
Um detalhe de design que vale destacar: não criamos um release novo a cada rodada. Existe um único release, de tag fixa db-snapshots, e os snapshots são assets dentro dele. O nome de cada asset carrega o intervalo de CVEs e um timestamp:
snapshot-cve-1999-0001-to-cve-2026-28296-20260227T031447Z.tar.gz
Para “ler o bucket”, basta listar os assets do release e pegar o mais recente por data de criação. Essa primitiva aparece igualzinha em todo lugar do projeto. No script de setup local, por exemplo, é um gh com um filtro jq:
# scripts/setup-db.sh
LATEST_ASSET="$(gh release view "$RELEASE_TAG" \
--repo "$REPO_SLUG" \
--json assets \
--jq '.assets
| map(select(.name | test("^snapshot-.*\\.tar\\.gz$")))
| sort_by(.createdAt)
| reverse
| .[0].name // ""')"
gh release download "$RELEASE_TAG" \
--repo "$REPO_SLUG" \
--pattern "$LATEST_ASSET" \
--output "$TMP_FILE"
tar -xzf "$TMP_FILE" -C .
Filtra assets que batem com snapshot-*.tar.gz, ordena por createdAt, inverte e pega o primeiro. É o GET latest do nosso bucket, escrito em três linhas de shell.
O bootstrap local
O carregamento inicial, aquele que levaria uma semana no Actions, roda na máquina local pelo scripts/manual-db-snapshot.sh. Ele reproduz o mesmo pipeline do workflow, mas sem teto de tempo, e publica o resultado com os comandos diretos do gh:
# scripts/manual-db-snapshot.sh (trechos)
# cria o release-bucket se ainda não existir
gh release create "$SNAPSHOT_TAG" \
--repo "$REPO_SLUG" \
--title "DB Snapshots" \
--notes "Rolling snapshots for incremental CVE DB state"
# ... processa os dados ...
# sobe o snapshot como asset
gh release upload "$SNAPSHOT_TAG" "$SNAPSHOT_NAME" --repo "$REPO_SLUG"
Depois do primeiro snapshot pesado no ar, o Actions assume a manutenção. O humano só volta a rodar isso se precisar de um reprocessamento grande, tipo um backfill que não cabe no incremental.
O job incremental, passo a passo
O coração do sistema é o workflow db-snapshots.yml. Ele dispara todo dia por schedule e também pode ser rodado à mão:
# .github/workflows/db-snapshots.yml
on:
workflow_dispatch:
# ... inputs manuais ...
schedule:
- cron: "0 7 * * *" # todo dia às 07:00 UTC
permissions:
contents: write # precisa para escrever no release
actions: write # precisa para disparar o deploy no fim
O primeiro passo do job garante que o release existe (cria se for a primeira vez) e descobre qual é o asset mais recente. Depois vem o passo que torna tudo incremental, o restore do último snapshot:
- name: Restore Latest Snapshot
if: steps.release.outputs.latest_asset_id != ''
run: |
mkdir -p data public/db
curl -L \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/octet-stream" \
"https://api.github.com/repos/${{ github.repository }}/releases/assets/${{ steps.release.outputs.latest_asset_id }}" \
-o /tmp/latest-snapshot.tar.gz
tar -xzf /tmp/latest-snapshot.tar.gz -C .
Um detalhe que fecha o design com elegância: o cursor de retomada mora dentro do próprio SQLite. O banco tem uma tabela sources que guarda, entre outras coisas, qual foi a última release de deltas do cvelistV5 já processada. Quando o job restaura o snapshot, ele não precisa de nenhum estado externo para saber de onde continuar: o ponto de retomada veio dentro do arquivo. O snapshot é, ao mesmo tempo, os dados e o marcador de progresso.
Com o SQLite de ontem restaurado, o pipeline roda a atualização incremental. Cada passo é uma etapa de enriquecimento que só processa o que faltava:
- name: Incremental CVE Update
run: python scripts/create-manifest.py cves --year-auto
- name: Enrich from GitHub Advisory Database
run: python scripts/create-manifest.py advisories
- name: Enrich POCs from PoC-in-GitHub
run: python scripts/create-manifest.py pocs
- name: Incremental Repository Verification
run: python scripts/create-manifest.py repos --batch-size "${REPO_BATCH_SIZE}"
# ... npm, packagist, wordpress, osv, recálculo de fixes ...
No fim, ele reconstrói os artefatos que o browser consome (build-db-artifacts.sh gera o SQLite comprimido e um manifest.json), e antes de publicar passa por uma trava de sanidade que evita subir um banco quebrado:
- name: Validate Snapshot DB Size
env:
MIN_CVE_COUNT: "1000"
run: |
total=$(sqlite3 data/source.sqlite "SELECT COUNT(*) FROM cves;")
if [ "${total}" -lt "${MIN_CVE_COUNT}" ]; then
echo "[ERROR] Snapshot aborted: only ${total} CVEs. Likely stale/incomplete DB state."
exit 1
fi
Essa guarda é importante quando o storage é escrito por automação: uma falha de rede no meio do processamento poderia gerar um banco quase vazio, e sem essa checagem ele sobreporia o snapshot bom. A regra é simples: um snapshot suspeito não vira o novo estado.
Anatomia de um snapshot
O que vai para o release não é o SQLite cru. O passo Pack Snapshot empacota dois arquivos:
tar -czf "${SNAPSHOT_NAME}" \
public/db/manifest.json \
public/db/source_com_repositorios.sqlite.gz
O source_com_repositorios.sqlite.gz é o banco comprimido com gzip (nível 9), e o manifest.json é um índice pequeno com metadados: versão, URL do gzip, tamanho, sha256 para checar integridade, e o intervalo de CVEs que aquele snapshot cobre. O manifest é o que o front lê primeiro para saber o que baixar e como validar.
Retenção: os últimos como backup
Um bucket sem política de retenção vira lixão. Como cada rodada sobe um snapshot novo, sem limpeza o release cresceria para sempre. O passo final de manutenção mantém só os últimos e apaga o resto:
- name: Prune Old Snapshots (Keep 3)
uses: actions/github-script@v7
with:
script: |
const keep = 3;
const snapshots = (release.data.assets || [])
.filter(a => /^snapshot-.*\.tar\.gz$/.test(a.name))
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
for (const asset of snapshots.slice(keep)) {
await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
}
Manter os três últimos dá algo que um bucket comum não te dá de graça: backup versionado embutido. Se uma rodada gerar dados ruins que passem pela trava de sanidade, os snapshots anteriores ainda estão lá para rollback manual. É storage e backup na mesma estrutura.
Do release ao navegador
Ter os dados no release é metade da história. A outra metade é como eles chegam no usuário sem nunca passar por um servidor de aplicação.
O deploy puxa o snapshot
Quando o job incremental termina, ele dispara o workflow de deploy. O deploy faz o mesmo movimento de “ler o bucket”: encontra o snapshot mais recente e o restaura para dentro do build:
# .github/workflows/deploy.yml (trecho)
- name: Restore DB Files from Release Snapshot
run: |
curl -L \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/octet-stream" \
"https://api.github.com/repos/${{ github.repository }}/releases/assets/${{ steps.snapshot.outputs.asset_id }}" \
-o /tmp/latest-db-snapshot.tar.gz
tar -xzf /tmp/latest-db-snapshot.tar.gz -C .
test -f public/db/manifest.json
test -f public/db/source_com_repositorios.sqlite.gz
Aí o Next roda em modo de export estático, com o SQLite comprimido já dentro de public/db/, e o resultado sobe para o GitHub Pages. Do ponto de vista do Pages, o banco é só mais um arquivo estático servido junto com o HTML e o JS. Antes de buildar, ainda tem outra checagem de sanidade que descomprime o banco e conta as CVEs, para não publicar um Pages com dados quebrados.
O SQLite roda no browser
No cliente, não existe API para consultar. O navegador baixa o banco inteiro e roda SQL localmente via sql.js (SQLite compilado para WebAssembly). O fluxo é:
- Ler o
manifest.jsonpara descobrir a URL do gzip, o tamanho e o sha256. - Baixar o
.sqlite.gze descomprimir em streaming, no próprio browser, com a API nativaDecompressionStream. - Carregar o SQLite resultante no sql.js e rodar as queries direto na memória do cliente.
A descompressão em streaming evita ter que carregar o arquivo comprimido inteiro na memória antes de descomprimir:
// src/lib/sqlite/sqlite-loader.ts (trecho)
if (encoding && encoding !== 'identity') {
stream = stream.pipeThrough(
new DecompressionStream(encoding as CompressionFormat)
);
}
O resultado é guardado no armazenamento local do navegador (OPFS) para não rebaixar tudo a cada visita. A partir daí, buscar, filtrar e agregar CVEs é tudo local, sem round-trip de rede. O usuário está, na prática, rodando um banco de dados de 300 mil registros dentro da própria aba.
O que a gente ganhou
Somando tudo, um desenho sem nenhum servidor entrega o projeto inteiro:
- Zero infra e zero custo. Sem servidor, sem banco gerenciado, sem bucket pago. Tudo mora no GitHub, junto do código.
- Git limpo. A história git volta a ser só de features e correções. Clonar o repositório é leve de novo, e a barreira para contribuir cai.
- Processamento que cabe no runner. O incremental parte do último snapshot, então nenhuma rodada estoura o limite de tempo do Actions.
- Backup embutido. A retenção dos últimos snapshots dá rollback de graça, algo que um bucket cru não oferece.
- Roda no navegador. O banco viaja para o cliente e as queries acontecem ali, via WebAssembly. Nada para escalar do lado do servidor, porque não existe servidor.
O truque conceitual é enxergar o GitHub Releases como um bucket de objetos versionado, com uma primitiva de leitura (“pegue o asset mais recente”) e uma de escrita (“suba um asset novo e apague os antigos”). Duas operações simples, mais uma trava de sanidade, e você tem storage de dados grande grudado no seu repositório de código.
Ressalvas: até onde vai essa abordagem
Não é bala de prata. Vale saber onde o modelo aperta:
Quando o Releases-como-bucket faz sentido
Ele brilha para dados grandes, versionados por snapshot e majoritariamente de leitura, onde uma latência de atualização de horas é aceitável. Não é para dados quentes, gravações concorrentes ou consulta parcial: você baixa o banco inteiro, não uma fatia. Também vale ficar de olho nos limites de tamanho de asset e nos custos de banda em repositórios muito populares.
Para o SunCVE o encaixe é perfeito: os dados de CVE são um dataset grande que só cresce, a atualização diária é mais que suficiente, e o cliente sempre quis o banco local mesmo. Fora desse perfil, um bucket de verdade ou um banco continuam sendo a escolha certa.
Fechando
A restrição, “não quero manter servidor”, foi o que forçou o design bom. Sem um lugar para jogar os dados, a gente foi obrigado a olhar para o que já estava ali: o GitHub Releases, que ninguém pensa como storage, mas que faz o papel de bucket muito bem quando o acesso é por snapshot.
No fim, três decisões carregam o projeto: tirar os dados do git para a história git respirar, processar incrementalmente a partir do último snapshot para caber no runner, e guardar esse snapshot no Releases para fechar o ciclo sem infra. O resultado é um banco de 300 mil CVEs que qualquer pessoa abre no navegador, servido por um repositório que continua sendo, essencialmente, só código.
SunCVE GitHub Actions SQLite sql.js arquitetura serverless