Initial commit: Bot Detector Dashboard for SOC Incident Response

🛡️ Dashboard complet pour l'analyse et la classification des menaces

Fonctionnalités principales:
- Visualisation des détections en temps réel (24h)
- Investigation multi-entités (IP, JA4, ASN, Host, User-Agent)
- Analyse de corrélation pour classification SOC
- Clustering automatique par subnet/JA4/UA
- Export des classifications pour ML

Composants:
- Backend: FastAPI (Python) + ClickHouse
- Frontend: React + TypeScript + TailwindCSS
- 6 routes API: metrics, detections, variability, attributes, analysis, entities
- 7 types d'entités investigables

Documentation ajoutée:
- NAVIGATION_GRAPH.md: Graph complet de navigation
- SOC_OPTIMIZATION_PROPOSAL.md: Proposition d'optimisation pour SOC
  • Réduction de 7 à 2 clics pour classification
  • Nouvelle vue /incidents clusterisée
  • Panel latéral d'investigation
  • Quick Search (Cmd+K)
  • Timeline interactive
  • Graph de corrélations

Sécurité:
- .gitignore configuré (exclut .env, secrets, node_modules)
- Credentials dans .env (à ne pas committer)

⚠️ Audit sécurité réalisé - Voir recommandations dans SOC_OPTIMIZATION_PROPOSAL.md

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
SOC Analyst
2026-03-14 21:33:55 +01:00
commit a61828d1e7
55 changed files with 11189 additions and 0 deletions

View File

