Files
toto 6babc55e3e fix(dashboard): hover infobulles, full-width layout, UX polish
- Fix doc tooltips: split CSS into <style type='text/tailwindcss'> for
  @apply directives + raw CSS for reliable doc panel rendering
- Convert doc panels from click-toggle to hover-based infobulles with
  arrow pointer, fade-in animation, and auto-dismiss on mobile
- Replace '?' icons with 'ⓘ' across all 11 templates (51 tooltips)
- Full-width layout: reduce padding on mobile (px-3), scale up on
  desktop (lg:px-5, xl:px-6) for maximum screen utilization
- Auto-collapse sidebar on narrow screens (<1024px)
- Keyboard shortcuts: Alt+1–9 for page navigation, Alt+B toggle sidebar
- Add LEGITIMATE_BROWSER filter button to detections page
- Sticky header with stronger blur (backdrop-blur-md)
- All 46 routes pass tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 13:30:16 +02:00

315 lines
19 KiB
HTML

{% extends "base.html" %}
{% block title %}JA4 SOC — Analyse Réseau{% endblock %}
{% block page_title %}
Analyse Réseau
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Analyse réseau</h4>
<p>Vue complète de l'infrastructure réseau : ASN, pays, fingerprints JA4, rotation de fingerprints, brute-force et menaces persistantes.</p>
<p><strong>Workflow :</strong> Identifiez les ASN suspects → vérifiez la rotation JA4 → contrôlez le brute-force → investiguez les IPs récurrentes.</p>
<p class="doc-source">Sources : view_ai_features_1h, view_host_ip_ja4_rotation, view_form_bruteforce_detected, view_ip_recurrence</p>
</div></span>
{% endblock %}
{% block content %}
<div class="space-y-4">
<!-- KPI Row -->
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-3">
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Pays</div><div class="text-xl font-bold text-brand-500" id="kpi-countries"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">ASNs</div><div class="text-xl font-bold text-yellow-400" id="kpi-asns"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Sessions ISP</div><div class="text-xl font-bold text-green-400" id="kpi-human"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Sessions DC</div><div class="text-xl font-bold text-red-400" id="kpi-datacenter"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Rotation JA4</div><div class="text-xl font-bold text-purple-400" id="kpi-rotation"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Brute-force</div><div class="text-xl font-bold text-red-400" id="kpi-brute"></div></div>
</div>
<!-- Row: ASN Treemap + Sunburst -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="section-card">
<div class="section-header"><span class="section-title">Treemap ASN
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Treemap ASN</h4>
<p>Taille = nombre de sessions. Regroupé par type : <span class="text-green-400">ISP</span> (résidentiel), <span class="text-red-400">Datacenter</span>, <span class="text-orange-400">Hosting</span>, <span class="text-cyan-400">CDN</span>.</p>
<p><strong>Action :</strong> Un ASN datacenter avec beaucoup de sessions mérite investigation.</p>
<p class="doc-source">Source : view_ai_features_1h</p>
</div></span>
</span></div>
<div class="section-body"><div id="chart-treemap" style="height:340px"></div></div>
</div>
<div class="section-card">
<div class="section-header"><span class="section-title">Pays → Type ASN
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Sunburst géographique</h4>
<p>Niveau 1 : pays. Niveau 2 : type d'ASN. Identifiez les pays avec forte proportion datacenter.</p>
<p class="doc-source">Source : view_ai_features_1h</p>
</div></span>
</span></div>
<div class="section-body"><div id="chart-sunburst" style="height:340px"></div></div>
</div>
</div>
<!-- Row: JA4 Rotation + Brute-force -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- JA4 Rotation -->
<div class="section-card">
<div class="section-header"><span class="section-title">
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Rotation JA4 (évasion TLS)
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Rotation de fingerprints JA4</h4>
<p>IPs utilisant plusieurs fingerprints TLS distinctes sur une fenêtre horaire. Indique une tentative d'évasion de détection (rotation de client TLS).</p>
<p><strong>Seuil critique :</strong> ≥ 3 JA4 distincts par IP/host/heure.</p>
<p class="doc-source">Source : view_host_ip_ja4_rotation</p>
</div></span>
</span></div>
<div class="section-body p-0">
<div class="overflow-x-auto" style="max-height:300px; overflow-y:auto">
<table class="data-table"><thead><tr>
<th>IP</th><th>Host</th><th>JA4 distincts</th><th>Hits</th><th>Fenêtre</th>
</tr></thead><tbody id="rotation-body"></tbody></table>
</div>
</div>
</div>
<!-- Brute-force -->
<div class="section-card">
<div class="section-header"><span class="section-title">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
Brute-force / Credential stuffing
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Détection brute-force</h4>
<p>IPs envoyant ≥10 requêtes POST par host sur 24h. Indique du credential stuffing ou du brute-force de formulaires.</p>
<p class="doc-source">Source : view_form_bruteforce_detected</p>
</div></span>
</span></div>
<div class="section-body p-0">
<div class="overflow-x-auto" style="max-height:300px; overflow-y:auto">
<table class="data-table"><thead><tr>
<th>IP</th><th>Host</th><th>POSTs</th><th>Paths</th><th>Première</th><th>Dernière</th>
</tr></thead><tbody id="brute-body"></tbody></table>
</div>
</div>
</div>
</div>
<!-- Row: Persistent threats + JA4 table -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Persistent threats -->
<div class="section-card">
<div class="section-header"><span class="section-title">
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Menaces persistantes
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>IPs récurrentes</h4>
<p>IPs détectées sur plusieurs fenêtres horaires. Récurrence élevée = acteur persistant. Le score indique le pire score observé.</p>
<p class="doc-source">Source : view_ip_recurrence</p>
</div></span>
</span></div>
<div class="section-body p-0">
<div class="overflow-y-auto" style="max-height:360px">
<table class="data-table"><thead><tr>
<th>IP</th><th>Réc.</th><th>Score</th><th>Threat</th>
</tr></thead><tbody id="recurrence-body"></tbody></table>
</div>
</div>
</div>
<!-- JA4 Fingerprints -->
<div class="lg:col-span-2 section-card">
<div class="section-header"><span class="section-title">Empreintes JA4
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Fingerprints TLS (JA4)</h4>
<p>Chaque combinaison unique de paramètres TLS génère un hash JA4. Les navigateurs courants partagent des fingerprints connues.</p>
<p><strong>Indicateurs :</strong> Velocity élevée + browser score bas = bot probable.</p>
<p class="doc-source">Source : view_ai_features_1h GROUP BY ja4</p>
</div></span>
</span></div>
<div class="section-body p-0">
<div class="overflow-x-auto" style="max-height:360px; overflow-y:auto">
<table class="data-table" id="ja4-table"><thead><tr>
<th class="cursor-pointer" data-col="0">JA4</th>
<th class="cursor-pointer" data-col="1">Sessions</th>
<th class="cursor-pointer" data-col="2">Hits</th>
<th class="cursor-pointer" data-col="3">Velocity</th>
<th class="cursor-pointer" data-col="4">Fuzz</th>
<th class="cursor-pointer" data-col="5">Browser</th>
<th>Label</th><th>Bot</th>
</tr></thead><tbody id="ja4-body"></tbody></table>
</div>
</div>
</div>
</div>
<!-- Row: ASN table + Bot pie -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="lg:col-span-2 section-card">
<div class="section-header"><span class="section-title">Détail ASN</span></div>
<div class="section-body p-0">
<div class="overflow-x-auto" style="max-height:360px; overflow-y:auto">
<table class="data-table"><thead><tr>
<th>ASN Org</th><th>Label</th><th>Pays</th><th>Sessions</th><th>Hits</th><th>Velocity</th><th>Fuzz</th>
</tr></thead><tbody id="asn-body"></tbody></table>
</div>
</div>
</div>
<div class="section-card">
<div class="section-header"><span class="section-title">Bots par empreinte</span></div>
<div class="section-body"><div id="chart-botpie" style="height:300px"></div></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const LABEL_COLORS = {isp:'#22c55e', datacenter:'#ef4444', hosting:'#f97316', cdn:'#06b6d4', unknown:'#6b7280'};
let charts = {};
function initChart(id) {
const el = document.getElementById(id);
if (!el) return null;
if (charts[id]) charts[id].dispose();
charts[id] = echarts.init(el);
return charts[id];
}
let ja4Rows = [], sortCol = 1, sortAsc = false;
function renderJA4Table() {
const sorted = [...ja4Rows].sort((a,b) => {
const va = a[sortCol], vb = b[sortCol];
if (typeof va === 'number') return sortAsc ? va-vb : vb-va;
return sortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
document.getElementById('ja4-body').innerHTML = sorted.map(r =>
`<tr onclick="window.location='/detections?ja4=${encodeURIComponent(r[0])}'">
<td class="font-mono text-[11px]">${fmtJA4Full(r[0])}</td>
<td>${r[1]}</td><td>${r[2]}</td><td>${r[3].toFixed(3)}</td><td>${r[4].toFixed(3)}</td><td>${r[5].toFixed(2)}</td>
<td>${fmtLabel(r[6])}</td><td class="text-xs">${fmtBotName(r[7])}</td>
</tr>`).join('');
}
document.getElementById('ja4-table').querySelector('thead').addEventListener('click', e => {
const th = e.target.closest('th[data-col]'); if(!th) return;
const col = parseInt(th.dataset.col);
if(col===sortCol) sortAsc=!sortAsc; else { sortCol=col; sortAsc=false; }
renderJA4Table();
});
async function loadAll() {
try {
const [geo, fp, rot, bf, rec] = await Promise.all([
fetch('/api/geo').then(r=>r.json()),
fetch('/api/fingerprints').then(r=>r.json()),
fetch('/api/ja4-rotation').then(r=>r.json()),
fetch('/api/brute-force').then(r=>r.json()),
fetch('/api/recurrence').then(r=>r.json()),
]);
const countries = geo.countries||[], asns = geo.asns||[];
const ja4Stats = fp.ja4_stats||[], botJa4 = fp.bot_ja4||[];
const rotData = rot.data||[], bfData = bf.data||[], recData = rec.data||[];
// KPIs
document.getElementById('kpi-countries').textContent = new Set(countries.map(c=>c.country_code)).size;
document.getElementById('kpi-asns').textContent = new Set(asns.map(a=>a.asn_org)).size;
document.getElementById('kpi-human').textContent = fmtNum(asns.filter(a=>a.asn_label==='isp').reduce((s,a)=>s+(a.sessions||0),0));
document.getElementById('kpi-datacenter').textContent = fmtNum(asns.filter(a=>a.asn_label==='datacenter').reduce((s,a)=>s+(a.sessions||0),0));
document.getElementById('kpi-rotation').textContent = rotData.length;
document.getElementById('kpi-brute').textContent = bfData.length;
// ASN Treemap
const treemapChart = initChart('chart-treemap');
if (treemapChart && asns.length) {
const byLabel = {};
asns.forEach(a => {
const lbl = a.asn_label||'unknown';
if(!byLabel[lbl]) byLabel[lbl] = {name:lbl, value:0, children:[], itemStyle:{color:LABEL_COLORS[lbl]||'#6b7280'}};
byLabel[lbl].children.push({name:a.asn_org, value:a.sessions||0});
byLabel[lbl].value += a.sessions||0;
});
treemapChart.setOption(ecBase({
tooltip: ecTooltip({formatter:i=>`${i.name}<br>Sessions: <b>${(i.value||0).toLocaleString()}</b>`}),
series:[{type:'treemap', data:Object.values(byLabel).sort((a,b)=>b.value-a.value),
width:'100%',height:'100%', label:{show:true,fontSize:11,color:'#fff'},
upperLabel:{show:true,height:20,fontSize:11,color:'#fff',fontWeight:'bold',backgroundColor:'transparent'},
itemStyle:{borderColor:'#111827',borderWidth:2,gapWidth:1},
levels:[{itemStyle:{borderColor:'#1f2937',borderWidth:3},upperLabel:{show:true}},{colorSaturation:[0.4,0.8]}],
}]
}));
treemapChart.on('click', p => { if(p.data?.name && p.treePathInfo?.length>2) window.location.href='/detections?asn_org='+encodeURIComponent(p.data.name); });
}
// Sunburst
const sunChart = initChart('chart-sunburst');
if (sunChart && countries.length) {
const byC = {};
countries.forEach(c => {
if(!byC[c.country_code]) byC[c.country_code] = {name:c.country_code, children:[]};
byC[c.country_code].children.push({name:c.asn_label||'unknown', value:c.sessions||0, itemStyle:{color:LABEL_COLORS[c.asn_label]||'#6b7280'}});
});
sunChart.setOption(ecBase({
tooltip:ecTooltip({formatter:i=>`${i.name}: ${(i.value||0).toLocaleString()}`}),
series:[{type:'sunburst', data:Object.values(byC).sort((a,b)=>{const va=a.children.reduce((s,c)=>s+c.value,0);const vb=b.children.reduce((s,c)=>s+c.value,0);return vb-va;}),
radius:['15%','90%'], label:{color:'#e5e7eb',fontSize:10,rotate:'radial'},
itemStyle:{borderColor:'#111827',borderWidth:1},
levels:[{},{r0:'15%',r:'50%',label:{fontSize:12,fontWeight:'bold'}},{r0:'50%',r:'90%',label:{fontSize:9}}],
}]
}));
}
// JA4 Rotation table
document.getElementById('rotation-body').innerHTML = rotData.map(r =>
`<tr onclick="window.location='/ip/${encodeURIComponent(String(r.src_ip).replace('::ffff:',''))}'">
<td>${fmtIP(r.src_ip)}</td>
<td class="text-xs">${escapeHtml(r.host||'')}</td>
<td class="text-center"><span class="badge badge-high">${r.distinct_ja4}</span></td>
<td class="font-mono text-xs">${r.total_hits||0}</td>
<td class="text-[11px] text-gray-400">${(r.window_start||'').substring(0,16)}</td>
</tr>`).join('') || '<tr><td colspan="5" class="text-center py-4 text-gray-500">Aucune rotation détectée</td></tr>';
// Brute-force table
document.getElementById('brute-body').innerHTML = bfData.map(r =>
`<tr onclick="window.location='/ip/${encodeURIComponent(String(r.src_ip).replace('::ffff:',''))}'">
<td>${fmtIP(r.src_ip)}</td>
<td class="text-xs">${escapeHtml(r.host||'')}</td>
<td class="font-mono text-red-400">${r.post_count}</td>
<td class="text-xs">${r.distinct_paths}</td>
<td class="text-[11px] text-gray-400">${(r.first_seen||'').substring(0,16)}</td>
<td class="text-[11px] text-gray-400">${(r.last_seen||'').substring(0,16)}</td>
</tr>`).join('') || '<tr><td colspan="6" class="text-center py-4 text-gray-500">Aucun brute-force détecté</td></tr>';
// Recurrence table
document.getElementById('recurrence-body').innerHTML = recData.map(r =>
`<tr onclick="window.location='/ip/${encodeURIComponent(String(r.src_ip).replace('::ffff:',''))}'">
<td>${fmtIP(r.src_ip)}</td>
<td class="text-center font-bold text-orange-400">${r.recurrence}</td>
<td>${fmtScore(r.worst_score)}</td>
<td>${threatBadge(r.worst_threat)}</td>
</tr>`).join('') || '<tr><td colspan="4" class="text-center py-4 text-gray-500">Aucune récurrence</td></tr>';
// JA4 table
const botMap = {};
botJa4.forEach(b => { botMap[b.ja4] = b.bot_name; });
ja4Rows = ja4Stats.map(j => [j.ja4, j.sessions||0, j.total_hits||0, j.avg_velocity||0, j.avg_fuzz||0, j.avg_browser_score||0, j.asn_label||'unknown', botMap[j.ja4]||'']);
renderJA4Table();
// ASN table
document.getElementById('asn-body').innerHTML = asns.sort((a,b)=>(b.sessions||0)-(a.sessions||0)).map(a =>
`<tr><td class="text-xs">${fmtASN(a.asn_org)}</td><td>${fmtLabel(a.asn_label)}</td><td>${fmtCountry(a.country_code)}</td>
<td>${(a.sessions||0).toLocaleString()}</td><td>${(a.total_hits||0).toLocaleString()}</td>
<td>${(a.avg_velocity||0).toFixed(3)}</td><td>${(a.avg_fuzz||0).toFixed(3)}</td></tr>`).join('');
// Bot pie
const bpChart = initChart('chart-botpie');
if (bpChart && botJa4.length) {
const byBot = {};
botJa4.forEach(b => { byBot[b.bot_name] = (byBot[b.bot_name]||0)+(b.sessions||0); });
bpChart.setOption(ecBase({
tooltip:ecTooltip({trigger:'item',formatter:'{b}: {c} ({d}%)'}),
series:[{type:'pie',radius:['35%','75%'],center:['50%','55%'],
label:{color:EC_TEXT,fontSize:10,formatter:'{b}\n{d}%'},
data:Object.entries(byBot).map(([n,v],i)=>({name:n,value:v,itemStyle:{color:EC_COLORS[i%EC_COLORS.length]}})).sort((a,b)=>b.value-a.value),
}]
}));
}
} catch(e) { console.error('Network load error:', e); }
}
loadAll();
setInterval(loadAll, 60000);
</script>
{% endblock %}