398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { useEffect, useState } from 'react';
|
|
import { InfoTip } from './ui/Tooltip';
|
|
import { TIPS } from './ui/tooltips';
|
|
import { formatDateOnly } from '../utils/dateUtils';
|
|
import { getCountryFlag } from '../utils/countryUtils';
|
|
|
|
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);
|
|
const [showAllUA, setShowAllUA] = useState(false);
|
|
|
|
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;
|
|
};
|
|
|
|
;
|
|
|
|
|
|
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={formatDateOnly(data.stats.first_seen)}
|
|
/>
|
|
<StatCard
|
|
label="Dernière Détection"
|
|
value={formatDateOnly(data.stats.last_seen)}
|
|
/>
|
|
</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"><span className="flex items-center gap-1">2. JA4 Fingerprints<InfoTip content={TIPS.ja4} /></span></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">
|
|
{(showAllUA ? data.user_agents : 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 leading-relaxed">
|
|
{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 && (
|
|
<button
|
|
onClick={() => setShowAllUA(v => !v)}
|
|
className="mt-4 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
|
|
>
|
|
{showAllUA ? '↑ Réduire' : `↓ Voir les ${data.user_agents.length - 10} autres`}
|
|
</button>
|
|
)}
|
|
</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"><span className="flex items-center gap-1">4. Client Headers<InfoTip content={TIPS.accept_encoding} /></span></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"><span className="flex items-center gap-1">ASNs<InfoTip content={TIPS.asn} /></span></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>
|
|
);
|
|
}
|