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:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bot Detector Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "bot-detector-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"axios": "^1.6.0",
|
||||
"recharts": "^2.10.0",
|
||||
"@tanstack/react-table": "^8.11.0",
|
||||
"date-fns": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
262
frontend/src/App.tsx
Normal file
262
frontend/src/App.tsx
Normal 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
151
frontend/src/api/client.ts
Normal 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 } }),
|
||||
};
|
||||
169
frontend/src/components/DetailsView.tsx
Normal file
169
frontend/src/components/DetailsView.tsx
Normal 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'
|
||||
});
|
||||
}
|
||||
571
frontend/src/components/DetectionsList.tsx
Normal file
571
frontend/src/components/DetectionsList.tsx
Normal 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));
|
||||
}
|
||||
401
frontend/src/components/EntityInvestigationView.tsx
Normal file
401
frontend/src/components/EntityInvestigationView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/InvestigationView.tsx
Normal file
64
frontend/src/components/InvestigationView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
373
frontend/src/components/JA4InvestigationView.tsx
Normal file
373
frontend/src/components/JA4InvestigationView.tsx
Normal 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'
|
||||
});
|
||||
}
|
||||
313
frontend/src/components/VariabilityPanel.tsx
Normal file
313
frontend/src/components/VariabilityPanel.tsx
Normal 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';
|
||||
}
|
||||
308
frontend/src/components/analysis/CorrelationSummary.tsx
Normal file
308
frontend/src/components/analysis/CorrelationSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
frontend/src/components/analysis/CountryAnalysis.tsx
Normal file
176
frontend/src/components/analysis/CountryAnalysis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
frontend/src/components/analysis/JA4Analysis.tsx
Normal file
142
frontend/src/components/analysis/JA4Analysis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
370
frontend/src/components/analysis/JA4CorrelationSummary.tsx
Normal file
370
frontend/src/components/analysis/JA4CorrelationSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
frontend/src/components/analysis/SubnetAnalysis.tsx
Normal file
120
frontend/src/components/analysis/SubnetAnalysis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
frontend/src/components/analysis/UserAgentAnalysis.tsx
Normal file
166
frontend/src/components/analysis/UserAgentAnalysis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
frontend/src/hooks/useDetections.ts
Normal file
48
frontend/src/hooks/useDetections.ts
Normal 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 };
|
||||
}
|
||||
29
frontend/src/hooks/useMetrics.ts
Normal file
29
frontend/src/hooks/useMetrics.ts
Normal 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 };
|
||||
}
|
||||
30
frontend/src/hooks/useVariability.ts
Normal file
30
frontend/src/hooks/useVariability.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
64
frontend/src/styles/globals.css
Normal file
64
frontend/src/styles/globals.css
Normal 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;
|
||||
}
|
||||
41
frontend/tailwind.config.js
Normal file
41
frontend/tailwind.config.js
Normal file
@ -0,0 +1,41 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Thème sombre Security Dashboard
|
||||
background: {
|
||||
DEFAULT: '#0F172A', // Slate 900
|
||||
secondary: '#1E293B', // Slate 800
|
||||
card: '#334155', // Slate 700
|
||||
},
|
||||
text: {
|
||||
primary: '#F8FAFC', // Slate 50
|
||||
secondary: '#94A3B8', // Slate 400
|
||||
disabled: '#64748B', // Slate 500
|
||||
},
|
||||
// Menaces
|
||||
threat: {
|
||||
critical: '#EF4444', // Red 500
|
||||
critical_bg: '#7F1D1D',
|
||||
high: '#F97316', // Orange 500
|
||||
high_bg: '#7C2D12',
|
||||
medium: '#EAB308', // Yellow 500
|
||||
medium_bg: '#713F12',
|
||||
low: '#22C55E', // Green 500
|
||||
low_bg: '#14532D',
|
||||
},
|
||||
// Accents
|
||||
accent: {
|
||||
primary: '#3B82F6', // Blue 500
|
||||
success: '#10B981', // Emerald 500
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user