mvzeppelin/super-recon
GitHub: mvzeppelin/super-recon
面向渗透测试与安全审计的自动化侦察平台,将多款 Kali 工具编排为流水线并统一索引扫描结果。
Stars: 0 | Forks: 0
# super-recon
🇧🇷 Português | 🇺🇸 [English](README.en.md)
Plataforma de reconhecimento (recon) automatizado: dispara ferramentas de
segurança do Kali Linux contra domínios/IPs de um cliente, normaliza a saída
de cada ferramenta e indexa tudo no OpenSearch, com um dashboard web para
explorar os achados por cliente e por ferramenta.
Tudo roda em Docker. O container do Kali é efêmero — sobe só para executar
uma ferramenta e morre logo em seguida.
## Arquitetura
┌──────────────┐
│ frontend │ React + Nginx
│ :3000 (host) │ proxy /api/ -> backend
└──────┬────────┘
│ recon-net
┌──────▼────────┐
│ backend │ FastAPI
│ :8000 (host) │ POST /scans, GET /clients/...
└──────┬────────┘
│ enqueue
┌──────▼────────┐
│ redis │ fila (Celery broker/backend)
└──────┬────────┘
│
┌──────▼────────┐
│ worker │ Celery — concorrência = RECON_CPUS
│ (docker.sock) │
└──────┬────────┘
│ docker run --rm --cpuset-cpus=N (efêmero)
┌──────▼────────┐
│ kali-tools │ assetfinder, subfinder, sublist3r, amass,
│ (sobe e morre) │ dnsenum, dnsrecon, dnsx, httpx, waybackurls,
└──────┬────────┘ gau, rdap, masscan, nmap, gobuster, nikto,
│ parse + index nuclei, katana, theHarvester
┌──────▼────────┐
│ opensearch │ :9200 (host, só 127.0.0.1)
└───────────────┘
- **Rede**: todos os serviços da aplicação ficam na rede docker `recon-net`.
As portas publicadas no host (`3000`, `8000`, `9200`) são todas bindadas em
`127.0.0.1` — nunca em `0.0.0.0`. Só acessíveis do host local ou de outros
containers da rede.
- **Kali efêmero**: o `worker` cria containers "irmãos" da imagem
`kali-tools:1.0` via `docker.sock` (docker-outside-of-docker) para cada
execução de ferramenta, com `--cpuset-cpus` fixando o core usado. O
container morre (`--rm`) assim que a ferramenta termina.
- **Paralelismo**: a quantidade de CPUs usada é `RECON_CPUS` (`.env`); vazio
= usa todos os cores do host. A concorrência do worker Celery é ajustada
para esse valor no boot (`backend/entrypoint-worker.sh`).
## Pipeline de recon (grafo de dependências)
Fase 1 (passivo, paralelo) Fase 2 (consolidação) Fase 3 Fase 4 (paralelo)
domínio: assetfinder, subfinder, ─┐
sublist3r, amass, dnsenum, ├─> consolida subdomínios ─> httpx ┐
dnsrecon, rdap, wayback, gau, │ + portas do masscan dnsx ├─> gobuster + nikto + nuclei
theHarvester │ nmap │ + katana (URL viva)
IP: rdap (bloco), masscan, ──┘ rdap (bloco)┘ + masscan/nmap/rdap/
shodan, censys (se configurados) shodan, censys shodan/censys
(IP de cada subdomínio)
Várias ferramentas da Fase 1 fazem enumeração de subdomínio por fontes
diferentes (passiva, brute-force de DNS, certificado, etc.) — a soma tende a
ser positiva, cada uma encontra algo que as outras não encontram; todas
gravam no mesmo índice `subdomains`, só o campo `tool` diferencia a origem.
`dnsx` na Fase 3 resolve/valida a lista consolidada (índice `dns`), sem
depender de brute-force. Para alvos de domínio, a Fase 3 também resolve o IP
do domínio raiz e roda `rdap` nesse IP (índice `rdap-network`) — o RDAP devolve
o bloco (CIDR) que contém o IP diretamente, sem precisar calcular a máscara.
**`nmap`/`masscan`/`rdap_network` também rodam por subdomínio, não só no IP do
domínio raiz.** Assim que o `dnsx` (Fase 3) resolve o IP de cada subdomínio, a
Fase 4 pega o conjunto de IPs únicos (deduplicado — vários subdomínios atrás
do mesmo host/CDN não geram scan repetido), exclui o IP do domínio raiz (já
coberto na Fase 3) e dispara `rdap_network` + `masscan` em paralelo pra cada
IP restante; o callback usa as portas que o `masscan` achou pra direcionar o
`nmap` (em vez do top-1000 padrão), o mesmo padrão já usado pra alvo IP puro.
`httpx` não roda de novo nesse IP — o hostname que resolve pra ele já foi
testado na Fase 3. IPs privados/loopback/reservados (ex: um subdomínio tipo
`localhost.exemplo.com` apontando pra `127.0.0.1`, visto na prática) são
descartados antes de escanear — sem esse filtro, um subdomínio malicioso ou
mal-configurado apontando pra dentro escanearia a própria infraestrutura em
vez do alvo do cliente.
Com `SHODAN_API_KEY`/`CENSYS_API_KEY` configuradas, Shodan e/ou Censys também
são consultadas nesses mesmos três pontos (IP do domínio raiz, IP de cada
subdomínio, alvo IP puro) — os dois podem ficar ligados ao mesmo tempo, são
motores de varredura independentes com cobertura diferente. Ver "Dados do
Shodan" e "Dados da Censys" abaixo.
Como um IP de subdomínio pode estar numa infraestrutura totalmente diferente
da raiz (PTR que não bate com o domínio do cliente, por exemplo), cada achado
por IP (nmap, masscan, shodan, censys) tem um botão "ⓘ" ao lado do valor do
IP na tabela — mostra de forma curta se aquele IP é o do domínio raiz, veio
da resolução de um subdomínio específico (dnsx), ou foi informado direto
como alvo do scan (`GET /clients/{client}/ip-provenance?ip=...&scan_id=...`).
A Fase 4 roda em qualquer URL que o httpx conseguiu obter resposta (`alive`),
não só nas que devolveram `200` — um 404 na raiz não quer dizer "morto", só
que não tem index, e é exatamente esse tipo de host que o gobuster existe
para investigar (acha caminho vivo que não está linkado em lugar nenhum).
`httpx` roda com `-fr` (segue redirects) para que o `status_code` reflita o
destino final, e o `gobuster` roda com `-r` (segue redirect) pelo mesmo
motivo.
O gobuster tem três perfis de wordlist selecionáveis por scan (campo
`gobuster_wordlist` em `POST /scans`, ou o seletor no formulário do
dashboard): `common` (dirb/common.txt, ~4.6k palavras, padrão — mais rápido),
`big` (dirb/big.txt, ~20k palavras — mais completo, porém bem mais lento; o
timeout do job sobe de 300s para 900s nesse perfil) e `custom` (wordlist
enviada pelo próprio usuário — ver seção abaixo).
### Wordlists customizadas do gobuster
Upload por cliente (`POST /clients/{client}/wordlists`, multipart/form-data,
ou pelo formulário "Novo recon" no dashboard, ao escolher "Personalizada").
Como é um recurso de upload, é tratado com os seguintes cuidados:
- **Nunca confia no nome do arquivo para gravar em disco** — o arquivo é
salvo com um id opaco gerado no servidor (`uuid4`); o nome enviado só é
usado, sanitizado (charset restrito, sem separador de path), para exibição.
Isso fecha qualquer tentativa de path traversal via nome de arquivo.
- **Tamanho e conteúdo validados antes de gravar**: o upload é lido em blocos
(nunca materializa o arquivo inteiro em memória antes de checar o
tamanho), rejeitando acima de `MAX_WORDLIST_BYTES` (padrão 5 MiB). O
conteúdo precisa ser texto UTF-8 puro (rejeita byte nulo/caracteres de
controle — sinal de binário), sem linhas acima de `MAX_WORDLIST_LINE_CHARS`
(padrão 512) nem mais que `MAX_WORDLIST_LINES` (padrão 200.000) linhas
válidas. nginx também rejeita corpos grandes antes de chegar no backend
(`client_max_body_size`), como uma camada extra.
- **Limite por cliente**: no máximo `MAX_WORDLISTS_PER_CLIENT` (padrão 5)
wordlists simultâneas — evita acúmulo sem controle no disco.
- **Isolamento entre clientes**: um scan só pode referenciar uma wordlist que
pertença ao mesmo cliente (checado tanto na criação do scan quanto na hora
de rodar o gobuster de fato) — um cliente não acessa o upload de outro.
- **Limpeza automática**: excluir um cliente (ou "limpar dados") remove
também os arquivos das wordlists customizadas em disco, não só o índice de
metadados — sem isso, ficariam órfãos no volume indefinidamente.
- No container efêmero do gobuster, o arquivo é montado **somente leitura**,
só aquele arquivo específico (não o diretório inteiro de wordlists).
Todos os limites são configuráveis via `.env` (`MAX_WORDLIST_BYTES`,
`MAX_WORDLIST_LINES`, `MAX_WORDLIST_LINE_CHARS`, `MAX_WORDLISTS_PER_CLIENT`,
`GOBUSTER_CUSTOM_TIMEOUT_SECONDS`) — ver `.env.example`.
curl -X POST http://localhost:8000/clients/acme/wordlists -F "file=@minha-wordlist.txt"
curl http://localhost:8000/clients/acme/wordlists
curl -X DELETE http://localhost:8000/clients/acme/wordlists/
**Limitação conhecida**: o limite por cliente é checado com uma leitura
seguida de escrita (não é atômico) — sob upload verdadeiramente concorrente
(duas requisições simultâneas), é possível passar o limite por 1-2 unidades.
Não é um problema para o uso normal (um operador ou time pequeno), só não é
uma trava dura sob concorrência adversarial.
## Requisitos
- Docker + Docker Compose v2
- Linux com `vm.max_map_count >= 262144` (exigido pelo OpenSearch — a maioria
das distros já vem assim; se o `opensearch` não subir, rode
`sudo sysctl -w vm.max_map_count=262144`)
## Subindo o stack
cp .env.example .env # ajuste as senhas antes de ir para produção
docker build -t kali-tools:1.0 -f kali/Dockerfile kali/ # kali-tools não é um "service" do compose (ver nota abaixo)
docker compose up -d
Acompanhe a subida:
docker compose ps
Ordem esperada: `redis` + `opensearch` ficam `healthy` → `opensearch-init`
roda os templates de índice e sai (`exited 0`) → `backend` + `worker` ficam
`healthy` → `frontend` fica `healthy`. Todos os cinco serviços têm healthcheck
(`worker` via `celery inspect ping` — confirma conexão real com o broker, não
só o processo vivo; `frontend` via `curl` na página servida pelo nginx).
## Uso
### Pelo dashboard
Abra **http://localhost:3000** — formulário "novo recon" (nome do cliente +
lista de domínios/IPs, um por linha, + perfil de wordlist do gobuster),
depois navegue pelos clientes e pelas ferramentas.
No topo, as bandeirinhas 🇧🇷/🇬🇧 trocam o idioma da interface (padrão:
português). A troca é só de interface — rótulos, botões, mensagens; os
dados descobertos pelas ferramentas (subdomínios, URLs, descrições de
achados etc.) continuam exatamente como foram encontrados, em nenhum idioma
específico. A escolha fica salva no navegador (`localStorage`), então
persiste entre sessões. Implementado em `frontend/src/i18n/` — um dicionário
(`translations.js`) mapeando cada texto em português para o inglês; textos
sem entrada lá continuam em português mesmo com o inglês selecionado (uma
rede de segurança, não o comportamento esperado — toda string de interface
nova deve ganhar uma entrada ali).
### Pela API
# Disparar um recon (gobuster_wordlist é opcional, default "common")
curl -X POST http://localhost:8000/scans \
-H "Content-Type: application/json" \
-d '{"client": "acme", "targets": ["acme.com", "203.0.113.10"], "gobuster_wordlist": "big"}'
# Mesma chamada, com API_KEY definida no .env (header X-API-Key obrigatório
# em toda rota exceto /health — ver "Segurança" para quando isso é necessário)
curl -X POST http://localhost:8000/scans \
-H "Content-Type: application/json" \
-H "X-API-Key: " \
-d '{"client": "acme", "targets": ["acme.com", "203.0.113.10"]}'
# Acompanhar as execuções de um scan
curl "http://localhost:8000/scans/?client=acme"
# Listar clientes com dados
curl http://localhost:8000/clients
# Índices/ferramentas de um cliente (com contagem de docs)
curl http://localhost:8000/clients/acme/indices
# Achados de uma ferramenta, paginado e filtrável
curl "http://localhost:8000/clients/acme/nuclei?severity=critical&page=1&size=25"
# Limpar dados: apaga achados + histórico de execuções, mas o cliente
# continua na lista (zerado, como se fosse recém-criado)
curl -X POST http://localhost:8000/clients/acme/clear
# Excluir cliente: some da lista de clientes
curl -X DELETE http://localhost:8000/clients/acme
"Limpar dados" (botão ↺ no dashboard do cliente) e "excluir cliente" (botão
⚠) resolvem propósitos diferentes: o primeiro reseta o cliente para um
estado zerado sem remover seu nome da lista — útil para descartar um recon
antigo e começar de novo no mesmo cliente; o segundo remove o cliente por
completo. Nenhum dos dois cancela scans em andamento na fila — só afeta o
que já foi indexado (para cancelar execuções, use o botão "■ cancelar scans
em andamento" antes).
`GET /clients/{client}/{suffix}` aceita: `q` (busca livre), `page`, `size`,
`sort` (ex: `-@timestamp`), e qualquer outro parâmetro vira filtro exato
sobre o campo — ex: `?tool=assetfinder`, `?status_code=200`.
`q` busca por "contém a string" em qualquer parte do valor (não só match
exato) — buscar `xxx` encontra `xxx.acme.com`, não precisa digitar o valor
inteiro. Caracteres especiais da sintaxe do query_string (`: / * ( ) ...`)
são escapados automaticamente, então a busca trata o texto digitado como
literal, não como uma expressão de busca.
### Excluindo achados específicos (falsos positivos)
Cada linha da tabela de achados tem um checkbox de seleção; marcando uma ou
mais, aparece uma barra "N selecionado(s)" com o botão "excluir
selecionados". Útil para descartar um falso positivo pontual sem apagar o
resto do índice (isso é diferente de "limpar dados", que zera o cliente
inteiro). Não vale para as tabelas de metadados (`jobs`/`scans`) — um job em
andamento precisa ser cancelado (botão "cancelar" na própria linha), não
apagado direto, senão o container/task fica órfão sem registro para parar.
curl -X POST http://localhost:8000/clients/acme/nuclei/delete \
-H "Content-Type: application/json" \
-d '{"ids": ["<_id do achado>", ""]}'
### Exportando dados
Em JSON, CSV ou PDF, no nível do cliente (todos os índices) ou de um único
índice/ferramenta. Também disponível como botões no dashboard.
# Todos os achados do cliente
curl "http://localhost:8000/clients/acme/export?format=json" -o acme.json
curl "http://localhost:8000/clients/acme/export?format=csv" -o acme.zip # um CSV por índice, dentro de um .zip
curl "http://localhost:8000/clients/acme/export?format=pdf" -o acme.pdf
# Só um índice/ferramenta
curl "http://localhost:8000/clients/acme/nuclei/export?format=csv" -o acme-nuclei.csv
# Exportação por índice aceita os mesmos filtros da tela (q + qualquer
# outro campo do índice vira filtro exato)
curl "http://localhost:8000/clients/acme/nuclei/export?format=pdf&severity=critical" -o acme-nuclei-critical.pdf
O PDF tem um teto de 500 linhas por seção (índices grandes como `katana`
passam de 10 mil documentos — não cabe num relatório legível); use JSON/CSV
para o dado completo. As colunas do PDF são curadas por ferramenta (ex:
nuclei prioriza `severity`/`template_id`/`cve`) — um índice novo sem
curadoria cai num fallback automático.
A exportação por índice/ferramenta respeita os filtros aplicados na tela
(busca livre, ferramenta de origem, severidade, status etc.) — os botões de
exportar no frontend já levam em conta o que está filtrado no momento. O PDF
filtrado registra "Filtros aplicados: ..." no cabeçalho do relatório, para
não ficar ambíguo se aquele PDF é um recorte ou o total. A exportação em
nível de cliente (todos os índices) não aceita filtros, já que cada índice
tem seu próprio schema de campos.
### Exportando valores únicos (sem duplicidade)
Múltiplas ferramentas achando o mesmo subdomínio (a "soma positiva" do
recon) ou o mesmo scan rodado de novo em outro dia geram repetições
esperadas nos índices — mas às vezes você só quer a lista enxuta (ex: todos
os subdomínios distintos, sem duplicar por ferramenta/execução). O checkbox
"exportar únicos" (ou `?unique=true` na API) resolve isso do lado do
servidor, sem precisar deduplicar no Excel depois:
curl "http://localhost:8000/clients/acme/subdomains/export?format=csv&unique=true" -o acme-subdominios.csv
Dois achados são considerados "o mesmo" se todo o conteúdo bate, ignorando
só os campos que naturalmente variam entre repetições (`tool`, `scan_id`,
`@timestamp`). Ao agrupar, o achado mantido junta as ferramentas que
confirmaram aquele dado no campo `tool` (ex: `["assetfinder", "subfinder"]`)
e soma as ocorrências em `_dedup_count` — nenhuma informação de "quantas
fontes concordam" é descartada, só a repetição de linhas. Combina com os
demais filtros (`q`, `severity`, `scan_id` etc.) e funciona nos três
formatos (JSON/CSV/PDF); só não se aplica à exportação em nível de cliente
(mesma razão dos filtros: cada índice tem seu próprio schema).
## Índices no OpenSearch
Um índice por ferramenta/tipo de achado, nomeado `{cliente}-{sufixo}`:
| Sufixo | Ferramentas | Principais campos |
|---|---|---|
| `subdomains` | assetfinder, subfinder, sublist3r, amass, dnsenum, dnsrecon | `subdomain`, `domain`, `sources[]` |
| `httpx` | httprobe, httpx | `url`, `status_code`, `alive` |
| `dns` | dnsx | `subdomain`, `ips[]`, `resolved` |
| `wayback` | waybackurls, gau | `url`, `path`, `has_params` |
| `katana` | katana | `url`, `domain` |
| `harvester` | theHarvester | `type` (email/host/ip/asn/url), `value` |
| `rdap-domain` | rdap | `domain`, `nameservers[]`, `registrant`, `events[]` |
| `rdap-network` | rdap | `handle`, `start_address`, `end_address`, `cidr`, `org` |
| `masscan` | masscan | `ip`, `port`, `proto`, `state` |
| `nmap` | nmap | `ip`, `port`, `service`, `product`, `version`, `cpe[]` |
| `shodan` | shodan | `ip`, `port`, `product`, `version`, `cpe[]`, `org`, `isp`, `vulns[]`, `hostnames[]` |
| `censys` | censys | `ip`, `port`, `protocol`, `software[]`, `asn`, `org`, `labels[]` |
| `nikto` | nikto | `host`, `uri`, `description`, `references` |
| `nuclei` | nuclei | `template_id`, `severity`, `matched_at`, `tags[]`, `cve` |
| `gobuster` | gobuster | `url`, `path`, `status_code`, `size` |
| `jobs` | (metadados) | `tool`, `target`, `status`, `scan_id`, `doc_count`, `error` |
| `scans` | (metadados) | `scan_id`, `targets[]`, `gobuster_wordlist`, `@timestamp` |
| `wordlists` | (metadados) | `wordlist_id`, `filename`, `line_count`, `size_bytes`, `@timestamp` |
Templates definidos em `opensearch/templates/`, aplicados pelo container
`opensearch-init`.
### Identificando de qual scan veio um achado
Todo documento indexado (achado ou job) já carrega `scan_id` e `@timestamp`
desde sempre — o que faltava era uma forma amigável de saber *qual* scan_id
corresponde a qual execução, já que ele é só um hex opaco. `POST /scans` agora
também grava um registro em `{cliente}-scans` (alvos originais + data/hora),
consultável via:
curl http://localhost:8000/clients/acme/scans
No dashboard, isso alimenta um seletor "scan" nos filtros de achados e de
execuções (aparece só quando o cliente tem mais de um scan registrado),
mostrando "dd/mm/aaaa hh:mm — alvos" em vez do hex. Escanear o mesmo alvo de
novo em outro dia não mistura os achados de forma indistinguível: o filtro
"scan" (e a exportação, que respeita esse mesmo filtro) deixam claro de qual
execução cada achado veio. O PDF exportado com esse filtro mostra "Filtros
aplicados: scan=dd/mm/aaaa hh:mm — alvos" no cabeçalho pelo mesmo motivo.
O painel do cliente também tem uma seção "Scans" própria, listando cada
execução com data/hora, os alvos exatos informados naquele disparo e o
perfil de wordlist usado — útil para responder diretamente "quais alvos
entraram no scan de ontem vs. no de hoje" (ex: o escopo de um cliente cresceu
e um IP foi adicionado numa segunda execução, mantendo o domínio da
primeira). Cada linha tem um link "ver execuções" que abre a aba de
execuções já filtrada por aquele `scan_id` específico.
### Comparando scans ("o que mudou desde a última vez")
Cada linha da seção "Scans" (exceto a mais antiga) tem um link "ver mudanças
desde a anterior", que compara aquele scan com o imediatamente anterior.
Também dá para marcar dois scans quaisquer (checkbox) e clicar em "comparar
selecionados" — útil para comparar execuções não-consecutivas. Isso abre uma
tela por índice/ferramenta mostrando:
- **Novos** — achados que só existem no scan mais recente;
- **Resolvidos** — achados que só existiam no mais antigo (ex: vulnerabilidade
corrigida, subdomínio desativado);
- **Inalterados** — achados presentes nos dois. Clique no bloco pra
abrir/fechar a lista completa (fica recolhida por padrão — normalmente
esse é o grupo menos interessante numa comparação, já que a ideia é focar
no que mudou).
curl "http://localhost:8000/clients/acme/nuclei/compare?from_scan=&to_scan="
Dois achados são considerados "o mesmo" pela mesma regra usada em "exportar
únicos": todo o conteúdo bate, ignorando só `tool`/`scan_id`/`@timestamp`/
`client` (os campos que naturalmente variam entre execuções). Não se aplica
a `jobs`/`scans` (metadados, não achados) — mesma restrição do "excluir
achados específicos".
### Excluindo um scan específico
Selecionar exatamente um scan (checkbox) na seção "Scans" faz aparecer o
botão "excluir scan selecionado". Diferente de "limpar dados" (que zera o
cliente inteiro), isso apaga só aquela execução: o registro do scan e todos
os achados/jobs com aquele `scan_id`, em todos os índices do cliente — os
achados dos demais scans não são afetados.
curl -X DELETE "http://localhost:8000/clients/acme/scans/"
Não dá pra saber de antemão quais índices um scan tocou (depende de quais
fases/ferramentas rodaram para aquele alvo), então a exclusão varre todos os
índices existentes do cliente filtrando por `scan_id` — a resposta traz
`deleted_by_suffix` com a contagem removida de cada um.
## Estrutura do projeto
super-recon/
├── docker-compose.yml
├── .env.example
├── kali/ # Dockerfile da imagem kali-tools:1.0
├── opensearch/ # templates de índice + policies de ILM (ISM) + script de init
│ ├── backup.sh # tira snapshot (backup) do OpenSearch
│ └── restore.sh # restaura um snapshot
├── backend/
│ ├── app/ # FastAPI + Celery (orquestrador)
│ ├── parsers/ # json/xml/txt -> documento normalizado, por ferramenta
│ └── tests/ # pytest, amostras de saída de cada ferramenta embutidas nos próprios testes
├── frontend/ # dashboard React + Nginx
├── data/exchange/ # diretório de troca worker <-> containers kali (nikto)
├── data/wordlists/ # uploads de wordlist customizada (gobuster)
└── data/opensearch-snapshots/ # arquivos dos backups (ver "Backup do OpenSearch")
## Variáveis de ambiente (`.env`)
| Variável | Descrição |
|---|---|
| `RECON_CPUS` | Nº de CPUs para paralelismo do worker. Vazio = todos os cores do host. |
| `REDIS_PASSWORD` | Senha do Redis (fila). |
| `OPENSEARCH_ADMIN_USER` / `OPENSEARCH_ADMIN_PASSWORD` | Credenciais do OpenSearch. |
| `OPENSEARCH_HOST_BIND` | IP de bind da porta 9200 no host (padrão `127.0.0.1` — não altere para `0.0.0.0`; é o banco cru, com as credenciais acima). |
| `BACKEND_HOST_BIND` / `FRONTEND_HOST_BIND` | IP de bind das portas 8000/3000 no host. `127.0.0.1` (padrão) = só local; `0.0.0.0` = acessível de qualquer IP que alcance a máquina (LAN ou internet, se tiver IP público). Só troque para `0.0.0.0` depois de definir `API_KEY`. |
| `API_KEY` | Vazio (padrão) = API sem autenticação. Definida = toda rota (exceto `/health`) exige o valor em `X-API-Key` (header) ou `?api_key=` (query string, usado pelos links de exportação). Gere com `openssl rand -hex 32`. |
| `ILM_SHORT_RETENTION_DAYS` / `ILM_LONG_RETENTION_DAYS` | Dias até um índice expirar automaticamente (ILM/ISM). Vazio (padrão) = nunca expira. Ver seção "Retenção de dados" abaixo. |
| `NOTIFY_SEVERITIES` | Severidades que disparam notificação (padrão `critical`). Lista separada por vírgula, ex: `critical,high`. |
| `SLACK_BOT_TOKEN` / `SLACK_CHANNEL` | Token de bot do Slack (`xoxb-...`, escopo `chat:write`) e ID do canal onde o bot foi adicionado. Vazios (padrão) = Slack desligado. |
| `NOTIFY_WEBHOOK_URL` | URL que recebe um POST JSON a cada achado crítico. Vazio (padrão) = desligado. Pode ser usado junto com o Slack, não é um ou-outro. |
| `PUBLIC_BASE_URL` | Opcional — URL pública do dashboard (ex: `http://minha-vps.example.com:3000`), só para montar um link clicável na notificação. |
| `HEALTH_CHECK_INTERVAL_SECONDS` | Intervalo (segundos) do monitor de saúde da plataforma (padrão 60). `<= 0` desliga o monitor. Ver "Monitor de saúde da plataforma". |
| `HEALTH_QUEUE_BACKLOG_THRESHOLD` | Nº de tarefas pendentes na fila do Celery acima do qual soa alarme de fila represada (padrão 50). |
| `HEALTH_STUCK_JOB_MINUTES` | Minutos que um job pode ficar "running" antes de ser considerado travado (padrão 60). |
| `SHODAN_API_KEY` | Vazio (padrão) = Shodan desligada. Definida = consulta a Shodan Host API pro IP do domínio raiz, de cada subdomínio e de alvo IP puro. Ver "Dados do Shodan". |
| `CENSYS_API_KEY` | Vazio (padrão) = Censys desligada. Definida = consulta a Censys Platform API nos mesmos pontos que a Shodan. Ver "Dados da Censys". |
## Retenção de dados (ILM)
Por padrão os dados ficam guardados indefinidamente. Se quiser expiração
automática, defina `ILM_SHORT_RETENTION_DAYS`/`ILM_LONG_RETENTION_DAYS` no
`.env` e rode `docker compose up opensearch-init` (reaplica a configuração
num cluster já no ar, sem precisar derrubar o resto do stack):
- **Retenção curta** (`ILM_SHORT_RETENTION_DAYS`) — `wayback`/`katana`: alto
volume de documentos e baixo valor de longo prazo (URLs históricas/
crawling), os índices que mais pesam no cluster.
- **Retenção longa** (`ILM_LONG_RETENTION_DAYS`) — todo o resto: achados de
mais peso (`nuclei`, `subdomains`, `nmap` etc.) e os metadados de execução
(`jobs`, `scans`). Pega qualquer índice que não bater com a retenção curta,
então uma ferramenta nova adicionada no futuro já nasce coberta por essa
política sem precisar mexer em nada.
Implementado via ISM (Index State Management, plugin já incluso na imagem do
OpenSearch — não é um recurso pago). Cada grupo é uma policy com uma única
transição, `active -> delete` após `min_index_age` dias; `ism_template` no
corpo da policy faz o attach automático em índices *novos* que baterem o
pattern, e o `opensearch-init` aplica retroativamente aos *já existentes* via
`_plugins/_ism/add`. Deixar a variável vazia pula a criação daquela policy
inteiramente — nenhum índice desse grupo fica sob gestão do ISM.
# Ver se um índice está sob alguma policy, e o estado atual dela
curl -sk -u admin: "https://localhost:9200/_plugins/_ism/explain/acme-nuclei"
## Backup do OpenSearch
Usa a [Snapshot API](https://opensearch.org/docs/latest/tuning-your-cluster/availability-and-recovery/snapshots/index/)
nativa do OpenSearch — sem ferramenta externa. O `opensearch-init` já registra,
a cada boot do stack, um repositório de snapshot chamado `recon-backups`
(tipo `fs`, ou seja, arquivo em disco) apontando para
`data/opensearch-snapshots/` no host; esse registro é só "deixar pronto pra
usar", tirar o snapshot em si é sempre uma ação explícita (ver abaixo).
# Tirar um backup (nome automático: backup-AAAAMMDD-HHMMSS)
./opensearch/backup.sh
# Ou com nome escolhido
./opensearch/backup.sh antes-da-migracao
# Listar os snapshots existentes
./opensearch/restore.sh
# Restaurar um snapshot (pede confirmação y/N antes de restaurar)
./opensearch/restore.sh backup-20260705-165144
# Restaurar só alguns índices do snapshot (padrão de nome, entre aspas)
./opensearch/restore.sh backup-20260705-165144 "acme-*"
Os dois scripts rodam do host (fora de container), usando as credenciais do
`.env` — não precisam de nada além de `curl`. `backup.sh` inclui todos os
índices de dados do projeto (achados, jobs, scans, wordlists) e exclui
índices internos do OpenSearch/plugins (`.opendistro-*`, `security-auditlog-*`,
`top_queries-*`).
**Restauração é intencionalmente não-destrutiva**: o OpenSearch recusa
restaurar por cima de um índice que já existe (erro
`snapshot_restore_exception`), então `restore.sh` nunca apaga nada sozinho. Se
o objetivo é realmente substituir um índice existente por uma versão antiga do
snapshot, apague-o (ou feche-o) manualmente antes:
curl -sk -u admin: -X DELETE "https://localhost:9200/acme-nuclei"
**Isso não é um backup fora do host.** Os arquivos do snapshot ficam em
`data/opensearch-snapshots/`, no mesmo disco dos dados originais
(`opensearch-data`) — protege contra erro humano (índice apagado/corrompido
sem querer), mas não contra falha de disco ou perda da máquina inteira. Para
disaster recovery de verdade, copie essa pasta pra fora do host depois de cada
backup (outro disco, outra máquina, S3, rsync para outro servidor etc.):
rsync -av data/opensearch-snapshots/ usuario@outro-host:/backups/super-recon/
## Notificação em achado crítico
"Fecha o loop" sem precisar ficar olhando a tela: quando um achado recém-
indexado tem severidade em `NOTIFY_SEVERITIES` (padrão `critical` — hoje só
o `nuclei` grava esse campo), o worker manda uma notificação por Slack e/ou
webhook genérico, o que estiver configurado no `.env`. Desligado por padrão
(vazio nos dois canais).
- **Uma mensagem por execução de ferramenta**, não uma por achado — uma
ferramenta que encontra vários de uma vez não inunda o canal. A mensagem
lista até 10 achados (template + host/URL) e resume o resto ("+ N").
- **Slack**: usa a API real (`chat.postMessage`), não um "incoming webhook" —
precisa de um bot token (`xoxb-...`) com escopo `chat:write`, adicionado ao
canal de destino (o ID do canal, não o nome — pegue em "Copiar link do
canal" no próprio Slack).
- **Webhook genérico**: POST de JSON (`{"text", "client", "tool", "target",
"findings"}`) pra qualquer URL — compatível com um "Incoming Webhook" do
Slack, ou um endpoint próprio. Pode ser usado ao mesmo tempo que o Slack.
- **Nunca derruba o pipeline de recon**: uma falha de rede, token inválido
ou canal errado só gera um log de erro — o job da ferramenta continua
marcado como "ok" normalmente.
# Disparar via Slack Web API diretamente (útil pra validar token/canal antes
# de configurar no .env)
curl -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" -H "Content-Type: application/json" \
-d '{"channel":"'"$SLACK_CHANNEL"'","text":"teste"}'
**Cuidado com o token**: é um segredo — fica só no `.env` (já ignorado pelo
`.gitignore`), nunca em código ou log. Se o token vazar (ex: colado num chat,
commitado por engano), revogue/gere um novo nas configurações do app Slack.
## Monitor de saúde da plataforma
Além de achado crítico, o backend também monitora a própria saúde da
plataforma — fila, worker, cluster — e reaproveita o mesmo canal de
notificação (Slack e/ou webhook) configurado acima. Roda em background
dentro do próprio processo do backend (uma thread, checagem a cada
`HEALTH_CHECK_INTERVAL_SECONDS`, padrão 60s) — não precisa de container,
serviço ou Celery Beat novo.
Quatro checagens, cada uma isolada (uma falhar não impede as outras de rodar):
- **Cluster OpenSearch** — status (`green`/`yellow`/`red`) só dos índices do
projeto (`*,-.*,-security-auditlog-*,-top_queries-*`, o mesmo padrão de
exclusão do backup). Índices internos do próprio OpenSearch/plugins ficam
`yellow` pra sempre num cluster single-node (esperam réplica que nunca vai
ser alocada) — sem excluí-los o monitor acusaria problema o tempo todo,
mesmo com os dados do projeto 100% saudáveis.
- **Worker Celery** — `control.inspect().ping()`, chamado do processo do
*backend* (não do worker) — testa de fora se existe algum worker vivo e
respondendo, não só "o container está de pé".
- **Fila represada** — `LLEN` na fila do Celery no Redis; acima de
`HEALTH_QUEUE_BACKLOG_THRESHOLD` (padrão 50) soa alarme de fila que não
está sendo consumida.
- **Jobs travados** — jobs com status `running` (em qualquer cliente) há mais
de `HEALTH_STUCK_JOB_MINUTES` (padrão 60) — ex: o worker morreu no meio de
uma execução sem atualizar o status.
**Notifica só na transição de estado** (bom→ruim ou ruim→bom), nunca a cada
checagem — um problema persistente (cluster amarelo por horas, por exemplo)
geraria uma mensagem só, não uma por minuto. O resultado do último ciclo
também aparece em `GET /health` (`platform_problems`), sem custo extra —
não dispara uma checagem nova, só lê o resultado já calculado.
`HEALTH_CHECK_INTERVAL_SECONDS <= 0` desliga o monitor inteiramente (a thread
nem chega a subir).
## Dados do Shodan
Enriquecimento passivo por IP (org/ISP, portas/banners que a Shodan já tinha
indexado, CVEs conhecidos) — sem gastar tempo de scan ativo, é só uma
consulta HTTPS direta à Host API da Shodan (não sobe container Kali).
Desligado por padrão; defina `SHODAN_API_KEY` (mesmo do plano free, em
https://account.shodan.io/) para ativar. Roda nos mesmos três pontos onde
`nmap`/`masscan`/`rdap_network` já rodam: IP do domínio raiz, IP de cada
subdomínio (dedup, excluindo IPs privados/loopback — ver "Pipeline de
recon"), e alvo IP puro.
**No plano gratuito ("Membership"), nem todo IP com dado na Shodan é
acessível** — testado na prática contra IPs reais, não só pela documentação:
IP com achado normal -> HTTP 200, dado retornado normalmente
IP sem dado na Shodan -> HTTP 404 "No information available for that IP."
IP COM dado na Shodan, -> HTTP 403 "Requires membership or higher to access"
mas fora do plano free
O terceiro caso (a Shodan *tem* informação sobre aquele IP, mas o plano da
API key não dá acesso) é registrado como job `status: error` com a mensagem
da própria Shodan — de propósito, para não passar a impressão enganosa de
"verificamos e não achamos nada" quando na verdade "não conseguimos nem
verificar". Não existe um padrão previsível de antemão sobre quais IPs caem
em cada caso no plano free; um plano pago (Freelancer ou superior) dá acesso
consistente ao Host Lookup para qualquer IP.
O campo `vulns` (lista de CVEs que a Shodan já associou àquele host) vem em
formatos diferentes dependendo da resposta (às vezes lista simples de
CVE-ids, às vezes dict CVE→detalhes) — o parser normaliza os dois formatos
pra uma lista simples de strings.
## Dados da Censys
Mesma ideia do Shodan acima (enriquecimento passivo por IP, sem scan ativo,
consulta HTTPS direta — nesse caso à Censys Platform API), mas outro motor
de varredura: cobertura diferente, um acha o que o outro não acha, por isso
os dois ficam bem juntos ligados ao mesmo tempo. Desligado por padrão;
defina `CENSYS_API_KEY` (token de acesso em https://platform.censys.io/)
para ativar. Roda nos mesmos três pontos que a Shodan: IP do domínio raiz,
IP de cada subdomínio, e alvo IP puro.
Diferenças relevantes em relação à Shodan, testadas na prática:
- **Sempre responde 200** para IP válido, mesmo sem nenhum serviço
encontrado (`"services": []`, inclusive pra faixas reservadas tipo
TEST-NET) — não existe o "403 por causa do plano" visto na Shodan. Um IP
sem serviço simplesmente não gera documento (mesmo comportamento do nmap
sem portas abertas), sem virar erro.
- **Não tem campo de CVE/vulnerabilidade** na resposta do host lookup — o
valor aqui é outro: ASN/organização, WHOIS, e o software identificado por
serviço (`vendor:product`, ex: `apache:http_server`), incluindo às vezes
mais de uma identificação por serviço (ex: o servidor web e o framework
por trás dele).
- **Rate limit baixo em key de teste/trial**: 3 chamadas simultâneas já
produziram `429 Too Many Requests` em parte delas durante os testes —
tratado como job `status: error` normalmente (a Shodan/Censys do mesmo IP
não dependem uma da outra, então uma falhar não afeta a outra). Um plano
pago tem limites mais altos.
## Segurança / limitações conhecidas
- O `worker` tem `/var/run/docker.sock` montado (necessário para criar os
containers efêmeros do Kali) — equivale a acesso root no host. É um
trade-off aceito para permitir orquestração via Docker; não exponha esse
container além do ambiente local.
- `rdap_domain` só funciona para o domínio registrável (ex: `nmap.org`), não
para subdomínios arbitrários (ex: `scanme.nmap.org`) — limitação do
protocolo RDAP em si, não da implementação. O job aparece como `status:
error` no índice `jobs` quando isso acontece; as demais ferramentas do
pipeline não são afetadas.
- **Exposição além de localhost**: por padrão, todas as portas publicadas no
host (`opensearch`, `backend`, `frontend`) usam bind explícito em
`127.0.0.1`. `BACKEND_HOST_BIND`/`FRONTEND_HOST_BIND` (`.env`) tornam isso
configurável — útil para acessar via LAN ou rodar numa VPS com IP público.
**Antes de trocar para `0.0.0.0` (ou um IP não-loopback), defina `API_KEY`.**
Sem isso, qualquer um que alcançar a porta pode disparar scans contra
qualquer alvo usando sua infraestrutura, apagar dados de qualquer cliente e
ler todos os achados — não existe usuário/senha, só essa chave
compartilhada. `OPENSEARCH_HOST_BIND` é independente e deve continuar em
`127.0.0.1` sempre (é o banco cru, com as credenciais admin do OpenSearch,
não tem relação com `API_KEY`).
- Com `API_KEY` definida, o frontend mostra uma tela pedindo a chave na
primeira visita (fica salva no navegador via `localStorage` depois disso;
um botão "sair" no cabeçalho limpa e pede de novo). É um gate de segredo
compartilhado — não é um sistema de usuários/permissões por pessoa.
- `amass` roda com `-r 1.1.1.1,8.8.8.8` (resolvers explícitos) — sem isso, a
v4 trava indefinidamente fazendo qualificação de dezenas de resolvers
públicos, algo observado de forma reprodutível em ambiente containerizado.
- `dnsrecon` roda só com `-t std,brt` (padrão + brute-force), sem o módulo
`bing`: em teste real, a busca no Bing devolveu subdomínios inventados
(fragmentos de URL mal interpretados, ex: `3ascanme.nmap.org`) que passam
despercebidos em domínios com DNS wildcard. Ferramentas de subdomínio por
fontes diferentes (Findomain, DNSmap, Knock, Naabu, Photon) foram
deliberadamente deixadas de fora por redundância com o que já está no
pipeline.
- Hydra (brute-force de credenciais) foi removido do projeto — destoava do
resto do pipeline (que é só leitura/enumeração) e é uma ferramenta
intrusiva por natureza (tenta login ativamente contra um serviço real,
podendo travar contas por lockout de tentativas). Não existe mais nem como
etapa manual/opt-in.
标签:AV绕过, Docker, FastAPI, GitHub, React, Syscalls, 安全测试, 安全防御评估, 实时处理, 密码管理, 插件系统, 搜索引擎查询, 攻击性安全, 自动化侦察, 请求拦截, 逆向工具