feat: implement alert configuration and recent report features
- Added alert configuration management for email and chat notifications, allowing users to set preferences for severity levels, attack types, and notification channels. - Introduced functionality to save, edit, and delete alert configurations, enhancing user control over security notifications. - Implemented a new query to list recent security reports, providing users with quick access to the latest security incidents. - Enhanced the backend schema to support alert configurations and recent report tracking, improving overall security management capabilities.
This commit is contained in:
@@ -16,7 +16,117 @@
|
||||
limit: 50
|
||||
});
|
||||
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[] = [
|
||||
'informativo',
|
||||
'baixo',
|
||||
@@ -88,7 +198,8 @@
|
||||
let alertaVisualAtivo = $state(true);
|
||||
// Contagem de novos eventos detectados em tempo real (sem recarregar)
|
||||
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 comentarioManual = $state('');
|
||||
let porta = $state(443);
|
||||
@@ -146,26 +257,71 @@
|
||||
|
||||
// Efeito: observar chegada de novos eventos e acionar toast/contador
|
||||
$effect(() => {
|
||||
const atual = (eventos?.data ?? []).length;
|
||||
if (ultimoTotalEventos === null) {
|
||||
ultimoTotalEventos = atual;
|
||||
return;
|
||||
}
|
||||
if (atual > ultimoTotalEventos) {
|
||||
const delta = atual - ultimoTotalEventos;
|
||||
novosEventos += delta;
|
||||
feedback = {
|
||||
tipo: 'success',
|
||||
mensagem: `🔔 ${delta} novo(s) evento(s) de segurança detectado(s) em tempo real`
|
||||
};
|
||||
// Opcional: destacar visualmente
|
||||
const lista = (eventos?.data ?? []);
|
||||
if (!lista.length) return;
|
||||
// conta apenas eventos com timestamp maior que o último visto
|
||||
const novos = lista.filter((e) => e.timestamp > ultimoTsVisto).length;
|
||||
if (novos > 0) {
|
||||
novosEventos += novos;
|
||||
ultimoTsVisto = Math.max(ultimoTsVisto, ...lista.map((e) => e.timestamp));
|
||||
if (alertaVisualAtivo) {
|
||||
// classe CSS já existente de alertas visuais; aqui mantemos apenas o toast
|
||||
feedback = {
|
||||
tipo: 'success',
|
||||
mensagem: `🔔 ${novos} novo(s) evento(s) de segurança detectado(s)`
|
||||
};
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let max = 1;
|
||||
for (const serie of dataset) {
|
||||
@@ -526,6 +682,155 @@
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
@@ -535,6 +840,12 @@
|
||||
Correlação temporal entre DDoS, SQLi, ataques avançados e bloqueios automáticos.
|
||||
</p>
|
||||
</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">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<span class="label-text">Alerta Sonoro</span>
|
||||
@@ -599,6 +910,64 @@
|
||||
</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">
|
||||
{#each severidadesDisponiveis as severidade (severidade)}
|
||||
<button
|
||||
@@ -791,7 +1160,7 @@
|
||||
{/each}
|
||||
</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}
|
||||
<p class="text-base-content/60 text-sm">Nenhum evento correspondente aos filtros.</p>
|
||||
{:else}
|
||||
@@ -815,10 +1184,12 @@
|
||||
<p class="text-base-content/80 text-sm">{evento.descricao}</p>
|
||||
<div class="grid gap-2 text-xs md:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">Origem:</span>
|
||||
<span>{evento.origemIp ?? 'n/d'}</span>
|
||||
</div>
|
||||
{#if evento.origemIp}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">Origem:</span>
|
||||
<span>{evento.origemIp}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">Destino:</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>
|
||||
<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}
|
||||
<li class="text-base-content/60">Nenhum IP crítico listado.</li>
|
||||
{:else}
|
||||
@@ -908,9 +1279,10 @@
|
||||
</ul>
|
||||
</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>
|
||||
<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}
|
||||
<p class="text-base-content/60">Nenhuma regra cadastrada.</p>
|
||||
{:else}
|
||||
@@ -927,7 +1299,7 @@
|
||||
</p>
|
||||
{#if regra.expiraEm}
|
||||
<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>
|
||||
{/if}
|
||||
<div class="mt-2 flex gap-2">
|
||||
@@ -978,7 +1350,8 @@
|
||||
</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>
|
||||
<form class="mt-3 space-y-2" onsubmit={gerarRelatorioAvancado}>
|
||||
<label class="form-control">
|
||||
@@ -1011,6 +1384,37 @@
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-sm w-full">Gerar Relatório</button>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -1300,4 +1300,21 @@ export default defineSchema({
|
||||
.index("by_tipo_identificador", ["tipo", "identificador"])
|
||||
.index("by_ativo", ["ativo"])
|
||||
.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"])
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
args: {
|
||||
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({
|
||||
args: {
|
||||
eventoId: v.id('securityEvents')
|
||||
@@ -1729,6 +1877,12 @@ export const analisarRequisicaoHTTP = mutation({
|
||||
// Calcular severidade
|
||||
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
|
||||
const referencia = `http_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||
const agora = Date.now();
|
||||
@@ -1740,12 +1894,13 @@ export const analisarRequisicaoHTTP = mutation({
|
||||
status: statusInicial(severidade),
|
||||
descricao: `Ataque ${tipoAtaque} detectado na requisição HTTP ${args.method} ${args.url}`,
|
||||
origemIp: args.ipOrigem,
|
||||
protocolo: 'http',
|
||||
protocolo,
|
||||
transporte: 'tcp',
|
||||
detectadoPor: 'analisador_http_automatico',
|
||||
fingerprint: args.userAgent ? {
|
||||
userAgent: args.userAgent
|
||||
} : undefined,
|
||||
destinoIp: destinoIp ?? undefined,
|
||||
tags: ['detecção_automática', 'http', tipoAtaque],
|
||||
atualizadoEm: agora
|
||||
});
|
||||
@@ -2166,7 +2321,7 @@ export const seedRateLimitDev = mutation({
|
||||
nome: 'Bloqueio Login Dev',
|
||||
tipo: 'endpoint',
|
||||
identificador: 'api/auth/sign-in/email',
|
||||
limite: 10,
|
||||
limite: 5,
|
||||
janelaSegundos: 20,
|
||||
estrategia: 'token_bucket',
|
||||
acaoExcedido: 'bloquear',
|
||||
|
||||
@@ -23,3 +23,4 @@ fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ class SegurancaTeste:
|
||||
endpoint_analyze = urljoin(self.convex_url if self.convex_url.endswith('/') else self.convex_url + '/', "http/security/analyze")
|
||||
bloqueado = False
|
||||
|
||||
ip_origem = f"203.0.113.{random.randint(10, 250)}"
|
||||
for i, senha in enumerate(senhas_comuns[:tentativas], 1):
|
||||
try:
|
||||
payload = {
|
||||
@@ -88,6 +89,7 @@ class SegurancaTeste:
|
||||
response = self.session.post(
|
||||
endpoint,
|
||||
json=payload,
|
||||
headers={"X-Forwarded-For": ip_origem},
|
||||
timeout=5,
|
||||
allow_redirects=False
|
||||
)
|
||||
@@ -134,7 +136,15 @@ class SegurancaTeste:
|
||||
# Registrar tentativa de brute force no analisador para validar detecção no backend
|
||||
try:
|
||||
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:
|
||||
jd = r2.json()
|
||||
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_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
|
||||
|
||||
for payload in payloads_sql:
|
||||
ip_origem = f"203.0.113.{random.randint(10, 250)}"
|
||||
try:
|
||||
# Teste no campo email
|
||||
response = self.session.post(
|
||||
endpoint_login,
|
||||
json={"email": payload, "password": "test"},
|
||||
headers={"X-Forwarded-For": ip_origem},
|
||||
timeout=5,
|
||||
allow_redirects=False
|
||||
)
|
||||
@@ -207,7 +219,11 @@ class SegurancaTeste:
|
||||
time.sleep(0.3)
|
||||
# Registrar via analisador HTTP para validar detecção no backend
|
||||
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:
|
||||
jd = r2.json()
|
||||
if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "sql_injection":
|
||||
@@ -257,10 +273,12 @@ class SegurancaTeste:
|
||||
|
||||
for payload in payloads_xss:
|
||||
try:
|
||||
ip_origem = f"203.0.113.{random.randint(100, 200)}"
|
||||
# Teste no campo email
|
||||
response = self.session.post(
|
||||
endpoint_login,
|
||||
json={"email": payload, "password": "test"},
|
||||
headers={"X-Forwarded-For": ip_origem},
|
||||
timeout=5,
|
||||
allow_redirects=False
|
||||
)
|
||||
@@ -284,7 +302,7 @@ class SegurancaTeste:
|
||||
time.sleep(0.3)
|
||||
# Registrar via analisador HTTP
|
||||
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:
|
||||
jd = r2.json()
|
||||
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_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
|
||||
|
||||
for payload in payloads_path:
|
||||
try:
|
||||
ip_origem = f"203.0.113.{random.randint(10, 250)}"
|
||||
# Tentar em diferentes campos
|
||||
response = self.session.post(
|
||||
endpoint,
|
||||
json={"email": payload, "password": "test"},
|
||||
headers={"X-Forwarded-For": ip_origem},
|
||||
timeout=5,
|
||||
allow_redirects=False
|
||||
)
|
||||
@@ -437,7 +457,7 @@ class SegurancaTeste:
|
||||
time.sleep(0.3)
|
||||
# Registrar via analisador HTTP
|
||||
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:
|
||||
jd = r2.json()
|
||||
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_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
|
||||
|
||||
for payload in payloads_cmd:
|
||||
try:
|
||||
ip_origem = f"203.0.113.{random.randint(10, 250)}"
|
||||
response = self.session.post(
|
||||
endpoint,
|
||||
json={"email": f"test{payload}@example.com", "password": "test"},
|
||||
headers={"X-Forwarded-For": ip_origem},
|
||||
timeout=5,
|
||||
allow_redirects=False
|
||||
)
|
||||
@@ -498,7 +520,7 @@ class SegurancaTeste:
|
||||
time.sleep(0.3)
|
||||
# Registrar via analisador HTTP
|
||||
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:
|
||||
jd = r2.json()
|
||||
if jd.get("ataqueDetectado") and jd.get("tipoAtaque") == "command_injection":
|
||||
@@ -538,6 +560,7 @@ class SegurancaTeste:
|
||||
response = self.session.post(
|
||||
endpoint,
|
||||
json={"email": payload, "password": {"$ne": None}},
|
||||
headers={"X-Forwarded-For": f"203.0.113.{random.randint(10, 250)}"},
|
||||
timeout=5,
|
||||
allow_redirects=False
|
||||
)
|
||||
@@ -574,7 +597,8 @@ class SegurancaTeste:
|
||||
for payload in payloads_xxe:
|
||||
try:
|
||||
# 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(
|
||||
endpoint,
|
||||
data=payload,
|
||||
@@ -591,6 +615,18 @@ class SegurancaTeste:
|
||||
self.resultados['xxe']['detectado'] = True
|
||||
|
||||
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:
|
||||
self.log("XXE", f"Erro: {str(e)}", Colors.WARNING)
|
||||
|
||||
Reference in New Issue
Block a user