fix: correct CampaignsView, analysis.py IPv4 split, entities date filter
- CampaignsView: update ClusterData interface to match real API response
(severity/unique_ips/score instead of threat_level/total_ips/confidence_range)
Fix fetch to use data.items, rewrite ClusterCard and BehavioralTab
Remove unused getClassificationColor and THREAT_ORDER constants
- analysis.py: fix IPv4Address object has no attribute 'split' on line 322
Add str() conversion before calling .split('.')
- entities.py: fix Date vs DateTime comparison — log_date is a Date column,
comparing against now()-INTERVAL HOUR caused yesterday's entries to be excluded
Use toDate(now() - INTERVAL X HOUR) for correct Date-level comparison
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
367
frontend/src/components/PivotView.tsx
Normal file
367
frontend/src/components/PivotView.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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<EntityCol[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const fetchEntity = useCallback(async (col: EntityCol): Promise<EntityCol> => {
|
||||
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<string> => {
|
||||
const loaded = cols.filter(c => c.data);
|
||||
if (loaded.length < 2) return new Set();
|
||||
const valueCounts = new Map<string, number>();
|
||||
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<string>();
|
||||
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<AttrKey, Set<string>>;
|
||||
|
||||
const totalCorrelations = ATTR_ROWS.reduce(
|
||||
(sum, r) => sum + sharedByKey[r.key].size, 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🔗 Pivot — Corrélation Multi-Entités</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">
|
||||
Ajoutez des IPs ou JA4. Les valeurs partagées <span className="text-yellow-400 font-bold">★</span> révèlent des campagnes coordonnées.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={addEntity}
|
||||
disabled={!input.trim()}
|
||||
className="px-5 py-2.5 bg-accent-primary text-white rounded-lg text-sm font-medium hover:bg-accent-primary/80 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
{cols.length > 0 && (
|
||||
<button
|
||||
onClick={() => setCols([])}
|
||||
className="px-4 py-2.5 bg-background-card text-text-secondary rounded-lg text-sm hover:text-text-primary transition-colors"
|
||||
>
|
||||
Tout effacer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{cols.length === 0 && (
|
||||
<div className="bg-background-secondary rounded-xl p-12 text-center space-y-3">
|
||||
<div className="text-5xl">🔗</div>
|
||||
<div className="text-lg font-medium text-text-primary">Aucune entité ajoutée</div>
|
||||
<div className="text-sm text-text-secondary max-w-md mx-auto">
|
||||
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.
|
||||
</div>
|
||||
<div className="text-xs text-text-disabled pt-2">
|
||||
Exemple : <span className="font-mono text-text-secondary">1.2.3.4, 5.6.7.8, 9.10.11.12</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Correlation summary badge */}
|
||||
{cols.length >= 2 && cols.some(c => c.data) && (
|
||||
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border ${
|
||||
totalCorrelations > 0
|
||||
? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
|
||||
: 'bg-background-secondary border-background-card text-text-secondary'
|
||||
}`}>
|
||||
<span className="text-xl">{totalCorrelations > 0 ? '⚠️' : 'ℹ️'}</span>
|
||||
<div>
|
||||
{totalCorrelations > 0 ? (
|
||||
<>
|
||||
<span className="font-bold">{totalCorrelations} corrélation{totalCorrelations > 1 ? 's' : ''} détectée{totalCorrelations > 1 ? 's' : ''}</span>
|
||||
{' '}— attributs partagés par 2+ entités. Possible campagne coordonnée.
|
||||
</>
|
||||
) : (
|
||||
'Aucune corrélation détectée entre les entités analysées.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Matrix */}
|
||||
{cols.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-xl border border-background-card">
|
||||
<table className="w-full min-w-max">
|
||||
{/* Column headers */}
|
||||
<thead>
|
||||
<tr className="bg-background-secondary border-b border-background-card">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider w-40">
|
||||
Attribut
|
||||
</th>
|
||||
{cols.map(col => (
|
||||
<th key={col.id} className="px-4 py-3 text-left w-56">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-text-disabled">
|
||||
{col.type === 'ip' ? '🌐' : '🔐'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => navigate(
|
||||
col.type === 'ip'
|
||||
? `/investigation/${col.value}`
|
||||
: `/investigation/ja4/${col.value}`
|
||||
)}
|
||||
className="font-mono text-sm text-accent-primary hover:underline truncate max-w-[160px] text-left"
|
||||
title={col.value}
|
||||
>
|
||||
{col.value.length > 20 ? col.value.slice(0, 20) + '…' : col.value}
|
||||
</button>
|
||||
</div>
|
||||
{col.data && (
|
||||
<div className="text-xs text-text-disabled mt-0.5">
|
||||
{col.data.total_detections.toLocaleString()} det.
|
||||
{col.type === 'ja4' && col.data.unique_ips !== undefined && (
|
||||
<> · {col.data.unique_ips} IPs</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{col.loading && (
|
||||
<div className="text-xs text-text-disabled mt-0.5 animate-pulse">Chargement…</div>
|
||||
)}
|
||||
{col.error && (
|
||||
<div className="text-xs text-red-400 mt-0.5">⚠ {col.error}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeCol(col.id)}
|
||||
className="text-text-disabled hover:text-red-400 transition-colors text-base leading-none shrink-0 mt-0.5"
|
||||
title="Retirer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Attribute rows */}
|
||||
<tbody className="divide-y divide-background-card">
|
||||
{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 (
|
||||
<tr key={row.key} className="hover:bg-background-card/20 transition-colors">
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{row.icon}</span>
|
||||
<span className="text-xs font-medium text-text-secondary">{row.label}</span>
|
||||
</div>
|
||||
{shared.size > 0 && (
|
||||
<div className="mt-1">
|
||||
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
|
||||
★ {shared.size} commun{shared.size > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{cols.map(col => {
|
||||
const items = col.data?.attributes[row.key] ?? [];
|
||||
return (
|
||||
<td key={col.id} className="px-3 py-3 align-top">
|
||||
{col.loading ? (
|
||||
<div className="h-16 bg-background-card/30 rounded animate-pulse" />
|
||||
) : items.length === 0 ? (
|
||||
<span className="text-xs text-text-disabled">—</span>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{items.slice(0, MAX_VALUES_PER_CELL).map((item, i) => {
|
||||
const isShared = shared.has(item.value);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded px-2 py-1.5 text-xs ${
|
||||
isShared
|
||||
? 'bg-yellow-500/15 border border-yellow-500/30'
|
||||
: 'bg-background-card/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className={`font-mono break-all leading-tight ${
|
||||
isShared ? 'text-yellow-300' : 'text-text-primary'
|
||||
}`}>
|
||||
{isShared && <span className="mr-1 text-yellow-400">★</span>}
|
||||
{row.key === 'countries'
|
||||
? `${getCountryFlag(item.value)} ${item.value}`
|
||||
: item.value.length > 60
|
||||
? item.value.slice(0, 60) + '…'
|
||||
: item.value}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-text-disabled mt-0.5 flex gap-2">
|
||||
<span>{item.count.toLocaleString()}</span>
|
||||
<span>{item.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.length > MAX_VALUES_PER_CELL && (
|
||||
<div className="text-xs text-text-disabled px-2">
|
||||
+{items.length - MAX_VALUES_PER_CELL} autres
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
{cols.length >= 2 && (
|
||||
<div className="flex items-center gap-4 text-xs text-text-disabled">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded bg-yellow-500/20 border border-yellow-500/30 inline-block" />
|
||||
Valeur partagée par 2+ entités ★
|
||||
</span>
|
||||
<span>|</span>
|
||||
<span>Cliquer sur une entité → Investigation complète</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user