Files
ja4-platform/services/dashboard/frontend/src/components/InvestigationView.tsx
toto d469e39da7 feat: ja4-platform monorepo — 5 services unified, tests & RPM builds standardized
Services:
- ja4sentinel: TLS/JA4 fingerprint capture daemon (Go, libpcap)
- logcorrelator: JA4 log correlation engine (Go, ClickHouse)
- mod_reqin_log: Apache module (C, JSON request logging)
- bot_detector: ML bot detection pipeline (Python)
- dashboard: FastAPI/Streamlit analytics UI (Python)

Shared libraries:
- shared/go/ja4common: logger, config, shutdown, ipfilter (Go module)
- shared/python/ja4_common: ClickHouseClient, ClickHouseSettings (Python package)
- shared/clickhouse/: canonical SQL migrations (10 files)

Build & packaging:
- Unified 3-stage Dockerfile.package for Go RPMs (el8/el9/el10)
- go.work workspace linking sentinel, correlator, ja4common
- Makefile with test-all, build-all, rpm-* targets

Fixes applied:
- go.work: 1.21 → 1.24.6 (required by sentinel)
- correlator Dockerfiles: golang:1.21 → golang:1.24
- replace directives in go.mod for ja4common local path
- pyproject.toml: setuptools.backends → setuptools.build_meta
- Removed static libpcap linking (unavailable on Rocky 9)
- Fixed data races in output/writers_test.go (sync.Mutex + atomic.Int32)
- Rewrote corrupted test files (logger_test.go × 2)

Test coverage:
- correlator: 67.1% total (unixsocket 80.5%, config 91.7%, app 83.3%, multi 87.7%, stdout 100%)
- sentinel: all 10 packages pass (api, capture, config, fingerprint, ipfilter, logging, output, tlsparse)

Documentation:
- README.md + docs/ (architecture, development, 5 services, shared libs, DB schema & migrations)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 16:42:59 +02:00

