- 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>
273 lines
17 KiB
HTML
273 lines
17 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}JA4 SOC — IP {{ ip }}{% endblock %}
|
|
{% block page_title %}
|
|
<a href="/detections" class="text-gray-500 hover:text-gray-300 text-xs">← Retour</a>
|
|
<span class="mx-2 text-gray-700">/</span>
|
|
Investigation IP : <span class="text-brand-500 font-mono">{{ ip }}</span>
|
|
{% endblock %}
|
|
{% block header_actions %}
|
|
<div class="flex gap-2">
|
|
<select id="cls-select" class="px-2 py-1 bg-gray-900 border border-gray-800 rounded text-xs text-gray-300">
|
|
<option value="bot">🤖 Bot</option><option value="legitimate">✅ Légitime</option><option value="suspicious">⚠️ Suspect</option>
|
|
</select>
|
|
<input type="text" id="cls-comment" placeholder="Commentaire…" class="px-2 py-1 bg-gray-900 border border-gray-800 rounded text-xs text-gray-300 w-40 focus:border-brand-500 focus:outline-none">
|
|
<button id="cls-btn" class="px-3 py-1 bg-brand-600 text-white rounded text-xs font-medium hover:bg-brand-500">Classifier</button>
|
|
<span id="cls-result" class="text-xs self-center"></span>
|
|
</div>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<div class="space-y-4">
|
|
<!-- KPIs -->
|
|
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Détections</div><div class="text-xl font-bold text-red-400" id="ip-det-count">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Pire score</div><div class="text-xl font-bold" id="ip-worst-score">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Récurrence</div><div class="text-xl font-bold text-yellow-400" id="ip-recurrence">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Requêtes HTTP</div><div class="text-xl font-bold text-gray-200" id="ip-http-count">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Scores ML</div><div class="text-xl font-bold text-brand-500" id="ip-score-count">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">ASN / Pays</div><div class="text-sm font-medium" id="ip-asn-info">—</div></div>
|
|
</div>
|
|
|
|
<!-- Radar + Score timeline -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<div class="section-card">
|
|
<div class="section-header"><span class="section-title">Profil comportemental
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
|
<h4>Radar comportemental</h4>
|
|
<p>Compare cette IP aux profils moyens ISP (vert) et datacenter/bot (rouge). Les axes sont normalisés 0→1.</p>
|
|
<p><strong>Interprétation :</strong> Un profil proche du rouge indique un comportement bot. hit_velocity élevé + fuzzing élevé = scraping agressif.</p>
|
|
<p class="doc-source">Source : view_ai_features_1h</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="section-body"><div id="radar-chart" style="height:300px"></div></div>
|
|
</div>
|
|
<div class="section-card">
|
|
<div class="section-header"><span class="section-title">Scores ML dans le temps
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
|
<h4>Historique des scores</h4>
|
|
<p>Évolution du score ML normalisé sur les derniers cycles. Un score stable élevé = bot persistant. Un pic soudain = changement de comportement.</p>
|
|
<p class="doc-source">Source : ml_all_scores</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="section-body"><div id="score-chart" style="height:300px"></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detections -->
|
|
<div class="section-card overflow-hidden">
|
|
<div class="section-header"><span class="section-title">Détections
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
|
<h4>Historique des détections</h4>
|
|
<p>Chaque ligne = une session classée comme anormale. Le score combiné utilise l'ensemble triple-voix (EIF + AE + XGBoost).</p>
|
|
<p class="doc-source">Source : ml_detected_anomalies</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="overflow-x-auto" style="max-height:35vh; overflow-y:auto">
|
|
<table class="data-table"><thead><tr>
|
|
<th>Date</th><th>Score</th><th>Raw</th><th>AE</th><th>XGB</th><th>Threat</th><th>JA4</th><th>Host</th><th>Hits</th><th>Campagne</th>
|
|
</tr></thead><tbody id="det-body"></tbody></table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AI Features grid -->
|
|
<div class="section-card overflow-hidden" id="features-section" style="display:none">
|
|
<div class="section-header"><span class="section-title">Features AI (dernière fenêtre)
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
|
<h4>Features ML détaillées</h4>
|
|
<p>72 features extraites : velocity, fuzzing, entropie, ratios, métriques TLS, etc. Valeurs élevées en rouge indiquent un comportement suspect.</p>
|
|
<p class="doc-source">Source : view_ai_features_1h</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="p-4 grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-2 text-sm" id="features-grid"></div>
|
|
</div>
|
|
|
|
<!-- HTTP Logs -->
|
|
<div class="section-card overflow-hidden">
|
|
<div class="section-header"><span class="section-title">Requêtes HTTP récentes (100 max)
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
|
<h4>Logs HTTP bruts</h4>
|
|
<p>Dernières requêtes de cette IP. Cherchez des patterns : scraping séquentiel, POST répétés, paths suspects, absence de referer.</p>
|
|
<p class="doc-source">Source : http_logs</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="overflow-x-auto" style="max-height:35vh; overflow-y:auto">
|
|
<table class="data-table"><thead><tr>
|
|
<th>Time</th><th>Method</th><th>Status</th><th>Host</th><th>Path</th><th>HTTP</th><th>User-Agent</th><th>JA4</th>
|
|
</tr></thead><tbody id="http-body"></tbody></table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resource cascade -->
|
|
<div class="section-card overflow-hidden" id="cascade-section" style="display:none">
|
|
<div class="section-header"><span class="section-title">Cascade de ressources
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
|
<h4>Analyse de cascade</h4>
|
|
<p>Détection de navigateurs headless : un vrai navigateur charge une page HTML puis ses sous-ressources (CSS, JS, images) avec un délai croissant. Un bot ne charge souvent que la page principale.</p>
|
|
<p><strong>Indicateurs :</strong> page_count=1 + max_sub=0 = bot probable. avg_sub_delay très bas = headless rapide.</p>
|
|
<p class="doc-source">Source : view_resource_cascade_1h</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="overflow-x-auto" style="max-height:25vh; overflow-y:auto">
|
|
<table class="data-table"><thead><tr>
|
|
<th>Fenêtre</th><th>Host</th><th>Pages</th><th>Sub-resources max</th><th>Délai moyen (ms)</th><th>Écart-type (ms)</th>
|
|
</tr></thead><tbody id="cascade-body"></tbody></table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
{% block scripts %}
|
|
<script>
|
|
const IP = {{ ip | tojson }};
|
|
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];
|
|
}
|
|
|
|
async function loadIP() {
|
|
try {
|
|
const [d, radar, cascade] = await Promise.all([
|
|
fetch(`/api/ip/${encodeURIComponent(IP)}`).then(r=>r.json()),
|
|
fetch(`/api/ip/${encodeURIComponent(IP)}/radar`).then(r=>r.json()),
|
|
fetch(`/api/cascade/${encodeURIComponent(IP)}`).then(r=>r.json()),
|
|
]);
|
|
|
|
// KPIs
|
|
document.getElementById('ip-det-count').textContent = d.detections?.length ?? 0;
|
|
document.getElementById('ip-http-count').textContent = d.http_logs?.length ?? 0;
|
|
document.getElementById('ip-score-count').textContent = d.scores?.length ?? 0;
|
|
if (d.recurrence?.length) {
|
|
document.getElementById('ip-recurrence').textContent = d.recurrence[0].recurrence || 0;
|
|
document.getElementById('ip-worst-score').innerHTML = fmtScore(d.recurrence[0].worst_score);
|
|
}
|
|
// ASN info from first detection or score
|
|
const firstRow = d.detections?.[0] || d.scores?.[0] || {};
|
|
if (firstRow.asn_org || firstRow.country_code) {
|
|
document.getElementById('ip-asn-info').innerHTML =
|
|
(firstRow.asn_org ? fmtASN(firstRow.asn_org) : '') +
|
|
(firstRow.country_code ? ' ' + fmtCountry(firstRow.country_code) : '');
|
|
}
|
|
|
|
// Radar
|
|
if (radar.features?.length && Object.keys(radar.ip_values).length) {
|
|
const labels = radar.features.map(f => f.replace(/_/g,' '));
|
|
const ipVals = radar.features.map(f => radar.ip_values[f] ?? 0);
|
|
const humanVals = radar.features.map(f => radar.human_baseline[f] ?? 0);
|
|
const botVals = radar.features.map(f => radar.bot_baseline[f] ?? 0);
|
|
const maxVals = radar.features.map((_,i) => Math.max(ipVals[i], humanVals[i], botVals[i], 0.001));
|
|
const norm = arr => arr.map((v,i) => +(v/maxVals[i]).toFixed(3));
|
|
const ch = initChart('radar-chart');
|
|
ch.setOption(ecBase({
|
|
tooltip: ecTooltip({}),
|
|
legend: {data:['Cette IP','ISP moyen','Bot moyen'], bottom:0, textStyle:{color:EC_TEXT,fontSize:10}},
|
|
radar: {
|
|
indicator: labels.map(() => ({max:1})),
|
|
shape:'polygon', radius:'65%',
|
|
splitArea:{areaStyle:{color:['rgba(99,102,241,0.02)','rgba(99,102,241,0.04)']}},
|
|
splitLine:{lineStyle:{color:EC_GRID}}, axisLine:{lineStyle:{color:EC_GRID}},
|
|
axisName:{color:EC_TEXT,fontSize:9,formatter:(_,i)=>labels[i.dimensionIndex]||''},
|
|
},
|
|
series: [{type:'radar', data: [
|
|
{value:norm(ipVals), name:'Cette IP', lineStyle:{color:'#f97316',width:2}, areaStyle:{color:'rgba(249,115,22,0.15)'}, itemStyle:{color:'#f97316'}},
|
|
{value:norm(humanVals), name:'ISP moyen', lineStyle:{color:'#22c55e',width:1,type:'dashed'}, areaStyle:{color:'rgba(34,197,94,0.05)'}, itemStyle:{color:'#22c55e'}},
|
|
{value:norm(botVals), name:'Bot moyen', lineStyle:{color:'#ef4444',width:1,type:'dashed'}, areaStyle:{color:'rgba(239,68,68,0.05)'}, itemStyle:{color:'#ef4444'}},
|
|
]}]
|
|
}));
|
|
}
|
|
|
|
// Score timeline
|
|
if (d.scores?.length) {
|
|
const scores = [...d.scores].reverse();
|
|
const ch = initChart('score-chart');
|
|
ch.setOption(ecBase({
|
|
tooltip: ecTooltip({trigger:'axis'}),
|
|
grid: ecGrid(),
|
|
xAxis: {type:'category', data:scores.map(s=>(s.detected_at||'').substring(11,16)), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT,fontSize:10}},
|
|
yAxis: {type:'value', min:0, max:1, splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
|
|
series: [{
|
|
type:'line', data:scores.map(s=>s.anomaly_score), smooth:true,
|
|
areaStyle:{color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:'rgba(99,102,241,0.3)'},{offset:1,color:'rgba(99,102,241,0.02)'}])},
|
|
lineStyle:{color:'#6366f1',width:2}, itemStyle:{color:'#6366f1'}, symbol:'circle', symbolSize:4,
|
|
}]
|
|
}));
|
|
}
|
|
|
|
// Detections table
|
|
document.getElementById('det-body').innerHTML = (d.detections||[]).map(row => `<tr>
|
|
<td class="text-[11px] whitespace-nowrap text-gray-400">${(row.detected_at||'').substring(0,16)}</td>
|
|
<td>${fmtScore(row.anomaly_score)}</td>
|
|
<td class="font-mono text-[11px] text-gray-400">${parseFloat(row.raw_anomaly_score||0).toFixed(4)}</td>
|
|
<td class="font-mono text-[11px] text-gray-400">${parseFloat(row.ae_recon_error||0).toFixed(4)}</td>
|
|
<td class="font-mono text-[11px] text-gray-400">${parseFloat(row.xgb_prob||0).toFixed(4)}</td>
|
|
<td>${threatBadge(row.threat_level)}</td>
|
|
<td>${fmtJA4(row.ja4)}</td>
|
|
<td class="text-xs max-w-[100px] truncate">${escapeHtml(row.host||'')}</td>
|
|
<td class="font-mono text-xs">${row.hits||0}</td>
|
|
<td class="text-[11px] text-purple-400">${row.campaign_id && row.campaign_id!=='0' ? '#'+escapeHtml(String(row.campaign_id).substring(0,8)) : ''}</td>
|
|
</tr>`).join('') || '<tr><td colspan="10" class="text-center text-gray-500 py-4">Aucune détection</td></tr>';
|
|
|
|
// AI Features
|
|
if (d.ai_features?.length) {
|
|
const f = d.ai_features[0];
|
|
const skip = new Set(['src_ip','window_start','ja4','host','bot_name','src_ip_str']);
|
|
document.getElementById('features-grid').innerHTML = Object.entries(f).filter(([k])=>!skip.has(k)).map(([k,v]) => {
|
|
let val = typeof v === 'number' ? v.toFixed(4) : v;
|
|
let color = 'text-gray-200';
|
|
if (typeof v === 'number' && v > 0.7) color = 'text-red-400';
|
|
else if (typeof v === 'number' && v > 0.4) color = 'text-orange-400';
|
|
let display = `<span class="${color} font-mono text-xs">${val}</span>`;
|
|
if (k === 'asn_org' && v) display = fmtASN(v);
|
|
else if (k === 'country_code' && v) display = fmtCountry(v);
|
|
else if (k === 'asn_label' && v) display = fmtLabel(v);
|
|
return `<div class="bg-gray-800/50 rounded p-2"><div class="text-[9px] text-gray-500 truncate">${escapeHtml(k)}</div>${display}</div>`;
|
|
}).join('');
|
|
document.getElementById('features-section').style.display = '';
|
|
}
|
|
|
|
// HTTP logs
|
|
const sc = s => s>=500?'text-red-400':s>=400?'text-orange-400':s>=300?'text-yellow-400':'text-green-400';
|
|
document.getElementById('http-body').innerHTML = (d.http_logs||[]).map(row => `<tr>
|
|
<td class="text-[11px] whitespace-nowrap text-gray-400">${row.time||''}</td>
|
|
<td><span class="font-mono text-xs ${row.method==='POST'?'text-orange-400':'text-gray-300'}">${escapeHtml(row.method||'')}</span></td>
|
|
<td class="${sc(row.status||0)} font-mono text-[11px]">${row.status||''}</td>
|
|
<td class="text-xs max-w-[100px] truncate">${escapeHtml(row.host||'')}</td>
|
|
<td class="text-xs max-w-[200px] truncate font-mono" title="${escapeHtml(row.path||'')}">${escapeHtml(row.path||'')}</td>
|
|
<td class="font-mono text-[11px] text-gray-400">${escapeHtml(row.http_version||'')}</td>
|
|
<td class="text-xs max-w-[180px] truncate text-gray-400">${escapeHtml(row.header_user_agent||'')}</td>
|
|
<td class="font-mono text-[11px]">${escapeHtml(row.ja4||'')}</td>
|
|
</tr>`).join('') || '<tr><td colspan="8" class="text-center text-gray-500 py-4">Aucun log</td></tr>';
|
|
|
|
// Cascade
|
|
const cascadeRows = cascade.data || [];
|
|
if (cascadeRows.length) {
|
|
document.getElementById('cascade-section').style.display = '';
|
|
document.getElementById('cascade-body').innerHTML = cascadeRows.map(row => `<tr>
|
|
<td class="text-[11px] whitespace-nowrap text-gray-400">${(row.window_start||'').substring(0,16)}</td>
|
|
<td class="text-xs">${escapeHtml(row.host||'')}</td>
|
|
<td class="font-mono text-xs">${row.page_count||0}</td>
|
|
<td class="font-mono text-xs">${row.max_sub_resources||0}</td>
|
|
<td class="font-mono text-xs ${(row.avg_sub_delay_ms||0)<50?'text-red-400':'text-gray-300'}">${(row.avg_sub_delay_ms||0).toFixed(0)}</td>
|
|
<td class="font-mono text-xs">${(row.stddev_sub_delay_ms||0).toFixed(0)}</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
} catch(e) { console.error(e); }
|
|
}
|
|
|
|
document.getElementById('cls-btn').onclick = async () => {
|
|
try {
|
|
const r = await fetch('/api/classify', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({src_ip:IP, classification:document.getElementById('cls-select').value, comment:document.getElementById('cls-comment').value})});
|
|
const d = await r.json();
|
|
document.getElementById('cls-result').innerHTML = r.ok
|
|
? `<span class="text-green-400">✓ ${escapeHtml(d.classification)}</span>`
|
|
: `<span class="text-red-400">✗ ${escapeHtml(d.detail||'erreur')}</span>`;
|
|
} catch(e) { document.getElementById('cls-result').innerHTML = `<span class="text-red-400">✗ erreur</span>`; }
|
|
};
|
|
|
|
loadIP();
|
|
</script>
|
|
{% endblock %}
|