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

262
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,262 @@
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
import { useMetrics } from './hooks/useMetrics';
import { DetectionsList } from './components/DetectionsList';
import { DetailsView } from './components/DetailsView';
import { InvestigationView } from './components/InvestigationView';
import { JA4InvestigationView } from './components/JA4InvestigationView';
import { EntityInvestigationView } from './components/EntityInvestigationView';
// Composant Dashboard
function Dashboard() {
const { data, loading, error } = useMetrics();
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;
const { summary } = data;
return (
<div className="space-y-6 animate-fade-in">
{/* Métriques */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="Total Détections"
value={summary.total_detections.toLocaleString()}
subtitle="24 heures"
color="bg-background-card"
/>
<MetricCard
title="Menaces"
value={summary.critical_count + summary.high_count}
subtitle={`${summary.critical_count} critiques, ${summary.high_count} hautes`}
color="bg-threat-critical_bg"
/>
<MetricCard
title="Bots Connus"
value={summary.known_bots_count.toLocaleString()}
subtitle={`${((summary.known_bots_count / summary.total_detections) * 100).toFixed(1)}% du trafic`}
color="bg-accent-primary/20"
/>
<MetricCard
title="IPs Uniques"
value={summary.unique_ips.toLocaleString()}
subtitle="Entités distinctes"
color="bg-background-card"
/>
</div>
{/* Répartition par menace */}
<div className="bg-background-secondary rounded-lg p-6">
<h2 className="text-xl font-semibold text-text-primary mb-4">Répartition par Menace</h2>
<div className="space-y-3">
<ThreatBar
level="CRITICAL"
count={summary.critical_count}
total={summary.total_detections}
color="bg-threat-critical"
/>
<ThreatBar
level="HIGH"
count={summary.high_count}
total={summary.total_detections}
color="bg-threat-high"
/>
<ThreatBar
level="MEDIUM"
count={summary.medium_count}
total={summary.total_detections}
color="bg-threat-medium"
/>
<ThreatBar
level="LOW"
count={summary.low_count}
total={summary.total_detections}
color="bg-threat-low"
/>
</div>
</div>
{/* Série temporelle */}
<div className="bg-background-secondary rounded-lg p-6">
<h2 className="text-xl font-semibold text-text-primary mb-4">Évolution (24h)</h2>
<TimeSeriesChart data={data.timeseries} />
</div>
{/* Accès rapide */}
<div className="bg-background-secondary rounded-lg p-6">
<h2 className="text-xl font-semibold text-text-primary mb-4">Accès Rapide</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Link
to="/detections"
className="bg-background-card hover:bg-background-card/80 rounded-lg p-4 transition-colors"
>
<h3 className="text-text-primary font-medium">Voir les détections</h3>
<p className="text-text-secondary text-sm mt-1">Explorer toutes les détections</p>
</Link>
<Link
to="/detections?threat_level=CRITICAL"
className="bg-threat-critical_bg hover:bg-threat-critical_bg/80 rounded-lg p-4 transition-colors"
>
<h3 className="text-text-primary font-medium">Menaces Critiques</h3>
<p className="text-text-secondary text-sm mt-1">{summary.critical_count} détections</p>
</Link>
<Link
to="/detections?model_name=Complet"
className="bg-accent-primary/20 hover:bg-accent-primary/30 rounded-lg p-4 transition-colors"
>
<h3 className="text-text-primary font-medium">Modèle Complet</h3>
<p className="text-text-secondary text-sm mt-1">Avec données TCP/TLS</p>
</Link>
</div>
</div>
</div>
);
}
// Composant MetricCard
function MetricCard({ title, value, subtitle, color }: {
title: string;
value: string | number;
subtitle: string;
color: string;
}) {
return (
<div className={`${color} rounded-lg p-6`}>
<h3 className="text-text-secondary text-sm font-medium">{title}</h3>
<p className="text-3xl font-bold text-text-primary mt-2">{value}</p>
<p className="text-text-disabled text-xs mt-2">{subtitle}</p>
</div>
);
}
// Composant ThreatBar
function ThreatBar({ level, count, total, color }: {
level: string;
count: number;
total: number;
color: string;
}) {
const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : '0';
const colors: Record<string, string> = {
CRITICAL: 'bg-threat-critical',
HIGH: 'bg-threat-high',
MEDIUM: 'bg-threat-medium',
LOW: 'bg-threat-low',
};
return (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-text-primary font-medium">{level}</span>
<span className="text-text-secondary">{count} ({percentage}%)</span>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className={`${colors[level] || color} h-2 rounded-full transition-all`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}
// Composant TimeSeriesChart (simplifié)
function TimeSeriesChart({ data }: { data: { hour: string; total: number }[] }) {
if (!data || data.length === 0) return null;
const maxVal = Math.max(...data.map(d => d.total), 1);
return (
<div className="h-48 flex items-end justify-between gap-1">
{data.map((point, i) => {
const height = (point.total / maxVal) * 100;
const hour = new Date(point.hour).getHours();
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-accent-primary/60 rounded-t transition-all hover:bg-accent-primary"
style={{ height: `${height}%` }}
title={`${point.total} détections`}
/>
{i % 4 === 0 && (
<span className="text-xs text-text-disabled">{hour}h</span>
)}
</div>
);
})}
</div>
);
}
// Navigation
function Navigation() {
const location = useLocation();
const links = [
{ path: '/', label: 'Dashboard' },
{ path: '/detections', label: 'Détections' },
];
return (
<nav className="bg-background-secondary border-b border-background-card">
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center h-16 gap-4">
<h1 className="text-xl font-bold text-text-primary">Bot Detector</h1>
<div className="flex gap-2">
{links.map(link => (
<Link
key={link.path}
to={link.path}
className={`px-4 py-2 rounded-lg transition-colors ${
location.pathname === link.path
? 'bg-accent-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-background-card'
}`}
>
{link.label}
</Link>
))}
</div>
</div>
</div>
</nav>
);
}
// App principale
export default function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-background">
<Navigation />
<main className="max-w-7xl mx-auto px-4 py-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/detections" element={<DetectionsList />} />
<Route path="/detections/:type/:value" element={<DetailsView />} />
<Route path="/investigation/:ip" element={<InvestigationView />} />
<Route path="/investigation/ja4/:ja4" element={<JA4InvestigationView />} />
<Route path="/entities/:type/:value" element={<EntityInvestigationView />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}

151
frontend/src/api/client.ts Normal file
View File

@ -0,0 +1,151 @@
import axios from 'axios';
const API_BASE_URL = '/api';
export const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Types
export interface MetricsSummary {
total_detections: number;
critical_count: number;
high_count: number;
medium_count: number;
low_count: number;
known_bots_count: number;
anomalies_count: number;
unique_ips: number;
}
export interface TimeSeriesPoint {
hour: string;
total: number;
critical: number;
high: number;
medium: number;
low: number;
}
export interface MetricsResponse {
summary: MetricsSummary;
timeseries: TimeSeriesPoint[];
threat_distribution: Record<string, number>;
}
export interface Detection {
detected_at: string;
src_ip: string;
ja4: string;
host: string;
bot_name: string;
anomaly_score: number;
threat_level: string;
model_name: string;
recurrence: number;
asn_number: string;
asn_org: string;
asn_detail: string;
asn_domain: string;
country_code: string;
asn_label: string;
hits: number;
hit_velocity: number;
fuzzing_index: number;
post_ratio: number;
reason: string;
client_headers: string;
}
export interface DetectionsListResponse {
items: Detection[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface AttributeValue {
value: string;
count: number;
percentage: number;
first_seen?: string;
last_seen?: string;
threat_levels?: Record<string, number>;
unique_ips?: number;
primary_threat?: string;
}
export interface VariabilityAttributes {
user_agents: AttributeValue[];
ja4: AttributeValue[];
countries: AttributeValue[];
asns: AttributeValue[];
hosts: AttributeValue[];
threat_levels: AttributeValue[];
model_names: AttributeValue[];
}
export interface Insight {
type: 'warning' | 'info' | 'success';
message: string;
}
export interface VariabilityResponse {
type: string;
value: string;
total_detections: number;
unique_ips: number;
date_range: {
first_seen: string;
last_seen: string;
};
attributes: VariabilityAttributes;
insights: Insight[];
}
export interface AttributeListItem {
value: string;
count: number;
}
export interface AttributeListResponse {
type: string;
items: AttributeListItem[];
total: number;
}
// API Functions
export const metricsApi = {
getMetrics: () => api.get<MetricsResponse>('/metrics'),
getThreatDistribution: () => api.get('/metrics/threats'),
};
export const detectionsApi = {
getDetections: (params?: {
page?: number;
page_size?: number;
threat_level?: string;
model_name?: string;
country_code?: string;
asn_number?: string;
search?: string;
sort_by?: string;
sort_order?: string;
}) => api.get<DetectionsListResponse>('/detections', { params }),
getDetails: (id: string) => api.get(`/detections/${encodeURIComponent(id)}`),
};
export const variabilityApi = {
getVariability: (type: string, value: string) =>
api.get<VariabilityResponse>(`/variability/${type}/${encodeURIComponent(value)}`),
};
export const attributesApi = {
getAttributes: (type: string, limit?: number) =>
api.get<AttributeListResponse>(`/attributes/${type}`, { params: { limit } }),
};

View File

@ -0,0 +1,169 @@
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useVariability } from '../hooks/useVariability';
import { VariabilityPanel } from './VariabilityPanel';
export function DetailsView() {
const { type, value } = useParams<{ type: string; value: string }>();
const navigate = useNavigate();
const { data, loading, error } = useVariability(type || '', value || '');
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>
<button
onClick={() => navigate('/detections')}
className="mt-4 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
>
Retour aux détections
</button>
</div>
);
}
if (!data) return null;
const typeLabels: Record<string, { label: string }> = {
ip: { label: 'IP' },
ja4: { label: 'JA4' },
country: { label: 'Pays' },
asn: { label: 'ASN' },
host: { label: 'Host' },
user_agent: { label: 'User-Agent' },
};
const typeInfo = typeLabels[type || ''] || { label: type };
return (
<div className="space-y-6 animate-fade-in">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-text-secondary">
<Link to="/" className="hover:text-text-primary transition-colors">Dashboard</Link>
<span>/</span>
<Link to="/detections" className="hover:text-text-primary transition-colors">Détections</Link>
<span>/</span>
<span className="text-text-primary">{typeInfo.label}: {value}</span>
</nav>
{/* En-tête */}
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary mb-2">
{typeInfo.label}
</h1>
<p className="font-mono text-text-secondary break-all">{value}</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-text-primary">{data.total_detections}</div>
<div className="text-text-secondary text-sm">détections (24h)</div>
{type === 'ip' && value && (
<button
onClick={() => navigate(`/investigation/${encodeURIComponent(value)}`)}
className="mt-2 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm transition-colors"
>
🔍 Investigation complète
</button>
)}
{type === 'ja4' && value && (
<button
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(value)}`)}
className="mt-2 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm transition-colors"
>
🔍 Investigation JA4
</button>
)}
</div>
</div>
{/* Stats rapides */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<StatBox
label="IPs Uniques"
value={data.unique_ips.toLocaleString()}
/>
<StatBox
label="Première détection"
value={formatDate(data.date_range.first_seen)}
/>
<StatBox
label="Dernière détection"
value={formatDate(data.date_range.last_seen)}
/>
<StatBox
label="User-Agents"
value={data.attributes.user_agents.length.toString()}
/>
</div>
</div>
{/* Insights */}
{data.insights.length > 0 && (
<div className="space-y-2">
<h2 className="text-lg font-semibold text-text-primary">Insights</h2>
{data.insights.map((insight, i) => (
<InsightCard key={i} insight={insight} />
))}
</div>
)}
{/* Variabilité */}
<VariabilityPanel attributes={data.attributes} />
{/* Bouton retour */}
<div className="flex justify-center">
<button
onClick={() => navigate('/detections')}
className="bg-background-card hover:bg-background-card/80 text-text-primary px-6 py-3 rounded-lg transition-colors"
>
Retour aux détections
</button>
</div>
</div>
);
}
// Composant StatBox
function StatBox({ label, value }: { label: string; value: string }) {
return (
<div className="bg-background-card rounded-lg p-4">
<div className="text-xl font-bold text-text-primary">{value}</div>
<div className="text-text-secondary text-xs">{label}</div>
</div>
);
}
// Composant InsightCard
function InsightCard({ insight }: { insight: { type: string; message: string } }) {
const styles: Record<string, string> = {
warning: 'bg-yellow-500/10 border-yellow-500/50 text-yellow-500',
info: 'bg-blue-500/10 border-blue-500/50 text-blue-400',
success: 'bg-green-500/10 border-green-500/50 text-green-400',
};
return (
<div className={`${styles[insight.type] || styles.info} border rounded-lg p-4`}>
<span>{insight.message}</span>
</div>
);
}
// Helper pour formater la date
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}

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));
}

View File

@ -0,0 +1,401 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
interface EntityStats {
entity_type: string;
entity_value: string;
total_requests: number;
unique_ips: number;
first_seen: string;
last_seen: string;
}
interface EntityRelatedAttributes {
ips: string[];
ja4s: string[];
hosts: string[];
asns: string[];
countries: string[];
}
interface AttributeValue {
value: string;
count: number;
percentage: number;
}
interface EntityInvestigationData {
stats: EntityStats;
related: EntityRelatedAttributes;
user_agents: AttributeValue[];
client_headers: AttributeValue[];
paths: AttributeValue[];
query_params: AttributeValue[];
}
export function EntityInvestigationView() {
const { type, value } = useParams<{ type: string; value: string }>();
const navigate = useNavigate();
const [data, setData] = useState<EntityInvestigationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!type || !value) {
setError("Type ou valeur d'entité manquant");
setLoading(false);
return;
}
const fetchInvestigation = async () => {
setLoading(true);
try {
const response = await fetch(`/api/entities/${type}/${encodeURIComponent(value)}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Erreur chargement données');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchInvestigation();
}, [type, value]);
const getEntityLabel = (entityType: string) => {
const labels: Record<string, string> = {
ip: 'Adresse IP',
ja4: 'Fingerprint JA4',
user_agent: 'User-Agent',
client_header: 'Client Header',
host: 'Host',
path: 'Path',
query_param: 'Query Params'
};
return labels[entityType] || entityType;
};
const getCountryFlag = (code: string) => {
const flags: Record<string, string> = {
CN: '🇨🇳', US: '🇺🇸', FR: '🇫🇷', DE: '🇩🇪', GB: '🇬🇧',
RU: '🇷🇺', CA: '🇨🇦', AU: '🇦🇺', JP: '🇯🇵', IN: '🇮🇳',
BR: '🇧🇷', IT: '🇮🇹', ES: '🇪🇸', NL: '🇳🇱', BE: '🇧🇪',
CH: '🇨🇭', SE: '🇸🇪', NO: '🇳🇴', DK: '🇩🇰', FI: '🇫🇮'
};
return flags[code] || code;
};
const truncateUA = (ua: string, maxLength: number = 150) => {
if (ua.length <= maxLength) return ua;
return ua.substring(0, maxLength) + '...';
};
if (loading) {
return (
<div className="min-h-screen bg-background-primary">
<div className="container mx-auto px-4 py-8">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen bg-background-primary">
<div className="container mx-auto px-4 py-8">
<div className="bg-threat-high/10 border border-threat-high rounded-lg p-6 text-center">
<div className="text-threat-high font-medium mb-2">Erreur</div>
<div className="text-text-secondary">{error || 'Données non disponibles'}</div>
<button
onClick={() => navigate(-1)}
className="mt-4 bg-accent-primary text-white px-6 py-2 rounded-lg hover:bg-accent-primary/80"
>
Retour
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background-primary">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<button
onClick={() => navigate(-1)}
className="text-text-secondary hover:text-text-primary transition-colors mb-4"
>
Retour
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-text-primary mb-2">
Investigation: {getEntityLabel(data.stats.entity_type)}
</h1>
<div className="text-text-secondary font-mono text-sm break-all max-w-4xl">
{data.stats.entity_value}
</div>
</div>
<div className="text-right text-sm text-text-secondary">
<div>Requêtes: <span className="text-text-primary font-bold">{data.stats.total_requests.toLocaleString()}</span></div>
<div>IPs Uniques: <span className="text-text-primary font-bold">{data.stats.unique_ips.toLocaleString()}</span></div>
</div>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
label="Total Requêtes"
value={data.stats.total_requests.toLocaleString()}
/>
<StatCard
label="IPs Uniques"
value={data.stats.unique_ips.toLocaleString()}
/>
<StatCard
label="Première Détection"
value={new Date(data.stats.first_seen).toLocaleDateString('fr-FR')}
/>
<StatCard
label="Dernière Détection"
value={new Date(data.stats.last_seen).toLocaleDateString('fr-FR')}
/>
</div>
{/* Panel 1: IPs Associées */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">1. IPs Associées</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{data.related.ips.slice(0, 20).map((ip, idx) => (
<button
key={idx}
onClick={() => navigate(`/investigation/${ip}`)}
className="text-left px-3 py-2 bg-background-card rounded-lg text-sm text-text-primary hover:bg-background-card/80 transition-colors font-mono"
>
{ip}
</button>
))}
</div>
{data.related.ips.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucune IP associée</div>
)}
{data.related.ips.length > 20 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.ips.length - 20} autres IPs
</div>
)}
</div>
{/* Panel 2: JA4 Fingerprints */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">2. JA4 Fingerprints</h3>
<div className="space-y-2">
{data.related.ja4s.slice(0, 10).map((ja4, idx) => (
<div key={idx} className="flex items-center justify-between bg-background-card rounded-lg p-3">
<div className="font-mono text-sm text-text-primary break-all flex-1">
{ja4}
</div>
<button
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(ja4)}`)}
className="ml-4 text-xs bg-accent-primary text-white px-3 py-1 rounded hover:bg-accent-primary/80 whitespace-nowrap"
>
Investigation
</button>
</div>
))}
</div>
{data.related.ja4s.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun JA4 associé</div>
)}
{data.related.ja4s.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.ja4s.length - 10} autres JA4
</div>
)}
</div>
{/* Panel 3: User-Agents */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">3. User-Agents</h3>
<div className="space-y-3">
{data.user_agents.slice(0, 10).map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="text-xs text-text-primary font-mono break-all">
{truncateUA(ua.value)}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{ua.count} requêtes</div>
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
{data.user_agents.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun User-Agent</div>
)}
{data.user_agents.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.user_agents.length - 10} autres User-Agents
</div>
)}
</div>
{/* Panel 4: Client Headers */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">4. Client Headers</h3>
<div className="space-y-3">
{data.client_headers.slice(0, 10).map((header, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="text-xs text-text-primary font-mono break-all">
{header.value}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{header.count} requêtes</div>
<div className="text-text-secondary text-xs">{header.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
{data.client_headers.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun Client Header</div>
)}
{data.client_headers.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.client_headers.length - 10} autres Client Headers
</div>
)}
</div>
{/* Panel 5: Hosts */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">5. Hosts Ciblés</h3>
<div className="space-y-2">
{data.related.hosts.slice(0, 15).map((host, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3">
<div className="text-sm text-text-primary break-all">{host}</div>
</div>
))}
</div>
{data.related.hosts.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun Host associé</div>
)}
{data.related.hosts.length > 15 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.hosts.length - 15} autres Hosts
</div>
)}
</div>
{/* Panel 6: Paths */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">6. Paths</h3>
<div className="space-y-2">
{data.paths.slice(0, 15).map((path, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3">
<div className="text-sm text-text-primary font-mono break-all">{path.value}</div>
<div className="flex items-center gap-2 mt-1">
<div className="text-text-secondary text-xs">{path.count} requêtes</div>
<div className="text-text-secondary text-xs">{path.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
{data.paths.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun Path</div>
)}
{data.paths.length > 15 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.paths.length - 15} autres Paths
</div>
)}
</div>
{/* Panel 7: Query Params */}
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">7. Query Params</h3>
<div className="space-y-2">
{data.query_params.slice(0, 15).map((qp, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3">
<div className="text-sm text-text-primary font-mono break-all">{qp.value}</div>
<div className="flex items-center gap-2 mt-1">
<div className="text-text-secondary text-xs">{qp.count} requêtes</div>
<div className="text-text-secondary text-xs">{qp.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
{data.query_params.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun Query Param</div>
)}
{data.query_params.length > 15 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.query_params.length - 15} autres Query Params
</div>
)}
</div>
{/* Panel 8: ASNs & Pays */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* ASNs */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">ASNs</h3>
<div className="space-y-2">
{data.related.asns.slice(0, 10).map((asn, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3">
<div className="text-sm text-text-primary">{asn}</div>
</div>
))}
</div>
{data.related.asns.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun ASN</div>
)}
{data.related.asns.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.asns.length - 10} autres ASNs
</div>
)}
</div>
{/* Pays */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">Pays</h3>
<div className="space-y-2">
{data.related.countries.slice(0, 10).map((country, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 flex items-center gap-2">
<span className="text-xl">{getCountryFlag(country)}</span>
<span className="text-sm text-text-primary">{country}</span>
</div>
))}
</div>
{data.related.countries.length === 0 && (
<div className="text-center text-text-secondary py-8">Aucun pays</div>
)}
{data.related.countries.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.related.countries.length - 10} autres pays
</div>
)}
</div>
</div>
</div>
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="bg-background-secondary rounded-lg p-4">
<div className="text-xs text-text-secondary mb-1">{label}</div>
<div className="text-2xl font-bold text-text-primary">{value}</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
import { useParams, useNavigate } from 'react-router-dom';
import { SubnetAnalysis } from './analysis/SubnetAnalysis';
import { CountryAnalysis } from './analysis/CountryAnalysis';
import { JA4Analysis } from './analysis/JA4Analysis';
import { UserAgentAnalysis } from './analysis/UserAgentAnalysis';
import { CorrelationSummary } from './analysis/CorrelationSummary';
export function InvestigationView() {
const { ip } = useParams<{ ip: string }>();
const navigate = useNavigate();
if (!ip) {
return (
<div className="text-center text-text-secondary py-12">
IP non spécifiée
</div>
);
}
const handleClassify = (label: string, tags: string[], comment: string, confidence: number) => {
// Callback optionnel après classification
console.log('IP classifiée:', { ip, label, tags, comment, confidence });
};
return (
<div className="space-y-6 animate-fade-in">
{/* En-tête */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-4 mb-2">
<button
onClick={() => navigate('/detections')}
className="text-text-secondary hover:text-text-primary transition-colors"
>
Retour
</button>
<h1 className="text-2xl font-bold text-text-primary">Investigation: {ip}</h1>
</div>
<div className="text-text-secondary text-sm">
Analyse de corrélations pour classification SOC
</div>
</div>
</div>
{/* Panels d'analyse */}
<div className="space-y-6">
{/* Panel 1: Subnet/ASN */}
<SubnetAnalysis ip={ip} />
{/* Panel 2: Country (relatif à l'IP) */}
<CountryAnalysis ip={ip} />
{/* Panel 3: JA4 */}
<JA4Analysis ip={ip} />
{/* Panel 4: User-Agents */}
<UserAgentAnalysis ip={ip} />
{/* Panel 5: Correlation Summary + Classification */}
<CorrelationSummary ip={ip} onClassify={handleClassify} />
</div>
</div>
);
}

View File

@ -0,0 +1,373 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { JA4CorrelationSummary } from './analysis/JA4CorrelationSummary';
interface JA4InvestigationData {
ja4: string;
total_detections: number;
unique_ips: number;
first_seen: string;
last_seen: string;
top_ips: { ip: string; count: number; percentage: number }[];
top_countries: { code: string; name: string; count: number; percentage: number }[];
top_asns: { asn: string; org: string; count: number; percentage: number }[];
top_hosts: { host: string; count: number; percentage: number }[];
user_agents: { ua: string; count: number; percentage: number; classification: string }[];
}
export function JA4InvestigationView() {
const { ja4 } = useParams<{ ja4: string }>();
const navigate = useNavigate();
const [data, setData] = useState<JA4InvestigationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchJA4Investigation = async () => {
setLoading(true);
try {
// Récupérer les données de base
const baseResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}`);
if (!baseResponse.ok) throw new Error('Erreur chargement données JA4');
const baseData = await baseResponse.json();
// Récupérer les IPs associées
const ipsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/ips?limit=20`);
const ipsData = await ipsResponse.json();
// Récupérer les attributs associés
const countriesResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/attributes?target_attr=countries&limit=10`);
const countriesData = await countriesResponse.json();
const asnsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/attributes?target_attr=asns&limit=10`);
const asnsData = await asnsResponse.json();
const hostsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/attributes?target_attr=hosts&limit=10`);
const hostsData = await hostsResponse.json();
// Récupérer les user-agents
const uaResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/user_agents?limit=10`);
const uaData = await uaResponse.json();
// Formater les données
setData({
ja4: ja4 || '',
total_detections: baseData.total_detections || 0,
unique_ips: ipsData.total || 0,
first_seen: baseData.date_range?.first_seen || '',
last_seen: baseData.date_range?.last_seen || '',
top_ips: ipsData.ips?.slice(0, 10).map((ip: string) => ({
ip,
count: 0,
percentage: 0
})) || [],
top_countries: countriesData.items?.map((item: any) => ({
code: item.value,
name: item.value,
count: item.count,
percentage: item.percentage
})) || [],
top_asns: asnsData.items?.map((item: any) => {
const match = item.value.match(/AS(\d+)/);
return {
asn: match ? `AS${match[1]}` : item.value,
org: item.value.replace(/AS\d+\s*-\s*/, ''),
count: item.count,
percentage: item.percentage
};
}) || [],
top_hosts: hostsData.items?.map((item: any) => ({
host: item.value,
count: item.count,
percentage: item.percentage
})) || [],
user_agents: uaData.user_agents?.map((ua: any) => ({
ua: ua.value,
count: ua.count,
percentage: ua.percentage,
classification: ua.classification || 'normal'
})) || []
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
if (ja4) {
fetchJA4Investigation();
}
}, [ja4]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-6">
<div className="text-threat-critical mb-4">Erreur: {error || 'Données non disponibles'}</div>
<button
onClick={() => navigate('/detections')}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
>
Retour aux détections
</button>
</div>
);
}
const getFlag = (code: string) => {
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
};
const getClassificationBadge = (classification: string) => {
switch (classification) {
case 'normal':
return <span className="bg-threat-low/20 text-threat-low px-2 py-0.5 rounded text-xs"> Normal</span>;
case 'bot':
return <span className="bg-threat-medium/20 text-threat-medium px-2 py-0.5 rounded text-xs"> Bot</span>;
case 'script':
return <span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs"> Script</span>;
default:
return null;
}
};
const truncateUA = (ua: string, maxLength = 80) => {
if (ua.length <= maxLength) return ua;
return ua.substring(0, maxLength) + '...';
};
return (
<div className="space-y-6 animate-fade-in">
{/* En-tête */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-4 mb-2">
<button
onClick={() => navigate('/detections')}
className="text-text-secondary hover:text-text-primary transition-colors"
>
Retour
</button>
<h1 className="text-2xl font-bold text-text-primary">Investigation JA4</h1>
</div>
<div className="text-text-secondary text-sm">
Analyse de fingerprint JA4 pour classification SOC
</div>
</div>
</div>
{/* Stats principales */}
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<div className="text-sm text-text-secondary mb-2">JA4 Fingerprint</div>
<div className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all">
{data.ja4}
</div>
</div>
<div className="text-right ml-6">
<div className="text-3xl font-bold text-text-primary">{data.total_detections.toLocaleString()}</div>
<div className="text-text-secondary text-sm">détections (24h)</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatBox
label="IPs Uniques"
value={data.unique_ips.toLocaleString()}
/>
<StatBox
label="Première détection"
value={formatDate(data.first_seen)}
/>
<StatBox
label="Dernière détection"
value={formatDate(data.last_seen)}
/>
<StatBox
label="User-Agents"
value={data.user_agents.length.toString()}
/>
</div>
</div>
{/* Panel 1: Top IPs */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">1. TOP IPs (Utilisant ce JA4)</h3>
<div className="space-y-2">
{data.top_ips.length > 0 ? (
data.top_ips.map((ipData, idx) => (
<div
key={idx}
className="flex items-center justify-between bg-background-card rounded-lg p-3"
>
<div className="flex items-center gap-3">
<span className="text-text-secondary text-sm w-6">{idx + 1}.</span>
<button
onClick={() => navigate(`/investigation/${ipData.ip}`)}
className="font-mono text-sm text-accent-primary hover:text-accent-primary/80 transition-colors text-left"
>
{ipData.ip}
</button>
</div>
<div className="text-right">
<div className="text-text-primary font-medium">{ipData.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{ipData.percentage.toFixed(1)}%</div>
</div>
</div>
))
) : (
<div className="text-center text-text-secondary py-8">
Aucune IP trouvée
</div>
)}
</div>
{data.unique_ips > 10 && (
<p className="text-text-secondary text-sm mt-4 text-center">
... et {data.unique_ips - 10} autres IPs
</p>
)}
</div>
{/* Panel 2: Top Pays */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">2. TOP Pays</h3>
<div className="space-y-3">
{data.top_countries.map((country, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{getFlag(country.code)}</span>
<div className="text-text-primary font-medium text-sm">
{country.name} ({country.code})
</div>
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{country.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{country.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-accent-primary transition-all"
style={{ width: `${Math.min(country.percentage, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Panel 3: Top ASN */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">3. TOP ASN</h3>
<div className="space-y-3">
{data.top_asns.map((asn, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium text-sm">
{asn.asn} - {asn.org}
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{asn.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{asn.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-accent-primary transition-all"
style={{ width: `${Math.min(asn.percentage, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Panel 4: Top Hosts */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">4. TOP Hosts Ciblés</h3>
<div className="space-y-3">
{data.top_hosts.map((host, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium text-sm truncate max-w-md">
{host.host}
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{host.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{host.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-accent-primary transition-all"
style={{ width: `${Math.min(host.percentage, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Panel 5: User-Agents + Classification */}
<div className="space-y-6">
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">5. User-Agents</h3>
<div className="space-y-3">
{data.user_agents.map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-text-primary text-xs font-mono break-all flex-1">
{truncateUA(ua.ua)}
</div>
{getClassificationBadge(ua.classification)}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{ua.count} IPs</div>
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
</div>
</div>
))}
{data.user_agents.length === 0 && (
<div className="text-center text-text-secondary py-8">
Aucun User-Agent trouvé
</div>
)}
</div>
</div>
{/* Classification JA4 */}
<JA4CorrelationSummary ja4={ja4 || ''} />
</div>
</div>
);
}
function StatBox({ label, value }: { label: string; value: string }) {
return (
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">{label}</div>
<div className="text-2xl font-bold text-text-primary">{value}</div>
</div>
);
}
function formatDate(dateStr: string): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}

View File

@ -0,0 +1,313 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { VariabilityAttributes, AttributeValue } from '../api/client';
interface VariabilityPanelProps {
attributes: VariabilityAttributes;
}
export function VariabilityPanel({ attributes }: VariabilityPanelProps) {
const [showModal, setShowModal] = useState<{
type: string;
title: string;
items: string[];
total: number;
} | null>(null);
const [loading, setLoading] = useState(false);
// Fonction pour charger la liste des IPs associées
const loadAssociatedIPs = async (attrType: string, value: string, total: number) => {
setLoading(true);
try {
const response = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
const data = await response.json();
setShowModal({
type: 'ips',
title: `${data.total || total} IPs associées à ${value}`,
items: data.ips || [],
total: data.total || total,
});
} catch (error) {
console.error('Erreur chargement IPs:', error);
}
setLoading(false);
};
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-text-primary">Variabilité des Attributs</h2>
{/* JA4 Fingerprints */}
{attributes.ja4 && attributes.ja4.length > 0 && (
<AttributeSection
title="JA4 Fingerprints"
items={attributes.ja4}
getValue={(item) => item.value}
getLink={(item) => `/investigation/ja4/${encodeURIComponent(item.value)}`}
onViewAll={(value, count) => loadAssociatedIPs('ja4', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* User-Agents */}
{attributes.user_agents && attributes.user_agents.length > 0 && (
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">
User-Agents ({attributes.user_agents.length})
</h3>
<div className="space-y-3">
{attributes.user_agents.slice(0, 10).map((item, index) => (
<div key={index} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium truncate max-w-lg text-sm">
{item.value}
</div>
<div className="text-right">
<div className="text-text-primary font-medium">{item.count}</div>
<div className="text-text-secondary text-xs">{item.percentage?.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-threat-medium transition-all"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
{attributes.user_agents.length > 10 && (
<p className="text-text-secondary text-sm mt-4 text-center">
... et {attributes.user_agents.length - 10} autres (top 10 affiché)
</p>
)}
</div>
)}
{/* Pays */}
{attributes.countries && attributes.countries.length > 0 && (
<AttributeSection
title="Pays"
items={attributes.countries}
getValue={(item) => item.value}
getLink={(item) => `/detections/country/${encodeURIComponent(item.value)}`}
onViewAll={(value, count) => loadAssociatedIPs('country', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* ASN */}
{attributes.asns && attributes.asns.length > 0 && (
<AttributeSection
title="ASN"
items={attributes.asns}
getValue={(item) => item.value}
getLink={(item) => {
const asnNumber = item.value.match(/AS(\d+)/)?.[1] || item.value;
return `/detections/asn/${encodeURIComponent(asnNumber)}`;
}}
onViewAll={(value, count) => loadAssociatedIPs('asn', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* Hosts */}
{attributes.hosts && attributes.hosts.length > 0 && (
<AttributeSection
title="Hosts"
items={attributes.hosts}
getValue={(item) => item.value}
getLink={(item) => `/detections/host/${encodeURIComponent(item.value)}`}
onViewAll={(value, count) => loadAssociatedIPs('host', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* Threat Levels */}
{attributes.threat_levels && attributes.threat_levels.length > 0 && (
<AttributeSection
title="Niveaux de Menace"
items={attributes.threat_levels}
getValue={(item) => item.value}
getLink={(item) => `/detections?threat_level=${encodeURIComponent(item.value)}`}
onViewAll={(value, count) => loadAssociatedIPs('threat_level', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* Modal pour afficher la liste complète */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-background-secondary rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-background-card">
<h3 className="text-xl font-semibold text-text-primary">{showModal.title}</h3>
<button
onClick={() => setShowModal(null)}
className="text-text-secondary hover:text-text-primary transition-colors text-xl"
>
×
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="text-center text-text-secondary py-8">Chargement...</div>
) : showModal.items.length > 0 ? (
<div className="space-y-2">
{showModal.items.map((item, index) => (
<div
key={index}
className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all"
>
{item}
</div>
))}
{showModal.total > showModal.items.length && (
<p className="text-center text-text-secondary text-sm mt-4">
Affichage de {showModal.items.length} sur {showModal.total} éléments
</p>
)}
</div>
) : (
<div className="text-center text-text-secondary py-8">
Aucune donnée disponible
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-background-card text-right">
<button
onClick={() => setShowModal(null)}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-6 py-2 rounded-lg transition-colors"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Composant AttributeSection
function AttributeSection({
title,
items,
getValue,
getLink,
onViewAll,
showViewAll = false,
viewAllLabel = 'Voir les IPs',
}: {
title: string;
items: AttributeValue[];
getValue: (item: AttributeValue) => string;
getLink: (item: AttributeValue) => string;
onViewAll?: (value: string, count: number) => void;
showViewAll?: boolean;
viewAllLabel?: string;
}) {
const displayItems = items.slice(0, 10);
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">
{title} ({items.length})
</h3>
{showViewAll && items.length > 0 && (
<select
onChange={(e) => {
if (e.target.value && onViewAll) {
const item = items.find(i => i.value === e.target.value);
if (item) {
onViewAll(item.value, item.count);
}
}
}}
defaultValue=""
className="bg-background-card border border-background-card rounded-lg px-3 py-1 text-sm text-text-primary focus:outline-none focus:border-accent-primary"
>
<option value="">{viewAllLabel}...</option>
{displayItems.map((item, idx) => (
<option key={idx} value={item.value}>
{getValue(item).substring(0, 40)}{getValue(item).length > 40 ? '...' : ''}
</option>
))}
</select>
)}
</div>
<div className="space-y-3">
{displayItems.map((item, index) => (
<AttributeRow
key={index}
value={item}
getValue={getValue}
getLink={getLink}
/>
))}
</div>
{items.length > 10 && (
<p className="text-text-secondary text-sm mt-4 text-center">
... et {items.length - 10} autres (top 10 affiché)
</p>
)}
</div>
);
}
// Composant AttributeRow
function AttributeRow({
value,
getValue,
getLink,
}: {
value: AttributeValue;
getValue: (item: AttributeValue) => string;
getLink: (item: AttributeValue) => string;
}) {
const percentage = value.percentage || 0;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Link
to={getLink(value)}
className="text-text-primary hover:text-accent-primary transition-colors font-medium truncate max-w-md"
>
{getValue(value)}
</Link>
<div className="text-right">
<div className="text-text-primary font-medium">{value.count}</div>
<div className="text-text-secondary text-xs">{percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${getPercentageColor(percentage)}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}
// Helper pour la couleur de la barre
function getPercentageColor(percentage: number): string {
if (percentage >= 50) return 'bg-threat-critical';
if (percentage >= 25) return 'bg-threat-high';
if (percentage >= 10) return 'bg-threat-medium';
return 'bg-threat-low';
}

View File

@ -0,0 +1,308 @@
import { useEffect, useState } from 'react';
interface CorrelationIndicators {
subnet_ips_count: number;
asn_ips_count: number;
country_percentage: number;
ja4_shared_ips: number;
user_agents_count: number;
bot_ua_percentage: number;
}
interface ClassificationRecommendation {
label: 'legitimate' | 'suspicious' | 'malicious';
confidence: number;
indicators: CorrelationIndicators;
suggested_tags: string[];
reason: string;
}
interface CorrelationSummaryProps {
ip: string;
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
}
const PREDEFINED_TAGS = [
'scraping',
'bot-network',
'scanner',
'bruteforce',
'data-exfil',
'ddos',
'spam',
'proxy',
'tor',
'vpn',
'hosting-asn',
'distributed',
'ja4-rotation',
'ua-rotation',
'country-cn',
'country-us',
'country-ru',
];
export function CorrelationSummary({ ip, onClassify }: CorrelationSummaryProps) {
const [data, setData] = useState<ClassificationRecommendation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedLabel, setSelectedLabel] = useState<string>('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
const fetchRecommendation = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/recommendation`);
if (!response.ok) throw new Error('Erreur chargement recommandation');
const result = await response.json();
setData(result);
setSelectedLabel(result.label);
setSelectedTags(result.suggested_tags || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchRecommendation();
}, [ip]);
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
);
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/analysis/classifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip,
label: selectedLabel,
tags: selectedTags,
comment,
confidence: data?.confidence || 0.5,
features: data?.indicators || {},
analyst: 'soc_user'
})
});
if (!response.ok) throw new Error('Erreur sauvegarde');
if (onClassify) {
onClassify(selectedLabel, selectedTags, comment, data?.confidence || 0.5);
}
alert('Classification sauvegardée !');
} catch (err) {
alert(`Erreur: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
} finally {
setSaving(false);
}
};
const handleExportML = async () => {
try {
const mlData = {
ip,
label: selectedLabel,
confidence: data?.confidence || 0.5,
tags: selectedTags,
features: {
subnet_ips_count: data?.indicators.subnet_ips_count || 0,
asn_ips_count: data?.indicators.asn_ips_count || 0,
country_percentage: data?.indicators.country_percentage || 0,
ja4_shared_ips: data?.indicators.ja4_shared_ips || 0,
user_agents_count: data?.indicators.user_agents_count || 0,
bot_ua_percentage: data?.indicators.bot_ua_percentage || 0,
},
comment,
analyst: 'soc_user',
timestamp: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(mlData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `classification_${ip}_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert(`Erreur export: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
}
};
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
</div>
);
}
return (
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">5. CORRELATION SUMMARY</h3>
{/* Indicateurs */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<IndicatorCard
label="IPs subnet"
value={data.indicators.subnet_ips_count}
alert={data.indicators.subnet_ips_count > 10}
/>
<IndicatorCard
label="IPs ASN"
value={data.indicators.asn_ips_count}
alert={data.indicators.asn_ips_count > 100}
/>
<IndicatorCard
label="JA4 partagés"
value={data.indicators.ja4_shared_ips}
alert={data.indicators.ja4_shared_ips > 50}
/>
<IndicatorCard
label="Bots UA"
value={`${data.indicators.bot_ua_percentage.toFixed(0)}%`}
alert={data.indicators.bot_ua_percentage > 20}
/>
<IndicatorCard
label="UAs différents"
value={data.indicators.user_agents_count}
alert={data.indicators.user_agents_count > 5}
/>
<IndicatorCard
label="Confiance"
value={`${(data.confidence * 100).toFixed(0)}%`}
alert={false}
/>
</div>
{/* Raison */}
{data.reason && (
<div className="bg-background-card rounded-lg p-4 mb-6">
<div className="text-sm text-text-secondary mb-2">Analyse</div>
<div className="text-text-primary">{data.reason}</div>
</div>
)}
{/* Classification */}
<div className="border-t border-background-card pt-6">
<h4 className="text-md font-medium text-text-primary mb-4">CLASSIFICATION</h4>
{/* Boutons de label */}
<div className="flex gap-3 mb-6">
<button
onClick={() => setSelectedLabel('legitimate')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'legitimate'
? 'bg-threat-low text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
LÉGITIME
</button>
<button
onClick={() => setSelectedLabel('suspicious')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'suspicious'
? 'bg-threat-medium text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
SUSPECT
</button>
<button
onClick={() => setSelectedLabel('malicious')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'malicious'
? 'bg-threat-high text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
MALVEILLANT
</button>
</div>
{/* Tags */}
<div className="mb-6">
<div className="text-sm text-text-secondary mb-3">Tags</div>
<div className="flex flex-wrap gap-2">
{PREDEFINED_TAGS.map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-3 py-1 rounded text-xs transition-colors ${
selectedTags.includes(tag)
? 'bg-accent-primary text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
{tag}
</button>
))}
</div>
</div>
{/* Commentaire */}
<div className="mb-6">
<div className="text-sm text-text-secondary mb-2">Commentaire</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Notes d'analyse..."
className="w-full bg-background-card border border-background-card rounded-lg p-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary"
rows={3}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !selectedLabel}
className="flex-1 bg-accent-primary hover:bg-accent-primary/80 disabled:opacity-50 disabled:cursor-not-allowed text-white py-3 px-4 rounded-lg font-medium transition-colors"
>
{saving ? 'Sauvegarde...' : '💾 Sauvegarder'}
</button>
<button
onClick={handleExportML}
className="flex-1 bg-background-card hover:bg-background-card/80 text-text-primary py-3 px-4 rounded-lg font-medium transition-colors"
>
📤 Export ML
</button>
</div>
</div>
</div>
);
}
function IndicatorCard({ label, value, alert }: { label: string; value: string | number; alert: boolean }) {
return (
<div className={`bg-background-card rounded-lg p-3 ${alert ? 'border-2 border-threat-high' : ''}`}>
<div className="text-xs text-text-secondary mb-1">{label}</div>
<div className={`text-xl font-bold ${alert ? 'text-threat-high' : 'text-text-primary'}`}>
{value}
</div>
</div>
);
}

View File

@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
interface CountryData {
code: string;
name: string;
count: number;
percentage: number;
}
interface CountryAnalysisProps {
ip?: string; // Si fourni, affiche stats relatives à cette IP
asn?: string; // Si fourni, affiche stats relatives à cet ASN
}
interface CountryAnalysisData {
ip_country?: { code: string; name: string };
asn_countries: CountryData[];
}
export function CountryAnalysis({ ip, asn }: CountryAnalysisProps) {
const [data, setData] = useState<CountryAnalysisData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchCountryAnalysis = async () => {
setLoading(true);
try {
if (ip) {
// Mode Investigation IP: Récupérer le pays de l'IP + répartition ASN
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/country`);
if (!response.ok) throw new Error('Erreur chargement pays');
const result = await response.json();
setData(result);
} else if (asn) {
// Mode Investigation ASN
const response = await fetch(`/api/analysis/asn/${encodeURIComponent(asn)}/country`);
if (!response.ok) throw new Error('Erreur chargement pays');
const result = await response.json();
setData(result);
} else {
// Mode Global (stats générales)
const response = await fetch('/api/analysis/country?days=1');
if (!response.ok) throw new Error('Erreur chargement pays');
const result = await response.json();
setData({
ip_country: undefined,
asn_countries: result.top_countries || []
});
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchCountryAnalysis();
}, [ip, asn]);
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
</div>
);
}
const getFlag = (code: string) => {
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
};
// Mode Investigation IP avec pays unique
if (ip && data.ip_country) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">2. PAYS DE L'IP</h3>
</div>
{/* Pays de l'IP */}
<div className="bg-background-card rounded-lg p-4 mb-6">
<div className="flex items-center gap-3 mb-2">
<span className="text-4xl">{getFlag(data.ip_country.code)}</span>
<div>
<div className="text-text-primary font-bold text-lg">
{data.ip_country.name} ({data.ip_country.code})
</div>
<div className="text-text-secondary text-sm">Pays de l'IP</div>
</div>
</div>
</div>
{/* Répartition ASN par pays */}
{data.asn_countries.length > 0 && (
<div>
<div className="text-sm text-text-secondary mb-3">
Autres pays du même ASN (24h)
</div>
<div className="space-y-2">
{data.asn_countries.slice(0, 5).map((country, idx) => (
<div key={idx} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{getFlag(country.code)}</span>
<span className="text-text-primary text-sm">{country.name}</span>
</div>
<div className="text-right">
<div className="text-text-primary font-bold text-sm">{country.count}</div>
<div className="text-text-secondary text-xs">{country.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
// Mode Global ou ASN
const getThreatColor = (percentage: number, baseline: number) => {
if (baseline > 0 && percentage > baseline * 2) return 'bg-threat-high';
if (percentage > 30) return 'bg-threat-medium';
return 'bg-accent-primary';
};
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">
{asn ? '2. TOP Pays (ASN)' : '2. TOP Pays (Global)'}
</h3>
</div>
<div className="space-y-3">
{data.asn_countries.map((country, idx) => {
const baselinePct = 0; // Pas de baseline en mode ASN
return (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{getFlag(country.code)}</span>
<div>
<div className="text-text-primary font-medium text-sm">
{country.name} ({country.code})
</div>
</div>
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{country.count}</div>
<div className="text-text-secondary text-xs">{country.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${getThreatColor(country.percentage, baselinePct)}`}
style={{ width: `${Math.min(country.percentage, 100)}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
interface JA4SubnetData {
subnet: string;
count: number;
}
interface JA4Analysis {
ja4: string;
shared_ips_count: number;
top_subnets: JA4SubnetData[];
other_ja4_for_ip: string[];
}
interface JA4AnalysisProps {
ip: string;
}
export function JA4Analysis({ ip }: JA4AnalysisProps) {
const [data, setData] = useState<JA4Analysis | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchJA4Analysis = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/ja4`);
if (!response.ok) throw new Error('Erreur chargement JA4');
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchJA4Analysis();
}, [ip]);
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data || !data.ja4) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">JA4 non disponible</div>
</div>
);
}
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">3. JA4 FINGERPRINT ANALYSIS</h3>
{data.shared_ips_count > 50 && (
<span className="bg-threat-high text-white px-3 py-1 rounded text-xs font-medium">
🔴 {data.shared_ips_count} IPs
</span>
)}
</div>
<div className="space-y-6">
{/* JA4 Fingerprint */}
<div>
<div className="text-sm text-text-secondary mb-2">JA4 Fingerprint</div>
<div className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all">
{data.ja4}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* IPs avec même JA4 */}
<div>
<div className="text-sm text-text-secondary mb-2">
IPs avec le MÊME JA4 (24h)
</div>
<div className="text-3xl font-bold text-text-primary mb-2">
{data.shared_ips_count}
</div>
{data.shared_ips_count > 50 && (
<div className="text-threat-high text-sm">
🔴 PATTERN: Même outil/bot sur {data.shared_ips_count} IPs
</div>
)}
</div>
{/* Autres JA4 pour cette IP */}
<div>
<div className="text-sm text-text-secondary mb-2">
Autres JA4 pour cette IP
</div>
{data.other_ja4_for_ip.length > 0 ? (
<div className="space-y-1">
{data.other_ja4_for_ip.slice(0, 3).map((ja4, idx) => (
<div key={idx} className="bg-background-card rounded p-2 font-mono text-xs text-text-primary truncate">
{ja4}
</div>
))}
{data.other_ja4_for_ip.length > 3 && (
<div className="text-text-secondary text-xs">
+{data.other_ja4_for_ip.length - 3} autres
</div>
)}
</div>
) : (
<div className="text-text-secondary text-sm">
1 seul JA4 Comportement stable
</div>
)}
</div>
</div>
{/* Top subnets */}
{data.top_subnets.length > 0 && (
<div>
<div className="text-sm text-text-secondary mb-2">
Top subnets pour ce JA4
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{data.top_subnets.map((subnet, idx) => (
<div
key={idx}
className="bg-background-card rounded-lg p-3 flex items-center justify-between"
>
<div className="font-mono text-sm text-text-primary">{subnet.subnet}</div>
<div className="text-text-primary font-bold">{subnet.count} IPs</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,370 @@
import { useEffect, useState } from 'react';
interface CorrelationIndicators {
subnet_ips_count: number;
asn_ips_count: number;
country_percentage: number;
ja4_shared_ips: number;
user_agents_count: number;
bot_ua_percentage: number;
}
interface JA4ClassificationRecommendation {
label: 'legitimate' | 'suspicious' | 'malicious';
confidence: number;
indicators: CorrelationIndicators;
suggested_tags: string[];
reason: string;
}
interface JA4CorrelationSummaryProps {
ja4: string;
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
}
const PREDEFINED_TAGS = [
'scraping',
'bot-network',
'scanner',
'bruteforce',
'data-exfil',
'ddos',
'spam',
'proxy',
'tor',
'vpn',
'hosting-asn',
'distributed',
'ja4-rotation',
'ua-rotation',
'country-cn',
'country-us',
'country-ru',
'known-bot',
'crawler',
'search-engine',
];
export function JA4CorrelationSummary({ ja4, onClassify }: JA4CorrelationSummaryProps) {
const [data, setData] = useState<JA4ClassificationRecommendation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedLabel, setSelectedLabel] = useState<string>('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
const fetchRecommendation = async () => {
setLoading(true);
try {
// Récupérer les IPs associées
const ipsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4)}/ips?limit=100`);
const ipsData = await ipsResponse.json();
// Récupérer les user-agents
const uaResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4)}/user_agents?limit=100`);
const uaData = await uaResponse.json();
// Calculer les indicateurs
const indicators: CorrelationIndicators = {
subnet_ips_count: 0,
asn_ips_count: ipsData.total || 0,
country_percentage: 0,
ja4_shared_ips: ipsData.total || 0,
user_agents_count: uaData.user_agents?.length || 0,
bot_ua_percentage: 0
};
// Calculer le pourcentage de bots
if (uaData.user_agents?.length > 0) {
const botCount = uaData.user_agents
.filter((ua: any) => ua.classification === 'bot' || ua.classification === 'script')
.reduce((sum: number, ua: any) => sum + ua.count, 0);
const totalCount = uaData.user_agents.reduce((sum: number, ua: any) => sum + ua.count, 0);
indicators.bot_ua_percentage = totalCount > 0 ? (botCount / totalCount * 100) : 0;
}
// Score de confiance
let score = 0.0;
const reasons: string[] = [];
const tags: string[] = [];
// JA4 partagé > 50 IPs
if (indicators.ja4_shared_ips > 50) {
score += 0.30;
reasons.push(`${indicators.ja4_shared_ips} IPs avec même JA4`);
tags.push('ja4-rotation');
}
// Bot UA > 20%
if (indicators.bot_ua_percentage > 20) {
score += 0.25;
reasons.push(`${indicators.bot_ua_percentage.toFixed(0)}% UAs bots/scripts`);
tags.push('bot-ua');
}
// Multiple UAs
if (indicators.user_agents_count > 5) {
score += 0.15;
reasons.push(`${indicators.user_agents_count} UAs différents`);
tags.push('ua-rotation');
}
// Déterminer label
if (score >= 0.7) {
score = Math.min(score, 1.0);
tags.push('known-bot');
} else if (score >= 0.4) {
score = Math.min(score, 1.0);
}
const reason = reasons.join(' | ') || 'Aucun indicateur fort';
setData({
label: score >= 0.7 ? 'malicious' : score >= 0.4 ? 'suspicious' : 'legitimate',
confidence: score,
indicators,
suggested_tags: tags,
reason
});
setSelectedLabel(score >= 0.7 ? 'malicious' : score >= 0.4 ? 'suspicious' : 'legitimate');
setSelectedTags(tags);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
if (ja4) {
fetchRecommendation();
}
}, [ja4]);
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
);
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/analysis/classifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ja4,
label: selectedLabel,
tags: selectedTags,
comment,
confidence: data?.confidence || 0.5,
features: data?.indicators || {},
analyst: 'soc_user'
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Erreur sauvegarde');
}
if (onClassify) {
onClassify(selectedLabel, selectedTags, comment, data?.confidence || 0.5);
}
alert('Classification JA4 sauvegardée !');
} catch (err) {
alert(`Erreur: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
} finally {
setSaving(false);
}
};
const handleExportML = async () => {
try {
const mlData = {
ja4,
label: selectedLabel,
confidence: data?.confidence || 0.5,
tags: selectedTags,
features: {
ja4_shared_ips: data?.indicators.ja4_shared_ips || 0,
user_agents_count: data?.indicators.user_agents_count || 0,
bot_ua_percentage: data?.indicators.bot_ua_percentage || 0,
},
comment,
analyst: 'soc_user',
timestamp: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(mlData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `classification_ja4_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert(`Erreur export: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
}
};
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
</div>
);
}
return (
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">5. CORRELATION SUMMARY</h3>
{/* Indicateurs */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<IndicatorCard
label="IPs partagées"
value={data.indicators.ja4_shared_ips}
alert={data.indicators.ja4_shared_ips > 50}
/>
<IndicatorCard
label="UAs différents"
value={data.indicators.user_agents_count}
alert={data.indicators.user_agents_count > 5}
/>
<IndicatorCard
label="Bots UA"
value={`${data.indicators.bot_ua_percentage.toFixed(0)}%`}
alert={data.indicators.bot_ua_percentage > 20}
/>
<IndicatorCard
label="Confiance"
value={`${(data.confidence * 100).toFixed(0)}%`}
alert={false}
/>
</div>
{/* Raison */}
{data.reason && (
<div className="bg-background-card rounded-lg p-4 mb-6">
<div className="text-sm text-text-secondary mb-2">Analyse</div>
<div className="text-text-primary">{data.reason}</div>
</div>
)}
{/* Classification */}
<div className="border-t border-background-card pt-6">
<h4 className="text-md font-medium text-text-primary mb-4">CLASSIFICATION</h4>
{/* Boutons de label */}
<div className="flex gap-3 mb-6">
<button
onClick={() => setSelectedLabel('legitimate')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'legitimate'
? 'bg-threat-low text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
LÉGITIME
</button>
<button
onClick={() => setSelectedLabel('suspicious')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'suspicious'
? 'bg-threat-medium text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
SUSPECT
</button>
<button
onClick={() => setSelectedLabel('malicious')}
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
selectedLabel === 'malicious'
? 'bg-threat-high text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
MALVEILLANT
</button>
</div>
{/* Tags */}
<div className="mb-6">
<div className="text-sm text-text-secondary mb-3">Tags</div>
<div className="flex flex-wrap gap-2">
{PREDEFINED_TAGS.map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-3 py-1 rounded text-xs transition-colors ${
selectedTags.includes(tag)
? 'bg-accent-primary text-white'
: 'bg-background-card text-text-secondary hover:text-text-primary'
}`}
>
{tag}
</button>
))}
</div>
</div>
{/* Commentaire */}
<div className="mb-6">
<div className="text-sm text-text-secondary mb-2">Commentaire</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Notes d'analyse..."
className="w-full bg-background-card border border-background-card rounded-lg p-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary"
rows={3}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !selectedLabel}
className="flex-1 bg-accent-primary hover:bg-accent-primary/80 disabled:opacity-50 disabled:cursor-not-allowed text-white py-3 px-4 rounded-lg font-medium transition-colors"
>
{saving ? 'Sauvegarde...' : '💾 Sauvegarder'}
</button>
<button
onClick={handleExportML}
className="flex-1 bg-background-card hover:bg-background-card/80 text-text-primary py-3 px-4 rounded-lg font-medium transition-colors"
>
📤 Export ML
</button>
</div>
</div>
</div>
);
}
function IndicatorCard({ label, value, alert }: { label: string; value: string | number; alert: boolean }) {
return (
<div className={`bg-background-card rounded-lg p-3 ${alert ? 'border-2 border-threat-high' : ''}`}>
<div className="text-xs text-text-secondary mb-1">{label}</div>
<div className={`text-xl font-bold ${alert ? 'text-threat-high' : 'text-text-primary'}`}>
{value}
</div>
</div>
);
}

View File

@ -0,0 +1,120 @@
import { useEffect, useState } from 'react';
interface SubnetAnalysisData {
ip: string;
subnet: string;
ips_in_subnet: string[];
total_in_subnet: number;
asn_number: string;
asn_org: string;
total_in_asn: number;
alert: boolean;
}
interface SubnetAnalysisProps {
ip: string;
}
export function SubnetAnalysis({ ip }: SubnetAnalysisProps) {
const [data, setData] = useState<SubnetAnalysisData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSubnetAnalysis = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/subnet`);
if (!response.ok) throw new Error('Erreur chargement subnet');
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchSubnetAnalysis();
}, [ip]);
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
</div>
);
}
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">1. SUBNET / ASN ANALYSIS</h3>
{data.alert && (
<span className="bg-threat-high text-white px-3 py-1 rounded text-xs font-medium">
{data.total_in_subnet} IPs du subnet
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Subnet */}
<div>
<div className="text-sm text-text-secondary mb-2">Subnet (/24)</div>
<div className="text-text-primary font-mono text-sm">{data.subnet}</div>
<div className="mt-4">
<div className="text-sm text-text-secondary mb-2">
IPs du même subnet ({data.total_in_subnet})
</div>
<div className="flex flex-wrap gap-1">
{data.ips_in_subnet.slice(0, 15).map((ipAddr: string, idx: number) => (
<span
key={idx}
className="bg-background-card px-2 py-1 rounded text-xs font-mono text-text-primary"
>
{ipAddr.split('.').slice(0, 3).join('.')}.{ipAddr.split('.')[3]}
</span>
))}
{data.ips_in_subnet.length > 15 && (
<span className="bg-background-card px-2 py-1 rounded text-xs text-text-secondary">
+{data.ips_in_subnet.length - 15} autres
</span>
)}
</div>
</div>
</div>
{/* ASN */}
<div>
<div className="text-sm text-text-secondary mb-2">ASN</div>
<div className="text-text-primary font-medium">{data.asn_org || 'Unknown'}</div>
<div className="text-sm text-text-secondary font-mono">AS{data.asn_number}</div>
<div className="mt-4">
<div className="text-sm text-text-secondary mb-2">
Total IPs dans l'ASN (24h)
</div>
<div className="text-2xl font-bold text-text-primary">{data.total_in_asn}</div>
</div>
</div>
</div>
{data.alert && (
<div className="mt-4 bg-threat-high/10 border border-threat-high rounded-lg p-3">
<div className="text-threat-high text-sm font-medium">
🔴 PATTERN: {data.total_in_subnet} IPs du même subnet en 24h
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
interface UserAgentData {
value: string;
count: number;
percentage: number;
classification: 'normal' | 'bot' | 'script';
}
interface UserAgentAnalysis {
ip_user_agents: UserAgentData[];
ja4_user_agents: UserAgentData[];
bot_percentage: number;
alert: boolean;
}
interface UserAgentAnalysisProps {
ip: string;
}
export function UserAgentAnalysis({ ip }: UserAgentAnalysisProps) {
const [data, setData] = useState<UserAgentAnalysis | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUserAgentAnalysis = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/user-agents`);
if (!response.ok) throw new Error('Erreur chargement User-Agents');
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchUserAgentAnalysis();
}, [ip]);
if (loading) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">Chargement...</div>
</div>
);
}
if (error || !data) {
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-center text-text-secondary">User-Agents non disponibles</div>
</div>
);
}
const getClassificationBadge = (classification: string) => {
switch (classification) {
case 'normal':
return <span className="bg-threat-low/20 text-threat-low px-2 py-0.5 rounded text-xs"> Normal</span>;
case 'bot':
return <span className="bg-threat-medium/20 text-threat-medium px-2 py-0.5 rounded text-xs"> Bot</span>;
case 'script':
return <span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs"> Script</span>;
default:
return null;
}
};
const truncateUA = (ua: string, maxLength = 80) => {
if (ua.length <= maxLength) return ua;
return ua.substring(0, maxLength) + '...';
};
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">4. USER-AGENT ANALYSIS</h3>
{data.alert && (
<span className="bg-threat-high text-white px-3 py-1 rounded text-xs font-medium">
{data.bot_percentage.toFixed(0)}% bots/scripts
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* User-Agents pour cette IP */}
<div>
<div className="text-sm text-text-secondary mb-3">
User-Agents pour cette IP ({data.ip_user_agents.length})
</div>
<div className="space-y-2">
{data.ip_user_agents.slice(0, 5).map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-text-primary text-xs font-mono break-all flex-1">
{truncateUA(ua.value)}
</div>
{getClassificationBadge(ua.classification)}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{ua.count} requêtes</div>
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
</div>
</div>
))}
{data.ip_user_agents.length === 0 && (
<div className="text-text-secondary text-sm">Aucun User-Agent trouvé</div>
)}
</div>
</div>
{/* User-Agents pour le JA4 */}
<div>
<div className="text-sm text-text-secondary mb-3">
User-Agents pour le JA4 (toutes IPs)
</div>
<div className="space-y-2">
{data.ja4_user_agents.slice(0, 5).map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-text-primary text-xs font-mono break-all flex-1">
{truncateUA(ua.value)}
</div>
{getClassificationBadge(ua.classification)}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{ua.count} IPs</div>
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Stats bots */}
<div className="mt-6">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-text-secondary">Pourcentage de bots/scripts</div>
<div className={`text-lg font-bold ${data.bot_percentage > 20 ? 'text-threat-high' : 'text-text-primary'}`}>
{data.bot_percentage.toFixed(1)}%
</div>
</div>
<div className="w-full bg-background-card rounded-full h-3">
<div
className={`h-3 rounded-full transition-all ${
data.bot_percentage > 50 ? 'bg-threat-high' :
data.bot_percentage > 20 ? 'bg-threat-medium' :
'bg-threat-low'
}`}
style={{ width: `${Math.min(data.bot_percentage, 100)}%` }}
/>
</div>
{data.bot_percentage > 20 && (
<div className="mt-2 text-threat-high text-sm">
ALERT: {data.bot_percentage.toFixed(0)}% d'UAs bots/scripts
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import { detectionsApi, DetectionsListResponse } from '../api/client';
interface UseDetectionsParams {
page?: number;
page_size?: number;
threat_level?: string;
model_name?: string;
country_code?: string;
asn_number?: string;
search?: string;
sort_by?: string;
sort_order?: string;
}
export function useDetections(params: UseDetectionsParams = {}) {
const [data, setData] = useState<DetectionsListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchDetections = async () => {
setLoading(true);
try {
const response = await detectionsApi.getDetections(params);
setData(response.data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Erreur inconnue'));
} finally {
setLoading(false);
}
};
fetchDetections();
}, [
params.page,
params.page_size,
params.threat_level,
params.model_name,
params.country_code,
params.asn_number,
params.search,
params.sort_by,
params.sort_order,
]);
return { data, loading, error };
}

View File

@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { metricsApi, MetricsResponse } from '../api/client';
export function useMetrics() {
const [data, setData] = useState<MetricsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchMetrics = async () => {
try {
const response = await metricsApi.getMetrics();
setData(response.data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Erreur inconnue'));
} finally {
setLoading(false);
}
};
fetchMetrics();
// Rafraîchissement automatique toutes les 30 secondes
const interval = setInterval(fetchMetrics, 30000);
return () => clearInterval(interval);
}, []);
return { data, loading, error };
}

View File

@ -0,0 +1,30 @@
import { useState, useEffect } from 'react';
import { variabilityApi, VariabilityResponse } from '../api/client';
export function useVariability(type: string, value: string) {
const [data, setData] = useState<VariabilityResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!type || !value) {
setLoading(false);
return;
}
const fetchVariability = async () => {
try {
const response = await variabilityApi.getVariability(type, value);
setData(response.data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Erreur inconnue'));
} finally {
setLoading(false);
}
};
fetchVariability();
}, [type, value]);
return { data, loading, error };
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/globals.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,64 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
/* Scrollbar personnalisée */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1E293B;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748B;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.4s ease-out;
}