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>
This commit is contained in:
524
services/dashboard/frontend/src/components/InvestigationView.tsx
Normal file
524
services/dashboard/frontend/src/components/InvestigationView.tsx
Normal file
@ -0,0 +1,524 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user