Feat cibersecurity #27

Merged
deyvisonwanderley merged 14 commits from feat-cibersecurity into master 2025-11-17 14:49:34 +00:00
63 changed files with 12086 additions and 4369 deletions
Showing only changes of commit 70d405d98d - Show all commits

View File

@@ -16,7 +16,117 @@
limit: 50 limit: 50
}); });
const meuPerfil = useQuery(api.usuarios.obterPerfil, {}); const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
const usuariosQuery = useQuery(api.usuarios.listar, {});
const usuariosParaChat = useQuery(api.usuarios.listarParaChat, {});
const relatoriosRecentes = useQuery(api.security.listarRelatoriosRecentes, { limit: 10 });
const health = useQuery(api.security.healthStatus, {});
const alertConfigs = useQuery(api.security.listarAlertConfigs, { limit: 100 });
// Estado local para preferências de alertas
let alertEmails = $state('');
let alertSeveridadeMin: SeveridadeSeguranca = 'alto';
let alertTiposAtaque: string[] = [];
let alertReenvioMin = $state(15);
let alertTemplate = $state('incidente_critico');
let alertUsersChat = $state('');
let chatSeveridadeMin: SeveridadeSeguranca = 'alto';
let chatTiposAtaque: string[] = [];
let chatReenvioMin = $state(10);
let enviarPorEmail = $state(true);
let enviarPorChat = $state(false);
let editarAlertConfigId: Id<'alertConfigs'> | null = null;
// Sugestões a partir de configs salvas
const sugestoesEmails = $derived.by(() => {
const set = new Set<string>();
for (const cfg of alertConfigs?.data ?? []) {
for (const e of cfg.emails ?? []) set.add(e);
}
for (const u of usuariosQuery?.data ?? []) {
if (u?.email) set.add(u.email);
}
return Array.from(set).slice(0, 16);
});
const sugestoesChatUsers = $derived.by(() => {
const set = new Set<string>();
for (const cfg of alertConfigs?.data ?? []) {
for (const u of cfg.chatUsers ?? []) set.add(u);
}
for (const u of usuariosQuery?.data ?? []) {
if (u?.username) set.add(u.username);
}
return Array.from(set).slice(0, 16);
});
function adicionarEmailSugestao(email: string) {
const linhas = alertEmails.split('\n').map((s) => s.trim()).filter(Boolean);
if (!linhas.includes(email)) {
linhas.push(email);
alertEmails = linhas.join('\n');
}
}
function adicionarChatSugestao(user: string) {
const linhas = alertUsersChat.split('\n').map((s) => s.trim()).filter(Boolean);
if (!linhas.includes(user)) {
linhas.push(user);
alertUsersChat = linhas.join('\n');
}
}
function parseLinhasParaArray(valor: string): string[] {
return valor
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
async function salvarPreferenciasAlertas(configId?: string) {
try {
const canais = { email: enviarPorEmail, chat: enviarPorChat };
const resp = await client.mutation(api.security.salvarAlertConfig, {
configId: configId as Id<'alertConfigs'> | undefined,
nome: alertTemplate || 'Notificação',
canais,
emails: parseLinhasParaArray(alertEmails),
chatUsers: parseLinhasParaArray(alertUsersChat),
severidadeMin: alertSeveridadeMin,
tiposAtaque: (alertTiposAtaque as AtaqueCiberneticoTipo[]),
reenvioMin: alertReenvioMin,
criadoPor: obterUsuarioId()
});
feedback = { tipo: 'success', mensagem: 'Preferências salvas.' };
editarAlertConfigId = resp._id as Id<'alertConfigs'>;
return resp._id;
} catch (erro: unknown) {
feedback = { tipo: 'error', mensagem: mensagemErro(erro) };
}
}
async function excluirPreferenciasAlertas(configId: Id<'alertConfigs'>) {
try {
await client.mutation(api.security.deletarAlertConfig, { configId });
feedback = { tipo: 'success', mensagem: 'Configuração removida.' };
} catch (erro: unknown) {
feedback = { tipo: 'error', mensagem: mensagemErro(erro) };
}
}
function carregarParaEdicao(config: {
_id: Id<'alertConfigs'>;
nome: string;
canais: { email: boolean; chat: boolean };
emails: string[];
chatUsers: string[];
severidadeMin: SeveridadeSeguranca;
tiposAtaque?: AtaqueCiberneticoTipo[];
reenvioMin: number;
}) {
alertTemplate = config.nome ?? 'incidente_critico';
enviarPorEmail = config.canais?.email ?? true;
enviarPorChat = config.canais?.chat ?? false;
alertEmails = (config.emails ?? []).join('\n');
alertUsersChat = (config.chatUsers ?? []).join('\n');
alertSeveridadeMin = config.severidadeMin ?? 'alto';
alertTiposAtaque = (config.tiposAtaque ?? []) as string[];
alertReenvioMin = config.reenvioMin ?? 15;
editarAlertConfigId = config._id;
}
const severidadesDisponiveis: SeveridadeSeguranca[] = [ const severidadesDisponiveis: SeveridadeSeguranca[] = [
'informativo', 'informativo',
'baixo', 'baixo',
@@ -88,7 +198,8 @@
let alertaVisualAtivo = $state(true); let alertaVisualAtivo = $state(true);
// Contagem de novos eventos detectados em tempo real (sem recarregar) // Contagem de novos eventos detectados em tempo real (sem recarregar)
let novosEventos = $state(0); let novosEventos = $state(0);
let ultimoTotalEventos: number | null = null; // Timestamp do último evento observado no feed para cálculo de "novos"
let ultimoTsVisto = $state<number>(Date.now());
let ipManual = $state(''); let ipManual = $state('');
let comentarioManual = $state(''); let comentarioManual = $state('');
let porta = $state(443); let porta = $state(443);
@@ -146,26 +257,71 @@
// Efeito: observar chegada de novos eventos e acionar toast/contador // Efeito: observar chegada de novos eventos e acionar toast/contador
$effect(() => { $effect(() => {
const atual = (eventos?.data ?? []).length; const lista = (eventos?.data ?? []);
if (ultimoTotalEventos === null) { if (!lista.length) return;
ultimoTotalEventos = atual; // conta apenas eventos com timestamp maior que o último visto
return; const novos = lista.filter((e) => e.timestamp > ultimoTsVisto).length;
} if (novos > 0) {
if (atual > ultimoTotalEventos) { novosEventos += novos;
const delta = atual - ultimoTotalEventos; ultimoTsVisto = Math.max(ultimoTsVisto, ...lista.map((e) => e.timestamp));
novosEventos += delta; if (alertaVisualAtivo) {
feedback = { feedback = {
tipo: 'success', tipo: 'success',
mensagem: `🔔 ${delta} novo(s) evento(s) de segurança detectado(s) em tempo real` mensagem: `🔔 ${novos} novo(s) evento(s) de segurança detectado(s)`
}; };
// Opcional: destacar visualmente
if (alertaVisualAtivo) {
// classe CSS já existente de alertas visuais; aqui mantemos apenas o toast
} }
} }
ultimoTotalEventos = atual;
}); });
// Dados para gráfico “Realtime por tipo” (últimos 60 min em buckets de 5 min)
const janelaMs = 60 * 60 * 1000;
const bucketMs = 5 * 60 * 1000;
const coresTipo: Record<string, string> = {
ddos: 'rgba(248,113,113,0.9)',
sql_injection: 'rgba(59,130,246,0.9)',
xss: 'rgba(234,179,8,0.9)',
brute_force: 'rgba(99,102,241,0.9)',
path_traversal: 'rgba(34,197,94,0.9)',
command_injection: 'rgba(236,72,153,0.9)',
nosql_injection: 'rgba(20,184,166,0.9)'
};
const tiposParaChart = Object.keys(coresTipo);
const timelineBuckets = $derived.by(() => {
const agora = Date.now();
const inicio = agora - janelaMs;
const lista = (eventos?.data ?? []).filter((e) => e.timestamp >= inicio);
const numBuckets = Math.floor(janelaMs / bucketMs);
const buckets: Array<{
inicio: number;
fim: number;
counts: Record<string, number>;
topDestinos: Record<string, number>;
}> = [];
for (let i = 0; i < numBuckets; i++) {
const bIni = inicio + i * bucketMs;
const bFim = bIni + bucketMs;
const counts: Record<string, number> = {};
const topDestinos: Record<string, number> = {};
for (const tipo of tiposParaChart) counts[tipo] = 0;
for (const ev of lista) {
if (ev.timestamp >= bIni && ev.timestamp < bFim) {
const t = ev.tipoAtaque as string;
if (t in counts) counts[t] += 1;
const key = `${ev.destinoIp ?? 'app'}|${ev.protocolo ?? 'http'}`;
topDestinos[key] = (topDestinos[key] ?? 0) + 1;
}
}
buckets.push({ inicio: bIni, fim: bFim, counts, topDestinos });
}
return buckets;
});
const maxBucketTotal = $derived.by(() =>
Math.max(
1,
...timelineBuckets.map((b) => Object.values(b.counts).reduce((a, n) => a + n, 0))
)
);
function maxSeriesValue(dataset: Array<Array<number>>): number { function maxSeriesValue(dataset: Array<Array<number>>): number {
let max = 1; let max = 1;
for (const serie of dataset) { for (const serie of dataset) {
@@ -526,6 +682,155 @@
</div> </div>
</section> </section>
<!-- Alertas e Notificações (rodapé) -->
<section class="border-info/20 bg-base-100/80 mt-6 rounded-3xl border p-6 shadow-2xl">
<h3 class="text-info text-2xl font-bold">Alertas e Notificações</h3>
<p class="text-base-content/70 mt-1 text-sm">Configure destinatários, níveis e tipos de alarme e reenvio.</p>
<div class="mt-4 grid gap-6 md:grid-cols-2">
<!-- Emails -->
<div class="space-y-3">
<h4 class="text-base-content font-semibold">Emails de destino</h4>
<textarea class="textarea textarea-bordered w-full" rows="4" bind:value={alertEmails} placeholder="Um email por linha"></textarea>
{#if sugestoesEmails.length}
<div class="mt-2 flex flex-wrap gap-2">
{#each sugestoesEmails as s (s)}
<button type="button" class="badge badge-outline" onclick={() => adicionarEmailSugestao(s)}>{s}</button>
{/each}
</div>
{/if}
<div class="grid gap-3 md:grid-cols-2">
<label class="form-control">
<span class="label-text text-xs">Severidade mínima</span>
<select class="select select-bordered select-sm" bind:value={alertSeveridadeMin}>
{#each severidadesDisponiveis as s (s)}
<option value={s}>{severityLabels[s]}</option>
{/each}
</select>
</label>
<label class="form-control">
<span class="label-text text-xs">Tipos de ataque</span>
<select class="select select-bordered select-sm" multiple size="5" bind:value={alertTiposAtaque}>
{#each Object.keys(attackLabels) as t (t)}
<option value={t}>{attackLabels[t as AtaqueCiberneticoTipo]}</option>
{/each}
</select>
</label>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-xs">
<input type="checkbox" class="checkbox checkbox-xs" bind:checked={enviarPorEmail} />
<span>Enviar por Email</span>
</label>
<label class="flex items-center gap-2 text-xs">
<input type="checkbox" class="checkbox checkbox-xs" bind:checked={enviarPorChat} />
<span>Enviar por Chat</span>
</label>
</div>
<label class="form-control">
<span class="label-text text-xs">Tempo de reenvio (minutos)</span>
<input type="number" min="1" max="1440" class="input input-bordered input-sm" bind:value={alertReenvioMin} />
</label>
<label class="form-control">
<span class="label-text text-xs">Template de email</span>
<select class="select select-bordered select-sm" bind:value={alertTemplate}>
<option value="incidente_critico">Incidente Crítico - Ação Imediata</option>
<option value="bloqueio_automatico">Bloqueio Automático</option>
<option value="sumario_30min">Sumário 30 Min</option>
<option value="anormalidade">Anomalia Detectada</option>
</select>
</label>
<button type="button" class="btn btn-info btn-sm" onclick={() => salvarPreferenciasAlertas(editarAlertConfigId ?? undefined)}>
{editarAlertConfigId ? 'Atualizar preferências' : 'Salvar preferências'}
</button>
</div>
<!-- Chat -->
<div class="space-y-3">
<h4 class="text-base-content font-semibold">Alertas por Chat</h4>
<textarea class="textarea textarea-bordered w-full" rows="3" bind:value={alertUsersChat} placeholder="IDs de usuários de chat (um por linha)"></textarea>
<label class="form-control">
<span class="label-text text-xs">Selecionar usuários de chat</span>
<select
class="select select-bordered select-sm"
multiple
size="6"
onchange={(e) => {
const options = Array.from((e.target as HTMLSelectElement).selectedOptions).map((o) => o.value);
for (const val of options) adicionarChatSugestao(val);
}}
>
{#each (usuariosParaChat?.data ?? []) as u (u._id)}
<option value={u.username ?? u.email}>{u.nome} ({u.username ?? u.email})</option>
{/each}
</select>
</label>
{#if sugestoesChatUsers.length}
<div class="mt-2 flex flex-wrap gap-2">
{#each sugestoesChatUsers as s (s)}
<button type="button" class="badge badge-outline" onclick={() => adicionarChatSugestao(s)}>{s}</button>
{/each}
</div>
{/if}
<div class="grid gap-3 md:grid-cols-2">
<label class="form-control">
<span class="label-text text-xs">Severidade mínima</span>
<select class="select select-bordered select-sm" bind:value={chatSeveridadeMin}>
{#each severidadesDisponiveis as s (s)}
<option value={s}>{severityLabels[s]}</option>
{/each}
</select>
</label>
<label class="form-control">
<span class="label-text text-xs">Tipos de ataque</span>
<select class="select select-bordered select-sm" multiple size="5" bind:value={chatTiposAtaque}>
{#each Object.keys(attackLabels) as t (t)}
<option value={t}>{attackLabels[t as AtaqueCiberneticoTipo]}</option>
{/each}
</select>
</label>
</div>
<label class="form-control">
<span class="label-text text-xs">Tempo de reenvio (minutos)</span>
<input type="number" min="1" max="1440" class="input input-bordered input-sm" bind:value={chatReenvioMin} />
</label>
<button type="button" class="btn btn-outline btn-sm" onclick={() => salvarPreferenciasAlertas(editarAlertConfigId ?? undefined)}>
Salvar preferências de chat
</button>
</div>
</div>
<div class="divider my-6"></div>
<h4 class="text-base-content text-sm font-semibold">Configurações salvas</h4>
{#if alertConfigs?.data?.length}
<div class="mt-2 grid gap-3 md:grid-cols-2">
{#each alertConfigs.data as cfg (cfg._id)}
<div class="border-base-200 rounded-xl border p-3">
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="font-semibold">{cfg.nome}</p>
<p class="text-base-content/60 text-xs">
Canais: {cfg.canais.email ? 'Email' : ''}{cfg.canais.email && cfg.canais.chat ? ' + ' : ''}{cfg.canais.chat ? 'Chat' : ''}
• Sev. mínima: {severityLabels[cfg.severidadeMin]}
• Reenvio: {cfg.reenvioMin} min
</p>
{#if cfg.emails.length}
<p class="text-base-content/60 text-xs">Emails: {cfg.emails.join(', ')}</p>
{/if}
{#if cfg.chatUsers.length}
<p class="text-base-content/60 text-xs">Chat: {cfg.chatUsers.join(', ')}</p>
{/if}
</div>
<div class="flex gap-2">
<button class="btn btn-xs" type="button" onclick={() => carregarParaEdicao(cfg)}>Editar</button>
<button class="btn btn-xs btn-error" type="button" onclick={() => excluirPreferenciasAlertas(cfg._id)}>Excluir</button>
</div>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-base-content/60 mt-2 text-xs">Nenhuma configuração salva.</p>
{/if}
</section>
<!-- (Seção Alertas e Notificações removida desta posição; renderizada ao final) -->
<section class="grid gap-6 lg:grid-cols-3"> <section class="grid gap-6 lg:grid-cols-3">
<div class="border-primary/20 bg-base-100/80 rounded-3xl border p-6 shadow-2xl lg:col-span-2"> <div class="border-primary/20 bg-base-100/80 rounded-3xl border p-6 shadow-2xl lg:col-span-2">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
@@ -535,6 +840,12 @@
Correlação temporal entre DDoS, SQLi, ataques avançados e bloqueios automáticos. Correlação temporal entre DDoS, SQLi, ataques avançados e bloqueios automáticos.
</p> </p>
</div> </div>
{#if health?.data}
<div class="badge badge-outline">
<span class="mr-2 h-2 w-2 rounded-full {health.data.ok ? 'bg-success' : 'bg-error'}"></span>
{health.data.ok ? 'Saúde OK' : 'Instável'} · Pendentes: {health.data.pendingReports}
</div>
{/if}
<div class="flex gap-2"> <div class="flex gap-2">
<label class="label cursor-pointer gap-2"> <label class="label cursor-pointer gap-2">
<span class="label-text">Alerta Sonoro</span> <span class="label-text">Alerta Sonoro</span>
@@ -599,6 +910,64 @@
</div> </div>
</div> </div>
<!-- Novo gráfico: Realtime por Tipo (últimos 60min, buckets 5min) -->
<div class="bg-base-200/40 mt-6 rounded-2xl p-4">
<h3 class="text-base-content text-lg font-semibold">Realtime por tipo (60 min)</h3>
<div class="mt-3 grid grid-cols-12 gap-2">
{#each timelineBuckets as b}
<div class="col-span-1">
<div class="h-24 w-full overflow-hidden rounded bg-base-300">
{#each tiposParaChart as t}
{#if b.counts[t] > 0}
<div class="w-full"
style={`height:${(b.counts[t] / Math.max(1, Object.values(b.counts).reduce((a, n) => a + n, 0))) * 100}%; background:${coresTipo[t]}`}
title={`${t}: ${b.counts[t]}`}></div>
{/if}
{/each}
</div>
<div class="mt-1 text-center text-[10px] leading-tight">
{new Date(b.inicio).toLocaleTimeString('pt-BR', { hour12: false, hour: '2-digit', minute: '2-digit' })}
</div>
</div>
{/each}
</div>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
{#each tiposParaChart as t}
<span class="badge" style={`background:${coresTipo[t]}`}>{t.replace('_', ' ')}</span>
{/each}
</div>
<!-- Top destinos (IP/protocolo) no período -->
<div class="mt-4">
<h4 class="text-sm font-semibold">Top destinos (IP · protocolo)</h4>
<table class="table table-zebra mt-2 w-full text-xs">
<thead>
<tr>
<th>Destino</th>
<th>Protocolo</th>
<th>Ocorrências</th>
</tr>
</thead>
<tbody>
{#each [...new Map(
timelineBuckets
.map((b) => Object.entries(b.topDestinos))
.flat()
.reduce((acc, [k, v]) => acc.set(k, (acc.get(k) ?? 0) + (v as number)), new Map())
)].slice(0, 8) as item}
{#key item[0]}
{@const partes = item[0].split('|')}
<tr>
<td>{partes[0]}</td>
<td>{partes[1]}</td>
<td>{item[1] as number}</td>
</tr>
{/key}
{/each}
</tbody>
</table>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-2"> <div class="mt-6 flex flex-wrap gap-2">
{#each severidadesDisponiveis as severidade (severidade)} {#each severidadesDisponiveis as severidade (severidade)}
<button <button
@@ -791,7 +1160,7 @@
{/each} {/each}
</div> </div>
</div> </div>
<div class="mt-4 space-y-4"> <div class={`mt-4 space-y-4 ${eventosFiltrados.length > 6 ? 'max-h-[28rem] overflow-auto pr-2' : ''}`}>
{#if eventosFiltrados.length === 0} {#if eventosFiltrados.length === 0}
<p class="text-base-content/60 text-sm">Nenhum evento correspondente aos filtros.</p> <p class="text-base-content/60 text-sm">Nenhum evento correspondente aos filtros.</p>
{:else} {:else}
@@ -815,10 +1184,12 @@
<p class="text-base-content/80 text-sm">{evento.descricao}</p> <p class="text-base-content/80 text-sm">{evento.descricao}</p>
<div class="grid gap-2 text-xs md:grid-cols-2"> <div class="grid gap-2 text-xs md:grid-cols-2">
<div class="space-y-1"> <div class="space-y-1">
{#if evento.origemIp}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-semibold">Origem:</span> <span class="font-semibold">Origem:</span>
<span>{evento.origemIp ?? 'n/d'}</span> <span>{evento.origemIp}</span>
</div> </div>
{/if}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-semibold">Destino:</span> <span class="font-semibold">Destino:</span>
<span>{evento.destinoIp ?? 'n/d'}:{evento.destinoPorta ?? '--'}</span> <span>{evento.destinoIp ?? 'n/d'}:{evento.destinoPorta ?? '--'}</span>
@@ -883,7 +1254,7 @@
<div class="border-accent/20 bg-base-100 space-y-6 rounded-3xl border p-6 shadow-2xl"> <div class="border-accent/20 bg-base-100 space-y-6 rounded-3xl border p-6 shadow-2xl">
<div> <div>
<h4 class="text-accent text-lg font-bold">Lista Negra Inteligente</h4> <h4 class="text-accent text-lg font-bold">Lista Negra Inteligente</h4>
<ul class="mt-4 space-y-3 text-sm"> <ul class="mt-4 space-y-3 text-sm {ipCriticos.length >= 10 ? 'max-h-64 overflow-auto pr-2' : ''}">
{#if ipCriticos.length === 0} {#if ipCriticos.length === 0}
<li class="text-base-content/60">Nenhum IP crítico listado.</li> <li class="text-base-content/60">Nenhum IP crítico listado.</li>
{:else} {:else}
@@ -908,9 +1279,10 @@
</ul> </ul>
</div> </div>
<div> <!-- Bloco separado: Regras de Portas -->
<div class="border border-base-300 rounded-2xl p-4">
<h4 class="text-info text-lg font-bold">Regras de Portas Monitoradas</h4> <h4 class="text-info text-lg font-bold">Regras de Portas Monitoradas</h4>
<div class="mt-4 space-y-2 text-xs"> <div class="mt-4 space-y-2 text-xs {regras.length >= 6 ? 'max-h-64 overflow-auto pr-2' : ''}">
{#if regras.length === 0} {#if regras.length === 0}
<p class="text-base-content/60">Nenhuma regra cadastrada.</p> <p class="text-base-content/60">Nenhuma regra cadastrada.</p>
{:else} {:else}
@@ -927,7 +1299,7 @@
</p> </p>
{#if regra.expiraEm} {#if regra.expiraEm}
<p class="text-base-content/50"> <p class="text-base-content/50">
Expira em: {new Date(regra.expiraEm).toLocaleString('pt-BR', { hour12: false })} Expira em: {new Date(regra.expiraEm).toString().slice(0, 21)}
</p> </p>
{/if} {/if}
<div class="mt-2 flex gap-2"> <div class="mt-2 flex gap-2">
@@ -978,7 +1350,8 @@
</div> </div>
</div> </div>
<div> <!-- Bloco separado: Relatórios -->
<div class="border border-base-300 rounded-2xl p-4">
<h4 class="text-primary text-lg font-bold">Relatórios refinados</h4> <h4 class="text-primary text-lg font-bold">Relatórios refinados</h4>
<form class="mt-3 space-y-2" onsubmit={gerarRelatorioAvancado}> <form class="mt-3 space-y-2" onsubmit={gerarRelatorioAvancado}>
<label class="form-control"> <label class="form-control">
@@ -1011,6 +1384,37 @@
</label> </label>
<button type="submit" class="btn btn-primary btn-sm w-full">Gerar Relatório</button> <button type="submit" class="btn btn-primary btn-sm w-full">Gerar Relatório</button>
</form> </form>
<!-- Grade de relatórios recentes -->
<div class="divider my-4"></div>
<h5 class="text-base-content text-sm font-semibold">Relatórios recentes</h5>
{#if relatoriosRecentes?.data?.length}
<div class="mt-2 overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Status</th>
<th>Criado</th>
<th>Concluído</th>
<th>Observações</th>
</tr>
</thead>
<tbody>
{#each relatoriosRecentes.data as r (r._id)}
<tr>
<td>
<span class="badge badge-outline">{r.status}</span>
</td>
<td>{new Date(r.criadoEm).toLocaleString('pt-BR', { hour12: false })}</td>
<td>{r.concluidoEm ? new Date(r.concluidoEm).toLocaleString('pt-BR', { hour12: false }) : '-'}</td>
<td class="max-w-xs truncate">{r.observacoes ?? '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-base-content/60 mt-2 text-xs">Nenhum relatório recente.</p>
{/if}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1300,4 +1300,21 @@ export default defineSchema({
.index("by_tipo_identificador", ["tipo", "identificador"]) .index("by_tipo_identificador", ["tipo", "identificador"])
.index("by_ativo", ["ativo"]) .index("by_ativo", ["ativo"])
.index("by_prioridade", ["prioridade"]) .index("by_prioridade", ["prioridade"])
,
alertConfigs: defineTable({
nome: v.string(),
canais: v.object({
email: v.boolean(),
chat: v.boolean(),
}),
emails: v.array(v.string()),
chatUsers: v.array(v.string()),
severidadeMin: severidadeSeguranca,
tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)),
reenvioMin: v.number(),
criadoPor: v.id("usuarios"),
criadoEm: v.number(),
atualizadoEm: v.number(),
})
.index("by_criadoEm", ["criadoEm"])
}); });

View File

@@ -1244,6 +1244,63 @@ export const solicitarRelatorioSeguranca = mutation({
} }
}); });
// Lista relatórios recentes para exibição no dashboard
export const listarRelatoriosRecentes = query({
args: {
limit: v.optional(v.number())
},
returns: v.array(
v.object({
_id: v.id('reportRequests'),
status: v.union(
v.literal('pendente'),
v.literal('processando'),
v.literal('concluido'),
v.literal('falhou')
),
criadoEm: v.number(),
concluidoEm: v.optional(v.number()),
observacoes: v.optional(v.string())
})
),
handler: async (ctx, args) => {
const max = Math.min(args.limit ?? 10, 50);
const rows = await ctx.db
.query('reportRequests')
.withIndex('by_criado_em', (q) => q.gte('criadoEm', 0))
.order('desc')
.take(max);
return rows.map((r) => ({
_id: r._id,
status: r.status,
criadoEm: r.criadoEm,
concluidoEm: r.concluidoEm,
observacoes: r.observacoes
}));
}
});
// Health check leve para o dashboard
export const healthStatus = query({
args: {},
returns: v.object({
ok: v.boolean(),
now: v.number(),
pendingReports: v.number()
}),
handler: async (ctx) => {
// Contar rapidamente quantos relatórios pendentes existem (limitado)
const pending = await ctx.db
.query('reportRequests')
.withIndex('by_status', (q) => q.eq('status', 'pendente'))
.take(1);
return {
ok: true,
now: Date.now(),
pendingReports: pending.length
};
}
});
export const processarRelatorioSegurancaInternal = internalMutation({ export const processarRelatorioSegurancaInternal = internalMutation({
args: { args: {
relatorioId: v.id('reportRequests') relatorioId: v.id('reportRequests')
@@ -1319,6 +1376,97 @@ export const processarRelatorioSegurancaInternal = internalMutation({
} }
}); });
// ---------- Alertas (email/chat) ----------
export const listarAlertConfigs = query({
args: { limit: v.optional(v.number()) },
returns: v.array(
v.object({
_id: v.id('alertConfigs'),
nome: v.string(),
canais: v.object({ email: v.boolean(), chat: v.boolean() }),
emails: v.array(v.string()),
chatUsers: v.array(v.string()),
severidadeMin: severidadeValidator,
tiposAtaque: v.optional(v.array(ataqueValidator)),
reenvioMin: v.number(),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx, args) => {
const max = Math.min(args.limit ?? 100, 200);
const rows = await ctx.db
.query('alertConfigs')
.withIndex('by_criadoEm', (q) => q.gte('criadoEm', 0))
.order('desc')
.take(max);
return rows.map((r) => ({
_id: r._id,
nome: r.nome,
canais: r.canais,
emails: r.emails,
chatUsers: r.chatUsers,
severidadeMin: r.severidadeMin,
tiposAtaque: r.tiposAtaque,
reenvioMin: r.reenvioMin,
criadoEm: r.criadoEm,
atualizadoEm: r.atualizadoEm
}));
}
});
export const salvarAlertConfig = mutation({
args: {
configId: v.optional(v.id('alertConfigs')),
nome: v.string(),
canais: v.object({ email: v.boolean(), chat: v.boolean() }),
emails: v.array(v.string()),
chatUsers: v.array(v.string()),
severidadeMin: severidadeValidator,
tiposAtaque: v.optional(v.array(ataqueValidator)),
reenvioMin: v.number(),
criadoPor: v.id('usuarios')
},
returns: v.object({ _id: v.id('alertConfigs') }),
handler: async (ctx, args) => {
const agora = Date.now();
if (args.configId) {
await ctx.db.patch(args.configId, {
nome: args.nome,
canais: args.canais,
emails: args.emails,
chatUsers: args.chatUsers,
severidadeMin: args.severidadeMin,
tiposAtaque: args.tiposAtaque,
reenvioMin: args.reenvioMin,
atualizadoEm: agora
});
return { _id: args.configId };
}
const id = await ctx.db.insert('alertConfigs', {
nome: args.nome,
canais: args.canais,
emails: args.emails,
chatUsers: args.chatUsers,
severidadeMin: args.severidadeMin,
tiposAtaque: args.tiposAtaque,
reenvioMin: args.reenvioMin,
criadoPor: args.criadoPor,
criadoEm: agora,
atualizadoEm: agora
});
return { _id: id };
}
});
export const deletarAlertConfig = mutation({
args: { configId: v.id('alertConfigs') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.configId);
return null;
}
});
export const dispararAlertasInternos = internalMutation({ export const dispararAlertasInternos = internalMutation({
args: { args: {
eventoId: v.id('securityEvents') eventoId: v.id('securityEvents')
@@ -1729,6 +1877,12 @@ export const analisarRequisicaoHTTP = mutation({
// Calcular severidade // Calcular severidade
const severidade = calcularSeveridade(tipoAtaque, undefined, undefined); const severidade = calcularSeveridade(tipoAtaque, undefined, undefined);
// Permitir que o chamador informe o destino/protocolo via query string em cenários de dev/teste
const destinoIp =
(args.queryParams && (args.queryParams['dst'] || args.queryParams['dest'] || args.queryParams['destino'])) ||
undefined;
const protocolo = (args.queryParams && (args.queryParams['proto'] as string)) || 'http';
// Registrar evento de segurança // Registrar evento de segurança
const referencia = `http_${Date.now()}_${Math.random().toString(36).substring(7)}`; const referencia = `http_${Date.now()}_${Math.random().toString(36).substring(7)}`;
const agora = Date.now(); const agora = Date.now();
@@ -1740,12 +1894,13 @@ export const analisarRequisicaoHTTP = mutation({
status: statusInicial(severidade), status: statusInicial(severidade),
descricao: `Ataque ${tipoAtaque} detectado na requisição HTTP ${args.method} ${args.url}`, descricao: `Ataque ${tipoAtaque} detectado na requisição HTTP ${args.method} ${args.url}`,
origemIp: args.ipOrigem, origemIp: args.ipOrigem,
protocolo: 'http', protocolo,
transporte: 'tcp', transporte: 'tcp',
detectadoPor: 'analisador_http_automatico', detectadoPor: 'analisador_http_automatico',
fingerprint: args.userAgent ? { fingerprint: args.userAgent ? {
userAgent: args.userAgent userAgent: args.userAgent
} : undefined, } : undefined,
destinoIp: destinoIp ?? undefined,
tags: ['detecção_automática', 'http', tipoAtaque], tags: ['detecção_automática', 'http', tipoAtaque],
atualizadoEm: agora atualizadoEm: agora
}); });
@@ -2166,7 +2321,7 @@ export const seedRateLimitDev = mutation({
nome: 'Bloqueio Login Dev', nome: 'Bloqueio Login Dev',
tipo: 'endpoint', tipo: 'endpoint',
identificador: 'api/auth/sign-in/email', identificador: 'api/auth/sign-in/email',
limite: 10, limite: 5,
janelaSegundos: 20, janelaSegundos: 20,
estrategia: 'token_bucket', estrategia: 'token_bucket',
acaoExcedido: 'bloquear', acaoExcedido: 'bloquear',

View File

@@ -23,3 +23,4 @@ fi

View File

@@ -78,6 +78,7 @@ class SegurancaTeste:
endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze") endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze")
bloqueado = False bloqueado = False
ip_origem = f"203.0.113.{random.randint(10, 250)}"
for i, senha in enumerate(senhas_comuns[:tentativas], 1): for i, senha in enumerate(senhas_comuns[:tentativas], 1):
try: try:
payload = { payload = {
@@ -88,6 +89,7 @@ class SegurancaTeste:
response = self.session.post( response = self.session.post(
endpoint, endpoint,
json=payload, json=payload,
headers={"X-Forwarded-For": ip_origem},
timeout=5, timeout=5,
allow_redirects=False allow_redirects=False
) )
@@ -134,7 +136,15 @@ class SegurancaTeste:
# Registrar tentativa de brute force no analisador para validar detecção no backend # Registrar tentativa de brute force no analisador para validar detecção no backend
try: try:
mark = "multiple failed login; brute force password guess" mark = "multiple failed login; brute force password guess"
r2 = self.session.post(endpoint_analyze, data=mark, headers={"Content-Type":"text/plain","X-Test-Scenario":"brute_force"}) r2 = self.session.post(
endpoint_analyze,
data=mark,
headers={
"Content-Type": "text/plain",
"X-Test-Scenario": "brute_force",
"X-Forwarded-For": ip_origem
}
)
if r2.status_code == 200: if r2.status_code == 200:
jd = r2.json() jd = r2.json()
if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "brute_force": if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "brute_force":
@@ -174,15 +184,17 @@ class SegurancaTeste:
] ]
endpoint_login = f"{self.base_url}/api/auth/sign-in/email" endpoint_login = f"{self.base_url}/api/auth/sign-in/email"
endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze") endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze?dst=127.0.0.1&proto=http")
detectado = False detectado = False
for payload in payloads_sql: for payload in payloads_sql:
ip_origem = f"203.0.113.{random.randint(10, 250)}"
try: try:
# Teste no campo email # Teste no campo email
response = self.session.post( response = self.session.post(
endpoint_login, endpoint_login,
json={"email": payload, "password": "test"}, json={"email": payload, "password": "test"},
headers={"X-Forwarded-For": ip_origem},
timeout=5, timeout=5,
allow_redirects=False allow_redirects=False
) )
@@ -207,7 +219,11 @@ class SegurancaTeste:
time.sleep(0.3) time.sleep(0.3)
# Registrar via analisador HTTP para validar detecção no backend # Registrar via analisador HTTP para validar detecção no backend
try: try:
r2 = self.session.post(endpoint_analyze, data=payload, headers={"Content-Type":"text/plain"}) r2 = self.session.post(
endpoint_analyze,
data=payload,
headers={"Content-Type": "text/plain", "X-Forwarded-For": ip_origem}
)
if r2.status_code == 200: if r2.status_code == 200:
jd = r2.json() jd = r2.json()
if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "sql_injection": if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "sql_injection":
@@ -257,10 +273,12 @@ class SegurancaTeste:
for payload in payloads_xss: for payload in payloads_xss:
try: try:
ip_origem = f"203.0.113.{random.randint(100, 200)}"
# Teste no campo email # Teste no campo email
response = self.session.post( response = self.session.post(
endpoint_login, endpoint_login,
json={"email": payload, "password": "test"}, json={"email": payload, "password": "test"},
headers={"X-Forwarded-For": ip_origem},
timeout=5, timeout=5,
allow_redirects=False allow_redirects=False
) )
@@ -284,7 +302,7 @@ class SegurancaTeste:
time.sleep(0.3) time.sleep(0.3)
# Registrar via analisador HTTP # Registrar via analisador HTTP
try: try:
r2 = self.session.post(endpoint_analyze, data=payload, headers={"Content-Type":"text/html"}) r2 = self.session.post(endpoint_analyze, data=payload, headers={"Content-Type":"text/html","X-Forwarded-For": ip_origem})
if r2.status_code == 200: if r2.status_code == 200:
jd = r2.json() jd = r2.json()
if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "xss": if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "xss":
@@ -414,15 +432,17 @@ class SegurancaTeste:
] ]
endpoint = f"{self.base_url}/api/auth/sign-in/email" endpoint = f"{self.base_url}/api/auth/sign-in/email"
endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze") endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze?dst=127.0.0.1&proto=http")
detectado = False detectado = False
for payload in payloads_path: for payload in payloads_path:
try: try:
ip_origem = f"203.0.113.{random.randint(10, 250)}"
# Tentar em diferentes campos # Tentar em diferentes campos
response = self.session.post( response = self.session.post(
endpoint, endpoint,
json={"email": payload, "password": "test"}, json={"email": payload, "password": "test"},
headers={"X-Forwarded-For": ip_origem},
timeout=5, timeout=5,
allow_redirects=False allow_redirects=False
) )
@@ -437,7 +457,7 @@ class SegurancaTeste:
time.sleep(0.3) time.sleep(0.3)
# Registrar via analisador HTTP # Registrar via analisador HTTP
try: try:
r2 = self.session.post(endpoint_analyze + f"?file={payload}") r2 = self.session.post(endpoint_analyze + f"&file={payload}", headers={"X-Forwarded-For": ip_origem})
if r2.status_code == 200: if r2.status_code == 200:
jd = r2.json() jd = r2.json()
if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "path_traversal": if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "path_traversal":
@@ -476,14 +496,16 @@ class SegurancaTeste:
] ]
endpoint = f"{self.base_url}/api/auth/sign-in/email" endpoint = f"{self.base_url}/api/auth/sign-in/email"
endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze") endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze?dst=127.0.0.1&proto=http")
detectado = False detectado = False
for payload in payloads_cmd: for payload in payloads_cmd:
try: try:
ip_origem = f"203.0.113.{random.randint(10, 250)}"
response = self.session.post( response = self.session.post(
endpoint, endpoint,
json={"email": f"test{payload}@example.com", "password": "test"}, json={"email": f"test{payload}@example.com", "password": "test"},
headers={"X-Forwarded-For": ip_origem},
timeout=5, timeout=5,
allow_redirects=False allow_redirects=False
) )
@@ -498,7 +520,7 @@ class SegurancaTeste:
time.sleep(0.3) time.sleep(0.3)
# Registrar via analisador HTTP # Registrar via analisador HTTP
try: try:
r2 = self.session.post(endpoint_analyze, data=payload, headers={"Content-Type":"text/plain"}) r2 = self.session.post(endpoint_analyze, data=payload, headers={"Content-Type":"text/plain","X-Forwarded-For": ip_origem})
if r2.status_code == 200: if r2.status_code == 200:
jd = r2.json() jd = r2.json()
if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "command_injection": if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "command_injection":
@@ -538,6 +560,7 @@ class SegurancaTeste:
response = self.session.post( response = self.session.post(
endpoint, endpoint,
json={"email": payload, "password": {"$ne": None}}, json={"email": payload, "password": {"$ne": None}},
headers={"X-Forwarded-For": f"203.0.113.{random.randint(10, 250)}"},
timeout=5, timeout=5,
allow_redirects=False allow_redirects=False
) )
@@ -574,7 +597,8 @@ class SegurancaTeste:
for payload in payloads_xxe: for payload in payloads_xxe:
try: try:
# Tentar enviar como XML # Tentar enviar como XML
headers = {'Content-Type': 'application/xml'} ip_origem = f"203.0.113.{random.randint(10, 250)}"
headers = {'Content-Type': 'application/xml', 'X-Forwarded-For': ip_origem}
response = self.session.post( response = self.session.post(
endpoint, endpoint,
data=payload, data=payload,
@@ -591,6 +615,18 @@ class SegurancaTeste:
self.resultados['xxe']['detectado'] = True self.resultados['xxe']['detectado'] = True
time.sleep(0.3) time.sleep(0.3)
# Registrar via analisador HTTP
try:
endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze?dst=127.0.0.1&proto=http")
r2 = self.session.post(endpoint_analyze, data=payload, headers={'Content-Type': 'application/xml', 'X-Forwarded-For': ip_origem})
if r2.status_code == 200:
jd = r2.json()
if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "xxe":
self.log("XXE", "✅ DETECTADO (analisador)!", Colors.OKGREEN)
detectado = True
self.resultados['xxe']['detectado'] = True
except Exception:
pass
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.log("XXE", f"Erro: {str(e)}", Colors.WARNING) self.log("XXE", f"Erro: {str(e)}", Colors.WARNING)