525 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useParams, useNavigate, Link } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { useVariability } from '../hooks/useVariability';
import { VariabilityPanel } from './VariabilityPanel';
import { formatDateShort } from '../utils/dateUtils';
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';
import { CorrelationGraph } from './CorrelationGraph';
import { ReputationPanel } from './ReputationPanel';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
// ─── Multi-source Activity Summary Widget ─────────────────────────────────────
interface IPSummary {
ip: string;
risk_score: number;
ml: { max_score: number; threat_level: string; attack_type: string; total_detections: number; distinct_hosts: number; distinct_ja4: number };
bruteforce: { active: boolean; hosts_attacked: number; total_hits: number; total_params: number; top_hosts: string[] };
tcp_spoofing: { detected: boolean; tcp_ttl: number | null; suspected_os: string | null; declared_os: string | null };
ja4_rotation: { rotating: boolean; distinct_ja4_count: number; total_hits?: number };
persistence: { persistent: boolean; recurrence: number; worst_score?: number; worst_threat_level?: string; first_seen?: string; last_seen?: string };
timeline_24h: { hour: number; hits: number; ja4s: string[] }[];
}
function RiskGauge({ score }: { score: number }) {
const color = score >= 75 ? '#ef4444' : score >= 50 ? '#f97316' : score >= 25 ? '#eab308' : '#22c55e';
return (
<div className="flex flex-col items-center gap-1">
<svg width="80" height="80" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(100,116,139,0.2)" strokeWidth="8" />
<circle cx="40" cy="40" r="34" fill="none" stroke={color} strokeWidth="8"
strokeDasharray={`${(score / 100) * 213.6} 213.6`}
strokeLinecap="round"
transform="rotate(-90 40 40)" />
<text x="40" y="44" textAnchor="middle" fontSize="18" fontWeight="bold" fill={color}>{score}</text>
</svg>
<span className="text-xs text-text-secondary flex items-center">Risk Score<InfoTip content={TIPS.risk_score_inv} /></span>
</div>
);
}
function ActivityBadge({ active, label, color }: { active: boolean; label: string; color: string }) {
return (
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium ${
active ? `border-${color}/40 bg-${color}/10 text-${color}` : 'border-border bg-background-card text-text-disabled'
}`}>
<span>{active ? '●' : '○'}</span>
{label}
</div>
);
}
function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
if (!data.length) return <span className="text-text-disabled text-xs">Pas d'activité 24h</span>;
const max = Math.max(...data.map(d => d.hits), 1);
return (
<div className="flex items-end gap-0.5 h-8">
{Array.from({ length: 24 }, (_, h) => {
const d = data.find(x => x.hour === h);
const pct = d ? (d.hits / max) * 100 : 0;
return (
<div key={h} className="flex-1 flex flex-col justify-end" title={d ? `${h}h: ${d.hits} hits` : `${h}h: 0`}>
<div className={`w-full rounded-sm ${pct > 0 ? 'bg-accent-primary' : 'bg-background-card'}`}
style={{ height: `${Math.max(pct, 2)}%` }} />
</div>
);
})}
</div>
);
}
function IPActivitySummary({ ip }: { ip: string }) {
const [open, setOpen] = useState(false); // fermée par défaut
const [loading, setLoading] = useState(true);
const [data, setData] = useState<IPSummary | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/investigation/${encodeURIComponent(ip)}/summary`)
.then(r => r.ok ? r.json() : null)
.then(d => setData(d))
.catch(() => null)
.finally(() => setLoading(false));
}, [ip]);
return (
<div className="bg-background-secondary rounded-lg border border-border">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-background-card/50 transition-colors"
>
<span className="font-semibold text-text-primary flex items-center gap-2">
🔎 Synthèse multi-sources
{data && <span className={`text-xs px-2 py-0.5 rounded-full font-bold ${
data.risk_score >= 75 ? 'bg-threat-critical/20 text-threat-critical' :
data.risk_score >= 50 ? 'bg-threat-high/20 text-threat-high' :
data.risk_score >= 25 ? 'bg-threat-medium/20 text-threat-medium' :
'bg-threat-low/20 text-threat-low'
}`}>Score: {data.risk_score}</span>}
</span>
<span className="text-text-secondary">{open ? '' : ''}</span>
</button>
{open && (
<div className="px-5 pb-5">
{loading && <div className="text-text-disabled text-sm py-4">Chargement des données multi-sources…</div>}
{!loading && !data && <div className="text-text-disabled text-sm py-4">Données insuffisantes pour cette IP.</div>}
{data && (
<div className="space-y-4">
{/* Risk + badges row */}
<div className="flex items-start gap-6">
<RiskGauge score={data.risk_score} />
<div className="flex-1 space-y-3">
<div className="flex flex-wrap gap-2">
<ActivityBadge active={data.ml.total_detections > 0} label={`ML: ${data.ml.total_detections} détections`} color="threat-critical" />
<ActivityBadge active={data.bruteforce.active} label={`Brute Force: ${data.bruteforce.hosts_attacked} hosts`} color="threat-high" />
<span title={TIPS.spoof_verdict}><ActivityBadge active={data.tcp_spoofing.detected} label={`TCP Spoof: TTL ${data.tcp_spoofing.tcp_ttl ?? ''}`} color="threat-medium" /></span>
<span title={TIPS.ja4_rotation}><ActivityBadge active={data.ja4_rotation.rotating} label={`JA4 Rotation: ${data.ja4_rotation.distinct_ja4_count} signatures`} color="threat-medium" /></span>
<span title={TIPS.persistence}><ActivityBadge active={data.persistence.persistent} label={`Persistance: ${data.persistence.recurrence}x récurrences`} color="threat-high" /></span>
</div>
{/* Detail grid */}
<div className="grid grid-cols-3 gap-3 text-xs">
{data.ml.total_detections > 0 && (
<div className="bg-background-card rounded p-2">
<div className="text-text-disabled mb-1">ML Detection</div>
<div className="text-text-primary font-medium">{data.ml.threat_level || ''} · {data.ml.attack_type || ''}</div>
<div className="text-text-secondary">Score: {data.ml.max_score} · {data.ml.distinct_ja4} JA4(s)</div>
</div>
)}
{data.bruteforce.active && (
<div className="bg-background-card rounded p-2">
<div className="text-text-disabled mb-1">Brute Force</div>
<div className="text-threat-high font-medium">{data.bruteforce.total_hits.toLocaleString(navigator.language || undefined)} hits</div>
<div className="text-text-secondary truncate" title={data.bruteforce.top_hosts.join(', ')}>
{data.bruteforce.top_hosts[0] ?? ''}
</div>
</div>
)}
{data.tcp_spoofing.detected && (
<div className="bg-background-card rounded p-2">
<div className="text-text-disabled mb-1" title={TIPS.spoof_verdict}>TCP Spoofing</div>
<div className="text-threat-medium font-medium">TTL {data.tcp_spoofing.tcp_ttl} → {data.tcp_spoofing.suspected_os}</div>
<div className="text-text-secondary">UA déclare: {data.tcp_spoofing.declared_os}</div>
</div>
)}
{data.persistence.persistent && (
<div className="bg-background-card rounded p-2">
<div className="text-text-disabled mb-1">Persistance</div>
<div className="text-threat-high font-medium">{data.persistence.recurrence}× sessions</div>
<div className="text-text-secondary">{data.persistence.first_seen?.substring(0, 10)} → {data.persistence.last_seen?.substring(0, 10)}</div>
</div>
)}
</div>
</div>
</div>
{/* Mini timeline */}
<div>
<div className="text-xs text-text-disabled mb-1 font-medium uppercase tracking-wide">Activité dernières 24h</div>
<MiniTimeline data={data.timeline_24h} />
<div className="flex justify-between text-xs text-text-disabled mt-0.5"><span>0h</span><span>12h</span><span>23h</span></div>
</div>
</div>
)}
</div>
)}
</div>
);
}
interface CoherenceData {
verdict: string;
spoofing_score: number;
explanation: string[];
indicators: {
ua_ch_mismatch_rate: number;
sni_mismatch_rate: number;
avg_browser_score: number;
distinct_ja4_count: number;
is_ua_rotating: boolean;
rare_ja4_rate: number;
};
fingerprints: { ja4_list: string[]; latest_ja4: string };
user_agents: { ua: string; count: number; type: string }[];
}
const VERDICT_STYLE: Record<string, { cls: string; icon: string; label: string }> = {
high_confidence_spoofing: { cls: 'bg-threat-critical/10 border-threat-critical/40 text-threat-critical', icon: '🎭', label: 'Spoofing haute confiance' },
suspicious_spoofing: { cls: 'bg-threat-high/10 border-threat-high/40 text-threat-high', icon: '', label: 'Spoofing suspect' },
known_bot_no_spoofing: { cls: 'bg-threat-medium/10 border-threat-medium/40 text-threat-medium', icon: '🤖', label: 'Bot connu (pas de spoofing)' },
legitimate_browser: { cls: 'bg-threat-low/10 border-threat-low/40 text-threat-low', icon: '', label: 'Navigateur légitime' },
inconclusive: { cls: 'bg-background-card border-background-card text-text-secondary', icon: '', label: 'Non concluant' },
};
function FingerprintCoherenceWidget({ ip }: { ip: string }) {
const navigate = useNavigate();
const [data, setData] = useState<CoherenceData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
setLoading(true);
setError(false);
fetch(`/api/fingerprints/ip/${encodeURIComponent(ip)}/coherence`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d) setData(d); else setError(true); })
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [ip]);
const vs = data ? (VERDICT_STYLE[data.verdict] ?? VERDICT_STYLE.inconclusive) : null;
return (
<div className="bg-background-secondary rounded-lg p-5">
<h3 className="text-base font-semibold text-text-primary mb-4">🎭 Cohérence JA4 / User-Agent</h3>
{loading && <div className="text-text-disabled text-sm">Analyse en cours…</div>}
{error && <div className="text-text-disabled text-sm">Données insuffisantes pour cette IP</div>}
{data && vs && (
<div className="space-y-4">
{/* Verdict badge + score */}
<div className={`flex items-center gap-3 p-3 rounded-lg border ${vs.cls}`}>
<span className="text-2xl">{vs.icon}</span>
<div className="flex-1">
<div className="font-semibold text-sm">{vs.label}</div>
<div className="text-xs opacity-75 mt-0.5 flex items-center gap-1">
Score de spoofing: <strong>{data.spoofing_score}/100</strong><InfoTip content={TIPS.spoofing_score} />
</div>
</div>
<div className="w-24">
<div className="h-2 bg-white/20 rounded-full overflow-hidden">
<div
className={`h-2 rounded-full ${
data.spoofing_score >= 70 ? 'bg-threat-critical' :
data.spoofing_score >= 40 ? 'bg-threat-high' :
data.spoofing_score >= 20 ? 'bg-threat-medium' : 'bg-threat-low'
}`}
style={{ width: `${data.spoofing_score}%` }}
/>
</div>
</div>
</div>
{/* Explanation */}
<ul className="space-y-1">
{data.explanation.map((e, i) => (
<li key={i} className="text-xs text-text-secondary flex items-start gap-1.5">
<span className="text-text-disabled mt-0.5">•</span> {e}
</li>
))}
</ul>
{/* Key indicators */}
<div className="grid grid-cols-2 gap-2">
{[
{ label: 'UA/CH mismatch', tip: TIPS.ua_mismatch, value: `${data.indicators.ua_ch_mismatch_rate}%`, warn: data.indicators.ua_ch_mismatch_rate > 20 },
{ label: 'Browser score', tip: TIPS.browser_score, value: `${data.indicators.avg_browser_score}/100`, warn: data.indicators.avg_browser_score > 60 },
{ label: 'JA4 distincts', tip: TIPS.ja4_distinct, value: data.indicators.distinct_ja4_count, warn: data.indicators.distinct_ja4_count > 2 },
{ label: 'JA4 rares %', tip: TIPS.ja4_rare_pct, value: `${data.indicators.rare_ja4_rate}%`, warn: data.indicators.rare_ja4_rate > 50 },
].map((ind) => (
<div key={ind.label} className={`rounded p-2 text-center ${ind.warn ? 'bg-threat-high/10' : 'bg-background-card'}`}>
<div className={`text-sm font-semibold ${ind.warn ? 'text-threat-high' : 'text-text-primary'}`}>{ind.value}</div>
<div className="text-xs text-text-disabled flex items-center justify-center gap-0.5">
{ind.label}
<InfoTip content={ind.tip} />
</div>
</div>
))}
</div>
{/* Top UAs */}
{data.user_agents.length > 0 && (
<div>
<div className="text-xs text-text-disabled mb-1.5 font-medium uppercase tracking-wide">User-Agents observés</div>
<div className="space-y-1">
{data.user_agents.slice(0, 4).map((u, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${
u.type === 'bot' ? 'bg-threat-critical/20 text-threat-critical' :
u.type === 'browser' ? 'bg-accent-primary/20 text-accent-primary' :
'bg-background-card text-text-secondary'
}`}>{u.type}</span>
<span className="truncate text-text-secondary font-mono" title={u.ua}>
{u.ua.length > 45 ? u.ua.slice(0, 45) + '' : u.ua}
</span>
</div>
))}
</div>
</div>
)}
{/* JA4 links */}
{data.fingerprints.ja4_list.length > 0 && (
<div>
<div className="text-xs text-text-disabled mb-1 font-medium uppercase tracking-wide">JA4 utilisés</div>
<div className="flex flex-wrap gap-1">
{data.fingerprints.ja4_list.map((j4) => (
<button
key={j4}
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(j4)}`)}
className="text-xs font-mono text-accent-primary hover:underline truncate max-w-[140px]"
title={j4}
>
{j4.length > 18 ? `${j4.slice(0, 9)}…${j4.slice(-8)}` : j4}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ─── Section "Attributs détectés" (données de variabilité, ex-DetailsView) ───
function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
return (
<div className="bg-background-card rounded-xl p-3">
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">{label}</p>
<p className={`text-xl font-bold ${accent ? 'text-accent-primary' : 'text-text-primary'}`}>{value}</p>
</div>
);
}
function DetectionAttributesSection({ ip }: { ip: string }) {
const [open, setOpen] = useState(true); // ouvert par défaut
const { data, loading } = useVariability('ip', ip);
const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
const last = data?.date_range.last_seen ? new Date(data.date_range.last_seen) : null;
const sameDate = first && last && first.getTime() === last.getTime();
const fmt = (d: Date) => formatDateShort(d.toISOString());
return (
<div className="bg-background-secondary rounded-lg border border-border">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-background-card/50 transition-colors"
>
<span className="font-semibold text-text-primary flex items-center gap-2">
📋 Attributs détectés
{data && (
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-primary/20 text-accent-primary font-normal">
{data.total_detections} détections · {data.attributes.user_agents?.length ?? 0} UA · {data.attributes.ja4?.length ?? 0} JA4
</span>
)}
</span>
<span className="text-text-secondary">{open ? '' : ''}</span>
</button>
{open && (
<div className="px-5 pb-5 space-y-4">
{loading && <div className="text-text-disabled text-sm py-4">Chargement…</div>}
{data && (
<>
{/* Métriques */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Metric label="Détections (24h)" value={data.total_detections.toLocaleString()} accent />
<Metric label="User-Agents" value={(data.attributes.user_agents?.length ?? 0).toString()} />
{first && last && (
sameDate ? (
<Metric label="Détecté le" value={fmt(last!)} />
) : (
<div className="bg-background-card rounded-xl p-3 col-span-2">
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">Période</p>
<p className="text-xs text-text-primary font-medium">{fmt(first)}</p>
<p className="text-[10px] text-text-secondary">→ {fmt(last!)}</p>
</div>
)
)}
</div>
{/* Insights */}
{data.insights.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{data.insights.map((ins, i) => {
const s: Record<string, string> = {
warning: 'bg-yellow-500/10 border-yellow-500/40 text-yellow-400',
info: 'bg-blue-500/10 border-blue-500/40 text-blue-400',
success: 'bg-green-500/10 border-green-500/40 text-green-400',
};
return (
<div key={i} className={`${s[ins.type] ?? s.info} border rounded-xl p-3 text-sm`}>
{ins.message}
</div>
);
})}
</div>
)}
{/* Attributs (JA4, hosts, ASN, pays, UA…) */}
<VariabilityPanel attributes={data.attributes} hideAssociatedIPs />
</>
)}
</div>
)}
</div>
);
}
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">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-text-secondary">
<Link to="/" className="hover:text-text-primary">Dashboard</Link>
<span>/</span>
<Link to="/detections" className="hover:text-text-primary">Détections</Link>
<span>/</span>
<span className="text-text-primary font-mono">{ip}</span>
</nav>
{/* En-tête */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-4 mb-2">
<button
onClick={() => navigate(-1)}
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>
{/* Navigation ancres inter-sections */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs font-medium sticky top-0 z-10 bg-background py-2">
<span className="text-text-disabled shrink-0">Aller à :</span>
{[
{ id: 'section-attributs', label: '📡 Attributs' },
{ id: 'section-synthese', label: '🔎 Synthèse' },
{ id: 'section-reputation', label: '🌍 Réputation' },
{ id: 'section-correlations', label: '🕸 Corrélations' },
{ id: 'section-geo', label: '🌐 Géo / JA4' },
{ id: 'section-classification', label: '🏷 Classification' },
].map(({ id, label }) => (
<a key={id} href={`#${id}`} className="shrink-0 px-3 py-1 rounded-full bg-background-card text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors">
{label}
</a>
))}
</div>
{/* Attributs détectés (ex-DetailsView) */}
<div id="section-attributs">
<DetectionAttributesSection ip={ip} />
</div>
{/* Synthèse multi-sources */}
<div id="section-synthese">
<IPActivitySummary ip={ip} />
</div>
{/* Réputation (1/3) + Graph de corrélations (2/3) */}
<div id="section-reputation" className="grid grid-cols-3 gap-6 items-start">
<div className="bg-background-secondary rounded-lg p-6 h-full">
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP</h3>
<ReputationPanel ip={ip} />
</div>
<div id="section-correlations" className="col-span-2 bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🕸️ Graph de Corrélations</h3>
<CorrelationGraph ip={ip} height="600px" />
</div>
</div>
{/* Subnet / Country / JA4 */}
<div id="section-geo" className="grid grid-cols-3 gap-6 items-start">
<SubnetAnalysis ip={ip} />
<CountryAnalysis ip={ip} />
<JA4Analysis ip={ip} />
</div>
{/* User-Agents (1/2) + Classification (1/2) */}
<div id="section-classification" className="grid grid-cols-2 gap-6 items-start">
<UserAgentAnalysis ip={ip} />
<CorrelationSummary ip={ip} onClassify={handleClassify} />
</div>
{/* Cohérence JA4/UA (spoofing) */}
<div className="grid grid-cols-3 gap-6 items-start">
<FingerprintCoherenceWidget ip={ip} />
<div className="col-span-2 bg-background-secondary rounded-lg p-5">
<h3 className="text-base font-semibold text-text-primary mb-3 flex items-center gap-1">
🔏 JA4 Légitimes (baseline)
<InfoTip content={TIPS.baseline_ja4} />
</h3>
<p className="text-xs text-text-secondary mb-3">
Comparez les fingerprints de cette IP avec la baseline des JA4 légitimes pour évaluer le risque de spoofing.
</p>
<button
onClick={() => navigate('/fingerprints?tab=spoofing')}
className="text-sm px-4 py-2 rounded-lg bg-accent-primary/20 text-accent-primary hover:bg-accent-primary/30 transition-colors"
>
🎭 Voir l'analyse de spoofing globale
</button>
</div>
</div>
</div>
);
}