feat(dashboard): fingerprint discovery page — extract and group JA4/H2/headers from traffic
- GET /api/fingerprint-discovery: queries http_logs, groups by JA4, aggregates UA family, header presence rates (Sec-CH-UA, Sec-Fetch, Accept-Language, zstd, brotli, gzip, XFF), H2 data, TLS info, dict lookups - /fingerprints page: KPIs, doughnut chart by family, stacked header bars, filterable/sortable profile table, expandable detail panel - Promote button: push H2 fingerprints to browser_h2_signatures via existing POST /api/browser-signatures/entries endpoint - Nav link: Découverte added after Navigateurs in sidebar Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -1897,3 +1897,134 @@ async def browser_sig_delete(fingerprint: str = Query(...)) -> dict[str, Any]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("browser_h2_signatures delete failed")
|
logger.exception("browser_h2_signatures delete failed")
|
||||||
raise HTTPException(status_code=500, detail=str(exc))
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/fingerprint-discovery — Extraction et regroupement des fingerprints
|
||||||
|
# du trafic réel pour proposer des signatures navigateur
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/fingerprint-discovery")
|
||||||
|
async def fingerprint_discovery(
|
||||||
|
days: int = Query(default=7, ge=1, le=30),
|
||||||
|
min_hits: int = Query(default=10, ge=1, le=100000),
|
||||||
|
limit: int = Query(default=300, ge=10, le=1000),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Découverte de profils fingerprint depuis http_logs.
|
||||||
|
|
||||||
|
Regroupe par JA4 et agrège : user-agent, headers HTTP,
|
||||||
|
données H2, TLS — pour proposer des signatures navigateur.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
profiles = query(
|
||||||
|
f"SELECT "
|
||||||
|
f" ja4, "
|
||||||
|
# ── Famille navigateur extraite du User-Agent (vote majoritaire) ──
|
||||||
|
f" topK(1)("
|
||||||
|
f" multiIf("
|
||||||
|
f" position(header_user_agent, 'Edg/') > 0, 'Edge', "
|
||||||
|
f" position(header_user_agent, 'OPR/') > 0, 'Opera', "
|
||||||
|
f" position(header_user_agent, 'Chrome/') > 0 AND "
|
||||||
|
f" position(header_user_agent, 'Safari/') > 0, 'Chrome', "
|
||||||
|
f" position(header_user_agent, 'Firefox/') > 0, 'Firefox', "
|
||||||
|
f" position(header_user_agent, 'Safari/') > 0, 'Safari', "
|
||||||
|
f" position(lower(header_user_agent), 'bot') > 0 OR "
|
||||||
|
f" position(lower(header_user_agent), 'crawl') > 0 OR "
|
||||||
|
f" position(lower(header_user_agent), 'spider') > 0, 'Bot', "
|
||||||
|
f" header_user_agent = '', 'Vide', "
|
||||||
|
f" 'Autre'"
|
||||||
|
f" )"
|
||||||
|
f" )[1] AS ua_family, "
|
||||||
|
# ── Volume ──
|
||||||
|
f" count() AS total_hits, "
|
||||||
|
f" uniqExact(src_ip) AS unique_ips, "
|
||||||
|
f" uniqExact(header_user_agent) AS distinct_uas, "
|
||||||
|
# ── Échantillons UA (top 3) ──
|
||||||
|
f" topK(3)(header_user_agent) AS ua_samples, "
|
||||||
|
# ── TLS ──
|
||||||
|
f" any(tls_version) AS tls_version, "
|
||||||
|
f" any(tls_alpn) AS tls_alpn, "
|
||||||
|
# ── H2 ──
|
||||||
|
f" anyIf(h2_fingerprint, h2_fingerprint != '') AS h2_fp, "
|
||||||
|
f" anyIf(h2_settings_fp, h2_settings_fp != '') AS h2_settings, "
|
||||||
|
f" max(h2_window_update) AS h2_wu, "
|
||||||
|
f" anyIf(h2_pseudo_order, h2_pseudo_order != '') AS h2_pseudo, "
|
||||||
|
# ── Taux de présence headers (%) ──
|
||||||
|
f" round(countIf(header_sec_ch_ua != '') * 100.0 / count(), 1) "
|
||||||
|
f" AS pct_sec_ch_ua, "
|
||||||
|
f" round(countIf(header_sec_fetch_mode != '') * 100.0 / count(), 1) "
|
||||||
|
f" AS pct_sec_fetch, "
|
||||||
|
f" round(countIf(header_accept_language != '') * 100.0 / count(), 1) "
|
||||||
|
f" AS pct_accept_lang, "
|
||||||
|
f" round(countIf(position(header_accept_encoding, 'zstd') > 0) "
|
||||||
|
f" * 100.0 / count(), 1) AS pct_zstd, "
|
||||||
|
f" round(countIf(position(header_accept_encoding, 'br') > 0) "
|
||||||
|
f" * 100.0 / count(), 1) AS pct_brotli, "
|
||||||
|
f" round(countIf(position(header_accept_encoding, 'gzip') > 0) "
|
||||||
|
f" * 100.0 / count(), 1) AS pct_gzip, "
|
||||||
|
f" round(countIf(header_x_forwarded_for != '') * 100.0 / count(), 1) "
|
||||||
|
f" AS pct_xff, "
|
||||||
|
# ── Détails Sec-CH-UA ──
|
||||||
|
f" anyIf(header_sec_ch_ua, header_sec_ch_ua != '') AS sec_ch_ua_sample, "
|
||||||
|
f" anyIf(header_sec_ch_ua_platform, header_sec_ch_ua_platform != '') "
|
||||||
|
f" AS platform_sample, "
|
||||||
|
f" anyIf(header_sec_ch_ua_mobile, header_sec_ch_ua_mobile != '') "
|
||||||
|
f" AS mobile_sample, "
|
||||||
|
# ── Accept-Encoding dominant ──
|
||||||
|
f" topK(1)(header_accept_encoding)[1] AS accept_enc_main, "
|
||||||
|
# ── Lookup dictionnaire ──
|
||||||
|
f" dictGetOrDefault('{_DB}.dict_browser_ja4', 'browser_family', "
|
||||||
|
f" tuple(ja4), '') AS dict_family "
|
||||||
|
# ── Source ──
|
||||||
|
f"FROM {_DB_LOGS}.http_logs "
|
||||||
|
"WHERE ja4 != '' AND log_date >= today() - {days:UInt32} "
|
||||||
|
"GROUP BY ja4 "
|
||||||
|
"HAVING count() >= {min_hits:UInt32} "
|
||||||
|
"ORDER BY total_hits DESC "
|
||||||
|
"LIMIT {lim:UInt32}",
|
||||||
|
{"days": days, "min_hits": min_hits, "lim": limit},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("fingerprint-discovery query failed")
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
# ── Regroupement par famille navigateur côté Python ──
|
||||||
|
groups: dict[str, dict[str, Any]] = {}
|
||||||
|
for p in profiles:
|
||||||
|
# Famille prioritaire : dict > UA
|
||||||
|
family = p.get("dict_family") or p.get("ua_family") or "Inconnu"
|
||||||
|
if family not in groups:
|
||||||
|
groups[family] = {
|
||||||
|
"family": family,
|
||||||
|
"ja4_count": 0,
|
||||||
|
"total_hits": 0,
|
||||||
|
"unique_ips": 0,
|
||||||
|
"has_h2": False,
|
||||||
|
"has_sec_ch_ua": False,
|
||||||
|
"has_sec_fetch": False,
|
||||||
|
}
|
||||||
|
g = groups[family]
|
||||||
|
g["ja4_count"] += 1
|
||||||
|
g["total_hits"] += p.get("total_hits", 0)
|
||||||
|
g["unique_ips"] += p.get("unique_ips", 0)
|
||||||
|
if p.get("h2_fp"):
|
||||||
|
g["has_h2"] = True
|
||||||
|
if (p.get("pct_sec_ch_ua") or 0) > 50:
|
||||||
|
g["has_sec_ch_ua"] = True
|
||||||
|
if (p.get("pct_sec_fetch") or 0) > 50:
|
||||||
|
g["has_sec_fetch"] = True
|
||||||
|
|
||||||
|
groups_sorted = sorted(
|
||||||
|
groups.values(), key=lambda g: g["total_hits"], reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"profiles": profiles,
|
||||||
|
"groups": groups_sorted,
|
||||||
|
"meta": {
|
||||||
|
"total_ja4": len(profiles),
|
||||||
|
"total_groups": len(groups_sorted),
|
||||||
|
"days": days,
|
||||||
|
"min_hits": min_hits,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@ -96,3 +96,8 @@ async def health_page(request: Request):
|
|||||||
@router.get("/browsers")
|
@router.get("/browsers")
|
||||||
async def browsers_page(request: Request):
|
async def browsers_page(request: Request):
|
||||||
return templates.TemplateResponse("browsers.html", _ctx(request, "browsers"))
|
return templates.TemplateResponse("browsers.html", _ctx(request, "browsers"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fingerprints")
|
||||||
|
async def fingerprints_page(request: Request):
|
||||||
|
return templates.TemplateResponse("fingerprints.html", _ctx(request, "fingerprints"))
|
||||||
|
|||||||
@ -163,6 +163,10 @@
|
|||||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||||
<span class="nav-text">Navigateurs</span>
|
<span class="nav-text">Navigateurs</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/fingerprints" class="nav-item {% if active_page == 'fingerprints' %}active{% endif %}" title="Découverte de fingerprints (extraction DB)">
|
||||||
|
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||||
|
<span class="nav-text">Découverte</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="nav-group-title">Investigation</div>
|
<div class="nav-group-title">Investigation</div>
|
||||||
<a href="/traffic" class="nav-item {% if active_page == 'traffic' %}active{% endif %}" title="Logs HTTP bruts">
|
<a href="/traffic" class="nav-item {% if active_page == 'traffic' %}active{% endif %}" title="Logs HTTP bruts">
|
||||||
|
|||||||
398
services/dashboard/backend/templates/fingerprints.html
Normal file
398
services/dashboard/backend/templates/fingerprints.html
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block page_title %}Découverte de fingerprints{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="p-4 lg:p-6 space-y-4 max-w-[1920px] mx-auto">
|
||||||
|
|
||||||
|
<!-- ═══ Header + KPIs ═══ -->
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mb-2">
|
||||||
|
<h1 class="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||||
|
Découverte de fingerprints
|
||||||
|
</h1>
|
||||||
|
<div class="ml-auto flex items-center gap-3">
|
||||||
|
<div class="text-center px-3">
|
||||||
|
<div class="text-2xl font-bold text-cyan-400" id="kpi-ja4">—</div>
|
||||||
|
<div class="text-[10px] text-gray-500 uppercase tracking-wider">JA4 distincts</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center px-3 border-l border-gray-700">
|
||||||
|
<div class="text-2xl font-bold text-green-400" id="kpi-groups">—</div>
|
||||||
|
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Familles</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center px-3 border-l border-gray-700">
|
||||||
|
<div class="text-2xl font-bold text-purple-400" id="kpi-h2">—</div>
|
||||||
|
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Avec H2</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center px-3 border-l border-gray-700">
|
||||||
|
<div class="text-2xl font-bold text-yellow-400" id="kpi-hits">—</div>
|
||||||
|
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Requêtes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Filtres ═══ -->
|
||||||
|
<div class="bg-gray-800/60 rounded-lg p-3 flex flex-wrap items-center gap-3">
|
||||||
|
<label class="text-xs text-gray-400">Période</label>
|
||||||
|
<select id="fil-days" class="bg-gray-700 text-white text-xs rounded px-2 py-1">
|
||||||
|
<option value="1">24h</option>
|
||||||
|
<option value="3">3 jours</option>
|
||||||
|
<option value="7" selected>7 jours</option>
|
||||||
|
<option value="14">14 jours</option>
|
||||||
|
<option value="30">30 jours</option>
|
||||||
|
</select>
|
||||||
|
<label class="text-xs text-gray-400">Min hits</label>
|
||||||
|
<input id="fil-min" type="number" value="10" min="1" max="100000"
|
||||||
|
class="bg-gray-700 text-white text-xs rounded px-2 py-1 w-20">
|
||||||
|
<label class="text-xs text-gray-400">Famille</label>
|
||||||
|
<select id="fil-family" class="bg-gray-700 text-white text-xs rounded px-2 py-1">
|
||||||
|
<option value="">Toutes</option>
|
||||||
|
</select>
|
||||||
|
<input id="fil-search" type="text" placeholder="Rechercher JA4 / UA…"
|
||||||
|
class="bg-gray-700 text-white text-xs rounded px-2 py-1 flex-1 min-w-[180px]">
|
||||||
|
<button onclick="loadData()" class="bg-cyan-600 hover:bg-cyan-500 text-white text-xs rounded px-3 py-1 font-semibold">
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Row 1 : Répartition par famille (chart + table) ═══ -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-gray-800/60 rounded-lg p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-3">Répartition par famille</h2>
|
||||||
|
<canvas id="chart-families" height="260"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="lg:col-span-2 bg-gray-800/60 rounded-lg p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-3">Résumé par groupe</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs text-left">
|
||||||
|
<thead class="text-gray-500 uppercase border-b border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1">Famille</th>
|
||||||
|
<th class="px-2 py-1 text-right">JA4</th>
|
||||||
|
<th class="px-2 py-1 text-right">Hits</th>
|
||||||
|
<th class="px-2 py-1 text-right">IPs</th>
|
||||||
|
<th class="px-2 py-1 text-center">H2</th>
|
||||||
|
<th class="px-2 py-1 text-center">Sec-CH-UA</th>
|
||||||
|
<th class="px-2 py-1 text-center">Sec-Fetch</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="groups-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Row 2 : Signaux HTTP headers (chart) ═══ -->
|
||||||
|
<div class="bg-gray-800/60 rounded-lg p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-3">Présence des headers HTTP par JA4 (top 50)</h2>
|
||||||
|
<canvas id="chart-headers" height="100"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Row 3 : Table principale des profils ═══ -->
|
||||||
|
<div class="bg-gray-800/60 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Profils JA4 détaillés</h2>
|
||||||
|
<span class="text-xs text-gray-500" id="profile-count">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-xs text-left">
|
||||||
|
<thead class="text-gray-500 uppercase border-b border-gray-700 sticky top-0 bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1 cursor-pointer hover:text-white" onclick="sortBy('ja4')">JA4</th>
|
||||||
|
<th class="px-2 py-1 cursor-pointer hover:text-white" onclick="sortBy('ua_family')">Famille</th>
|
||||||
|
<th class="px-2 py-1 text-right cursor-pointer hover:text-white" onclick="sortBy('total_hits')">Hits</th>
|
||||||
|
<th class="px-2 py-1 text-right cursor-pointer hover:text-white" onclick="sortBy('unique_ips')">IPs</th>
|
||||||
|
<th class="px-2 py-1 text-right" title="Distinct User-Agents">UAs</th>
|
||||||
|
<th class="px-2 py-1">TLS</th>
|
||||||
|
<th class="px-2 py-1 text-center" title="Sec-CH-UA %">CH-UA</th>
|
||||||
|
<th class="px-2 py-1 text-center" title="Sec-Fetch %">Fetch</th>
|
||||||
|
<th class="px-2 py-1 text-center" title="Accept-Language %">Lang</th>
|
||||||
|
<th class="px-2 py-1 text-center" title="Brotli %">Br</th>
|
||||||
|
<th class="px-2 py-1 text-center" title="Zstd %">Zstd</th>
|
||||||
|
<th class="px-2 py-1">H2 FP</th>
|
||||||
|
<th class="px-2 py-1">H2 WU</th>
|
||||||
|
<th class="px-2 py-1">Pseudo</th>
|
||||||
|
<th class="px-2 py-1" title="Famille dictionnaire">Dict</th>
|
||||||
|
<th class="px-2 py-1">UA principal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="profiles-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Row 4 : Détail expandable (caché par défaut) ═══ -->
|
||||||
|
<div id="detail-panel" class="bg-gray-800/60 rounded-lg p-4 hidden">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">Détail — <span id="detail-ja4" class="text-cyan-400 font-mono"></span></h2>
|
||||||
|
<button onclick="closeDetail()" class="text-gray-500 hover:text-white text-xs">✕ Fermer</button>
|
||||||
|
</div>
|
||||||
|
<div id="detail-content" class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const FAMILY_COLORS = {
|
||||||
|
Chrome: '#4285F4', Edge: '#0078D7', Opera: '#FF1B2D',
|
||||||
|
Firefox: '#FF7139', Safari: '#000000', Bot: '#EF4444',
|
||||||
|
Vide: '#6B7280', Autre: '#9CA3AF', Inconnu: '#6B7280'
|
||||||
|
};
|
||||||
|
|
||||||
|
let allProfiles = [];
|
||||||
|
let allGroups = [];
|
||||||
|
let currentSort = { col: 'total_hits', asc: false };
|
||||||
|
let familiesChart = null;
|
||||||
|
let headersChart = null;
|
||||||
|
|
||||||
|
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
function fmt(n) { return n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n); }
|
||||||
|
function pctBadge(v) {
|
||||||
|
if (v == null) return '<span class="text-gray-600">—</span>';
|
||||||
|
const c = v > 80 ? 'text-green-400' : v > 40 ? 'text-yellow-400' : v > 0 ? 'text-orange-400' : 'text-gray-600';
|
||||||
|
return `<span class="${c}">${v}%</span>`;
|
||||||
|
}
|
||||||
|
function familyBadge(f) {
|
||||||
|
const c = FAMILY_COLORS[f] || '#6B7280';
|
||||||
|
return `<span class="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold" style="background:${c}22;color:${c};border:1px solid ${c}44">${esc(f)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const days = document.getElementById('fil-days').value;
|
||||||
|
const minHits = document.getElementById('fil-min').value;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/fingerprint-discovery?days=${days}&min_hits=${minHits}&limit=500`);
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
const data = await r.json();
|
||||||
|
allProfiles = data.profiles || [];
|
||||||
|
allGroups = data.groups || [];
|
||||||
|
|
||||||
|
document.getElementById('kpi-ja4').textContent = data.meta?.total_ja4 ?? '—';
|
||||||
|
document.getElementById('kpi-groups').textContent = data.meta?.total_groups ?? '—';
|
||||||
|
document.getElementById('kpi-h2').textContent = allProfiles.filter(p => p.h2_fp).length;
|
||||||
|
document.getElementById('kpi-hits').textContent = fmt(allProfiles.reduce((s,p) => s + (p.total_hits||0), 0));
|
||||||
|
|
||||||
|
populateFamilyFilter();
|
||||||
|
renderGroups();
|
||||||
|
renderFamiliesChart();
|
||||||
|
renderHeadersChart();
|
||||||
|
renderProfiles();
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Discovery load failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateFamilyFilter() {
|
||||||
|
const sel = document.getElementById('fil-family');
|
||||||
|
const cur = sel.value;
|
||||||
|
const families = [...new Set(allProfiles.map(p => p.dict_family || p.ua_family || 'Inconnu'))].sort();
|
||||||
|
sel.innerHTML = '<option value="">Toutes</option>' + families.map(f => `<option value="${esc(f)}">${esc(f)}</option>`).join('');
|
||||||
|
sel.value = cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFiltered() {
|
||||||
|
const fam = document.getElementById('fil-family').value;
|
||||||
|
const q = (document.getElementById('fil-search').value || '').toLowerCase();
|
||||||
|
return allProfiles.filter(p => {
|
||||||
|
const family = p.dict_family || p.ua_family || 'Inconnu';
|
||||||
|
if (fam && family !== fam) return false;
|
||||||
|
if (q) {
|
||||||
|
const haystack = [p.ja4, family, ...(p.ua_samples || [])].join(' ').toLowerCase();
|
||||||
|
if (!haystack.includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroups() {
|
||||||
|
const tb = document.getElementById('groups-body');
|
||||||
|
tb.innerHTML = allGroups.map(g => `<tr class="border-b border-gray-700/50 hover:bg-gray-700/30">
|
||||||
|
<td class="px-2 py-1">${familyBadge(g.family)}</td>
|
||||||
|
<td class="px-2 py-1 text-right font-mono">${g.ja4_count}</td>
|
||||||
|
<td class="px-2 py-1 text-right font-mono">${fmt(g.total_hits)}</td>
|
||||||
|
<td class="px-2 py-1 text-right font-mono">${fmt(g.unique_ips)}</td>
|
||||||
|
<td class="px-2 py-1 text-center">${g.has_h2 ? '✓' : '—'}</td>
|
||||||
|
<td class="px-2 py-1 text-center">${g.has_sec_ch_ua ? '✓' : '—'}</td>
|
||||||
|
<td class="px-2 py-1 text-center">${g.has_sec_fetch ? '✓' : '—'}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFamiliesChart() {
|
||||||
|
const labels = allGroups.map(g => g.family);
|
||||||
|
const values = allGroups.map(g => g.ja4_count);
|
||||||
|
const colors = labels.map(l => FAMILY_COLORS[l] || '#6B7280');
|
||||||
|
|
||||||
|
if (familiesChart) familiesChart.destroy();
|
||||||
|
familiesChart = new Chart(document.getElementById('chart-families'), {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: { labels, datasets: [{ data: values, backgroundColor: colors, borderWidth: 0 }] },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'right', labels: { color: '#9CA3AF', font: { size: 10 }, padding: 8 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeadersChart() {
|
||||||
|
const filtered = getFiltered().slice(0, 50);
|
||||||
|
const labels = filtered.map(p => p.ja4?.substring(0,16) || '?');
|
||||||
|
|
||||||
|
const ds = (label, key, color) => ({
|
||||||
|
label, data: filtered.map(p => p[key] || 0),
|
||||||
|
backgroundColor: color, borderWidth: 0, barPercentage: 0.8
|
||||||
|
});
|
||||||
|
|
||||||
|
if (headersChart) headersChart.destroy();
|
||||||
|
headersChart = new Chart(document.getElementById('chart-headers'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
ds('Sec-CH-UA', 'pct_sec_ch_ua', '#4285F488'),
|
||||||
|
ds('Sec-Fetch', 'pct_sec_fetch', '#FF713988'),
|
||||||
|
ds('Accept-Language', 'pct_accept_lang', '#34D39988'),
|
||||||
|
ds('Brotli', 'pct_brotli', '#A78BFA88'),
|
||||||
|
ds('Zstd', 'pct_zstd', '#FBBF2488'),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#6B7280', font: { size: 8 }, maxRotation: 45 }, grid: { display: false } },
|
||||||
|
y: { min: 0, max: 100, ticks: { color: '#6B7280', callback: v => v+'%' }, grid: { color: '#374151' } }
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { labels: { color: '#9CA3AF', font: { size: 10 }, padding: 8 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBy(col) {
|
||||||
|
if (currentSort.col === col) currentSort.asc = !currentSort.asc;
|
||||||
|
else { currentSort.col = col; currentSort.asc = false; }
|
||||||
|
renderProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfiles() {
|
||||||
|
let rows = getFiltered();
|
||||||
|
const { col, asc } = currentSort;
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
let va = a[col], vb = b[col];
|
||||||
|
if (typeof va === 'string') return asc ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||||
|
return asc ? (va||0) - (vb||0) : (vb||0) - (va||0);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('profile-count').textContent = `${rows.length} profils`;
|
||||||
|
const tb = document.getElementById('profiles-body');
|
||||||
|
tb.innerHTML = rows.map(p => {
|
||||||
|
const family = p.dict_family || p.ua_family || 'Inconnu';
|
||||||
|
const ua0 = (p.ua_samples && p.ua_samples[0]) || '';
|
||||||
|
const uaShort = ua0.length > 60 ? ua0.substring(0,57)+'…' : ua0;
|
||||||
|
return `<tr class="border-b border-gray-700/50 hover:bg-gray-700/30 cursor-pointer" onclick="showDetail('${esc(p.ja4)}')">
|
||||||
|
<td class="px-2 py-1 font-mono text-cyan-400 whitespace-nowrap"><a href="/ja4/${esc(p.ja4)}" class="hover:underline" onclick="event.stopPropagation()">${esc(p.ja4?.substring(0,24))}</a></td>
|
||||||
|
<td class="px-2 py-1">${familyBadge(family)}</td>
|
||||||
|
<td class="px-2 py-1 text-right font-mono">${fmt(p.total_hits)}</td>
|
||||||
|
<td class="px-2 py-1 text-right font-mono">${fmt(p.unique_ips)}</td>
|
||||||
|
<td class="px-2 py-1 text-right font-mono text-gray-400">${p.distinct_uas || '—'}</td>
|
||||||
|
<td class="px-2 py-1 text-gray-400 whitespace-nowrap">${esc(p.tls_version||'')} ${p.tls_alpn ? '<span class="text-green-400 text-[10px]">'+esc(p.tls_alpn)+'</span>' : ''}</td>
|
||||||
|
<td class="px-2 py-1 text-center">${pctBadge(p.pct_sec_ch_ua)}</td>
|
||||||
|
<td class="px-2 py-1 text-center">${pctBadge(p.pct_sec_fetch)}</td>
|
||||||
|
<td class="px-2 py-1 text-center">${pctBadge(p.pct_accept_lang)}</td>
|
||||||
|
<td class="px-2 py-1 text-center">${pctBadge(p.pct_brotli)}</td>
|
||||||
|
<td class="px-2 py-1 text-center">${pctBadge(p.pct_zstd)}</td>
|
||||||
|
<td class="px-2 py-1 font-mono text-[10px] text-gray-400 max-w-[120px] truncate" title="${esc(p.h2_fp)}">${esc(p.h2_fp || '—')}</td>
|
||||||
|
<td class="px-2 py-1 font-mono text-gray-400">${p.h2_wu || '—'}</td>
|
||||||
|
<td class="px-2 py-1 font-mono text-gray-400">${esc(p.h2_pseudo || '—')}</td>
|
||||||
|
<td class="px-2 py-1">${p.dict_family ? familyBadge(p.dict_family) : '<span class="text-gray-600">—</span>'}</td>
|
||||||
|
<td class="px-2 py-1 text-gray-400 max-w-[200px] truncate" title="${esc(ua0)}">${esc(uaShort)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(ja4) {
|
||||||
|
const p = allProfiles.find(x => x.ja4 === ja4);
|
||||||
|
if (!p) return;
|
||||||
|
const panel = document.getElementById('detail-panel');
|
||||||
|
const family = p.dict_family || p.ua_family || 'Inconnu';
|
||||||
|
document.getElementById('detail-ja4').textContent = ja4;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
// Colonne 1 : Identité
|
||||||
|
html += '<div class="space-y-2">';
|
||||||
|
html += `<div class="text-gray-400"><strong class="text-white">Famille :</strong> ${familyBadge(family)}</div>`;
|
||||||
|
html += `<div class="text-gray-400"><strong class="text-white">Dict :</strong> ${p.dict_family ? familyBadge(p.dict_family) : 'Non référencé'}</div>`;
|
||||||
|
html += `<div class="text-gray-400"><strong class="text-white">Hits :</strong> ${fmt(p.total_hits)} — <strong class="text-white">IPs :</strong> ${fmt(p.unique_ips)}</div>`;
|
||||||
|
html += `<div class="text-gray-400"><strong class="text-white">TLS :</strong> ${esc(p.tls_version||'—')} — ALPN: ${esc(p.tls_alpn||'—')}</div>`;
|
||||||
|
if (p.sec_ch_ua_sample) html += `<div class="text-gray-400"><strong class="text-white">Sec-CH-UA :</strong> <span class="font-mono text-[10px]">${esc(p.sec_ch_ua_sample)}</span></div>`;
|
||||||
|
if (p.platform_sample) html += `<div class="text-gray-400"><strong class="text-white">Platform :</strong> ${esc(p.platform_sample)}</div>`;
|
||||||
|
if (p.accept_enc_main) html += `<div class="text-gray-400"><strong class="text-white">Accept-Encoding :</strong> <span class="font-mono text-[10px]">${esc(p.accept_enc_main)}</span></div>`;
|
||||||
|
|
||||||
|
html += '<div class="text-gray-400"><strong class="text-white">User-Agents :</strong></div>';
|
||||||
|
html += '<ul class="ml-3 text-[10px] text-gray-500 font-mono space-y-0.5">';
|
||||||
|
(p.ua_samples || []).forEach(ua => { html += `<li class="truncate" title="${esc(ua)}">${esc(ua)}</li>`; });
|
||||||
|
html += '</ul>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Colonne 2 : H2 + Headers
|
||||||
|
html += '<div class="space-y-2">';
|
||||||
|
html += '<div class="text-gray-400"><strong class="text-white">HTTP/2</strong></div>';
|
||||||
|
html += `<div class="ml-3 text-gray-400 text-[10px] font-mono">`;
|
||||||
|
html += `FP: ${esc(p.h2_fp || '—')}<br>`;
|
||||||
|
html += `Settings: ${esc(p.h2_settings || '—')}<br>`;
|
||||||
|
html += `Window Update: ${p.h2_wu || '—'}<br>`;
|
||||||
|
html += `Pseudo-header order: ${esc(p.h2_pseudo || '—')}`;
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
html += '<div class="text-gray-400 mt-2"><strong class="text-white">Headers (taux de présence)</strong></div>';
|
||||||
|
html += '<div class="ml-3 grid grid-cols-2 gap-x-4 gap-y-0.5 text-[10px]">';
|
||||||
|
const hdrs = [
|
||||||
|
['Sec-CH-UA', p.pct_sec_ch_ua], ['Sec-Fetch', p.pct_sec_fetch],
|
||||||
|
['Accept-Language', p.pct_accept_lang], ['Gzip', p.pct_gzip],
|
||||||
|
['Brotli', p.pct_brotli], ['Zstd', p.pct_zstd],
|
||||||
|
['X-Forwarded-For', p.pct_xff],
|
||||||
|
];
|
||||||
|
hdrs.forEach(([l,v]) => { html += `<span class="text-gray-500">${l}</span> ${pctBadge(v)}`; });
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Bouton promouvoir si H2 data disponible
|
||||||
|
if (p.h2_fp) {
|
||||||
|
html += `<div class="mt-3 pt-2 border-t border-gray-700">`;
|
||||||
|
html += `<button onclick="promoteH2('${esc(p.h2_fp)}','${esc(family)}')" class="bg-purple-600 hover:bg-purple-500 text-white text-xs rounded px-3 py-1 font-semibold">`;
|
||||||
|
html += `↑ Promouvoir en signature H2</button>`;
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
document.getElementById('detail-content').innerHTML = html;
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
panel.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
document.getElementById('detail-panel').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promoteH2(fingerprint, family) {
|
||||||
|
if (!confirm(`Ajouter "${fingerprint}" comme signature ${family} ?`)) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/browser-signatures/entries', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ h2_fingerprint: fingerprint, browser_family: family, confidence: 0.8, notes: 'Découvert via fingerprint-discovery' }),
|
||||||
|
});
|
||||||
|
if (!r.ok) { const e = await r.json(); alert(e.detail || 'Erreur'); return; }
|
||||||
|
alert('Signature ajoutée ! Visible dans /browsers');
|
||||||
|
} catch(e) {
|
||||||
|
alert('Erreur réseau : ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtres en temps réel
|
||||||
|
document.getElementById('fil-family').addEventListener('change', () => { renderProfiles(); renderHeadersChart(); });
|
||||||
|
document.getElementById('fil-search').addEventListener('input', () => { renderProfiles(); renderHeadersChart(); });
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user