Files
ja4-platform/docs/thesis/04_browser_matcher.md
Jacquin Antoine 0e5f94dd0d docs: restructure thesis into chapter files with corrected references
Split monolithic thesis into separate chapter markdown files under
docs/thesis/. Remove fabricated bibliography entries, correct inflated
claims, add GNN/Transformers section, and rename MetaLearner to Fusion LR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 13:51:38 +02:00

32 KiB
Raw Blame History

<< Sommaire | Suivant >>


3.9 Browser Signature Detection (browser_matcher)

[impl.] Statut global : capture H2, scoring statique et profiling dynamique entièrement implémentés

Le système browser_confidence à 6 axes (§3.8) fournit un score agrégé utile comme feature ML, mais sa logique de bypass (seuil 0,55) manque de précision face aux outils d'évasion modernes — notamment httpcloak, curl_cffi et python-requests patchés — qui reproduisent les couches TLS et HTTP/1.1 sans reproduire les subtilités HTTP/2. browser_matcher adresse cette lacune en implémentant une correspondance structurée par famille de navigateur, fondée sur l'empreinte passive du protocole HTTP/2. Le module de scoring à 7 dimensions pondérées (browser_matcher.py, 497 lignes) et la base de signatures Chrome/Firefox/Safari (browser_signatures.py, 165 lignes) sont entièrement opérationnels. En complément, un moteur de profiling dynamique automatique (profile_builder.py, 614 lignes et browser_matcher_dynamic.py, 387 lignes) apprend les signatures des navigateurs à partir des logs réels via HDBSCAN, éliminant la dépendance aux signatures codées en dur et s'adaptant automatiquement aux nouvelles versions de navigateurs (§3.9.6).

3.9.1 Principes du fingerprinting HTTP/2 passif

Rappel du protocole HTTP/2

HTTP/2 (RFC 7540) est un protocole binaire, multiplexé, qui remplace la communication textuelle d'HTTP/1.1. Ses principales caractéristiques architecturales pertinentes pour le fingerprinting sont :

  • Framing binaire : toute communication est découpée en frames (trames) de type défini. Les types principaux sont SETTINGS, HEADERS, DATA, WINDOW_UPDATE, PRIORITY, PUSH_PROMISE, PING, GOAWAY.
  • Multiplexage de streams : plusieurs échanges requête/réponse coexistent sur une seule connexion TCP, identifiés par un stream ID entier.
  • Compression d'en-têtes HPACK : RFC 7541 définit HPACK, une compression d'en-têtes HTTP par table d'indexation. L'ordre des entrées dans la table statique HPACK est normalisé, mais l'ordre de sérialisation des pseudo-headers est laissé à l'implémentation.
  • Contrôle de flux : mécanisme de fenêtres (WINDOW_UPDATE) limitant le débit pour éviter la saturation du récepteur.

