Files
ja4-platform/services/dashboard/backend/templates/browsers.html
toto 9c308747bd feat(dashboard): page Browser Signature Detection (/browsers)
Nouvelle page dédiée à l'analyse passive des signatures navigateur (§4) :

API — GET /api/browsers :
  Requête view_ai_features_1h pour :
  - Compteurs globaux (total, sessions_with_h2, matched, mismatch %)
  - Distribution h2_dict_family (Chrome/Firefox/Safari/Edge)
  - Répartition des signaux WINDOW_UPDATE (chrome/firefox/safari/absent/autre)
  - Mismatch TLS↔H2 par famille JA4 (total + count + %)
  - Top 20 sessions suspectes (tls_h2_family_mismatch=1, triées par hits)

Page /browsers :
  - 6 KPI header (sessions, avec H2, famille connue, taux match, mismatch, % mismatch)
  - Doc banner expliquant browser_matcher §4 et le mode DUAL_MODE
  - Donut : familles H2 (dict_browser_h2 lookup)
  - Bar horizontal : WINDOW_UPDATE signals par famille
  - Bar groupé + ligne : mismatch TLS↔H2 par famille JA4 (count + %)
  - Table : top 20 imposteurs potentiels avec IP cliquable, pseudo-order, cohérence
  - Mini-KPIs : ordres pseudo-headers Chrome/Safari, Firefox, inconnu, PRIORITY frames
  - Lien nav 'Navigateurs' dans le groupe Surveillance de base.html

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 14:02:39 +02:00

382 lines
21 KiB
HTML