@ -0,0 +1,571 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDetections } from '../hooks/useDetections';
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;
}
export function DetectionsList() {
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') || 'anomaly_score') as SortField;
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'asc') as SortOrder;
const { data, loading, error } = useDetections({
page,
page_size: 25,
model_name: modelName,
search,
sort_by: sortField,
sort_order: sortOrder,
});
const [searchInput, setSearchInput] = useState(search || '');
const [showColumnSelector, setShowColumnSelector] = useState(false);
const [groupByIP, setGroupByIP] = useState(true); // Grouper par IP par défaut
// Configuration des colonnes
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: '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 handleSort = (field: SortField) => {
const newParams = new URLSearchParams(searchParams);
const currentSortField = newParams.get('sort_by') || 'detected_at';
const currentOrder = newParams.get('sort_order') || 'desc';
if (currentSortField === field) {
// Inverser l'ordre ou supprimer le tri
if (currentOrder === 'desc') {
newParams.set('sort_order', 'asc');
} else {
newParams.delete('sort_by');
newParams.delete('sort_order');
}
} else {
newParams.set('sort_by', field);
newParams.set('sort_order', 'desc');
}
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 getSortIcon = (field: SortField) => {
if (sortField !== field) return '⇅';
return sortOrder === 'asc' ? '↑' : '↓';
};
// Par défaut, trier par score croissant (scores négatifs en premier)
const getDefaultSortIcon = (field: SortField) => {
if (!searchParams.has('sort_by') && !searchParams.has('sort')) {
if (field === 'anomaly_score') return '↑';
return '⇅';
}
return getSortIcon(field);
};
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;
// Traiter les données pour le regroupement par IP
const processedData = (() => {
if (!groupByIP) {
return data;
}
// Grouper par IP
const ipGroups = new Map<string, typeof data.items[0]>();
const ipStats = new Map<string, {
first: Date;
last: Date;
count: number;
ja4s: Set<string>;
hosts: Set<string>;
clientHeaders: Set<string>;
}>();
data.items.forEach(item => {
if (!ipGroups.has(item.src_ip)) {
ipGroups.set(item.src_ip, item);
ipStats.set(item.src_ip, {
first: new Date(item.detected_at),
last: new Date(item.detected_at),
count: 1,
ja4s: new Set([item.ja4 || '']),
hosts: new Set([item.host || '']),
clientHeaders: new Set([item.client_headers || ''])
});
} else {
const stats = ipStats.get(item.src_ip)!;
const itemDate = new Date(item.detected_at);
if (itemDate < stats.first) stats.first = itemDate;
if (itemDate > stats.last) stats.last = itemDate;
stats.count++;
if (item.ja4) stats.ja4s.add(item.ja4);
if (item.host) stats.hosts.add(item.host);
if (item.client_headers) stats.clientHeaders.add(item.client_headers);
}
});
return {
...data,
items: Array.from(ipGroups.values()).map(item => ({
...item,
hits: ipStats.get(item.src_ip)!.count,
first_seen: ipStats.get(item.src_ip)!.first.toISOString(),
last_seen: ipStats.get(item.src_ip)!.last.toISOString(),
unique_ja4s: Array.from(ipStats.get(item.src_ip)!.ja4s),
unique_hosts: Array.from(ipStats.get(item.src_ip)!.hosts),
unique_client_headers: Array.from(ipStats.get(item.src_ip)!.clientHeaders)
}))
};
})();
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>{groupByIP ? processedData.items.length : 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'
}`}
>
{groupByIP ? '⊟ Détections individuelles' : '⊞ Grouper par IP'}
</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="grid grid-cols-1 md:grid-cols-2 gap-4">
<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>
{(modelName || search || sortField) && (
<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">
<table className="w-full">
<thead className="bg-background-card">
<tr>
{columns.filter(col => col.visible).map(col => (
<th
key={col.key}
className={`px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase ${col.sortable ? 'cursor-pointer hover:text-text-primary' : ''}`}
onClick={() => col.sortable && handleSort(col.key as SortField)}
>
<div className="flex items-center gap-1">
{col.label}
{col.sortable && (
<span className="text-text-disabled">{getDefaultSortIcon(col.key as SortField)}</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-background-card">
{processedData.items.map((detection) => (
<tr
key={`${detection.src_ip}-${detection.detected_at}-${groupByIP ? 'grouped' : 'individual'}`}
className="hover:bg-background-card/50 transition-colors cursor-pointer"
onClick={() => {
window.location.href = `/detections/ip/${encodeURIComponent(detection.src_ip)}`;
}}
>
{columns.filter(col => col.visible).map(col => {
if (col.key === 'ip_ja4') {
const detectionAny = detection as any;
return (
<td key={col.key} className="px-4 py-3">
<div className="font-mono text-sm text-text-primary">{detection.src_ip}</div>
{groupByIP && detectionAny.unique_ja4s?.length > 0 ? (
<div className="mt-1 space-y-1">
<div className="text-xs text-text-secondary font-medium">
{detectionAny.unique_ja4s.length} JA4{detectionAny.unique_ja4s.length > 1 ? 's' : ''} unique{detectionAny.unique_ja4s.length > 1 ? 's' : ''}
</div>
{detectionAny.unique_ja4s.slice(0, 3).map((ja4: string, idx: number) => (
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{ja4}
</div>
))}
{detectionAny.unique_ja4s.length > 3 && (
<div className="font-mono text-xs text-text-disabled">
+{detectionAny.unique_ja4s.length - 3} autre{detectionAny.unique_ja4s.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{detection.ja4 || '-'}
</div>
)}
</td>
);
}
if (col.key === 'host') {
const detectionAny = detection as any;
return (
<td key={col.key} className="px-4 py-3">
{groupByIP && detectionAny.unique_hosts?.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{detectionAny.unique_hosts.length} Host{detectionAny.unique_hosts.length > 1 ? 's' : ''} unique{detectionAny.unique_hosts.length > 1 ? 's' : ''}
</div>
{detectionAny.unique_hosts.slice(0, 3).map((host: string, idx: number) => (
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{host}
</div>
))}
{detectionAny.unique_hosts.length > 3 && (
<div className="text-xs text-text-disabled">
+{detectionAny.unique_hosts.length - 3} autre{detectionAny.unique_hosts.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{detection.host || '-'}
</div>
)}
</td>
);
}
if (col.key === 'client_headers') {
const detectionAny = detection as any;
return (
<td key={col.key} className="px-4 py-3">
{groupByIP && detectionAny.unique_client_headers?.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{detectionAny.unique_client_headers.length} Header{detectionAny.unique_client_headers.length > 1 ? 's' : ''} unique{detectionAny.unique_client_headers.length > 1 ? 's' : ''}
</div>
{detectionAny.unique_client_headers.slice(0, 3).map((header: string, idx: number) => (
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
{header}
</div>
))}
{detectionAny.unique_client_headers.length > 3 && (
<div className="text-xs text-text-disabled">
+{detectionAny.unique_client_headers.length - 3} autre{detectionAny.unique_client_headers.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
{detection.client_headers || '-'}
</div>
)}
</td>
);
}
if (col.key === 'model_name') {
return (
<td key={col.key} className="px-4 py-3">
<ModelBadge model={detection.model_name} />
</td>
);
}
if (col.key === 'anomaly_score') {
return (
<td key={col.key} className="px-4 py-3">
<ScoreBadge score={detection.anomaly_score} />
</td>
);
}
if (col.key === 'hits') {
return (
<td key={col.key} className="px-4 py-3">
<div className="text-sm text-text-primary font-medium">
{detection.hits || 0}
</div>
</td>
);
}
if (col.key === 'hit_velocity') {
return (
<td key={col.key} className="px-4 py-3">
<div className={`text-sm font-medium ${
detection.hit_velocity && detection.hit_velocity > 10
? 'text-threat-high'
: detection.hit_velocity && detection.hit_velocity > 1
? 'text-threat-medium'
: 'text-text-primary'
}`}>
{detection.hit_velocity ? detection.hit_velocity.toFixed(2) : '0.00'}
<span className="text-xs text-text-secondary ml-1">req/s</span>
</div>
</td>
);
}
if (col.key === 'asn') {
return (
<td key={col.key} className="px-4 py-3">
<div className="text-sm text-text-primary">{detection.asn_org || detection.asn_number || '-'}</div>
{detection.asn_number && (
<div className="text-xs text-text-secondary">AS{detection.asn_number}</div>
)}
</td>
);
}
if (col.key === 'country') {
return (
<td key={col.key} className="px-4 py-3">
{detection.country_code ? (
<span className="text-lg">{getFlag(detection.country_code)}</span>
) : (
'-'
)}
</td>
);
}
if (col.key === 'detected_at') {
const detectionAny = detection as any;
return (
<td key={col.key} className="px-4 py-3">
{groupByIP && detectionAny.first_seen ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary">
<span className="font-medium">Premier:</span>{' '}
{new Date(detectionAny.first_seen).toLocaleDateString('fr-FR')}{' '}
{new Date(detectionAny.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="text-xs text-text-secondary">
<span className="font-medium">Dernier:</span>{' '}
{new Date(detectionAny.last_seen).toLocaleDateString('fr-FR')}{' '}
{new Date(detectionAny.last_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
) : (
<>
<div className="text-sm text-text-primary">
{new Date(detection.detected_at).toLocaleDateString('fr-FR')}
</div>
<div className="text-xs text-text-secondary">
{new Date(detection.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div>
</>
)}
</td>
);
}
return null;
})}
</tr>
))}
</tbody>
</table>
{data.items.length === 0 && (
<div className="text-center py-12 text-text-secondary">
Aucune détection trouvée
</div>
)}
</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
function ScoreBadge({ score }: { score: number }) {
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));
}