Files
dashboard/frontend/src/components/DetectionsList.tsx
SOC Analyst dbb9bb3f94 Add score_type filter and detection attributes section
- Backend: Add score_type query parameter to filter detections by threat level (BOT, REGLE, BOT_REGLE, SCORE)
- Frontend: Add score_type dropdown filter in DetectionsList component
- Frontend: Add IP detection route redirect (/detections/ip/:ip → /investigation/:ip)
- Frontend: Add DetectionAttributesSection component showing variability metrics
- API client: Update detectionsApi to support score_type parameter

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-20 09:09:17 +01:00

638 lines
24 KiB
TypeScript

import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useDetections } from '../hooks/useDetections';
import DataTable, { Column } from './ui/DataTable';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { formatDate, formatDateOnly, formatTimeOnly } from '../utils/dateUtils';
type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
type SortOrder = 'asc' | 'desc';
interface ColumnConfig {
key: string;
label: string;
visible: boolean;
sortable: boolean;
}
interface DetectionRow {
src_ip: string;
ja4?: string;
host?: string;
client_headers?: string;
model_name: string;
anomaly_score: number;
threat_level?: string;
bot_name?: string;
hits?: number;
hit_velocity?: number;
asn_org?: string;
asn_number?: string | number;
asn_score?: number | null;
asn_rep_label?: string;
country_code?: string;
detected_at: string;
first_seen?: string;
last_seen?: string;
unique_ja4s?: string[];
unique_hosts?: string[];
unique_client_headers?: string[];
anubis_bot_name?: string;
anubis_bot_action?: string;
anubis_bot_category?: string;
}
export function DetectionsList() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const page = parseInt(searchParams.get('page') || '1');
const modelName = searchParams.get('model_name') || undefined;
const search = searchParams.get('search') || undefined;
const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'detected_at') as SortField;
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'desc') as SortOrder;
const scoreType = searchParams.get('score_type') || undefined;
const [groupByIP, setGroupByIP] = useState(true);
const { data, loading, error } = useDetections({
page,
page_size: 25,
model_name: modelName,
search,
sort_by: sortField,
sort_order: sortOrder,
group_by_ip: groupByIP,
score_type: scoreType,
});
const [searchInput, setSearchInput] = useState(search || '');
const [showColumnSelector, setShowColumnSelector] = useState(false);
const [columns, setColumns] = useState<ColumnConfig[]>([
{ key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true },
{ key: 'host', label: 'Host', visible: true, sortable: true },
{ key: 'client_headers', label: 'Client Headers', visible: false, sortable: false },
{ key: 'model_name', label: 'Modèle', visible: true, sortable: true },
{ key: 'anomaly_score', label: 'Score', visible: true, sortable: true },
{ key: 'anubis', label: '🤖 Anubis', visible: true, sortable: false },
{ key: 'hits', label: 'Hits', visible: true, sortable: true },
{ key: 'hit_velocity', label: 'Velocity', visible: true, sortable: true },
{ key: 'asn', label: 'ASN', visible: true, sortable: true },
{ key: 'country', label: 'Pays', visible: true, sortable: true },
{ key: 'detected_at', label: 'Date', visible: true, sortable: true },
]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const newParams = new URLSearchParams(searchParams);
if (searchInput.trim()) {
newParams.set('search', searchInput.trim());
} else {
newParams.delete('search');
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleFilterChange = (key: string, value: string) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
newParams.set('page', '1');
setSearchParams(newParams);
};
const toggleColumn = (key: string) => {
setColumns(cols => cols.map(col =>
col.key === key ? { ...col, visible: !col.visible } : col
));
};
const handlePageChange = (newPage: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', newPage.toString());
setSearchParams(newParams);
};
const handleSort = (key: string, dir: 'asc' | 'desc') => {
const newParams = new URLSearchParams(searchParams);
newParams.set('sort_by', key);
newParams.set('sort_order', dir);
newParams.set('page', '1');
setSearchParams(newParams);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement...</div>
</div>
);
}
if (error) {
return (
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-4">
<p className="text-threat-critical">Erreur: {error.message}</p>
</div>
);
}
if (!data) return null;
// Backend handles grouping — data is already grouped when groupByIP=true
const processedData = data;
// Build DataTable columns from visible column configs
const tableColumns: Column<DetectionRow>[] = columns
.filter((col) => col.visible)
.map((col): Column<DetectionRow> => {
switch (col.key) {
case 'ip_ja4':
return {
key: 'src_ip',
label: col.label,
sortable: true,
render: (_, row) => (
<div>
<div className="font-mono text-sm text-text-primary">{row.src_ip}</div>
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? (
<div className="mt-1 space-y-1">
<div className="text-xs text-text-secondary font-medium">
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''}
</div>
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => (
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{ja4}
</div>
))}
{row.unique_ja4s.length > 3 && (
<div className="font-mono text-xs text-text-disabled">
+{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{row.ja4 || '-'}
</div>
)}
</div>
),
};
case 'host':
return {
key: 'host',
label: col.label,
sortable: true,
render: (_, row) =>
groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''}
</div>
{row.unique_hosts.slice(0, 3).map((host, idx) => (
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{host}
</div>
))}
{row.unique_hosts.length > 3 && (
<div className="text-xs text-text-disabled">
+{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{row.host || '-'}
</div>
),
};
case 'client_headers':
return {
key: 'client_headers',
label: col.label,
sortable: false,
render: (_, row) =>
groupByIP && row.unique_client_headers && row.unique_client_headers.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{row.unique_client_headers.length} Header{row.unique_client_headers.length > 1 ? 's' : ''} unique{row.unique_client_headers.length > 1 ? 's' : ''}
</div>
{row.unique_client_headers.slice(0, 3).map((header, idx) => (
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
{header}
</div>
))}
{row.unique_client_headers.length > 3 && (
<div className="text-xs text-text-disabled">
+{row.unique_client_headers.length - 3} autre{row.unique_client_headers.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
{row.client_headers || '-'}
</div>
),
};
case 'model_name':
return {
key: 'model_name',
label: col.label,
sortable: true,
render: (_, row) => <ModelBadge model={row.model_name} />,
};
case 'anubis':
return {
key: 'anubis_bot_name',
label: (
<span className="inline-flex items-center gap-1">
🤖 Anubis
<InfoTip content={TIPS.anubis_identification} />
</span>
),
sortable: false,
render: (_, row) => {
const name = row.anubis_bot_name;
const action = row.anubis_bot_action;
const category = row.anubis_bot_category;
if (!name) return <span className="text-text-disabled text-xs"></span>;
const actionColor =
action === 'ALLOW' ? 'bg-green-500/15 text-green-400 border-green-500/30' :
action === 'DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30' :
'bg-yellow-500/15 text-yellow-400 border-yellow-500/30';
return (
<div className="space-y-0.5">
<div className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${actionColor}`}>
<span className="font-medium">{name}</span>
</div>
<div className="flex gap-1 flex-wrap">
{action && <span className="text-[10px] text-text-secondary">{action}</span>}
{category && <span className="text-[10px] text-text-disabled">· {category}</span>}
</div>
</div>
);
},
};
case 'anomaly_score':
return {
key: 'anomaly_score',
label: col.label,
sortable: true,
align: 'right' as const,
render: (_, row) => (
<ScoreBadge
score={row.anomaly_score}
threatLevel={row.threat_level}
botName={row.bot_name}
anubisAction={row.anubis_bot_action}
/>
),
};
case 'hits':
return {
key: 'hits',
label: col.label,
sortable: true,
align: 'right' as const,
render: (_, row) => (
<div className="text-sm text-text-primary font-medium">{row.hits ?? 0}</div>
),
};
case 'hit_velocity':
return {
key: 'hit_velocity',
label: col.label,
sortable: true,
align: 'right' as const,
render: (_, row) => (
<div
className={`text-sm font-medium ${
row.hit_velocity && row.hit_velocity > 10
? 'text-threat-high'
: row.hit_velocity && row.hit_velocity > 1
? 'text-threat-medium'
: 'text-text-primary'
}`}
>
{row.hit_velocity ? row.hit_velocity.toFixed(2) : '0.00'}
<span className="text-xs text-text-secondary ml-1">req/s</span>
</div>
),
};
case 'asn':
return {
key: 'asn_org',
label: col.label,
sortable: true,
render: (_, row) => (
<div>
<div className="text-sm text-text-primary">{row.asn_org || row.asn_number || '-'}</div>
{row.asn_number && (
<div className="text-xs text-text-secondary">AS{row.asn_number}</div>
)}
<AsnRepBadge score={row.asn_score} label={row.asn_rep_label} />
</div>
),
};
case 'country':
return {
key: 'country_code',
label: col.label,
sortable: true,
align: 'center' as const,
render: (_, row) =>
row.country_code ? (
<span className="text-lg">{getFlag(row.country_code)}</span>
) : (
<span>-</span>
),
};
case 'detected_at':
return {
key: 'detected_at',
label: col.label,
sortable: true,
render: (_, row) =>
groupByIP && row.first_seen ? (() => {
const first = new Date(row.first_seen!);
const last = new Date(row.last_seen!);
const sameTime = first.getTime() === last.getTime();
const fmt = (d: Date) => formatDate(d.toISOString());
return sameTime ? (
<div className="text-xs text-text-secondary">{fmt(last)}</div>
) : (
<div className="space-y-1">
<div className="text-xs text-text-secondary">
<span className="font-medium">Premier:</span> {fmt(first)}
</div>
<div className="text-xs text-text-secondary">
<span className="font-medium">Dernier:</span> {fmt(last)}
</div>
</div>
);
})() : (
<>
<div className="text-sm text-text-primary">
{formatDateOnly(row.detected_at)}
</div>
<div className="text-xs text-text-secondary">
{formatTimeOnly(row.detected_at)}
</div>
</>
),
};
default:
return { key: col.key, label: col.label, sortable: col.sortable };
}
});
return (
<div className="space-y-4 animate-fade-in">
{/* En-tête */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-text-primary">Détections</h1>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>{data.items.length}</span>
<span></span>
<span>{data.total} détections</span>
</div>
</div>
<div className="flex gap-2">
{/* Toggle Grouper par IP */}
<button
onClick={() => setGroupByIP(!groupByIP)}
className={`border rounded-lg px-4 py-2 text-sm transition-colors ${
groupByIP
? 'bg-accent-primary text-white border-accent-primary'
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
}`}
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
>
{groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
</button>
{/* Sélecteur de colonnes */}
<div className="relative">
<button
onClick={() => setShowColumnSelector(!showColumnSelector)}
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-primary transition-colors"
>
Colonnes
</button>
{showColumnSelector && (
<div className="absolute right-0 mt-2 w-48 bg-background-secondary border border-background-card rounded-lg shadow-lg z-10 p-2">
<p className="text-xs text-text-secondary mb-2 px-2">Afficher les colonnes</p>
{columns.map(col => (
<label
key={col.key}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-background-card rounded cursor-pointer"
>
<input
type="checkbox"
checked={col.visible}
onChange={() => toggleColumn(col.key)}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-sm text-text-primary">{col.label}</span>
</label>
))}
</div>
)}
</div>
{/* Recherche */}
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Rechercher IP, JA4, Host..."
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-64"
/>
<button
type="submit"
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
>
Rechercher
</button>
</form>
</div>
</div>
{/* Filtres */}
<div className="bg-background-secondary rounded-lg p-4">
<div className="flex flex-wrap gap-3 items-center">
<select
value={modelName || ''}
onChange={(e) => handleFilterChange('model_name', e.target.value)}
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
>
<option value="">Tous modèles</option>
<option value="Complet">Complet</option>
<option value="Applicatif">Applicatif</option>
</select>
<select
value={scoreType || ''}
onChange={(e) => handleFilterChange('score_type', e.target.value)}
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
>
<option value="">Tous types de score</option>
<option value="BOT">🟢 BOT seulement</option>
<option value="REGLE">🔴 RÈGLE seulement</option>
<option value="BOT_REGLE">BOT + RÈGLE</option>
<option value="SCORE">Score numérique seulement</option>
</select>
{(modelName || scoreType || search || sortField !== 'detected_at') && (
<button
onClick={() => setSearchParams({})}
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-secondary hover:text-text-primary transition-colors"
>
Effacer filtres
</button>
)}
</div>
</div>
{/* Tableau */}
<div className="bg-background-secondary rounded-lg overflow-x-auto">
<DataTable<DetectionRow>
data={processedData.items as DetectionRow[]}
columns={tableColumns}
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
defaultSortKey={sortField}
defaultSortDir={sortOrder}
onSort={handleSort}
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
emptyMessage="Aucune détection trouvée"
compact
/>
</div>
{/* Pagination */}
{data.total_pages > 1 && (
<div className="flex items-center justify-between">
<p className="text-text-secondary text-sm">
Page {data.page} sur {data.total_pages} ({data.total} détections)
</p>
<div className="flex gap-2">
<button
onClick={() => handlePageChange(data.page - 1)}
disabled={data.page === 1}
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
>
Précédent
</button>
<button
onClick={() => handlePageChange(data.page + 1)}
disabled={data.page === data.total_pages}
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
>
Suivant
</button>
</div>
</div>
)}
</div>
);
}
// Composant ModelBadge
function ModelBadge({ model }: { model: string }) {
const styles: Record<string, string> = {
Complet: 'bg-accent-primary/20 text-accent-primary',
Applicatif: 'bg-purple-500/20 text-purple-400',
};
return (
<span className={`${styles[model] || 'bg-background-card'} px-2 py-1 rounded text-xs`}>
{model}
</span>
);
}
// Composant ScoreBadge
// Les scores non-IF (ANUBIS_DENY, KNOWN_BOT) sont stockés comme sentinels
// (-1.0 et 0.0) et doivent être affichés comme des badges textuels,
// pas comme des scores numériques calculés par l'IsolationForest.
function ScoreBadge({
score,
threatLevel,
botName,
anubisAction,
}: {
score: number;
threatLevel?: string;
botName?: string;
anubisAction?: string;
}) {
// ANUBIS_DENY : menace identifiée par règle, pas par IF
if (threatLevel === 'ANUBIS_DENY' || anubisAction === 'DENY') {
return (
<span className="inline-flex items-center text-xs px-1.5 py-0.5 rounded border bg-red-500/15 text-red-400 border-red-500/30 font-medium">
RÈGLE
</span>
);
}
// KNOWN_BOT : bot légitime identifié par dictionnaire ou Anubis ALLOW
if (threatLevel === 'KNOWN_BOT' || (botName && botName !== '')) {
return (
<span className="inline-flex items-center text-xs px-1.5 py-0.5 rounded border bg-green-500/15 text-green-400 border-green-500/30 font-medium">
BOT
</span>
);
}
// Score IF réel
let color = 'text-threat-low';
if (score < -0.3) color = 'text-threat-critical';
else if (score < -0.15) color = 'text-threat-high';
else if (score < -0.05) color = 'text-threat-medium';
return (
<span className={`font-mono text-sm ${color}`}>
{score.toFixed(3)}
</span>
);
}
// Helper pour les drapeaux
function getFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
}
// Badge de réputation ASN
function AsnRepBadge({ score, label }: { score?: number | null; label?: string }) {
if (score == null) return null;
let bg: string;
let text: string;
let display: string;
if (score < 0.3) {
bg = 'bg-threat-critical/20';
text = 'text-threat-critical';
} else if (score < 0.6) {
bg = 'bg-threat-medium/20';
text = 'text-threat-medium';
} else {
bg = 'bg-threat-low/20';
text = 'text-threat-low';
}
display = label || (score < 0.3 ? 'malicious' : score < 0.6 ? 'suspect' : 'ok');
return (
<span className={`mt-1 inline-block text-xs px-1.5 py-0.5 rounded ${bg} ${text}`}>
{display}
</span>
);
}