Files
ja4-platform/services/dashboard/backend/templates/ip_detail.html
toto 63ba6d203c feat(dashboard): complete SOC dashboard with full monitoring and workflows
- models.html: Full rewrite — 6 KPIs, scoring volume timeline, anomaly rate
  chart, threat breakdown per model, enhanced model cards with validation gate
- classify.html: SOC workflow — suggested unclassified IPs, quick-classify
  buttons, classification stats pie, pre-fill from URL params
- traffic.html: Clickable rows → ip_detail, column sorting, status column,
  search filter, doc tooltips on all chart sections
- scores.html: Search input, clickable rows → ip_detail, LEGITIMATE_BROWSER
  filter button, doc tooltips on distribution + scatter charts
- ip_detail.html: Resource cascade section (headless browser detection),
  status column in HTTP logs table
- detections.html: Doc tooltips on threat/reason/ASN chart sections
- features.html: Doc tooltips on radar/importance/scatter sections
- api.py: 4 new endpoints — /api/models/timeline, /api/models/threats,
  /api/classify/stats, /api/classify/suggested. Traffic API: status + search.

46 routes total. All tests pass (dashboard + bot-detector 36/36).

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

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">&larr; 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 %}