Après terminaison TLS par le serveur web, le flux HTTP/2 est déchiffré et disponible en clair dans le contexte d'OpenSSL/BoringSSL. Le fingerprinting passif est réalisé par ja4ebpf via un uprobe sur SSL_read. Cependant, SSL_read retourne des octets bruts déchiffrés sans respecter les frontières des trames HTTP/2 — la fragmentation TCP et le buffering TLS signifient qu'un seul appel peut correspondre à un fragment de trame ou à plusieurs trames complètes. Le Go Magic Bytes dispatcher maintient un buffer circulaire de réassemblage par connexion, accumulant les données de plusieurs appels SSL_read jusqu'à ce que la logique de parsing HTTP/2 confirme qu'une trame complète est disponible (9 octets d'en-tête de trame + longueur payload). L'identification du preface PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n se fait dans ce buffer accumulé. Tout le parsing des trames SETTINGS, WINDOW_UPDATE et HEADERS (y compris le décodage HPACK partiel pour extraire l'ordre des pseudo-headers) s'effectue dans l'agent Go en espace utilisateur, et non dans la VM eBPF. Cette approche est agnostique au serveur web (Apache, Nginx, Varnish, HAProxy) et ne nécessite aucun module natif installé côté serveur.

Frame SETTINGS

La frame SETTINGS (RFC 7540 §6.5) est envoyée par le client immédiatement après le connection preface (octet sequence PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n). Elle contient des paires clé-valeur dont les clés sont des identifiants 16 bits normalisés. L'ensemble et les valeurs de ces paramètres constituent une empreinte stable par implémentation HTTP/2.

Table complète des paramètres SETTINGS par client (format : ID — Nom — Chrome 119+ / Firefox 90+ / Safari 15+ / curl 8.x / Go net/http) :

ID Nom Chrome 119+ Firefox 90+ Safari 15+ curl 8.x Go net/http
1 HEADER_TABLE_SIZE 65536 65536 absent absent absent
2 ENABLE_PUSH 0 0 0 0 absent
3 MAX_CONCURRENT_STREAMS absent absent 100 absent absent
4 INITIAL_WINDOW_SIZE 6291456 131072 2097152 65535 4194304
5 MAX_FRAME_SIZE absent 16384 absent absent absent
6 MAX_HEADER_LIST_SIZE 262144 absent absent absent 10485760
9 ENABLE_CONNECT_PROTOCOL absent absent 1 absent absent

Analyse discriminante : le paramètre INITIAL_WINDOW_SIZE (ID 4) discrimine à lui seul les quatre grandes familles :

  • Chrome : 6 291 456 octets (6 Mo) — valeur la plus élevée, signalant une optimisation agressive pour les connexions haut débit
  • Safari : 2 097 152 octets (2 Mo) — intermédiaire
  • Go net/http : 4 194 304 octets (4 Mo) — non-navigateur, valeur élevée
  • Firefox : 131 072 octets (128 Ko) — valeur conservatrice
  • curl : 65 535 octets (64 Ko) — valeur par défaut minimale du standard

Explication de INITIAL_WINDOW_SIZE : ce paramètre fixe la taille initiale de la fenêtre de contrôle de flux pour les nouveaux streams. Une grande valeur (Chrome : 6 Mo) permet au serveur d'envoyer davantage de données avant d'attendre un accusé de réception WINDOW_UPDATE, optimisant les connexions à haute bande passante. Une petite valeur (curl : 64 Ko) limite le débit mais réduit les exigences mémoire. Cette différence reflète les priorités de conception : Chrome est optimisé pour la performance perçue par l'utilisateur sur des réseaux modernes ; curl est un outil en ligne de commande dont la sobriété mémoire est une contrainte historique.

Frame WINDOW_UPDATE

La frame WINDOW_UPDATE (RFC 7540 §6.9) met à jour le crédit de contrôle de flux au niveau de la connexion (stream ID 0) ou d'un stream particulier. Elle est envoyée par le client après le SETTINGS initial pour élargir la fenêtre de réception globale. Sa valeur constitue une signature extrêmement stable par implémentation :

Client Valeur WINDOW_UPDATE Tolérance
Chrome 119+ 15 663 105 ±1 000
Firefox 90+ 12 517 377 ±1 000
Safari 15+ 10 420 225 ±1 000
curl 8.x absent (0)
Python httpx absent (0)
Go net/http 1 073 676 289 ±1 000

L'absence de WINDOW_UPDATE (valeur 0) indique avec certitude un outil non-navigateur : aucun navigateur grand public n'omet ce frame dans son handshake H2. Go net/http envoie une valeur exceptionnellement grande (≈1 Go) correspondant à l'augmentation maximale de fenêtre autorisée par le standard.

Ordre des pseudo-headers

HTTP/2 remplace la ligne de requête HTTP/1.1 (par exemple GET /chemin HTTP/1.1) par des champs pseudo-header préfixés par deux points. Ces pseudo-headers sont définis par RFC 7540 §8.1.2 :

  • :method (:m) : verbe HTTP (GET, POST, etc.)
  • :authority (:a) : équivalent de l'en-tête Host
  • :scheme (:s) : schéma de l'URI (http, https)
  • :path (:p) : chemin et query string de l'URI

Les pseudo-headers doivent apparaître avant les en-têtes réguliers dans les frames HEADERS. Leur ordre entre eux n'est pas imposé par le standard mais est déterminé par l'implémentation HTTP/2 et reste stable par version de client. Cet ordre constitue donc une signature passive supplémentaire :

Client Ordre observé Notation abrégée
Chrome 119+ method, authority, scheme, path masp
Firefox 90+ method, path, scheme, authority mpsa
Safari 15+ method, authority, scheme, path masp
curl 8.x method, path, scheme, authority mpsa

Source : Bartlett, Cloudflare 2023.

Note sur les valeurs observées vs. références Cloudflare : les captures réelles effectuées par ja4ebpf montrent des écarts avec les données de référence Cloudflare pour certaines implémentations. Firefox présente un ordre mpsa (method, path, scheme, authority) dans nos captures, et non mpas (method, path, authority, scheme) comme rapporté par Cloudflare. Safari présente un ordre masp identique à Chrome dans nos captures, et non mspa. Safari envoie INITIAL_WINDOW_SIZE=65535 et WINDOW_UPDATE=10485760 dans nos observations, contre respectivement 2 097 152 et 10 420 225 dans les données Cloudflare. Ces écarts peuvent résulter de différences de version (nos captures Safari correspondent à WebKit ≥ 613.1.x sur macOS/iOS), de variantes de build, ou de l'évolution des piles HTTP/2 entre les mesures. Les signatures dans browser_signatures.py reflètent nos valeurs capturées réelles, garantissant la cohérence du scoring en production.

Cas httpcloak : httpcloak reproduit correctement le SETTINGS H2 de Chrome et la valeur WINDOW_UPDATE de Chrome, mais conserve l'ordre pseudo-header de curl (mpsa) au lieu de Chrome (masp). Résultat : score browser_matcher Chrome ≈ 0,60 — zone grise. La pénalité de zone grise (voir §3.9.3) réduit le score final ML sans le neutraliser totalement. Note : mpsa est l'ordre réel capturé pour Firefox dans notre infrastructure — httpcloak peut donc tromper partiellement la dimension pseudo-header, mais les dimensions SETTINGS et WINDOW_UPDATE révèlent l'incohérence.

Frames PRIORITY

Les frames PRIORITY (RFC 7540 §6.3) établissent un arbre de dépendances pour l'ordonnancement des streams HTTP/2. Elles permettent au client de communiquer au serveur ses priorités de rendu — par exemple, les ressources CSS bloquantes reçoivent une priorité plus élevée que les images de bas de page.

Comportement par famille :

  • Firefox 90+ : envoie des frames PRIORITY de démarrage pour les streams 3, 5, 7, 9, 11, 13, formant un arbre de priorités préétabli pour les ressources anticipées
  • Chrome ≥119 : n'envoie pas de frames PRIORITY — migré vers RFC 9218 PRIORITY_UPDATE, qui transmet les priorités via des en-têtes HTTP plutôt que des frames dédiées
  • Safari : n'envoie pas de frames PRIORITY
  • curl : n'envoie pas de frames PRIORITY
  • Go net/http : n'envoie pas de frames PRIORITY

La présence de frames PRIORITY est donc un marqueur exclusif de Firefox. Leur absence ne discrimine pas entre Chrome, Safari, curl et Go — elle doit être combinée avec les autres dimensions.

Stabilité des empreintes H2

La signature H2 de Chrome est stable depuis la version 119 (novembre 2023) jusqu'à la version 142 (2026) — plus de deux ans de stabilité. Ceci contraste avec JA4 TLS qui change à chaque mise à jour de suite de chiffrement. Cette stabilité s'explique par le fait que les paramètres H2 sont liés aux décisions d'architecture réseau du moteur de rendu Blink/Chrome, qui évoluent beaucoup moins fréquemment que la politique TLS.

3.9.2 Architecture du moteur browser_matcher

Le moteur browser_matcher est structuré en quatre modules Python distincts : la base de données de signatures statiques, la logique de scoring statique, le moteur de profiling dynamique hors-ligne, et le scorer dynamique temps réel.

Module browser_signatures.py

Ce module constitue la base de données de signatures par famille de navigateur. Chaque entrée est un objet structuré définissant :

# Extrait de browser_signatures.py — signature Chrome
BROWSER_SIGNATURES["Chrome"] = {
    "h2_settings_exact": {
        1: 65536,    # HEADER_TABLE_SIZE
        2: 0,        # ENABLE_PUSH (désactivé)
        4: 6291456,  # INITIAL_WINDOW_SIZE
        6: 262144,   # MAX_HEADER_LIST_SIZE
    },
    "h2_settings_forbidden_keys": [3, 5],  # MAX_CONCURRENT_STREAMS et MAX_FRAME_SIZE absents
    "h2_window_update": 15663105,
    "h2_window_update_tolerance": 1000,
    "h2_priority_frames_expected": False,  # Chrome ≥119 utilise PRIORITY_UPDATE (RFC 9218)
    "pseudo_header_order": "m,a,s,p",
    "tls": {"ja4_families": ["Chromium", "Chrome", "Edge"], "grease_expected": True},
    "headers_sec_fetch_required": True,
    "headers_ch_ua_required": True,
    "accept_language_required": True,
}

# Signature Firefox (valeurs capturées réelles)
BROWSER_SIGNATURES["Firefox"] = {
    "h2_settings_exact": {1: 65536, 4: 131072, 5: 16384},
    "h2_settings_forbidden_keys": [2, 3, 6],
    "h2_window_update": 12517377,
    "pseudo_header_order": "m,p,s,a",  # ordre réel capturé (pas m,p,a,s)
    "tls": {"ja4_families": ["Firefox"], "grease_expected": False},
    "headers_ch_ua_required": False,  # Firefox ne supporte pas les Client Hints
}

# Signature Safari (valeurs capturées réelles)
BROWSER_SIGNATURES["Safari"] = {
    "h2_settings_exact": {1: 4096, 3: 100, 4: 65535},
    "h2_settings_forbidden_keys": [2, 5, 6],
    "h2_window_update": 10485760,
    "pseudo_header_order": "m,a,s,p",  # identique à Chrome (WU=10485760 distingue)
    "tls": {"ja4_families": ["Safari"], "grease_expected": False},
    "headers_sec_fetch_forbidden": True,  # Présence = incohérence
}

Le champ h2_settings_exact est désormais consommé directement par _d1_h2_settings() via les colonnes individuelles de view_ai_features_1h (h2_header_table_size, h2_enable_push, h2_initial_window_size, h2_max_header_list_size, etc.). La comparaison colonne par colonne permet de scorer partiellement un fingerprint inconnu du dictionnaire mais structurellement proche (ex. variante de navigateur non encore répertoriée).

Implémentation de _d1_h2_settings() — algorithme de scoring :

# Pour chaque clé attendue (h2_settings_exact) : val_col == val_attendue → 1, sinon 0
# Pour chaque clé interdite (h2_settings_forbidden_keys) : col < 0 (absent) → 1, sinon 0
direct_score = nb_vérifications_réussies / nb_total_vérifications
base = direct_score × 0.60 + dict_match × 0.30 + h2_ja4_coherence × 0.10

Avantage vs l'approche dict-only : un fingerprint légèrement modifié (ex. une variante de Chrome avec un champ SETTINGS supplémentaire) serait rejeté par le dictionnaire (dict_match=0) mais obtiendrait quand même un score D1 élevé si les paramètres clés sont corrects. Par exemple, un client envoyant 1:65536, 2:0, 4:6291456, 6:262144 (4 clés Chrome attendues) + 3:200 (clé interdite) obtiendrait direct_score = 5/6 × 0.60 = 0.50 au lieu de 0 avec le dict seul.

Fallback : si les colonnes individuelles sont absentes du DataFrame (compatibilité ascendante), la fonction revient au comportement original dict_match × 0.80 + h2_ja4_coherence × 0.20.

Module browser_matcher.py

Ce module implémente le moteur de scoring. Il calcule un score de correspondance pour chaque famille de navigateur connue, sur 7 dimensions pondérées :

Table des 7 dimensions de scoring avec poids et logique de calcul :

# Dimension Poids Logique de scoring
1 H2 SETTINGS match 0,30 Comparaison directe par paramètre (colonnes individuelles) : chaque clé attendue exacte → 1,0 ; chaque clé interdite absente → 1,0 ; score = proportion de vérifications réussies. Pondération : direct × 0,60 + dict_lookup × 0,30 + ja4_coherence × 0,10. Fallback dict-only si colonnes indisponibles
2 H2 WINDOW_UPDATE 0,15 `
3 Ordre pseudo-headers 0,15 Correspondance exacte → 1,0 ; absent → neutre 0,5 ; sinon 0,0
4 Frames H2 PRIORITY 0,10 Présence/absence correspond à l'attendu → 1,0 ; pas de données H2 → neutre 0,5
5 Cohérence en-têtes HTTP 0,15 Accept-Language (+0,25) + Sec-Fetch cohérent (+0,25) + Sec-CH-UA cohérent (+0,25) + bonus (+0,25)
6 Structure TLS 0,10 Famille JA4 correcte (×0,7) + TLS 1.3 (×0,3)
7 JA4 dict lookup 0,05 Correspondance dans dict_browser_ja4 pour cette famille → 1,0 ; sinon 0,0

Formule générale :

score_family = Σ (weight_i × score_dim_i)    ∈ [0, 1]

Le score est calculé pour chaque famille (Chrome, Firefox, Safari). Le résultat maximal et la famille correspondante sont retenus. Les patterns NON_BROWSER_SIGNATURES sont appliqués en post-traitement : si un pattern négatif est détecté, score_family = min(score_family, 0.30) quelle que soit la famille.

Pseudo-code du pipeline browser_matcher

def match_browser(session: SessionFeatures) -> BrowserMatchResult:
    scores = {}
    for family, sig in BROWSER_SIGNATURES.items():
        s = 0.0
        # Dimension 1 : H2 SETTINGS
        s += 0.30 * score_h2_settings(session.h2_settings, sig)
        # Dimension 2 : WINDOW_UPDATE
        s += 0.15 * score_window_update(session.h2_window_update, sig)
        # Dimension 3 : pseudo-headers
        s += 0.15 * score_pseudo_order(session.h2_pseudo_order, sig)
        # Dimension 4 : PRIORITY frames
        s += 0.10 * score_priority_frames(session.h2_has_priority, sig)
        # Dimension 5 : en-têtes HTTP
        s += 0.15 * score_headers(session.headers, sig)
        # Dimension 6 : TLS structure
        s += 0.10 * score_tls(session.tls, sig)
        # Dimension 7 : JA4 dict
        s += 0.05 * score_ja4_dict(session.ja4, sig)
        scores[family] = s

    # Patterns négatifs
    for pattern in NON_BROWSER_SIGNATURES:
        if pattern.matches(session):
            for family in scores:
                scores[family] = min(scores[family], 0.30)

    best_family = max(scores, key=scores.get)
    best_score = scores[best_family]
    return BrowserMatchResult(
        family=best_family if best_score >= THRESHOLDS[best_family] else "",
        score_chrome=scores.get("chrome", 0.0),
        score_firefox=scores.get("firefox", 0.0),
        score_safari=scores.get("safari", 0.0),
        match_score=best_score,
    )

3.9.3 Logique de décision et intégration dans le pipeline

Seuils de décision par famille

Les seuils de confiance sont différenciés par famille en raison de la variabilité inter-version observée :

Famille Seuil de confiance Justification
Chrome ≥ 0,72 Grande stabilité H2 depuis v119 ; seuil élevé car JA4 Chrome très imitée
Firefox ≥ 0,68 Frames PRIORITY discriminantes ; seuil légèrement plus bas
Safari ≥ 0,68 MAX_CONCURRENT_STREAMS=100 et ENABLE_CONNECT_PROTOCOL=1 très spécifiques

Flowchart de trifurcation (ASCII)

Session entrante
       │
       ▼
  has_xff = 1 ?
  ┌────┴────┐
  │Oui      │Non
  ▼         ▼
Neutraliser  Dimensions H2
dim. H2 →   complètes
0.5 par
défaut
  │         │
  └────┬────┘
       ▼
  Calcul score_family
  (7 dimensions)
       │
       ▼
  Patterns NON_BROWSER ?
  ┌────┴────┐
  │Oui      │Non
  ▼         ▼
Cap score  Score intact
à 0.30      │
  │         │
  └────┬────┘
       ▼
  best_score ≥ seuil ?
  ┌────┴────────────────┐
  │Oui                  │Non
  ▼                     │
browser_family_detected  │
= best_family            ▼
  │             0.45 ≤ best_score < seuil ?
  │             ┌─────┴─────┐
  │             │Oui        │Non
  │             ▼           ▼
  │         ZONE GRISE   Aucun effet
  │         final_score  (best_score < 0.45)
  │         × (1 - 0.5 × best_score)
  │             │
  └──────┬──────┘
         ▼
  Sortie : features browser_match_*
  → Vecteur feature ML

Traitement zone grise [0,45 ; seuil[

Lorsque le meilleur score est en zone grise (supérieur à 0,45 mais inférieur au seuil de confiance de la famille), le score ML final est atténué par :

final_score = final_score × (1  0.5 × match_score)

Cette atténuation ne neutralise pas le score ML mais signal une incertitude sur l'authenticité du navigateur déclaré. En dessous de 0,45 : aucun effet, la signature est trop faible pour être exploitée.

Cas httpcloak illustratif :

  • H2 SETTINGS Chrome : correct → +0,30
  • WINDOW_UPDATE Chrome : correct → +0,15
  • Pseudo-headers : mpsa (curl) vs masp (Chrome attendu) → 2/4 positions → 0,0
  • PRIORITY frames : absentes (correct pour Chrome) → +0,10
  • En-têtes HTTP : sec-fetch-* absents (httpcloak) → +0,075
  • TLS : JA4 Chrome-like → +0,08
  • JA4 dict : correspondance → +0,05
  • Total ≈ 0,605 → zone grise [0,45 ; 0,72[ → atténuation active

Gestion CDN/reverse proxy (has_xff = 1)

Lorsqu'un CDN ou reverse proxy est détecté (has_xff = 1), le CDN rétablit sa propre connexion H2 vers le serveur Apache d'origine. Les paramètres H2 observés (SETTINGS, WINDOW_UPDATE, pseudo-headers, PRIORITY) reflètent alors le CDN, non le client final. Dans ce cas :

  • Les dimensions H2 (poids total 0,70) sont neutralisées à 0,50 (valeur neutre)
  • Le poids libéré est redistribué équitablement entre la dimension en-têtes HTTP (+0,35) et la dimension TLS (+0,35)
  • Seules les dimensions TLS et en-têtes HTTP subsistent, avec des pondérations ajustées

3.9.4 Nouvelles features dérivées

Le module browser_matcher génère les features suivantes dans le vecteur feature du pipeline :

Feature Type Description Statut
browser_match_chrome Float32 Score de correspondance Chrome (01) [impl.]
browser_match_firefox Float32 Score de correspondance Firefox (01) [impl.]
browser_match_safari Float32 Score de correspondance Safari (01) [impl.]
browser_match_max Float32 max(chrome, firefox, safari) [impl.]
browser_family_detected String Famille détectée ou chaîne vide [impl.]
h2_window_update_value UInt32 Valeur WINDOW_UPDATE observée [impl.] (colonne h2_window_update dans http_logs)
h2_has_priority_frames UInt8 1 si frames PRIORITY présentes [impl.] (colonne h2_has_priority dans http_logs)
h2_pseudo_order String Ordre observé (ex. m,a,s,p) [impl.] (colonne h2_pseudo_order dans http_logs)
tls_h2_family_mismatch UInt8 1 si JA4 dit Chrome mais H2 SETTINGS dit Firefox/outil [impl.] (feature F4)

Les features h2_window_update_value, h2_has_priority_frames et h2_pseudo_order sont désormais capturées par ja4ebpf via le parser HTTP/2 du flux SSL_read déchiffré et stockées dans des colonnes individuelles de ja4_logs.http_logs. De plus, chaque paramètre SETTINGS HTTP/2 dispose de sa propre colonne (h2_header_table_size, h2_enable_push, h2_max_concurrent_streams, h2_initial_window_size, h2_max_frame_size, h2_max_header_list_size, h2_enable_connect_protocol) avec la valeur -1 pour les paramètres absents du preface client. La feature tls_h2_family_mismatch est implémentée dans le vecteur feature global (famille F4 — TLS features) et se calcule à partir des données JA4 existantes et des colonnes H2 individuelles disponibles dans ja4_logs.http_logs. Les features browser_match_* sont calculées par le module browser_matcher.py (497 lignes) à chaque cycle d'analyse, avec les signatures statiques de browser_signatures.py (165 lignes) et un rechargement dynamique optionnel depuis ClickHouse toutes les 24 heures. En parallèle, le module browser_matcher_dynamic.py (387 lignes) scores les sessions contre les profils auto-appris de auto_browser_profiles (§3.9.6), fournissant une double couverture : signatures connues (statique) + signatures observées (dynamique).

3.9.5 Maintenance des signatures

Stockage et rechargement

Les signatures sont stockées dans la table ClickHouse ja4_processing.browser_h2_signatures, rechargée toutes les 24 heures par le module Python. Structure de la table :

CREATE TABLE ja4_processing.browser_h2_signatures
(
    family          String,
    version_min     String,
    version_max     String,
    h2_settings_json String,      -- JSON sérialisé du dict {key_id: value}
    h2_settings_forbidden String, -- JSON array des key_ids interdits
    h2_window_update UInt32,
    h2_window_update_tolerance UInt32,
    h2_priority_expected UInt8,
    pseudo_header_order String,   -- "m,a,s,p"
    tls_json        String,       -- JSON des paramètres TLS
    headers_required String,      -- JSON array
    headers_forbidden String,     -- JSON array
    created_at      DateTime DEFAULT now(),
    is_active       UInt8 DEFAULT 1
)
ENGINE = ReplacingMergeTree(created_at)
ORDER BY (family, version_min);

File d'examen pour signatures inconnues

Les sessions présentant une empreinte H2 inconnue (aucune famille avec score > 0,45) mais un comportement de type navigateur (browser_confidence ≥ 0,55, présence de sec-fetch-*, TLS 1.3) sont enregistrées dans une file d'examen (ja4_processing.unknown_h2_fingerprints) pour mise à jour progressive de la base de signatures lors des nouvelles versions de navigateurs.

INSERT INTO ja4_processing.unknown_h2_fingerprints
SELECT
    now() AS observed_at,
    src_ip,
    ja4_fingerprint,
    h2_settings_json,
    h2_window_update_value,
    h2_pseudo_order,
    h2_has_priority_frames,
    browser_confidence_score
FROM current_session_features
WHERE browser_match_max < 0.45
  AND browser_confidence_score >= 0.55
  AND tls_version = 'TLS1.3';

3.9.6 Profiling dynamique automatique des navigateurs

[impl.] Module profile_builder.py (614 lignes) + browser_matcher_dynamic.py (387 lignes)

Le système de signatures statiques (§3.9.2) est robuste mais fragile face aux mises à jour de navigateurs : chaque changement de version peut modifier les valeurs SETTINGS ou WINDOW_UPDATE, nécessitant une mise à jour manuelle du dictionnaire. Le profiling dynamique automatique résout ce problème en apprenant les signatures directement à partir du trafic observé, sans intervention humaine.

Architecture du pipeline

Le pipeline s'articule en deux phases :

  1. Phase hors-ligne (profile_builder.py, exécuté quotidiennement par cron) :

    • Lit la vue view_h2_profiling_raw (sessions H2 filtrées des 7 derniers jours, dédupliquées par IP)
    • Clusterise les sessions similaires via HDBSCAN (min_cluster_size=1000) sur un vecteur mixte (variables continues normalisées par StandardScaler + variables catégorielles brutes)
    • Calcule les centroïdes par cluster : moyenne et tolérance (mean + 3σ) pour les continues, mode pour les catégorielles
    • Labelise les clusters par analyse des User-Agents → familles Auto_Chrome, Auto_Firefox, Auto_Safari, Auto_Unknown
    • Fusionne les clusters redondants (même famille + même pseudo_order + window_update à < 5% d'écart)
    • Persiste les profils dans ja4_processing.auto_browser_profiles (ReplacingMergeTree, TTL 14 jours)
  2. Phase temps réel (browser_matcher_dynamic.py, appelé à chaque cycle de 300s) :

    • Charge les profils en mémoire au démarrage (rafraîchissement toutes les 24h)
    • Pour chaque session, calcule un score de similarité pondéré contre tous les profils :
score = Σ (poids_i × similarité_i) × confiance_volumétrique

Pondération :
  h2_window_update       : 0.40  (le plus discriminant entre familles)
  pseudo_order           : 0.40  (invariant par version mineure)
  h2_initial_window_size : 0.10
  h2_has_priority        : 0.10

Similarité continue : sim = max(0, 1 - |x - μ| / max(|μ|, 1))
Similarité catégorielle : sim = 1.0 si match exact, 0.0 sinon
Confiance volumétrique : conf = min(1.0, log10(count_ips + 1) / 4)
  → count_ips=10000 → confiance≈1.0 ; count_ips=100 → confiance≈0.50

Rejet rapide

Le scoring intègre deux mécanismes de rejet rapide pour éviter les calculs inutiles :

  1. Incompatibilité pseudo_order : si la session a pseudo_order = mpsa (curl) mais le profil exige masp (Chrome), le score est immédiatement 0. Ce signal est binaire et invariant — il ne peut pas être contourné sans reproduire exactement l'ordre des pseudo-headers du navigateur cible.

  2. Dépassement de tolérance : si |h2_window_update_session - h2_window_update_profil| > tolérance, le score est 0. La tolérance est calculée comme |μ| + 3σ lors du clustering, garantissant un intervalle de confiance à 99.7%.

Vue ClickHouse d'extraction

La vue view_h2_profiling_raw (créée dans 13_h2_profiling.sql) filtre le trafic bots évident et encode les variables catégorielles :

-- Encodage pseudo_order → UInt8
multiIf(
    h2_pseudo_order = 'm,a,s,p', 1,   -- Chrome/Safari
    h2_pseudo_order = 'm,p,a,s', 2,   -- Firefox (ancien)
    h2_pseudo_order = 'm,s,p,a', 3,
    h2_pseudo_order = 'm,p,s,a', 4,   -- curl/Firefox
    h2_pseudo_order = 'm,a,p,s', 5,
    0                                  -- inconnu
) AS pseudo_order_id

Filtres appliqués : h2_pseudo_order != '' (HTTP/2 uniquement), h2_window_update > 0 (exclut curl), h2_initial_window_size NOT IN (-1, 65535) (exclut absents et valeur typique curl), header_user_agent != '' (exclut scanners).

Cycle de vie des profils

Le profilage dynamique gère automatiquement l'obsolescence :

  • Rafraîchissement : last_seen_date est mis à jour quotidiennement pour les profils dont le vecteur H2 correspond à des sessions observées dans les dernières 24h.
  • Purge : les profils où last_seen_date < today() - 14 sont supprimés (navigateurs dépréciés, versions obsolètes). Cette TTL garantit que seules les signatures récentes sont utilisées pour le scoring.

Le TTL ClickHouse de 14 jours sur la table auto_browser_profiles assure une purge physique des partitions expirées lors du merge.

Fusion des clusters

La fusion des clusters redondants est critique pour éviter la prolifération de profils quasi-identiques. Deux clusters sont fusionnés si les trois conditions sont réunies :

  1. Même detected_family (ex: Auto_Chrome)
  2. Même pseudo_order_mode (même ordre des pseudo-headers)
  3. |window_update_A - window_update_B| / max(A, B) < 5% (valeurs proches)

La fusion calcule la moyenne pondérée par count_ips :

nouvelle_moyenne = (mean_A × count_A + mean_B × count_B) / (count_A + count_B)
nouvelle_tolérance = max(tol_A, tol_B)  # on élargit le seuil

Avantage sur les signatures statiques

Le profiling dynamique présente trois avantages décisifs :

  1. Adaptabilité : les nouvelles versions de navigateurs sont automatiquement apprises dès qu'elles atteignent un volume suffisant (min_cluster_size=1000 IPs uniques), sans mise à jour manuelle du dictionnaire.
  2. Robustesse : la tolérance mean + 3σ s'adapte à la variance réelle observée dans la population, contrairement à la tolérance fixe de 1000 du système statique.
  3. Couverture : les familles Auto_Unknown capturent les clients H2 qui ne correspondent à aucune famille connue mais qui présentent un comportement de navigateur légitime (nouvelles implémentations, clients exotiques).