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:
SOC Analyst
2026-03-15 23:10:35 +01:00
parent 8d35b91642
commit 1455e04303
50 changed files with 5442 additions and 7325 deletions

View 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>
);
}