/** * PivotView — Cross-entity correlation matrix * * SOC analysts add multiple IPs or JA4 fingerprints. * The page fetches variability data for each entity and renders a * comparison matrix highlighting shared values (correlations). * * Columns = entities added by the analyst * Rows = attribute categories (JA4, User-Agent, Country, ASN, Subnet) * ★ = value shared by 2+ entities → possible campaign link */ import { useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; // ─── Types ──────────────────────────────────────────────────────────────────── type EntityType = 'ip' | 'ja4'; interface EntityCol { id: string; type: EntityType; value: string; loading: boolean; error: string | null; data: VariabilityData | null; } interface AttrItem { value: string; count: number; percentage: number; } interface VariabilityData { total_detections: number; unique_ips?: number; attributes: { ja4?: AttrItem[]; user_agents?: AttrItem[]; countries?: AttrItem[]; asns?: AttrItem[]; hosts?: AttrItem[]; subnets?: AttrItem[]; }; } type AttrKey = keyof VariabilityData['attributes']; const ATTR_ROWS: { key: AttrKey; label: string; icon: string }[] = [ { key: 'ja4', label: 'JA4 Fingerprint', icon: '🔐' }, { key: 'user_agents', label: 'User-Agents', icon: '🤖' }, { key: 'countries', label: 'Pays', icon: '🌍' }, { key: 'asns', label: 'ASN', icon: '🏢' }, { key: 'hosts', label: 'Hosts cibles', icon: '🖥️' }, { key: 'subnets', label: 'Subnets', icon: '🔷' }, ]; const MAX_VALUES_PER_CELL = 4; function detectType(input: string): EntityType { // JA4 fingerprints are ~36 chars containing letters, digits, underscores // IP addresses match IPv4 pattern if (/^\d{1,3}(\.\d{1,3}){3}$/.test(input.trim())) return 'ip'; return 'ja4'; } function getCountryFlag(code: string): string { return (code || '').toUpperCase().replace(/./g, c => String.fromCodePoint(c.charCodeAt(0) + 127397) ); } // ─── Component ──────────────────────────────────────────────────────────────── export function PivotView() { const navigate = useNavigate(); const [cols, setCols] = useState([]); const [input, setInput] = useState(''); const fetchEntity = useCallback(async (col: EntityCol): Promise => { try { const encoded = encodeURIComponent(col.value); const url = col.type === 'ip' ? `/api/variability/ip/${encoded}` : `/api/variability/ja4/${encoded}`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: VariabilityData = await res.json(); return { ...col, loading: false, data }; } catch (e) { return { ...col, loading: false, error: (e as Error).message }; } }, []); const addEntity = useCallback(async () => { const val = input.trim(); if (!val) return; // Support batch: comma-separated const values = val.split(/[,\s]+/).map(v => v.trim()).filter(Boolean); setInput(''); for (const v of values) { const id = `${Date.now()}-${v}`; const type = detectType(v); const pending: EntityCol = { id, type, value: v, loading: true, error: null, data: null }; setCols(prev => { if (prev.some(c => c.value === v)) return prev; return [...prev, pending]; }); const resolved = await fetchEntity(pending); setCols(prev => prev.map(c => c.id === id ? resolved : c)); } }, [input, fetchEntity]); const removeCol = (id: string) => setCols(prev => prev.filter(c => c.id !== id)); // Find shared values across all loaded cols for a given attribute key const getSharedValues = (key: AttrKey): Set => { const loaded = cols.filter(c => c.data); if (loaded.length < 2) return new Set(); const valueCounts = new Map(); for (const col of loaded) { const items = col.data?.attributes[key] ?? []; for (const item of items.slice(0, 10)) { valueCounts.set(item.value, (valueCounts.get(item.value) ?? 0) + 1); } } const shared = new Set(); valueCounts.forEach((count, val) => { if (count >= 2) shared.add(val); }); return shared; }; const sharedByKey = Object.fromEntries( ATTR_ROWS.map(r => [r.key, getSharedValues(r.key)]) ) as Record>; const totalCorrelations = ATTR_ROWS.reduce( (sum, r) => sum + sharedByKey[r.key].size, 0 ); return (
{/* Header */}

🔗 Pivot — Corrélation Multi-Entités

Ajoutez des IPs ou JA4. Les valeurs partagées révèlent des campagnes coordonnées.

{/* Input bar */}
setInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addEntity(); }} placeholder="IP (ex: 1.2.3.4) ou JA4, séparés par des virgules…" className="flex-1 bg-background-secondary border border-background-card rounded-lg px-4 py-2.5 text-text-primary placeholder-text-disabled font-mono text-sm focus:outline-none focus:border-accent-primary" /> {cols.length > 0 && ( )}
{/* Empty state */} {cols.length === 0 && (
🔗
Aucune entité ajoutée
Entrez plusieurs IPs ou fingerprints JA4 pour identifier des corrélations — JA4 partagés, même pays d'origine, mêmes cibles, même ASN.
Exemple : 1.2.3.4, 5.6.7.8, 9.10.11.12
)} {/* Correlation summary badge */} {cols.length >= 2 && cols.some(c => c.data) && (
0 ? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400' : 'bg-background-secondary border-background-card text-text-secondary' }`}> {totalCorrelations > 0 ? '⚠️' : 'ℹ️'}
{totalCorrelations > 0 ? ( <> {totalCorrelations} corrélation{totalCorrelations > 1 ? 's' : ''} détectée{totalCorrelations > 1 ? 's' : ''} {' '}— attributs partagés par 2+ entités. Possible campagne coordonnée. ) : ( 'Aucune corrélation détectée entre les entités analysées.' )}
)} {/* Matrix */} {cols.length > 0 && (
{/* Column headers */} {cols.map(col => ( ))} {/* Attribute rows */} {ATTR_ROWS.map(row => { const shared = sharedByKey[row.key]; const hasAnyData = cols.some(c => (c.data?.attributes[row.key]?.length ?? 0) > 0); if (!hasAnyData && cols.every(c => c.data !== null && !c.loading)) return null; return ( {cols.map(col => { const items = col.data?.attributes[row.key] ?? []; return ( ); })} ); })}
Attribut
{col.type === 'ip' ? '🌐' : '🔐'}
{col.data && (
{col.data.total_detections.toLocaleString()} det. {col.type === 'ja4' && col.data.unique_ips !== undefined && ( <> · {col.data.unique_ips} IPs )}
)} {col.loading && (
Chargement…
)} {col.error && (
⚠ {col.error}
)}
{row.icon} {row.label}
{shared.size > 0 && (
★ {shared.size} commun{shared.size > 1 ? 's' : ''}
)}
{col.loading ? (
) : items.length === 0 ? ( ) : (
{items.slice(0, MAX_VALUES_PER_CELL).map((item, i) => { const isShared = shared.has(item.value); return (
{isShared && } {row.key === 'countries' ? `${getCountryFlag(item.value)} ${item.value}` : item.value.length > 60 ? item.value.slice(0, 60) + '…' : item.value}
{item.count.toLocaleString()} {item.percentage.toFixed(1)}%
); })} {items.length > MAX_VALUES_PER_CELL && (
+{items.length - MAX_VALUES_PER_CELL} autres
)}
)}
)} {/* Legend */} {cols.length >= 2 && (
Valeur partagée par 2+ entités ★ | Cliquer sur une entité → Investigation complète
)}
); }