{% extends "base.html" %}
{% block title %}JA4 SOC — Browser Signatures{% endblock %}
{% block page_title %}
Browser Signature Detection
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Détection passive des navigateurs (§4)</h4>
<p>Analyse croisée du fingerprint HTTP/2 (SETTINGS, WINDOW_UPDATE, pseudo-headers)
et du JA4 TLS pour identifier les vrais navigateurs et détecter les imposteurs.</p>
<p><strong>TLS↔H2 mismatch :</strong> sessions où le JA4 identifie une famille
(ex: Chromium) mais les SETTINGS H2 appartiennent à une autre (ex: Firefox).
Signal fort d'un outil qui émule un TLS de navigateur sans répliquer le H2.</p>
<p class="doc-source">Source : view_ai_features_1h (dict_browser_h2, h2_window_*)</p>
</div></span>
{% endblock %}
{% block content %}
<div class="p-4 lg:p-6 space-y-4 max-w-[1920px] mx-auto">
<!-- ═══ KPI Header ═══ -->
<div class="flex flex-wrap items-center gap-6 mb-2">
<div class="text-center px-3">
<div class="text-2xl font-bold text-white" id="kpi-total"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Sessions (24h)</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-indigo-400" id="kpi-h2"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Avec données H2</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-emerald-400" id="kpi-matched"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Famille H2 connue</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-amber-400" id="kpi-match-rate"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Taux match H2</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-red-400" id="kpi-mismatch"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Mismatch TLS↔H2</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-red-300" id="kpi-mismatch-rate"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">% Mismatch / H2</div>
</div>
</div>
<!-- ═══ Doc banner ═══ -->
<div class="bg-gray-900/50 border border-gray-800 rounded-lg px-4 py-3 text-xs text-gray-400 leading-relaxed">
<strong class="text-indigo-300">Browser Signature Detection §4</strong> — Le moteur
<code class="text-indigo-200">browser_matcher</code> score chaque session sur 7 dimensions :
<strong>H2 SETTINGS</strong> (0.30), <strong>WINDOW_UPDATE</strong> (0.15),
<strong>pseudo-headers</strong> (0.15), H2 PRIORITY (0.10), HTTP headers (0.15),
structure TLS (0.10), dictionnaire JA4 (0.05).
Un <strong class="text-amber-300">mismatch TLS↔H2</strong> est détecté quand le JA4 (couche TLS)
identifie Chrome mais le WINDOW_UPDATE H2 est celui de Firefox (ou vice-versa) — signature
d'un outil qui copie le TLS sans répliquer fidèlement le H2.
En mode <strong>DUAL_MODE</strong> (défaut), les décisions sont journalisées sans modifier le bypass —
activer <code class="text-green-300">BROWSER_MATCHER_REPLACE=true</code> pour basculer.
</div>
<!-- ═══ Row 1 : Familles H2 + WINDOW_UPDATE ═══ -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Donut : distribution des familles H2 -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Familles détectées — dict H2
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Familles H2 (dict_browser_h2)</h4>
<p>Correspondance du fingerprint SETTINGS H2 complet (format Akamai)
avec le dictionnaire <code>dict_browser_h2</code>. Une correspondance
exacte identifie Chrome, Firefox, Safari ou Edge avec un haut degré
de certitude.</p>
<p class="doc-source">Source : view_ai_features_1h.h2_dict_family</p>
</div></span>
</span>
</div>
<div class="section-body">
<div id="chart-families" style="height:320px"></div>
</div>
</div>
<!-- Bar : WINDOW_UPDATE signals -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Signal WINDOW_UPDATE H2
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Valeur WINDOW_UPDATE par famille</h4>
<p>La valeur du frame WINDOW_UPDATE est l'empreinte H2 la plus fiable car
chaque navigateur utilise une valeur distincte et constante :</p>
<ul class="list-disc ml-4 mt-1">
<li>Chrome : 15 663 105</li>
<li>Firefox : 12 517 377</li>
<li>Safari : 10 485 760</li>
<li>curl/httpx : absent (0)</li>
</ul>
<p class="doc-source">Source : view_ai_features_1h.h2_window_*</p>
</div></span>
</span>
</div>
<div class="section-body">
<div id="chart-wu-signals" style="height:320px"></div>
</div>
</div>
</div>
<!-- ═══ Row 2 : Mismatch par famille + table suspects ═══ -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Bar : mismatch par famille JA4 -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Mismatch TLS↔H2 par famille JA4
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Incohérences cross-layer</h4>
<p>Proportion de sessions dont la famille JA4 (TLS) contredit la famille H2.
Un taux élevé pour "Chromium" indique des outils qui imitent Chrome TLS
sans reproduire le comportement H2.</p>
<p><strong>Exemples de mismatch :</strong></p>
<ul class="list-disc ml-4 mt-1">
<li>JA4=Chromium + WU=12517377 → Firefox H2</li>
<li>JA4=Firefox + WU=15663105 → Chrome H2</li>
<li>JA4=Navigateur + WU absent → outil sans H2</li>
</ul>
<p class="doc-source">Source : view_ai_features_1h.tls_h2_family_mismatch</p>
</div></span>
</span>
</div>
<div class="section-body">
<div id="chart-mismatch" style="height:320px"></div>
</div>
</div>
<!-- Sessions suspectes avec mismatch -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Sessions suspectes (mismatch confirmé)
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Imposteurs potentiels</h4>
<p>Sessions dont le JA4 (TLS) et les SETTINGS H2 identifient des familles
différentes — signal fort d'un outil qui émule le TLS d'un navigateur
mais trahit son origine via le H2.</p>
<p><strong>Action :</strong> Investiguer l'IP, vérifier le JA4 dans la page
Détections, ou ajouter à la liste de blocage.</p>
</div></span>
</span>
</div>
<div class="section-body p-0 overflow-x-auto">
<table class="w-full text-xs text-left" id="tbl-suspects">
<thead class="text-gray-500 border-b border-gray-800">
<tr>
<th class="px-3 py-2 font-medium">IP</th>
<th class="px-3 py-2 font-medium">JA4 famille</th>
<th class="px-3 py-2 font-medium">H2 famille</th>
<th class="px-3 py-2 font-medium">WU value</th>
<th class="px-3 py-2 font-medium">Pseudo-order</th>
<th class="px-3 py-2 font-medium text-right">Hits</th>
<th class="px-3 py-2 font-medium text-right">Cohérence</th>
</tr>
</thead>
<tbody id="tbl-suspects-body" class="divide-y divide-gray-800/50">
<tr><td colspan="7" class="px-3 py-6 text-center text-gray-600">Chargement…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ Row 3 : Pseudo-header orders ═══ -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Distribution des ordres pseudo-headers H2
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Ordre des pseudo-headers HTTP/2</h4>
<p>L'ordre des pseudo-headers (<code>:method</code>, <code>:authority</code>,
<code>:scheme</code>, <code>:path</code>) est spécifique à chaque navigateur :</p>
<ul class="list-disc ml-4 mt-1">
<li><strong>m,a,s,p</strong> — Chrome et Safari</li>
<li><strong>m,p,s,a</strong> — Firefox</li>
</ul>
<p>Un ordre non répertorié indique un outil ou une version rare.</p>
<p class="doc-source">Source : view_ai_features_1h (h2_order_chromesafari, h2_order_firefox)</p>
</div></span>
</span>
</div>
<div class="section-body">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- mini KPIs pour les ordres pseudo-headers -->
<div class="bg-gray-900 rounded-lg px-4 py-3 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Chrome / Safari — <code>m,a,s,p</code></div>
<div class="text-2xl font-bold text-indigo-400" id="pseudo-chromesafari"></div>
<div class="text-[10px] text-gray-600 mt-1">sessions</div>
</div>
<div class="bg-gray-900 rounded-lg px-4 py-3 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Firefox — <code>m,p,s,a</code></div>
<div class="text-2xl font-bold text-orange-400" id="pseudo-firefox"></div>
<div class="text-[10px] text-gray-600 mt-1">sessions</div>
</div>
<div class="bg-gray-900 rounded-lg px-4 py-3 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">H2 présent — ordre non répertorié</div>
<div class="text-2xl font-bold text-gray-400" id="pseudo-other"></div>
<div class="text-[10px] text-gray-600 mt-1">sessions (outil probable)</div>
</div>
<div class="bg-gray-900 rounded-lg px-4 py-3 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">H2 PRIORITY frames présents</div>
<div class="text-2xl font-bold text-yellow-400" id="pseudo-priority"></div>
<div class="text-[10px] text-gray-600 mt-1">sessions (Firefox ancien)</div>
</div>
</div>
</div>
</div>
</div>
<script>
// ─── Chargement des données ───────────────────────────────────────────────
async function loadBrowserData() {
let data;
try {
const r = await fetch('/api/browsers');
if (!r.ok) throw new Error(r.statusText);
data = await r.json();
} catch (e) {
console.error('browsers API error', e);
return;
}
// KPIs
const s = data.stats || {};
setText('kpi-total', fmt(s.total_sessions));
setText('kpi-h2', fmt(s.sessions_with_h2));
setText('kpi-matched', fmt(s.sessions_matched));
setText('kpi-match-rate', (s.match_rate ?? '—') + (s.match_rate != null ? '%' : ''));
setText('kpi-mismatch', fmt(s.sessions_mismatch));
setText('kpi-mismatch-rate', (s.mismatch_rate ?? '—') + (s.mismatch_rate != null ? '%' : ''));
// Donut — familles H2
renderFamiliesDonut(data.h2_families || []);
// Bar — WINDOW_UPDATE signals
renderWuBar(data.h2_window_signals || []);
// Bar — mismatch par famille
renderMismatchBar(data.mismatch_by_family || []);
// Table suspects
renderSuspects(data.top_mismatches || []);
// Pseudo-header counters (déduire depuis mismatch_by_family stats + wu_signals)
// On recalcule depuis les familles et les sessions_with_h2
const totalH2 = s.sessions_with_h2 || 0;
const wuData = data.h2_window_signals || [];
const chrome = (wuData.find(r => r.signal.startsWith('Chrome')) || {}).sessions || 0;
const firefox = (wuData.find(r => r.signal.startsWith('Firefox')) || {}).sessions || 0;
const safari = (wuData.find(r => r.signal.startsWith('Safari')) || {}).sessions || 0;
const absent = (wuData.find(r => r.signal.startsWith('Absent')) || {}).sessions || 0;
const other = (wuData.find(r => r.signal.startsWith('Autre')) || {}).sessions || 0;
// h2_order_chromesafari ≈ chrome + safari (partagent m,a,s,p)
setText('pseudo-chromesafari', fmt(chrome + safari));
setText('pseudo-firefox', fmt(firefox));
setText('pseudo-other', fmt(other));
// Pour h2_priority_present on n'a pas le chiffre direct — afficher N/A
setText('pseudo-priority', '—');
}
// ─── Graphiques ──────────────────────────────────────────────────────────
function renderFamiliesDonut(rows) {
const chart = echarts.init(document.getElementById('chart-families'), 'dark');
if (!rows.length) { chart.setOption({ title: { text: 'Aucune donnée H2', left: 'center', top: 'center', textStyle: { color: '#6b7280', fontSize: 13 } } }); return; }
const COLORS = { Chrome: '#6366f1', Firefox: '#f97316', Safari: '#22d3ee', Edge: '#8b5cf6', '': '#374151' };
const data = rows.map(r => ({
name: r.family || 'Inconnu',
value: r.sessions,
itemStyle: { color: COLORS[r.family] || '#64748b' }
}));
chart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c} sessions ({d}%)' },
legend: { orient: 'vertical', right: 10, top: 'center', textStyle: { color: '#9ca3af', fontSize: 11 } },
series: [{
type: 'pie', radius: ['45%', '72%'], center: ['40%', '50%'],
avoidLabelOverlap: true,
label: { show: false },
emphasis: { label: { show: true, fontSize: 13, fontWeight: 'bold' } },
data
}]
});
window.addEventListener('resize', () => chart.resize());
}
function renderWuBar(rows) {
const chart = echarts.init(document.getElementById('chart-wu-signals'), 'dark');
if (!rows.length) { chart.setOption({ title: { text: 'Aucune donnée H2', left: 'center', top: 'center', textStyle: { color: '#6b7280', fontSize: 13 } } }); return; }
const COLORS = ['#6366f1', '#f97316', '#22d3ee', '#ef4444', '#6b7280'];
chart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '2%', right: '4%', bottom: '3%', top: '4%', containLabel: true },
xAxis: { type: 'value', axisLine: { lineStyle: { color: '#374151' } }, axisLabel: { color: '#6b7280', fontSize: 10 } },
yAxis: { type: 'category', data: rows.map(r => r.signal), axisLabel: { color: '#9ca3af', fontSize: 10 }, axisLine: { lineStyle: { color: '#374151' } } },
series: [{
type: 'bar', barMaxWidth: 32,
data: rows.map((r, i) => ({ value: r.sessions, itemStyle: { color: COLORS[i % COLORS.length] } })),
label: { show: true, position: 'right', color: '#9ca3af', fontSize: 10, formatter: v => fmt(v.value) }
}]
});
window.addEventListener('resize', () => chart.resize());
}
function renderMismatchBar(rows) {
const chart = echarts.init(document.getElementById('chart-mismatch'), 'dark');
if (!rows.length) { chart.setOption({ title: { text: 'Aucune donnée de mismatch', left: 'center', top: 'center', textStyle: { color: '#6b7280', fontSize: 13 } } }); return; }
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: params => {
const r = rows[params[0].dataIndex];
return `${r.ja4_family}<br/>Mismatch : ${fmt(r.mismatches)} / ${fmt(r.total)} (${r.mismatch_pct}%)`;
}
},
grid: { left: '2%', right: '4%', bottom: '3%', top: '4%', containLabel: true },
xAxis: { type: 'category', data: rows.map(r => r.ja4_family), axisLabel: { color: '#9ca3af', fontSize: 10 }, axisLine: { lineStyle: { color: '#374151' } } },
yAxis: [
{ type: 'value', name: 'Sessions', nameTextStyle: { color: '#6b7280', fontSize: 10 }, axisLabel: { color: '#6b7280', fontSize: 10 }, axisLine: { lineStyle: { color: '#374151' } } },
{ type: 'value', name: '% mismatch', max: 100, nameTextStyle: { color: '#ef4444', fontSize: 10 }, axisLabel: { color: '#ef4444', fontSize: 10, formatter: '{value}%' }, axisLine: { lineStyle: { color: '#374151' } } }
],
series: [
{ name: 'Total', type: 'bar', barMaxWidth: 40, itemStyle: { color: '#374151' }, data: rows.map(r => r.total) },
{ name: 'Mismatch', type: 'bar', barMaxWidth: 40, itemStyle: { color: '#ef4444' }, data: rows.map(r => r.mismatches) },
{ name: '% mismatch', type: 'line', yAxisIndex: 1, symbol: 'circle', symbolSize: 6, lineStyle: { color: '#fbbf24' }, itemStyle: { color: '#fbbf24' }, data: rows.map(r => r.mismatch_pct) }
],
legend: { textStyle: { color: '#9ca3af', fontSize: 10 }, top: 0 }
});
window.addEventListener('resize', () => chart.resize());
}
function renderSuspects(rows) {
const tbody = document.getElementById('tbl-suspects-body');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="7" class="px-3 py-6 text-center text-gray-600">Aucun mismatch détecté dans les 24 dernières heures</td></tr>';
return;
}
const WU_LABELS = {
15663105: '<span class="text-indigo-400">Chrome</span>',
12517377: '<span class="text-orange-400">Firefox</span>',
10485760: '<span class="text-cyan-400">Safari</span>',
1073676289: '<span class="text-gray-400">go net/http</span>',
0: '<span class="text-red-400">absent</span>',
};
tbody.innerHTML = rows.map(r => {
const wuLabel = WU_LABELS[r.wu_value] || `<span class="text-gray-400">${r.wu_value}</span>`;
const coh = r.coherence != null ? r.coherence.toFixed(2) : '—';
const cohColor = r.coherence < 0.4 ? 'text-red-400' : r.coherence < 0.6 ? 'text-amber-400' : 'text-emerald-400';
const h2fam = r.h2_dict_family || '<span class="text-gray-600">inconnu</span>';
return `<tr class="hover:bg-gray-800/30 transition-colors">
<td class="px-3 py-2 font-mono text-indigo-300"><a href="/ip/${r.ip}" class="hover:underline">${r.ip}</a></td>
<td class="px-3 py-2 text-gray-300">${r.ja4_family || '—'}</td>
<td class="px-3 py-2 text-amber-300">${h2fam}</td>
<td class="px-3 py-2">${wuLabel}</td>
<td class="px-3 py-2 font-mono text-gray-400">${r.pseudo_order || '—'}</td>
<td class="px-3 py-2 text-right text-gray-300">${fmt(r.hits)}</td>
<td class="px-3 py-2 text-right font-semibold ${cohColor}">${coh}</td>
</tr>`;
}).join('');
}
// ─── Utils ────────────────────────────────────────────────────────────────
function setText(id, val) { const el = document.getElementById(id); if (el) el.textContent = val ?? '—'; }
function fmt(n) { return n == null ? '—' : Number(n).toLocaleString('fr-FR'); }
loadBrowserData();
</script>
{% endblock %}