- Ajoute dict_browser_h2 dans /reflists (lecture seule via dict_browser_h2)
- Nouveaux endpoints API :
GET /api/browser-signatures/entries — liste browser_h2_signatures
(fallback dict CSV si migration 06 non appliquée)
POST /api/browser-signatures/entries — ajout fingerprint + reload dict
DELETE /api/browser-signatures/entries — suppression + reload dict
- Page /browsers : 2 nouvelles sections
'Base de signatures H2' — tableau des 10 fingerprints, form d'ajout,
mode lecture seule automatique si migration 06 non appliquée
'Règles de scoring browser_matcher.py' — tableau statique des 7 dimensions
(poids, valeurs par famille, seuils de bypass)
- Integration : browser_h2.csv copié dans user_files au démarrage ClickHouse
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
643 lines
37 KiB
HTML
643 lines
37 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">
|
||
<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>
|
||
|
||
<!-- ═══ Row 4 : Base de signatures H2 ═══ -->
|
||
<div class="section-card" id="section-sig-h2">
|
||
<div class="section-header flex items-center justify-between">
|
||
<span class="section-title">
|
||
Base de signatures H2
|
||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||
<h4>Table <code>browser_h2_signatures</code></h4>
|
||
<p>Source des fingerprints HTTP/2 (format Akamai) utilisés par
|
||
<code>dict_browser_h2</code>. Le dictionnaire est rechargé automatiquement
|
||
après chaque ajout ou suppression.</p>
|
||
<p>Format : <code>SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_ORDER</code><br>
|
||
Exemple Chrome : <code>1:65536,2:0,4:6291456,6:262144|15663105|0|m,a,s,p</code></p>
|
||
<p class="doc-source">Source : ja4_processing.browser_h2_signatures (migration 06)</p>
|
||
</div></span>
|
||
</span>
|
||
<button onclick="toggleAddForm()" class="text-xs bg-indigo-700 hover:bg-indigo-600 text-white px-3 py-1 rounded font-medium transition-colors">+ Ajouter</button>
|
||
</div>
|
||
|
||
<!-- Formulaire d'ajout (masqué par défaut) -->
|
||
<div id="form-add-sig" class="hidden px-4 py-3 bg-gray-950 border-b border-gray-800">
|
||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 text-xs">
|
||
<div class="md:col-span-2">
|
||
<label class="text-gray-400 block mb-1">H2 Fingerprint (format Akamai)</label>
|
||
<input id="inp-fp" type="text" placeholder="1:65536,2:0,4:6291456,6:262144|15663105|0|m,a,s,p"
|
||
class="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-gray-200 font-mono text-[11px] focus:outline-none focus:border-indigo-500">
|
||
</div>
|
||
<div>
|
||
<label class="text-gray-400 block mb-1">Famille</label>
|
||
<select id="inp-family" class="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500">
|
||
<option value="Chrome">Chrome</option>
|
||
<option value="Firefox">Firefox</option>
|
||
<option value="Safari">Safari</option>
|
||
<option value="Edge">Edge</option>
|
||
<option value="Other">Other</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-gray-400 block mb-1">Confidence (0–1)</label>
|
||
<input id="inp-conf" type="number" step="0.05" min="0" max="1" value="1.0"
|
||
class="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500">
|
||
</div>
|
||
<div class="md:col-span-3">
|
||
<label class="text-gray-400 block mb-1">Notes</label>
|
||
<input id="inp-notes" type="text" placeholder="ex: Chrome 143 beta"
|
||
class="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500">
|
||
</div>
|
||
<div class="flex items-end">
|
||
<button onclick="submitAdd()" class="w-full bg-emerald-700 hover:bg-emerald-600 text-white rounded px-3 py-1.5 text-xs font-medium transition-colors">Enregistrer</button>
|
||
</div>
|
||
</div>
|
||
<div id="add-status" class="mt-2 text-xs hidden"></div>
|
||
</div>
|
||
|
||
<div class="section-body overflow-x-auto">
|
||
<table class="w-full text-xs text-gray-400">
|
||
<thead class="border-b border-gray-800 text-left">
|
||
<tr>
|
||
<th class="px-3 py-2 font-medium">Fingerprint H2 (format Akamai)</th>
|
||
<th class="px-3 py-2 font-medium w-24">Famille</th>
|
||
<th class="px-3 py-2 font-medium w-24 text-center">Confidence</th>
|
||
<th class="px-3 py-2 font-medium">Notes</th>
|
||
<th class="px-3 py-2 font-medium w-16 text-center">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tbl-sigs-body" class="divide-y divide-gray-800/50">
|
||
<tr><td colspan="5" class="px-3 py-6 text-center text-gray-600">Chargement…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ Row 5 : Règles de scoring Python (browser_matcher) ═══ -->
|
||
<div class="section-card">
|
||
<div class="section-header">
|
||
<span class="section-title">
|
||
Règles de scoring — <code>browser_matcher.py</code>
|
||
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">ⓘ</button><div class="doc-panel">
|
||
<h4>Dimensions du scoring navigateur</h4>
|
||
<p>Le browser_matcher calcule un score 0–1 par famille en agrégeant 7 dimensions.
|
||
Ces règles sont définies dans <code>bot_detector/browser_signatures.py</code>.</p>
|
||
<p class="doc-source">Modification : éditer le fichier Python et redéployer bot-detector</p>
|
||
</div></span>
|
||
</span>
|
||
</div>
|
||
<div class="section-body">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-xs text-gray-400">
|
||
<thead class="border-b border-gray-800 text-left">
|
||
<tr>
|
||
<th class="px-3 py-2 font-medium">Dimension</th>
|
||
<th class="px-3 py-2 font-medium text-right w-16">Poids</th>
|
||
<th class="px-3 py-2 font-medium">Chrome</th>
|
||
<th class="px-3 py-2 font-medium">Firefox</th>
|
||
<th class="px-3 py-2 font-medium">Safari</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-800/50 font-mono text-[11px]">
|
||
<tr>
|
||
<td class="px-3 py-2 text-gray-300 font-sans text-xs">H2 SETTINGS exact</td>
|
||
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.30</td>
|
||
<td class="px-3 py-2">1:65536,2:0,4:6291456,6:262144</td>
|
||
<td class="px-3 py-2">1:65536,4:131072,5:16384</td>
|
||
<td class="px-3 py-2">1:4096,3:100,4:65535</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="px-3 py-2 text-gray-300 font-sans text-xs">H2 WINDOW_UPDATE</td>
|
||
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.15</td>
|
||
<td class="px-3 py-2 text-indigo-400">15 663 105</td>
|
||
<td class="px-3 py-2 text-orange-400">12 517 377</td>
|
||
<td class="px-3 py-2 text-cyan-400">10 485 760</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="px-3 py-2 text-gray-300 font-sans text-xs">Pseudo-header order</td>
|
||
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.15</td>
|
||
<td class="px-3 py-2">m,a,s,p</td>
|
||
<td class="px-3 py-2">m,p,s,a</td>
|
||
<td class="px-3 py-2">m,a,s,p</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="px-3 py-2 text-gray-300 font-sans text-xs">HTTP headers cohérence</td>
|
||
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.15</td>
|
||
<td class="px-3 py-2 text-[10px]">Sec-CH-UA ✓ · Sec-Fetch ✓</td>
|
||
<td class="px-3 py-2 text-[10px]">Sec-CH-UA ✗ · Sec-Fetch ✓</td>
|
||
<td class="px-3 py-2 text-[10px]">Sec-CH-UA ✗ · Sec-Fetch ✗</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="px-3 py-2 text-gray-300 font-sans text-xs">H2 PRIORITY frames</td>
|
||
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.10</td>
|
||
<td class="px-3 py-2 text-gray-500">absent</td>
|
||
<td class="px-3 py-2 text-gray-500">absent</td>
|
||
<td class="px-3 py-2 text-gray-500">absent</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="px-3 py-2 text-gray-300 font-sans text-xs">TLS structure (JA4 famille)</td>
|
||
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.10</td>
|
||
<td class="px-3 py-2 text-[10px]">Chromium · Chrome · Edge + GREASE</td>
|
||
<td class="px-3 py-2 text-[10px]">Firefox · pas de GREASE</td>
|
||
<td class="px-3 py-2 text-[10px]">Safari · pas de GREASE</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="px-3 py-2 text-gray-300 font-sans text-xs">JA4 dict lookup</td>
|
||
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.05</td>
|
||
<td colspan="3" class="px-3 py-2 text-gray-500">dict_browser_ja4 — correspondance fingerprint TLS exact</td>
|
||
</tr>
|
||
<tr class="bg-gray-900/50">
|
||
<td class="px-3 py-2 text-gray-300 font-sans font-semibold">Seuil de bypass ML</td>
|
||
<td class="px-3 py-2 text-right text-emerald-400 font-sans font-semibold">—</td>
|
||
<td class="px-3 py-2 text-emerald-400 font-sans">≥ 0.72</td>
|
||
<td class="px-3 py-2 text-emerald-400 font-sans">≥ 0.68</td>
|
||
<td class="px-3 py-2 text-emerald-400 font-sans">≥ 0.68</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p class="mt-3 text-[10px] text-gray-600">Mode actuel : <strong class="text-gray-400">DUAL_MODE</strong> — le matcher journalise les décisions sans modifier le scoring ML.
|
||
Activer le bypass : variable d'environnement <code>BROWSER_MATCHER_REPLACE=true</code> dans bot-detector.</p>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// ─── Gestion des signatures H2 ───────────────────────────────────────────
|
||
const FAM_COLORS = {
|
||
Chrome: 'text-indigo-400', Firefox: 'text-orange-400',
|
||
Safari: 'text-cyan-400', Edge: 'text-purple-400', Other: 'text-gray-400',
|
||
};
|
||
|
||
function toggleAddForm() {
|
||
const el = document.getElementById('form-add-sig');
|
||
el.classList.toggle('hidden');
|
||
}
|
||
|
||
async function loadSignatureEntries() {
|
||
try {
|
||
const r = await fetch('/api/browser-signatures/entries');
|
||
if (!r.ok) throw new Error(r.statusText);
|
||
const data = await r.json();
|
||
const isReadonly = data.readonly === true;
|
||
// Masquer le bouton Ajouter si source = dict CSV (table pas encore créée)
|
||
if (isReadonly) {
|
||
document.querySelector('#section-sig-h2 .section-header button')?.setAttribute('disabled', 'true');
|
||
document.querySelector('#section-sig-h2 .section-header button')?.classList.add('opacity-40', 'cursor-not-allowed');
|
||
}
|
||
renderSigTable(data.entries || [], isReadonly);
|
||
} catch (e) {
|
||
document.getElementById('tbl-sigs-body').innerHTML =
|
||
`<tr><td colspan="5" class="px-3 py-4 text-center text-gray-600">Indisponible</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function renderSigTable(rows, readonly = false) {
|
||
const tbody = document.getElementById('tbl-sigs-body');
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="px-3 py-6 text-center text-gray-600">Aucun fingerprint enregistré</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = rows.map(r => {
|
||
const fc = FAM_COLORS[r.browser_family] || 'text-gray-400';
|
||
const conf = typeof r.confidence === 'number' ? r.confidence.toFixed(2) : (r.confidence || '—');
|
||
const confColor = (r.confidence || 0) >= 0.95 ? 'text-emerald-400' : (r.confidence || 0) >= 0.8 ? 'text-amber-400' : 'text-red-400';
|
||
const fpEsc = encodeURIComponent(r.h2_fingerprint);
|
||
const actionCell = readonly
|
||
? '<td class="px-3 py-2 text-center text-gray-700 text-[10px]">lecture seule</td>'
|
||
: `<td class="px-3 py-2 text-center">
|
||
<button onclick="deleteSig('${fpEsc}')" class="text-red-500 hover:text-red-400 text-[11px] transition-colors" title="Supprimer">✕</button>
|
||
</td>`;
|
||
return `<tr class="hover:bg-gray-800/20 transition-colors">
|
||
<td class="px-3 py-2 font-mono text-[11px] text-gray-300 break-all">${escHtml(r.h2_fingerprint)}</td>
|
||
<td class="px-3 py-2 font-semibold ${fc}">${r.browser_family}</td>
|
||
<td class="px-3 py-2 text-center font-semibold ${confColor}">${conf}</td>
|
||
<td class="px-3 py-2 text-gray-500">${escHtml(r.notes || '')}</td>
|
||
${actionCell}
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function submitAdd() {
|
||
const fp = document.getElementById('inp-fp').value.trim();
|
||
const fam = document.getElementById('inp-family').value;
|
||
const conf = parseFloat(document.getElementById('inp-conf').value);
|
||
const notes = document.getElementById('inp-notes').value.trim();
|
||
const status = document.getElementById('add-status');
|
||
|
||
if (!fp) { showStatus(status, 'Le fingerprint est requis', 'error'); return; }
|
||
|
||
try {
|
||
const r = await fetch('/api/browser-signatures/entries', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ h2_fingerprint: fp, browser_family: fam, confidence: conf, notes }),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) { showStatus(status, data.detail || 'Erreur', 'error'); return; }
|
||
showStatus(status, '✓ Enregistré — dictionnaire rechargé', 'ok');
|
||
document.getElementById('inp-fp').value = '';
|
||
document.getElementById('inp-notes').value = '';
|
||
setTimeout(() => document.getElementById('form-add-sig').classList.add('hidden'), 1500);
|
||
await loadSignatureEntries();
|
||
} catch (e) {
|
||
showStatus(status, e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteSig(fpEncoded) {
|
||
const fp = decodeURIComponent(fpEncoded);
|
||
if (!confirm(`Supprimer ce fingerprint ?\n\n${fp}`)) return;
|
||
try {
|
||
const r = await fetch(`/api/browser-signatures/entries?fingerprint=${fpEncoded}`, { method: 'DELETE' });
|
||
if (!r.ok) { alert('Erreur lors de la suppression'); return; }
|
||
await loadSignatureEntries();
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
|
||
function showStatus(el, msg, type) {
|
||
el.textContent = msg;
|
||
el.className = 'mt-2 text-xs ' + (type === 'ok' ? 'text-emerald-400' : 'text-red-400');
|
||
el.classList.remove('hidden');
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// ─── Chargement des données ───────────────────────────────────────────────
|
||
async function loadBrowserData() {
|
||
let data;
|
||
try {
|
||
const r = await fetch('/api/browser-signatures');
|
||
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();
|
||
loadSignatureEntries();
|
||
</script>
|
||
{% endblock %}
|