diff --git a/docs/THESIS_HTTP_Traffic_Detection.md b/docs/THESIS_HTTP_Traffic_Detection.md index e13aa5f..0ee60c5 100644 --- a/docs/THESIS_HTTP_Traffic_Detection.md +++ b/docs/THESIS_HTTP_Traffic_Detection.md @@ -7,7 +7,7 @@ ## Résumé -Ce document présente une architecture complète de détection et classification du trafic HTTP malveillant, positionnée à la frontière des générations 3 et 4 de défenses applicatives. Le système exploite 96 features organisées en 8 familles couvrant les couches réseau L3 à L7, corrélant des signaux TCP, TLS et HTTP en un vecteur unifié par session. La détection repose sur un ensemble triple-voix combinant un Extended Isolation Forest (EIF), un autoencodeur (AE) et XGBoost, fusionnés par un MetaLearner à régression logistique activé à partir de 1 000 étiquettes accumulées. L'explicabilité est assurée par ExIFFI (nativement pour EIF) et SHAP TreeExplainer (pour XGBoost). Le clustering de campagnes est réalisé par HDBSCAN dans l'espace latent 16 dimensions de l'autoencodeur, et la détection de flottes coordonnées par graphes bipartis via NetworkX. Le fingerprinting HTTP/2 passif — extraction des trames SETTINGS, WINDOW_UPDATE et de l'ordre des pseudo-headers côté serveur — constitue un signal inédit difficile à contourner sans implémenter une pile HTTP/2 complète fidèle à un navigateur cible. L'infrastructure repose sur 14 modules Python (3 700 lignes), une base ClickHouse à double schéma (ja4_logs bruts TTL 2 h, ja4_processing agrégés TTL 7 j), des cycles d'analyse de 300 secondes, et traite en production plus de 3 millions de logs, environ 34 000 sessions par cycle, avec approximativement 777 anomalies détectées par cycle (≈ 2,3 %). Les mots-clés couvrent : fingerprinting réseau, JA4+, HTTP/2 fingerprinting passif, détection de bots, Extended Isolation Forest, ExIFFI, autoencodeurs, méta-learner, ensemble hybride, corrélation TCP/TLS/HTTP, WAF, classification de trafic, apprentissage semi-supervisé, clustering HDBSCAN. +Ce document présente une architecture complète de détection et classification du trafic HTTP malveillant, positionnée à la frontière des générations 3 et 4 de défenses applicatives. Le système exploite 96 features organisées en 8 familles couvrant les couches réseau L3 à L7, corrélant des signaux TCP, TLS et HTTP en un vecteur unifié par session. La détection repose sur un ensemble triple-voix combinant un Extended Isolation Forest (EIF), un autoencodeur (AE) et XGBoost, fusionnés par un MetaLearner à régression logistique activé à partir de 1 000 étiquettes accumulées. L'explicabilité est assurée par ExIFFI (nativement pour EIF) et SHAP TreeExplainer (pour XGBoost). Le clustering de campagnes est réalisé par HDBSCAN dans l'espace latent 16 dimensions de l'autoencodeur, et la détection de flottes coordonnées par graphes bipartis via NetworkX. Le fingerprinting HTTP/2 passif — extraction des trames SETTINGS, WINDOW_UPDATE et de l'ordre des pseudo-headers côté serveur — constitue un signal inédit difficile à contourner sans implémenter une pile HTTP/2 complète fidèle à un navigateur cible. L'infrastructure repose sur 16 modules Python (4 800 lignes), une base ClickHouse à double schéma (ja4_logs bruts TTL 2 h, ja4_processing agrégés TTL 7 j), des cycles d'analyse de 300 secondes, et traite en production plus de 3 millions de logs, environ 34 000 sessions par cycle, avec approximativement 777 anomalies détectées par cycle (≈ 2,3 %). Le système intègre un moteur de profiling dynamique automatique des navigateurs (HDBSCAN sur les vecteurs H2 observés, centroïdes auto-appris, scoring temps réel par distance normalisée) qui s'adapte aux évolutions des piles HTTP/2 sans intervention manuelle. Les mots-clés couvrent : fingerprinting réseau, JA4+, HTTP/2 fingerprinting passif, détection de bots, Extended Isolation Forest, ExIFFI, autoencodeurs, méta-learner, ensemble hybride, corrélation TCP/TLS/HTTP, WAF, classification de trafic, apprentissage semi-supervisé, clustering HDBSCAN. **Mots-clés** : fingerprinting réseau, JA4+, HTTP/2 fingerprinting, détection de bots, Extended Isolation Forest, ExIFFI, autoencoders, méta-learner, ensemble hybride, corrélation TCP/TLS/HTTP, WAF, classification de trafic, apprentissage semi-supervisé, clustering HDBSCAN @@ -847,14 +847,15 @@ httpcloak est un outil d'évasion qui tente d'imiter l'empreinte TLS de Chrome. │ ┌─────────▼─────────────────────────────┐ │ bot_detector │ - │ (14 modules Python, │ - │ 3 700 lignes) │ + │ (16 modules Python, │ + │ 4 800 lignes) │ │ │ │ Cycle 300s: │ │ ┌──────────────────────────────────┐ │ │ │ 1. Chargement features ClickHouse│ │ │ │ 2. dict lookups (IP, JA4, ASN) │ │ │ │ 3. browser_matcher scoring │ │ + │ │ 3b. dynamic H2 profiling scoring │ │ │ │ 4. EIF bifurqué (complet/appli) │ │ │ │ 5. AE reconstruction scoring │ │ │ │ 6. XGBoost probabilité │ │ @@ -1084,9 +1085,9 @@ La valeur `percentile_5` du historique des scores négatifs (anomalies confirmé ## 3.9 Browser Signature Detection (browser_matcher) -`[impl.]` **Statut global : capture H2 et scoring entièrement implémentés** +`[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. +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 @@ -1185,7 +1186,7 @@ La signature H2 de Chrome est stable depuis la version 119 (novembre 2023) jusqu ### 3.9.2 Architecture du moteur browser_matcher -Le moteur browser_matcher est structuré en deux modules Python distincts, séparant la base de données de signatures de la logique de scoring. +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 @@ -1417,7 +1418,7 @@ Le module `browser_matcher` génère les features suivantes dans le vecteur feat | `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. +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 @@ -1468,6 +1469,100 @@ WHERE browser_match_max < 0.45 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 : + +```sql +-- 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). + --- @@ -2933,6 +3028,8 @@ Le fingerprinting réseau opère sans déchiffrement TLS (les métadonnées TLS | AE inference | ~50 ms | Batch de 34 000 sessions | | XGBoost inference | ~30 ms | Batch de 34 000 sessions | | HDBSCAN (anomalies) | ~100 ms | ~34 000 sessions, espace latent AE | +| HDBSCAN (profiling) | ~2–5 s | Quotidien, ~200k sessions H2 dédupliquées, min_cluster=1000 | +| Dynamic matcher scoring | < 1 ms | Par session, lookup en mémoire contre ~5–10 profils | | Louvain (fleet.py) | ~50 ms | Graphe JA4×ASN, communautés | | MetaLearner LR | < 10 ms | Régression logistique, négligeable | | **Cycle complet** | **~300 secondes** | EIF + AE + XGBoost + HDBSCAN + Louvain | @@ -2989,7 +3086,8 @@ Un seul utilisateur réel alterne quelques connexions (2–6 ports source actifs **browser_matcher maintenance (§3.9)** : - État actuel : `[impl.]` — logique de score complète à 7 dimensions, base de signatures Chrome/Firefox/Safari complète - Données H2 brutes : `[impl.]` — capture des 7 paramètres SETTINGS individuels, WINDOW_UPDATE, flag PRIORITY et ordre pseudo-headers par ja4ebpf via le parser HTTP/2 du flux SSL_read déchiffré. Colonnes individuelles dans `ja4_logs.http_logs` (`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`, `h2_window_update`, `h2_has_priority`, `h2_pseudo_order`) -- Travail restant : maintenance des signatures lors des nouvelles versions de navigateurs, enrichissement de la base via la file d'examen `unknown_h2_fingerprints` +- Profiling dynamique : `[impl.]` — moteur HDBSCAN hors-ligne (`profile_builder.py`, 614 lignes) + scorer temps réel (`browser_matcher_dynamic.py`, 387 lignes). Les profils auto-appris dans `auto_browser_profiles` s'adaptent automatiquement aux nouvelles versions de navigateurs, éliminant la maintenance manuelle du dictionnaire statique. +- Travail restant : monitoring de la convergence des clusters dynamiques, validation croisée entre scores statique et dynamique **DNS Shadow Analysis (§5.6)** : - État actuel : `[todo]` non implémenté @@ -3061,6 +3159,8 @@ La capture passive est réalisée par ja4ebpf via un uprobe sur `SSL_read` (Open Cette technique permet de détecter des outils d'évasion qui reproduisent correctement la couche TLS (curl_cffi, httpcloak) mais échouent à reproduire les subtilités H2 — notamment l'ordre des pseudo-headers et la valeur WINDOW_UPDATE. Le module de scoring est entièrement implémenté (`browser_matcher.py`, 497 lignes) avec les signatures des trois familles majeures (Chrome, Firefox, Safari) et trois signatures non-navigateur (curl, python-httpx, Go net/http) dans `browser_signatures.py` (165 lignes). +Le **profiling dynamique automatique** (`profile_builder.py`, 614 lignes + `browser_matcher_dynamic.py`, 387 lignes) étend cette approche en apprenant les signatures H2 directement à partir du trafic observé via HDBSCAN, sans nécessiter de mise à jour manuelle du dictionnaire. Les centroïdes auto-appris (moyenne + tolérance 3σ pour les continues, mode pour les catégorielles) sont stockés dans `auto_browser_profiles` et scorés en temps réel par distance normalisée pondérée avec confiance volumétrique. Ce mécanisme élimine la fragilité du système statique face aux mises à jour de navigateurs et couvre les implémentations H2 non répertoriées via les familles `Auto_Unknown`. + La stabilité des empreintes H2 de Chrome sur 2+ ans (novembre 2023 – 2026) contraste favorablement avec JA4 TLS (instable à chaque mise à jour de ciphersuites), justifiant l'investissement dans cette dimension additionnelle. #### Contribution 4 : Huit techniques originales de détection @@ -3100,7 +3200,7 @@ Architecture de données fondée sur ClickHouse avec **AggregatingMergeTree view ### Perspective -Le système atteint ses objectifs opérationnels actuels. La capture HTTP/2 passive est intégrée avec 12 colonnes individuelles dans `ja4_logs.http_logs`, et le module `browser_matcher` est opérationnel avec ses 7 dimensions de scoring. Les axes d'amélioration prioritaires sont l'enrichissement continu des signatures navigateur via la file d'examen `unknown_h2_fingerprints`, l'extension DNS Shadow Analysis pour la couverture DNS (`[todo]` → `[partiel]`), et le passage à l'apprentissage en ligne pour XGBoost. À plus long terme, le support HTTP/3 (QUIC) deviendra nécessaire à mesure que la proportion de trafic HTTP/3 augmente dans la baseline. +Le système atteint ses objectifs opérationnels actuels. La capture HTTP/2 passive est intégrée avec 12 colonnes individuelles dans `ja4_logs.http_logs`, et le module `browser_matcher` est opérationnel avec ses 7 dimensions de scoring statique. Le moteur de profiling dynamique automatique (§3.9.6) complète le système statique en apprenant les signatures H2 à partir du trafic réel, éliminant la dépendance aux signatures codées en dur. Les axes d'amélioration prioritaires sont le monitoring de la convergence des clusters dynamiques, l'extension DNS Shadow Analysis pour la couverture DNS (`[todo]` → `[partiel]`), et le passage à l'apprentissage en ligne pour XGBoost. À plus long terme, le support HTTP/3 (QUIC) deviendra nécessaire à mesure que la proportion de trafic HTTP/3 augmente dans la baseline. La technique la plus prometteuse parmi les travaux futurs est le **PARD-SSM** (Hiremath et al., 2026 [Référence à vérifier / Identifier le vrai papier]), qui permettrait de modéliser explicitement les phases d'attaque séquentielles — comblant la lacune actuelle entre la détection de sessions individuelles et la détection de campagnes d'attaque coordonnées multi-phases. diff --git a/docs/architecture.md b/docs/architecture.md index 78d9bc8..0ebae55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ INSERT (Native TCP :9000) ### Phase 4 — Détection -7. **bot-detector** (Python 3.11, 12 modules) s'exécute en cycle de 5 minutes : +7. **bot-detector** (Python 3.11, 16 modules) s'exécute en cycle de 5 minutes : - **Pipeline bifurqué** : - **Complet** (L3→L7, ~85 features, `correlated=1`) — trafic corrélé TCP+TLS+HTTP - **Applicatif** (L7 seulement, ~73 features, `correlated=0`) — trafic HTTP non corrélé diff --git a/docs/development.md b/docs/development.md index b4e841f..a8c6345 100644 --- a/docs/development.md +++ b/docs/development.md @@ -129,7 +129,7 @@ uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 | `scikit-learn` | Fallback pour Isolation Forest si isotree indisponible | | `clickhouse-connect` | Client ClickHouse (via ja4_common) | -#### Structure modulaire du bot-detector (11 modules) +#### Structure modulaire du bot-detector (16 modules) ``` services/bot-detector/bot_detector/ diff --git a/docs/services/bot-detector.md b/docs/services/bot-detector.md index 4ad7800..cd5e478 100644 --- a/docs/services/bot-detector.md +++ b/docs/services/bot-detector.md @@ -12,7 +12,7 @@ la surveillance de performance par cycle. ## Architecture des modules -Le service est découpé en **12 modules** organisés ainsi : +Le service est découpé en **16 modules** organisés ainsi : ``` __main__.py Point d'entrée (python -m bot_detector) @@ -25,6 +25,10 @@ __main__.py Point d'entrée (python -m bot_detector) ├─ pipeline.py Orchestration : filtrage → entraînement → MetaLearner → ExIFFI → scoring → fusion │ ├─ models.py EIF, TrafficAutoEncoder (PyTorch), XGBoost │ └─ scoring.py Normalisation, MetaLearner, seuil adaptatif, ExIFFI, SHAP, HDBSCAN, dérive KS+KL + ├─ browser_matcher.py Scoring H2 statique à 7 dimensions pondérées + │ └─ browser_signatures.py Signatures statiques Chrome/Firefox/Safari + rechargement ClickHouse + ├─ browser_matcher_dynamic.py Scoring H2 dynamique temps réel (profils auto-appris) + ├─ profile_builder.py Profiling HDBSCAN hors-ligne, centroïdes, lifecycle (cron quotidien) ├─ fleet.py Graphe bipartite JA4×ASN (NetworkX), fleet_score, fleet_detections ├─ metrics.py Métriques de cycle, alertes, ml_performance_metrics └─ (insère dans ml_all_scores + ml_detected_anomalies + fleet_detections + ml_performance_metrics) @@ -36,6 +40,10 @@ __main__.py Point d'entrée (python -m bot_detector) | `log.py` | 65 | `log_info()`, `log_decision()`, `append_training_history()` — JSONL rotatif | | `infra.py` | 89 | Client ClickHouse (délègue à `ja4_common`), `score_to_threat_level()`, serveur de santé en thread daemon | | `browser.py` | 191 | Détection multifactorielle des navigateurs sur **6 axes** pondérés (ajout `axis_h2_coherence`) | +| `browser_matcher.py` | 498 | Scoring H2 statique à 7 dimensions pondérées (SETTINGS, WINDOW_UPDATE, pseudo-order, etc.) | +| `browser_signatures.py` | 166 | Signatures statiques Chrome/Firefox/Safari + rechargement dynamique depuis ClickHouse | +| `browser_matcher_dynamic.py` | 387 | Scoring H2 dynamique temps réel contre profils auto-appris (`auto_browser_profiles`) | +| `profile_builder.py` | 614 | Profiling HDBSCAN hors-ligne : clustering, centroïdes, fusion, lifecycle (cron quotidien) | | `scoring.py` | 564 | `MetaLearner` (régression logistique), normalisation, seuil adaptatif, ExIFFI, SHAP top-5, HDBSCAN, dérive KS+KL | | `models.py` | 484 | `TrafficAutoEncoder`, entraînement/chargement EIF, XGBoost, élagage de features | | `preprocessing.py` | 127 | `preprocess_df()` — nettoyage, typage, imputation, listes `FEATURES` / `FEATURES_COMPLET` | @@ -512,13 +520,14 @@ Chaque anomalie reçoit un `campaign_id` (−1 = pas de cluster). o. Détection de dérive (KS test + KL divergence) p. Alerte drift adversarial (dérive simultanée multiple features → direction commune) 8. Analyse de flotte (fleet.py) : graphe bipartite JA4×ASN → communautés Louvain → fleet_score -9. Mode multi-fenêtre (si activé) : idem sur view_ai_features_24h -10. Insertion → ml_all_scores (toutes les sessions scorées) -11. Déduplication intra-cycle (garder raw_anomaly_score le plus bas par IP) -12. Déduplication inter-cycle (TTL, skip si détecté récemment sauf aggravation ≥ 0.05) -13. Insertion → ml_detected_anomalies (anomalies filtrées) -14. Insertion → fleet_detections (flottes détectées avec fleet_score) -15. Enregistrement → ml_performance_metrics (métriques de cycle + alertes) +9. Scoring dynamique H2 (browser_matcher_dynamic.py) : profils auto-appris vs sessions entrantes +10. Mode multi-fenêtre (si activé) : idem sur view_ai_features_24h +11. Insertion → ml_all_scores (toutes les sessions scorées) +12. Déduplication intra-cycle (garder raw_anomaly_score le plus bas par IP) +13. Déduplication inter-cycle (TTL, skip si détecté récemment sauf aggravation ≥ 0.05) +14. Insertion → ml_detected_anomalies (anomalies filtrées) +15. Insertion → fleet_detections (flottes détectées avec fleet_score) +16. Enregistrement → ml_performance_metrics (métriques de cycle + alertes) ``` --- diff --git a/services/bot-detector/DOCUMENTATION.md b/services/bot-detector/DOCUMENTATION.md index 904494b..8dec67c 100644 --- a/services/bot-detector/DOCUMENTATION.md +++ b/services/bot-detector/DOCUMENTATION.md @@ -1,6 +1,6 @@ # Bot Detector IA — Documentation Technique -> Architecture modulaire (11 modules) | Dernière mise à jour : 2025-07-15 +> Architecture modulaire (16 modules) | Dernière mise à jour : 2026-04-13 --- @@ -33,7 +33,12 @@ services/bot-detector/bot_detector/ ├── log.py (65) # Journalisation structurée (structlog JSON) ├── infra.py (89) # Client ClickHouse, health check HTTP, arrêt propre ├── browser.py (170) # Détection multifactorielle 5 axes des navigateurs -├── scoring.py (279) # Validation, seuil adaptatif, SHAP, HDBSCAN, dérive +├── browser_matcher.py (498) # Scoring H2 statique à 7 dimensions pondérées +├── browser_signatures.py (166) # Signatures statiques Chrome/Firefox/Safari +├── browser_matcher_dynamic.py (387) # Scoring H2 dynamique temps réel (profils auto-appris) +├── profile_builder.py (614) # Profiling HDBSCAN hors-ligne, centroïdes, lifecycle +├── fleet.py (XXX) # Détection de flottes par graphes bipartis NetworkX +├── scoring.py (588) # Validation, seuil adaptatif, SHAP, HDBSCAN, dérive ├── models.py (478) # EIF (isotree), AutoEncoder (PyTorch), XGBoost, persistance ├── preprocessing.py (117) # Nettoyage, imputation, listes de features ├── pipeline.py (378) # run_semi_supervised_logic() — orchestrateur ML @@ -390,6 +395,83 @@ LEGITIMATE_BROWSER. --- +### 3.4b `browser_matcher.py` — Scoring H2 statique + +**Rôle** : Scoring à 7 dimensions pondérées des sessions HTTP/2 contre des +signatures de navigateurs connues (Chrome, Firefox, Safari). + +**Fonctions exportées** : + +| Fonction | Description | +|----------|-------------| +| `run_browser_matcher(df)` | Score un batch de sessions, retourne `browser_match_chrome/firefox/safari/max` | +| `log_dual_mode_comparison(df)` | Compare les scores statique vs confiance browser | + +**Dimensions de scoring** : + +| Dimension | Poids | Signal | +|-----------|-------|--------| +| D1 — SETTINGS H2 | 0.30 | Correspondance exacte des paramètres SETTINGS | +| D2 — WINDOW_UPDATE | 0.15 | Valeur de WINDOW_UPDATE ± tolérance | +| D3 — Pseudo-order | 0.15 | Ordre des pseudo-headers H2 | +| D4 — PRIORITY frames | 0.10 | Présence de frames PRIORITY | +| D5 — HTTP headers | 0.15 | Cohérence des headers HTTP | +| D6 — TLS structure | 0.10 | Famille TLS (JA4) | +| D7 — JA4 dict | 0.05 | Lookup dans le dictionnaire JA4 navigateurs | + +**Dépendance** : `browser_signatures.py` (signatures statiques), `config.py` (`BROWSER_CONFIDENCE_THRESHOLD`). + +--- + +### 3.4c `browser_matcher_dynamic.py` — Scoring H2 dynamique temps réel + +**Rôle** : Scoring des sessions HTTP/2 contre les profils auto-appris (centroïdes HDBSCAN). +Remplace le dictionnaire statique pour l'adaptation automatique aux nouvelles versions de navigateurs. + +**Fonctions exportées** : + +| Fonction | Description | +|----------|-------------| +| `get_dynamic_matcher()` | Singleton du chargeur/scorer | +| `load_dynamic_profiles(client, force)` | Charge les profils depuis `auto_browser_profiles` (refresh 24h) | +| `score_session_dynamic(session)` | Score une session → `(famille, score)` | +| `score_sessions_batch_dynamic(df)` | Score un batch (ajoute `dynamic_family`, `dynamic_score`) | + +**Pipeline de scoring** : + +1. Chargement des profils en mémoire depuis `ja4_processing.auto_browser_profiles` +2. Pour chaque session : rejet rapide (pseudo_order incompatible ou tolérance dépassée) +3. Similarité pondérée : `h2_window_update` (0.40), `pseudo_order` (0.40), `h2_initial_window_size` (0.10), `h2_has_priority` (0.10) +4. Confiance volumétrique : `min(1.0, log10(count_ips + 1) / 4)` + +--- + +### 3.4d `profile_builder.py` — Profiling HDBSCAN hors-ligne + +**Rôle** : Pipeline quotidien (cron) qui clusterise les sessions H2 similaires, +calcule les centroïdes, et gère le cycle de vie des profils dynamiques. + +**Fonction exportée** : + +| Fonction | Description | +|----------|-------------| +| `run_profile_builder(client)` | Pipeline complet : extraction → HDBSCAN → centroïdes → fusion → persistance → lifecycle | + +**Pipeline interne** : + +1. `_fetch_profiling_data()` — Lit `view_h2_profiling_raw`, déduplique par IP, limite 2M lignes +2. `_cluster_sessions()` — HDBSCAN (`min_cluster_size=1000`) sur variables mixtes (StandardScaler + brut) +3. `_compute_centroids()` — Moyenne + 3σ (tolérance) pour continues, mode pour catégorielles +4. `_label_family()` — Analyse des UAs → `Auto_Chrome`, `Auto_Firefox`, `Auto_Safari`, `Auto_Unknown` +5. `_merge_profiles()` — Fusion des clusters redondants (même famille + pseudo_order + WU < 5%) +6. `_persist_profiles()` — INSERT INTO `auto_browser_profiles` (ReplacingMergeTree) +7. `_update_last_seen()` — Rafraîchit les profils actifs (IPs vues dans les dernières 24h) +8. `_purge_stale_profiles()` — Supprime les profils > 14 jours + +**CLI** : `python -m bot_detector.profile_builder` + +--- + ### 3.5 `preprocessing.py` — Prétraitement des données **Rôle** : Nettoyage des DataFrames et définition des listes de features. diff --git a/services/bot-detector/IMPROVEMENTS.md b/services/bot-detector/IMPROVEMENTS.md index 2f86614..4c07e72 100644 --- a/services/bot-detector/IMPROVEMENTS.md +++ b/services/bot-detector/IMPROVEMENTS.md @@ -1,6 +1,6 @@ # Bot Detector IA — Axes d'amélioration -> Suivi d'implémentation — mis à jour le 2025-07-15 | Architecture modulaire (11 modules) +> Suivi d'implémentation — mis à jour le 2026-04-13 | Architecture modulaire (16 modules) --- @@ -259,7 +259,7 @@ est utilisé pour le score final combiné (EIF+AE+XGB) et l'insertion dans ## Notes d'implémentation générales - **Compatibilité** : toute amélioration doit rester rétrocompatible avec le schéma `ml_detected_anomalies` existant (ajout de colonnes optionnelles uniquement) -- **Architecture modulaire** : le code est réparti en 11 modules (voir `DOCUMENTATION.md` §1.1), chaque amélioration touche un ou deux modules spécifiques +- **Architecture modulaire** : le code est réparti en 16 modules (voir `DOCUMENTATION.md` §1.1), chaque amélioration touche un ou deux modules spécifiques - **Tests** : 36 tests auto-contenus dans `tests/test_detector.py`, exécutables via `make test-bot-detector` - **Feature flags** : les fonctionnalités sont activables via variables d'environnement (`ENABLE_SHAP`, `ENABLE_CLUSTERING`, `ENABLE_MULTIWINDOW`, `ENABLE_FEEDBACK`) - **Imports optionnels** : `isotree`, `torch`, `xgboost`, `shap`, `hdbscan` sont tous optionnels avec fallbacks (`config.py`) diff --git a/services/bot-detector/bot_detector/browser_matcher_dynamic.py b/services/bot-detector/bot_detector/browser_matcher_dynamic.py new file mode 100644 index 0000000..d6808e3 --- /dev/null +++ b/services/bot-detector/bot_detector/browser_matcher_dynamic.py @@ -0,0 +1,387 @@ +"""Moteur de scoring dynamique des sessions H2 (browser_matcher_dynamic). + +Remplace le dictionnaire statique browser_signatures.py par un scoring +basé sur les profils auto-appris (auto_browser_profiles). + +Ce module est appelé pour chaque session lors du cycle ML de 300s. +Au démarrage, il charge les profils en mémoire et les rafraîchit toutes +les 24h (même cadence que le profile_builder). + +Architecture : + auto_browser_profiles (ClickHouse) + ↓ (chargement initial + refresh 24h) + _DynamicMatcher (singleton en mémoire) + ↓ (score_session par session) + (family, score) → pipeline ML + +Calcul du score : + Score = Σ (poids_i × similarité_i) × confiance_volumétrique + + Similarité continue (window_update, initial_window_size) : + sim = 1 - |x - centroid| / range + où range = max(|centroid|, 1) pour la normalisation + + Similarité catégorielle (pseudo_order, has_priority) : + sim = 1.0 si match exact, 0.0 sinon (forte discriminabilité) + + Confiance volumétrique : + conf = min(1.0, log10(count_ips + 1) / 4) + Un profil basé sur 10 000 IPs est plus fiable qu'un profil basé sur 100. + +Pondération des dimensions : + h2_window_update : 0.40 (le plus discriminant entre familles) + pseudo_order : 0.40 (ordre des pseudo-headers, invariant par version) + h2_initial_window_size : 0.10 + h2_has_priority : 0.10 +""" + +import math +import time +from dataclasses import dataclass +from typing import Optional + +import numpy as np +import pandas as pd + +from .config import DB +from .log import log_info + + +# --------------------------------------------------------------------------- +# Constantes +# --------------------------------------------------------------------------- + +# Poids des dimensions (somme = 1.0) +_WEIGHT_WINDOW_UPDATE: float = 0.40 +_WEIGHT_PSEUDO_ORDER: float = 0.40 +_WEIGHT_INITIAL_WINDOW: float = 0.10 +_WEIGHT_HAS_PRIORITY: float = 0.10 + +# Mapping string → id (cohérent avec le multiIf SQL) +_PSEUDO_ORDER_MAP: dict[str, int] = { + "m,a,s,p": 1, + "m,p,a,s": 2, + "m,s,p,a": 3, + "m,p,s,a": 4, + "m,a,p,s": 5, +} + +# Intervalle de rafraîchissement des profils (secondes) +_REFRESH_INTERVAL: float = 86400.0 # 24 heures + + +# --------------------------------------------------------------------------- +# Data class pour un profil chargé en mémoire +# --------------------------------------------------------------------------- + +@dataclass +class _Profile: + """Profil navigateur en mémoire pour le scoring temps réel. + + Attributes: + profile_id: Identifiant unique du profil. + family: Famille détectée (Auto_Chrome, Auto_Firefox, etc.). + count_ips: Nombre d'IPs dans le cluster source. + iws_mean: Moyenne de h2_initial_window_size. + wu_mean: Moyenne de h2_window_update. + wu_tol: Tolérance sur window_update (mean + 3σ). + po_mode: Mode de pseudo_order_id (1-5). + prio_mode: Mode de h2_has_priority (0 ou 1). + """ + profile_id: str + family: str + count_ips: int + iws_mean: int + wu_mean: int + wu_tol: int + po_mode: int + prio_mode: int + + +# --------------------------------------------------------------------------- +# Scorer +# --------------------------------------------------------------------------- + +class _DynamicMatcher: + """Chargeur et scorer des profils dynamiques. + + Thread-safety : non requis. Le bot_detector est mono-thread (cycle séquentiel). + """ + + def __init__(self): + self._profiles: list[_Profile] = [] + self._last_load: float = 0.0 + self._loaded: bool = False + + # --- Chargement --- + + def load_profiles(self, client, force: bool = False) -> bool: + """Charge les profils depuis auto_browser_profiles en mémoire. + + Ne recharge que toutes les 24h sauf si force=True. + Si la table est vide ou n'existe pas, conserve les profils existants. + + Args: + client: Client ClickHouse. + force: Forcer le rechargement même si < 24h. + + Returns: + True si les profils ont été (re)chargés. + """ + now = time.time() + if not force and self._loaded and (now - self._last_load < _REFRESH_INTERVAL): + return False + + try: + df = client.query_df( + f"SELECT * FROM {DB}.auto_browser_profiles FINAL" + ) + except Exception as e: + log_info(f"[dynamic_matcher] Erreur chargement profils: {e}") + return False + + if df is None or df.empty: + if not self._loaded: + log_info("[dynamic_matcher] Aucun profil dynamique disponible.") + return False + + profiles = [] + for _, row in df.iterrows(): + profiles.append(_Profile( + profile_id=str(row["profile_id"]), + family=str(row["detected_family"]), + count_ips=int(row["count_ips"]), + iws_mean=int(row["h2_initial_window_size_mean"]), + wu_mean=int(row["h2_window_update_mean"]), + wu_tol=int(row["h2_window_update_tol"]), + po_mode=int(row["pseudo_order_mode"]), + prio_mode=int(row["h2_has_priority_mode"]), + )) + + self._profiles = profiles + self._last_load = now + self._loaded = True + log_info(f"[dynamic_matcher] {len(profiles)} profil(s) chargé(s).") + return True + + @property + def profiles(self) -> list[_Profile]: + """Accès en lecture seule aux profils chargés.""" + return self._profiles + + @property + def is_loaded(self) -> bool: + """Indique si les profils ont été chargés au moins une fois.""" + return self._loaded + + # --- Scoring --- + + def score_session(self, session: dict) -> tuple[str, float]: + """Score une session contre tous les profils chargés. + + Pipeline de scoring : + 1. Extraction du vecteur H2 de la session + 2. Pour chaque profil : + a. Rejet rapide si pseudo_order incompatible + b. Rejet rapide si window_update dépasse la tolérance + c. Calcul de la similarité pondérée par dimension + d. Application de la confiance volumétrique + 3. Retourne la famille et le score du meilleur match + + Args: + session: dict avec clés h2_initial_window_size, h2_window_update, + h2_pseudo_order (str), h2_has_priority (0/1). + + Returns: + (famille, score) : famille = "Auto_*" ou "" si aucun match, + score entre 0.0 et 1.0. + """ + if not self._profiles: + return ("", 0.0) + + # Extraction du vecteur session + s_iws = int(session.get("h2_initial_window_size", -1)) + s_wu = int(session.get("h2_window_update", 0)) + s_po_raw = str(session.get("h2_pseudo_order", "")) + s_po = _PSEUDO_ORDER_MAP.get(s_po_raw, 0) + s_prio = int(session.get("h2_has_priority", 0)) + + # Si la session n'a pas de données H2 valides, pas de scoring + if s_po == 0 and s_wu == 0: + return ("", 0.0) + + best_family = "" + best_score = 0.0 + + for p in self._profiles: + # --- Rejet rapide 1 : pseudo_order incompatible --- + # Si la session a un pseudo_order et le profil en exige un différent, + # c'est un mismatch immédiat. On saute ce profil. + if s_po != 0 and p.po_mode != 0 and s_po != p.po_mode: + continue + + # --- Rejet rapide 2 : window_update hors tolérance --- + # Si |wu_session - wu_profil| > tolérance, la session est trop éloignée + if p.wu_mean > 0 and abs(s_wu - p.wu_mean) > p.wu_tol: + continue + + # --- Calcul de la similarité pondérée --- + score = self._compute_weighted_score(s_iws, s_wu, s_po, s_prio, p) + + # --- Confiance volumétrique --- + # score *= min(1.0, log10(count_ips + 1) / 4) + # count_ips=10000 → log10(10001)/4 ≈ 1.0 → confiance max + # count_ips=100 → log10(101)/4 ≈ 0.50 → demi-confiance + volumetric = min(1.0, math.log10(p.count_ips + 1) / 4.0) + score *= volumetric + + if score > best_score: + best_score = score + best_family = p.family + + # Plafonnement à [0, 1] + return (best_family, min(1.0, max(0.0, best_score))) + + def score_sessions_batch(self, df: pd.DataFrame) -> pd.DataFrame: + """Score un batch de sessions (vectorisé pour la performance). + + Args: + df: DataFrame avec colonnes H2 (h2_initial_window_size, + h2_window_update, h2_pseudo_order, h2_has_priority). + + Returns: + DataFrame d'origine avec colonnes ajoutées : + - dynamic_family (str) + - dynamic_score (float) + """ + df = df.copy() + families = [] + scores = [] + + # Construction d'un dict par session pour le scoring + for _, row in df.iterrows(): + session = { + "h2_initial_window_size": row.get("h2_initial_window_size", -1), + "h2_window_update": row.get("h2_window_update", 0), + "h2_pseudo_order": row.get("h2_pseudo_order", ""), + "h2_has_priority": row.get("h2_has_priority", 0), + } + fam, sc = self.score_session(session) + families.append(fam) + scores.append(sc) + + df["dynamic_family"] = families + df["dynamic_score"] = scores + return df + + # --- Calcul de distance --- + + @staticmethod + def _compute_weighted_score( + s_iws: int, s_wu: int, s_po: int, s_prio: int, profile: _Profile + ) -> float: + """Calcule le score de similarité pondéré entre une session et un profil. + + Formule : + score = Σ (w_i × sim_i) + + Similarité continue (window_update, initial_window_size) : + sim = max(0, 1 - |x - μ| / max(|μ|, 1)) + Normalisation par la valeur absolue du centroïde pour que la + distance soit relative (une déviation de 1000 est négligeable + pour un centroïde de 15M, mais significative pour un de 5000). + + Similarité catégorielle (pseudo_order, has_priority) : + sim = 1.0 si match exact, 0.0 sinon + Rationnel : l'ordre des pseudo-headers est un signal binaire fort — + il ne varie pas au sein d'une même version de navigateur. + + Returns: + Score brut (non plafonné) dans [0, ~1.5] avant confiance volumétrique. + """ + score = 0.0 + + # --- Dimension 1 : h2_window_update (poids 0.40) --- + if profile.wu_mean > 0: + delta = abs(s_wu - profile.wu_mean) + # Normalisation par la valeur absolue du centroïde + norm = max(abs(profile.wu_mean), 1) + sim_wu = max(0.0, 1.0 - delta / norm) + elif s_wu == 0 and profile.wu_mean == 0: + sim_wu = 1.0 # Les deux sont à 0 = match parfait + else: + sim_wu = 0.0 + score += _WEIGHT_WINDOW_UPDATE * sim_wu + + # --- Dimension 2 : pseudo_order (poids 0.40) --- + if s_po != 0 and s_po == profile.po_mode: + sim_po = 1.0 + elif s_po == 0 or profile.po_mode == 0: + sim_po = 0.0 # Inconnu = pas de signal + else: + sim_po = 0.0 # Mismatch + score += _WEIGHT_PSEUDO_ORDER * sim_po + + # --- Dimension 3 : h2_initial_window_size (poids 0.10) --- + if s_iws > 0 and profile.iws_mean > 0: + delta = abs(s_iws - profile.iws_mean) + norm = max(abs(profile.iws_mean), 1) + sim_iws = max(0.0, 1.0 - delta / norm) + elif s_iws <= 0 and profile.iws_mean <= 0: + sim_iws = 1.0 # Les deux absents + else: + sim_iws = 0.0 + score += _WEIGHT_INITIAL_WINDOW * sim_iws + + # --- Dimension 4 : h2_has_priority (poids 0.10) --- + sim_prio = 1.0 if s_prio == profile.prio_mode else 0.0 + score += _WEIGHT_HAS_PRIORITY * sim_prio + + return score + + +# --------------------------------------------------------------------------- +# Singleton +# --------------------------------------------------------------------------- + +_matcher: Optional[_DynamicMatcher] = None + + +def get_dynamic_matcher() -> _DynamicMatcher: + """Retourne le singleton _DynamicMatcher (lazy initialization).""" + global _matcher + if _matcher is None: + _matcher = _DynamicMatcher() + return _matcher + + +def load_dynamic_profiles(client=None, force: bool = False) -> bool: + """Charge les profils dynamiques en mémoire (convenience wrapper). + + Appelé au démarrage du bot_detector et une fois par cycle (déduplication + interne par intervalle 24h). + """ + if client is None: + from .infra import get_client + client = get_client() + return get_dynamic_matcher().load_profiles(client, force=force) + + +def score_session_dynamic(session: dict) -> tuple[str, float]: + """Score une session H2 contre les profils dynamiques (convenience wrapper). + + Args: + session: dict avec clés h2_*. Voir _DynamicMatcher.score_session. + + Returns: + (famille, score) — famille "" si aucun match, score ∈ [0.0, 1.0]. + """ + return get_dynamic_matcher().score_session(session) + + +def score_sessions_batch_dynamic(df: pd.DataFrame) -> pd.DataFrame: + """Score un batch de sessions (convenience wrapper). + + Ajoute les colonnes dynamic_family et dynamic_score au DataFrame. + """ + return get_dynamic_matcher().score_sessions_batch(df) diff --git a/services/bot-detector/bot_detector/profile_builder.py b/services/bot-detector/bot_detector/profile_builder.py new file mode 100644 index 0000000..ecd233a --- /dev/null +++ b/services/bot-detector/bot_detector/profile_builder.py @@ -0,0 +1,614 @@ +"""Moteur de profiling dynamique automatique des navigateurs (profile_builder). + +Lancé via cron quotidiennement, ce script : + 1. Lit la vue view_h2_profiling_raw (sessions H2 filtrées) + 2. Clusterise les sessions similaires via HDBSCAN (min_cluster_size=1000) + 3. Calcule les centroïdes (moyenne + 3σ pour les variables continues, + mode pour les catégorielles) + 4. Labelise les clusters par famille (Auto_Chrome, Auto_Firefox, etc.) + 5. Fusionne les clusters redondants (même famille + pseudo_order + window_update proches) + 6. Écrit les profils dans ja4_processing.auto_browser_profiles + 7. Gère le cycle de vie : mise à jour last_seen_date, purge > 14 jours + +Usage CLI : + python -m bot_detector.profile_builder + +Architecture : + http_logs → view_h2_profiling_raw → HDBSCAN → auto_browser_profiles + ↓ + browser_matcher_dynamic.py (scoring) +""" + +import hashlib +import re +import uuid +from collections import Counter +from datetime import date, datetime, timedelta + +import numpy as np +import pandas as pd + +from .config import HDBSCAN_AVAILABLE, DB +from .log import log_info + +# --------------------------------------------------------------------------- +# Constantes +# --------------------------------------------------------------------------- + +# Taille minimale de cluster pour HDBSCAN (évite le bruit statistique) +_MIN_CLUSTER_SIZE: int = 1000 + +# Poids des variables dans l'espace de clustering. +# Les variables continues sont normalisées avant HDBSCAN, +# les catégorielles sont encodées en one-hot pour la distance. +_CONTINUOUS_COLS = ["h2_initial_window_size", "h2_window_update"] +_CATEGORICAL_COLS = ["pseudo_order_id", "h2_has_priority"] +_EMBEDDING_COLS = _CONTINUOUS_COLS + _CATEGORICAL_COLS + +# Nombre max de jours de lookback pour la requête de profiling +_LOOKBACK_DAYS: int = 7 + +# Seuil de similarité pour la fusion des clusters : si deux profils de même +# famille ont des h2_window_update à moins de _MERGE_TOLERANCE_RATIO (5%), +# ils sont fusionnés en un seul profil (moyenne des centroïdes). +_MERGE_TOLERANCE_RATIO: float = 0.05 + +# Durée de rétention des profils inactifs (en jours) +_PROFILE_TTL_DAYS: int = 14 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _label_family(ua_series: pd.Series) -> str: + """Attribue une famille navigateur à un cluster en analysant les User-Agents. + + Logique : + - Si > 50% des UAs contiennent "Chrome" ou "Chromium" → Auto_Chrome + - Si > 50% des UAs contiennent "Firefox" → Auto_Firefox + - Si > 50% des UAs contiennent "Safari" (mais pas Chrome) → Auto_Safari + - Sinon → Auto_Unknown + + Le test Safari exclut "Chrome" car Chrome sur iOS se déclare "Safari" + dans son UA mais avec un moteur Blink, ce qui fausserait le label. + """ + uas = ua_series.fillna("").astype(str).str.lower() + n = len(uas) + if n == 0: + return "Auto_Unknown" + + chrome_ratio = uas.str.contains("chrome|chromium|edg|opr", regex=True).sum() / n + firefox_ratio = uas.str.contains("firefox", regex=False).sum() / n + # Safari "pur" : contient Safari mais PAS Chrome (pour exclure Chrome iOS) + safari_mask = uas.str.contains("safari", regex=False) & ~uas.str.contains("chrome", regex=False) + safari_ratio = safari_mask.sum() / n + + if chrome_ratio > 0.50: + return "Auto_Chrome" + if firefox_ratio > 0.50: + return "Auto_Firefox" + if safari_ratio > 0.50: + return "Auto_Safari" + return "Auto_Unknown" + + +def _mode(series: pd.Series): + """Retourne le mode (valeur la plus fréquente) d'une série, ou 0 si vide.""" + if series.empty: + return 0 + return int(series.mode().iloc[0]) + + +def _compute_tolerance(values: pd.Series, mean: float) -> float: + """Calcule la tolérance = |mean| + 3 * std, avec garde-fous. + + Tolérance = intervalle de confiance à 99.7% (règle des 3 sigma). + Si std = 0 (toutes les valeurs identiques), tolérance = 5% de la moyenne + pour laisser une marge de manoeuvre aux variations mineures de version. + """ + std = float(values.std()) + if std == 0: + # Pas de variance : on garde 5% de marge + return max(abs(mean) * 0.05, 100.0) + return abs(mean) + 3.0 * std + + +def _generate_profile_id(family: str, pseudo_order: int, + window_update_mean: float) -> str: + """Génère un identifiant de profil déterministe basé sur les caractéristiques. + + Utilise SHA-256 tronqué pour éviter les collisions tout en restant lisible. + """ + raw = f"{family}|{pseudo_order}|{int(window_update_mean)}" + h = hashlib.sha256(raw.encode()).hexdigest()[:12] + return f"bp_{family.lower()}_{h}" + + +# --------------------------------------------------------------------------- +# Core pipeline +# --------------------------------------------------------------------------- + +def _fetch_profiling_data(client) -> pd.DataFrame: + """Récupère les sessions H2 depuis view_h2_profiling_raw. + + Requiert le client ClickHouse partagé (ja4_common.clickhouse). + Utilise un lookback de _LOOKBACK_DAYS pour avoir suffisamment de données. + Échantillonne si le volume dépasse 2M lignes (pour la performance HDBSCAN). + + Returns: + DataFrame avec colonnes : src_ip, h2_initial_window_size, + h2_window_update, pseudo_order_id, h2_has_priority, header_user_agent + """ + query = f""" + SELECT + src_ip, + h2_initial_window_size, + h2_window_update, + pseudo_order_id, + h2_has_priority, + header_user_agent + FROM {DB}.view_h2_profiling_raw + WHERE log_date >= today() - {_LOOKBACK_DAYS} + LIMIT 2000000 + """ + df = client.query_df(query) + + if df is None or df.empty: + log_info("[profile_builder] Aucune donnée dans view_h2_profiling_raw.") + return pd.DataFrame() + + # Déduplication par IP + vecteur H2 : on garde une seule observation + # par (IP, pseudo_order_id, h2_window_update) pour éviter qu'une IP + # unique avec 10k requêtes ne fausse le clustering. + df = df.drop_duplicates( + subset=["src_ip", "pseudo_order_id", "h2_window_update", "h2_has_priority"] + ) + + log_info(f"[profile_builder] {len(df)} sessions H2 uniques chargées.") + return df + + +def _cluster_sessions(df: pd.DataFrame) -> pd.DataFrame: + """Applique HDBSCAN sur les features normalisées. + + Pipeline de clustering : + 1. StandardScaler sur les variables continues + 2. Concaténation avec les variables catégorielles (encodage brut, + car ce sont des entiers de faible cardinalité : 0-5 et 0-1) + 3. HDBSCAN avec min_cluster_size=1000, cluster_selection_method='eom' + (Excess of Mass : sélectionne les clusters stables dans la hiérarchie) + 4. Le cluster -1 = bruit (sessions atypiques non rattachées à un cluster) + + Returns: + DataFrame d'origine avec colonne 'cluster_id' ajoutée. + """ + from sklearn.preprocessing import StandardScaler + + df = df.copy() + + if len(df) < _MIN_CLUSTER_SIZE: + log_info( + f"[profile_builder] Volume insuffisant ({len(df)}) pour le clustering " + f"(min={_MIN_CLUSTER_SIZE})." + ) + df["cluster_id"] = -1 + return df + + # Normalisation des variables continues + cont = df[_CONTINUOUS_COLS].replace([np.inf, -np.inf], np.nan).fillna(0) + cont_scaled = StandardScaler().fit_transform(cont) + + # Les catégorielles sont des petits entiers : on les conserve telles quelles + # après StandardScaler sur les continues (elles restent sur leur échelle 0-5) + cat = df[_CATEGORICAL_COLS].fillna(0).values.astype(np.float64) + + X = np.hstack([cont_scaled, cat]) + + if HDBSCAN_AVAILABLE: + import hdbscan + + clusterer = hdbscan.HDBSCAN( + min_cluster_size=_MIN_CLUSTER_SIZE, + min_samples=max(2, _MIN_CLUSTER_SIZE // 10), + cluster_selection_method="eom", + ) + labels = clusterer.fit_predict(X) + else: + from sklearn.cluster import DBSCAN + + labels = DBSCAN(eps=0.5, min_samples=_MIN_CLUSTER_SIZE).fit_predict(X) + + df["cluster_id"] = labels + + n_clusters = len(set(labels)) - (1 if -1 in labels else 0) + algo = "HDBSCAN" if HDBSCAN_AVAILABLE else "DBSCAN" + log_info(f"[profile_builder] {algo}: {n_clusters} cluster(s), {len(df)} sessions.") + + return df + + +def _compute_centroids(df: pd.DataFrame) -> list[dict]: + """Calcule le centroïde de chaque cluster (ignore le bruit cluster_id=-1). + + Pour chaque cluster : + - Variables continues → moyenne et écart-type + Tolérance = |moyenne| + 3 * σ (intervalle de confiance 99.7%) + - Variables catégorielles → mode (valeur la plus fréquente) + - Label de famille → analyse des User-Agents du cluster + + Returns: + Liste de dicts, chaque dict = un profil prêt à être inséré. + """ + profiles = [] + + for cid, group in df.groupby("cluster_id"): + if cid == -1: + continue # Bruit HDBSCAN + + # --- Variables continues --- + iws_mean = float(group["h2_initial_window_size"].mean()) + wu_series = group["h2_window_update"] + wu_mean = float(wu_series.mean()) + wu_tol = _compute_tolerance(wu_series, wu_mean) + + # --- Variables catégorielles --- + po_mode = _mode(group["pseudo_order_id"]) + prio_mode = _mode(group["h2_has_priority"]) + + # --- Labeling --- + family = _label_family(group["header_user_agent"]) + + # --- IPs uniques --- + count_ips = int(group["src_ip"].nunique()) + + profiles.append({ + "profile_id": _generate_profile_id(family, po_mode, wu_mean), + "detected_family": family, + "count_ips": count_ips, + "last_seen_date": date.today(), + "h2_initial_window_size_mean": int(round(iws_mean)), + "h2_window_update_mean": int(round(wu_mean)), + "h2_window_update_tol": int(round(wu_tol)), + "pseudo_order_mode": po_mode, + "h2_has_priority_mode": prio_mode, + }) + + return profiles + + +def _merge_profiles(profiles: list[dict]) -> list[dict]: + """Fusionne les clusters redondants. + + Critères de fusion (deux profils sont fusionnés si TOUS sont vrais) : + 1. Même detected_family (ex: Auto_Chrome) + 2. Même pseudo_order_mode (même ordre des pseudo-headers) + 3. h2_window_update_mean proches : |moyenne_A - moyenne_B| / max(A, B) < 5% + + 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) + + Returns: + Liste de profils dédupliqués et fusionnés. + """ + if not profiles: + return profiles + + merged = [] + used = set() + + # Grouper par (famille, pseudo_order) — prérequis pour la fusion + buckets: dict[tuple, list[int]] = {} + for i, p in enumerate(profiles): + key = (p["detected_family"], p["pseudo_order_mode"]) + buckets.setdefault(key, []).append(i) + + for key, indices in buckets.items(): + # Parcourir les profils du bucket et fusionner les proches + cluster_groups: list[list[int]] = [] + + for idx in indices: + if idx in used: + continue + + # Nouveau groupe de fusion + group = [idx] + used.add(idx) + + # Chercher d'autres profils fusionnables dans ce bucket + for other_idx in indices: + if other_idx in used: + continue + + # Vérifier la proximité de window_update + wu_a = profiles[idx]["h2_window_update_mean"] + wu_b = profiles[other_idx]["h2_window_update_mean"] + + if wu_a == 0 and wu_b == 0: + # Les deux sont à 0 : fusionnable + group.append(other_idx) + used.add(other_idx) + elif max(wu_a, wu_b) > 0: + ratio = abs(wu_a - wu_b) / max(wu_a, wu_b) + if ratio < _MERGE_TOLERANCE_RATIO: + group.append(other_idx) + used.add(other_idx) + + cluster_groups.append(group) + + # Construire le profil fusionné pour chaque groupe + for group in cluster_groups: + if len(group) == 1: + merged.append(profiles[group[0]]) + continue + + # Moyenne pondérée par count_ips + total_count = sum(profiles[i]["count_ips"] for i in group) + w_iws = sum( + profiles[i]["h2_initial_window_size_mean"] * profiles[i]["count_ips"] + for i in group + ) / total_count + w_wu = sum( + profiles[i]["h2_window_update_mean"] * profiles[i]["count_ips"] + for i in group + ) / total_count + + # Tolérance = max des tolérances du groupe (on élargit le seuil) + w_tol = max(profiles[i]["h2_window_update_tol"] for i in group) + + # Mode majoritaire (le plus fréquent parmi les membres) + prio_modes = Counter(profiles[i]["h2_has_priority_mode"] for i in group) + + merged.append({ + "profile_id": _generate_profile_id( + key[0], key[1], w_wu + ), + "detected_family": key[0], + "count_ips": total_count, + "last_seen_date": date.today(), + "h2_initial_window_size_mean": int(round(w_iws)), + "h2_window_update_mean": int(round(w_wu)), + "h2_window_update_tol": int(round(w_tol)), + "pseudo_order_mode": key[1], + "h2_has_priority_mode": prio_modes.most_common(1)[0][0], + }) + + return merged + + +def _persist_profiles(client, profiles: list[dict]) -> int: + """Insère les profils dans auto_browser_profiles via ReplacingMergeTree. + + Le moteur ReplacingMergeTree(created_at) déduplique automatiquement + sur ORDER BY (detected_family, profile_id) : si un profil avec le même + profile_id existe déjà, seule la version la plus récente est conservée. + + Returns: + Nombre de profils insérés. + """ + if not profiles: + return 0 + + columns = [ + "profile_id", "detected_family", "count_ips", "last_seen_date", + "h2_initial_window_size_mean", "h2_window_update_mean", + "h2_window_update_tol", "pseudo_order_mode", "h2_has_priority_mode", + "created_at", + ] + + now = datetime.now() + rows = [ + ( + p["profile_id"], + p["detected_family"], + p["count_ips"], + p["last_seen_date"], + p["h2_initial_window_size_mean"], + p["h2_window_update_mean"], + p["h2_window_update_tol"], + p["pseudo_order_mode"], + p["h2_has_priority_mode"], + now, + ) + for p in profiles + ] + + client.insert(f"{DB}.auto_browser_profiles", rows, column_names=columns) + return len(rows) + + +# --------------------------------------------------------------------------- +# Partie 4 : Cycle de vie des profils +# --------------------------------------------------------------------------- + +def _update_last_seen(client) -> int: + """Met à jour last_seen_date = today() pour les profils dont des IPs + ont été vues dans les logs des dernières 24h. + + Pour chaque profil actif, on vérifie si le vecteur (pseudo_order_mode, + h2_window_update_mean) correspond à des sessions récentes dans + view_h2_profiling_raw. Si oui, last_seen_date est mis à jour. + + Implémentation : INSERT INTO avec les profils existants mis à jour + (ReplacingMergeTree garantit la déduplication). + + Returns: + Nombre de profils rafraîchis. + """ + today_str = date.today().isoformat() + + # Récupérer les profils existants + existing = client.query_df( + f"SELECT * FROM {DB}.auto_browser_profiles FINAL" + ) + if existing is None or existing.empty: + return 0 + + # Récupérer les vecteurs H2 observés dans les dernières 24h + recent = client.query_df(f""" + SELECT + pseudo_order_id, + avg(toUInt64(h2_window_update)) AS avg_wu + FROM {DB}.view_h2_profiling_raw + WHERE log_date >= today() - 1 + GROUP BY pseudo_order_id + """) + + if recent is None or recent.empty: + return 0 + + # Construire un set des (pseudo_order, window_update) observés récemment + # avec une tolérance de ±5% sur window_update + recent_set = set() + for _, r in recent.iterrows(): + recent_set.add((int(r["pseudo_order_id"]), float(r["avg_wu"]))) + + # Mettre à jour les profils dont le vecteur correspond + refreshed = 0 + profiles_to_update = [] + + for _, profile in existing.iterrows(): + po = int(profile["pseudo_order_mode"]) + wu_mean = float(profile["h2_window_update_mean"]) + wu_tol = float(profile["h2_window_update_tol"]) + + # Vérifier si un vecteur récent correspond + for (r_po, r_wu) in recent_set: + if r_po != po: + continue + if wu_mean == 0: + continue + if abs(r_wu - wu_mean) <= wu_tol: + profiles_to_update.append(profile) + refreshed += 1 + break + + # Ré-insérer avec last_seen_date = today + if profiles_to_update: + columns = [ + "profile_id", "detected_family", "count_ips", "last_seen_date", + "h2_initial_window_size_mean", "h2_window_update_mean", + "h2_window_update_tol", "pseudo_order_mode", + "h2_has_priority_mode", "created_at", + ] + now = datetime.now() + rows = [ + ( + str(p["profile_id"]), + str(p["detected_family"]), + int(p["count_ips"]), + date.today(), + int(p["h2_initial_window_size_mean"]), + int(p["h2_window_update_mean"]), + int(p["h2_window_update_tol"]), + int(p["pseudo_order_mode"]), + int(p["h2_has_priority_mode"]), + now, + ) + for p in profiles_to_update + ] + client.insert( + f"{DB}.auto_browser_profiles", rows, column_names=columns + ) + + log_info(f"[profile_builder] {refreshed} profil(s) rafraîchi(s) (last_seen = {today_str}).") + return refreshed + + +def _purge_stale_profiles(client) -> int: + """Supprime les profils dont last_seen_date < today() - 14 jours. + + Utilise ALTER TABLE DELETE (mutation ClickHouse) car ReplacingMergeTree + ne supporte pas les DELETE directs. La suppression est asynchrone + (effectuée lors du prochain merge de partitions). + + Returns: + Nombre de profils supprimés. + """ + cutoff = (date.today() - timedelta(days=_PROFILE_TTL_DAYS)).isoformat() + + # Compter d'abord + count_result = client.query( + f"SELECT count() AS n FROM {DB}.auto_browser_profiles " + f"WHERE last_seen_date < '{cutoff}'" + ) + n_stale = count_result.result_rows[0][0] if count_result.result_rows else 0 + + if n_stale > 0: + client.command( + f"ALTER TABLE {DB}.auto_browser_profiles DELETE " + f"WHERE last_seen_date < '{cutoff}'" + ) + log_info( + f"[profile_builder] {n_stale} profil(s) obsolète(s) supprimé(s) " + f"(last_seen < {cutoff})." + ) + + return n_stale + + +# --------------------------------------------------------------------------- +# Pipeline principal +# --------------------------------------------------------------------------- + +def run_profile_builder(client=None): + """Point d'entrée principal du cron quotidien de profiling. + + Pipeline : + 1. Chargement des sessions H2 (view_h2_profiling_raw) + 2. Clustering HDBSCAN + 3. Calcul des centroïdes par cluster + 4. Fusion des clusters redondants + 5. Persistance dans auto_browser_profiles + 6. Mise à jour des last_seen_date + 7. Purge des profils obsolètes (> 14 jours) + + Args: + client: Client ClickHouse (si None, utilise le singleton partagé). + """ + if client is None: + from .infra import get_client + client = get_client() + + log_info("[profile_builder] Début du cycle de profiling quotidien.") + + # --- Étape 1 : Chargement --- + df = _fetch_profiling_data(client) + if df.empty: + log_info("[profile_builder] Arrêt : aucune donnée à profiler.") + return + + # --- Étape 2 : Clustering --- + df = _cluster_sessions(df) + + # --- Étape 3 : Centroïdes --- + profiles = _compute_centroids(df) + if not profiles: + log_info("[profile_builder] Aucun cluster significatif trouvé.") + return + + log_info(f"[profile_builder] {len(profiles)} profil(s) candidat(s) avant fusion.") + + # --- Étape 4 : Fusion --- + profiles = _merge_profiles(profiles) + log_info(f"[profile_builder] {len(profiles)} profil(s) après fusion.") + + # --- Étape 5 : Persistance --- + n_inserted = _persist_profiles(client, profiles) + log_info(f"[profile_builder] {n_inserted} profil(s) inséré(s) dans auto_browser_profiles.") + + # --- Étape 6 : Cycle de vie — rafraîchissement --- + _update_last_seen(client) + + # --- Étape 7 : Cycle de vie — purge --- + _purge_stale_profiles(client) + + log_info("[profile_builder] Cycle de profiling terminé.") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + run_profile_builder() diff --git a/shared/clickhouse/13_h2_profiling.sql b/shared/clickhouse/13_h2_profiling.sql new file mode 100644 index 0000000..b7fd3bb --- /dev/null +++ b/shared/clickhouse/13_h2_profiling.sql @@ -0,0 +1,110 @@ +-- ============================================================================= +-- 13_h2_profiling.sql — Dynamic browser profiling infrastructure +-- +-- Vue matérialisée + table cible pour le clustering HDBSCAN hors-ligne. +-- Le profile_builder.py lit cette vue quotidiennement, clusterise les sessions +-- H2 similaires, et écrit les centroïdes dans auto_browser_profiles. +-- +-- Flux de données : +-- http_logs → view_h2_profiling_raw (filtração + encodage) +-- → profile_builder.py (HDBSCAN + centroïdes) +-- → auto_browser_profiles (profils dynamiques) +-- → browser_matcher_dynamic.py (scoring temps réel) +-- ============================================================================= + + +-- ----------------------------------------------------------------------------- +-- auto_browser_profiles — centroïdes de profils navigateur auto-appris +-- +-- Chaque ligne = un profil issu du clustering HDBSCAN. +-- Le scoring temps réel compare les sessions entrantes à ces centroïdes. +-- Les profils obsolètes (> 14 jours sans observation) sont purgés par le cron. +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS ja4_processing.auto_browser_profiles +( + profile_id String, + detected_family LowCardinality(String), + count_ips UInt64, + last_seen_date Date, + + -- Variables continues : moyenne + h2_initial_window_size_mean Int64, + h2_window_update_mean UInt64, + + -- Tolérance sur window_update = mean + 3*std (plafonnée à la valeur mean) + h2_window_update_tol UInt64, + + -- Variables catégorielles : mode (valeur la plus fréquente dans le cluster) + pseudo_order_mode UInt8, + h2_has_priority_mode UInt8, + + created_at DateTime DEFAULT now() +) +ENGINE = ReplacingMergeTree(created_at) +ORDER BY (detected_family, profile_id) +TTL last_seen_date + INTERVAL 14 DAY +SETTINGS + index_granularity = 8192, + ttl_only_drop_parts = 1; + + +-- ----------------------------------------------------------------------------- +-- view_h2_profiling_raw — extraction du vecteur H2 pour le clustering +-- +-- Filtre le trafic de bots évidents et encode les variables catégorielles. +-- Cette vue est consommée par profile_builder.py lors du cron quotidien. +-- +-- Règles de filtrage : +-- 1. sec_fetch_absence_rate > 0.5 ET (h2_initial_window_size = -1 OU 65535) +-- → trafic curl/bots sans fingerprints H2 valides +-- 2. h2_window_update = 0 (absent du preface client) +-- 3. h2_pseudo_order vide (pas de HTTP/2 détecté) +-- +-- Encodage pseudo_order → UInt8 : +-- 1 = "m,a,s,p" (Chrome/Safari) 2 = "m,p,a,s" (Firefox ancien) +-- 3 = "m,s,p,a" 4 = "m,p,s,a" (curl/Firefox) +-- 5 = "m,a,p,s" 0 = inconnu +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE VIEW ja4_processing.view_h2_profiling_raw AS +SELECT + src_ip, + -- Variable cible pour le labeling du cluster + header_user_agent, + + -- === Variables continues (embedding) === + h2_initial_window_size, + h2_window_update, + + -- === Encodage catégoriel === + multiIf( + h2_pseudo_order = 'm,a,s,p', 1, + h2_pseudo_order = 'm,p,a,s', 2, + h2_pseudo_order = 'm,s,p,a', 3, + h2_pseudo_order = 'm,p,s,a', 4, + h2_pseudo_order = 'm,a,p,s', 5, + 0 + ) AS pseudo_order_id, + + h2_has_priority, + + -- === Agrégation utile pour le labeling === + -- Taux d'absence Sec-Fetch sur la session (= count_no_sec_fetch / (hits+1)) + -- Approximé ici au niveau log individuel : 1 si absent, 0 si présent + toUInt8(IF(length(header_sec_fetch_site) = 0, 1, 0)) AS sec_fetch_missing, + + -- Taille du header table H2 (-1 = absent) + h2_header_table_size, + + log_date + +FROM ja4_logs.http_logs +WHERE + -- HTTP/2 uniquement : pseudo_order non vide + h2_pseudo_order != '' + -- Exclure les sessions sans WINDOW_UPDATE (curl, outils basiques) + AND h2_window_update > 0 + -- Exclure les fingerprints manifestement bots : + -- h2_initial_window_size = -1 (absent) ou 65535 (curl/python) + AND h2_initial_window_size NOT IN (-1, 65535) + -- Exclure le trafic sans UA (scanners) + AND header_user_agent != '';