[<< Sommaire](README.md) | [Suivant >>](07_discussion_limites.md) --- ## 5. Techniques comportementales avancées ### Introduction Cette section présente huit techniques originales développées pour adresser les **angles morts** des systèmes de détection existants. Ces angles morts ont été identifiés par analyse systématique des faux négatifs observés en production : sessions mal classifiées comme humaines malgré un comportement automatisé, ou sessions légitimes pénalisées par des features trop sensibles au contexte réseau. Les huit techniques couvrent l'intégralité de la chaîne de détection : analyse des séquences de navigation (§5.1), détection de flottes distribuées (§5.2), fingerprinting temporel (§5.3), arbre de dépendances de ressources (§5.4), dérive intra-session de fingerprint (§5.5), analyse DNS passive (§5.6, non implémentée), invariant de compression (§5.7, non implémentée), et session multi-domaine (§5.8). **Récapitulatif des statuts** : | Section | Technique | Statut | |---------|-----------|--------| | 5.1 | Path Sequence Entropy | `[impl.]` | | 5.2 | Bipartite Bot Fleet Detection | `[impl.]` | | 5.3 | Request Cadence Fingerprint | `[impl.]` | | 5.4 | Resource Dependency Tree | `[impl.]` | | 5.5 | Intra-Session JA4 Drift | `[impl.]` | | 5.6 | DNS Shadow Analysis | `[todo]` | | 5.7 | Compression Ratio Invariant | `[todo]` | | 5.8 | Cross-Domain Session Linking | `[impl.]` | --- ### 5.1 Entropie de séquence de chemins (Path Sequence Entropy) `[impl.]` #### Constat et motivation La feature `path_diversity_ratio` mesure la **diversité** des chemins parcourus (nombre de chemins distincts / nombre total de requêtes), mais non leur **ordre**. Cette distinction est fondamentale : un crawler sophistiqué peut randomiser l'ordre de visite des pages pour augmenter la diversité apparente, sans reproduire les transitions naturelles d'un comportement humain. La navigation humaine suit des patterns séquentiels prévisibles déterminés par l'architecture du site : page d'accueil → catégorie → produit → panier d'achat. Ces transitions ont une structure probabiliste stable que les crawlers systématiques ne reproduisent pas, même avec randomisation. #### Modèle mathématique : chaîne de Markov du premier ordre Une **chaîne de Markov du premier ordre** modélise un système dans lequel l'état suivant dépend uniquement de l'état courant (propriété d'absence de mémoire, aussi appelée propriété markovienne). Pour les séquences de chemins, chaque chemin est un état, et la probabilité de transition est : ``` P(chemin_j | chemin_i) = count(i → j) / count(i) ``` où `count(i → j)` est le nombre de fois que le chemin `i` est immédiatement suivi du chemin `j`, et `count(i)` est le nombre total de départs depuis `i`. L'**entropie de la matrice de transition** mesure l'imprévisibilité des transitions : ``` H_transition = -Σ_{i,j} P(p_i → p_j) × log₂(P(p_i → p_j)) ``` Interprétation : - **Entropie élevée** : transitions imprévisibles — caractéristique d'une navigation humaine organique avec des chemins variés - **Entropie faible** : transitions prévisibles — caractéristique d'un crawler systématique (lexicographique, profondeur d'abord) - **Entropie nulle** : transition unique constante — bot répétant exactement le même chemin #### Résistance à la rotation de diversité Un bot qui randomise aléatoirement l'ordre de ses chemins augmente `path_diversity_ratio` mais produit une matrice de transition **quasi-uniforme** (entropie maximale). Or, la navigation humaine réelle possède des transitions **structurées** : les transitions produit → panier sont fréquentes, les transitions panier → page d'accueil sont rares. L'entropie maximale (uniforme) est donc elle-même un signal anormal, distinguable de l'entropie modérée mais structurée de la navigation humaine. La combinaison `(path_diversity_ratio, path_transition_entropy)` forme un espace bidimensionnel où les clusters bots et humains sont mieux séparés qu'avec une feature seule. #### Implémentation **Table ClickHouse** : `agg_path_sequences_1h` - Colonnes : `(src_ip, ja4, session_id, groupArray(path) AS path_sequence)` - Agrégation par session sur fenêtre glissante d'une heure **Vue** : `view_thesis_features_1h` - Colonne : `path_transition_entropy` **SQL de calcul** : ```sql -- Normalisation des préfixes à profondeur 2 -- ex. /shop/product/123 → /shop/product WITH normalized_paths AS ( SELECT session_id, arrayMap(p -> arrayStringConcat( arraySlice(splitByChar('/', p), 1, 3), '/'), path_sequence ) AS norm_seq FROM agg_path_sequences_1h ), -- Calcul des transitions transitions AS ( SELECT session_id, arrayZip( arraySlice(norm_seq, 1, length(norm_seq) - 1), arraySlice(norm_seq, 2) ) AS transition_pairs FROM normalized_paths ) -- L'entropie finale est calculée via UDF Python (scipy.stats.entropy) -- ou approximée par la formule SQL suivante : SELECT session_id, -sum(p * log2(p)) AS path_transition_entropy FROM ( SELECT session_id, count() / sum(count()) OVER (PARTITION BY session_id) AS p FROM transitions ARRAY JOIN transition_pairs AS tp GROUP BY session_id, tp ) GROUP BY session_id; ``` La feature `path_transition_entropy` est intégrée dans le vecteur feature de `bot_detector` (famille F7 — Comportement navigation). --- ### 5.2 Graphe de co-occurrence JA4×ASN (Bipartite Bot Fleet Detection) `[impl.]` #### Constat et motivation La feature `ja4_asn_concentration` mesure la concentration d'une paire (JA4, ASN) donnée — une valeur élevée signale un usage anormal d'une combinaison spécifique. Cependant, les botnets sophistiqués utilisent des **dizaines de JA4 et d'ASN en rotation** : chaque paire (JA4, ASN) individuelle est statistiquement anodine, mais le **pattern global de co-occurrence** révèle la coordination. Un botnet distribuant ses requêtes sur 20 ASN différents, avec 5 JA4 distincts par ASN, produira 100 paires (JA4, ASN) différentes, chacune avec une concentration faible — indétectable par `ja4_asn_concentration` seul. Pourtant, ces 5 JA4 co-apparaissent systématiquement dans les mêmes ASN, formant une signature de flotte détectable par analyse de graphe. #### Modèle mathématique : graphe bipartite et détection de communautés Un **graphe bipartite** G = (U ∪ V, E) est un graphe dont les sommets se répartissent en deux ensembles disjoints (ici : JA4 fingerprints **U** et ASNs **V**) où les arêtes ne connectent que des sommets d'ensembles différents. Une arête (u, v) ∈ E existe si ≥ N IPs utilisent le JA4 u depuis l'ASN v dans la fenêtre temporelle considérée. **Projection sur l'espace JA4** : le graphe projeté G_JA4 = (U, E') est défini par (u₁, u₂) ∈ E' si ∃ v ∈ V tel que (u₁, v) ∈ E et (u₂, v) ∈ E. Deux JA4 partagent une arête si et seulement si ils co-apparaissent dans le même ASN. **Détection de communautés** : une communauté est un sous-ensemble de sommets plus densément connectés entre eux qu'avec le reste du graphe. Dans le graphe projeté G_JA4, une communauté dense de JA4 indique un botnet utilisant plusieurs empreintes JA4 mais se coordonnant via une infrastructure ASN partagée. L'implémentation utilise **Louvain** ([Blondel et al., 2008](https://arxiv.org/abs/0803.0476)) via la bibliothèque `python-louvain` pour la détection de communautés sur le graphe projeté pondéré. L'algorithme de Louvain optimise la modularité du graphe par agrégation hiérarchique itérative, identifiant les regroupements de JA4 partageant un profil de co-occurrence ASN similaire. En l'absence de `python-louvain`, un fallback vers les **composantes connexes** de NetworkX est utilisé. **Formule du score de flotte** : ``` fleet_score = community_size × edge_density / log2(n_asn + 2) ``` où : - `community_size` : nombre de JA4 dans la communauté détectée - `edge_density` : densité du sous-graphe induit (arêtes observées / arêtes possibles) - `log(nb_ASN)` : terme de normalisation pour pénaliser les grandes flottes trop dispersées géographiquement (signal d'infrastructure légitime CDN) Une valeur de `fleet_score` élevée (> seuil empirique déterminé en production) déclenche un malus sur le score d'anomalie de toutes les IPs des communautés concernées. #### Implémentation **Module** : `fleet.py` dans `bot_detector` ```python import networkx as nx from networkx.algorithms import bipartite def build_fleet_graph(df: pd.DataFrame, min_ips: int = 3): """Construit le graphe bipartite JA4 × ASN.""" G = nx.Graph() edge_weights = ( df.groupby(['ja4', 'asn_number'])['src_ip'].nunique() .reset_index(name='n_ips') ) edge_weights = edge_weights[edge_weights['n_ips'] >= min_ips] for _, row in edge_weights.iterrows(): G.add_edge(f"ja4:{row['ja4']}", f"asn:{row['asn_number']}", weight=int(row['n_ips'])) return G, ja4_nodes, asn_nodes def detect_fleet_communities(df: pd.DataFrame) -> dict: """Analyse le graphe et retourne {src_ip: fleet_score}.""" G, ja4_nodes, asn_nodes = build_fleet_graph(df) # Projection bipartite : graphe des JA4 partageant des ASN G_ja4 = bipartite.weighted_projected_graph(G, ja4_nodes) # Détection de communautés (Louvain ou composantes connexes en fallback) try: from community import best_partition as louvain_partition partition = louvain_partition(G_ja4, weight='weight', random_state=42) except ImportError: communities = {i: set(c) for i, c in enumerate(nx.connected_components(G_ja4))} # fleet_score = taille × densité / log2(n_asn + 2) for cid, members in communities.items(): score = len(members) * density / max(np.log2(n_asn + 2), 0.1) for ja4_node in members: fleet_scores[ja4_node.replace('ja4:', '')] = score return ip_scores matrix = np.zeros((len(ja4_nodes), len(asn_nodes))) for i, ja4 in enumerate(ja4_nodes): for j, asn in enumerate(asn_nodes): if G.has_edge(ja4, asn): matrix[i, j] = G[ja4][asn]['weight'] return ja4_nodes, matrix def detect_fleets(sessions: pd.DataFrame) -> pd.DataFrame: G = build_bipartite_graph(sessions) ja4_nodes, matrix = project_ja4(G) if len(ja4_nodes) < 5: return pd.DataFrame() clusterer = HDBSCAN(min_cluster_size=3, metric='jaccard') labels = clusterer.fit_predict(matrix > 0) # Calcul fleet_score par cluster... ``` **Sortie** : les résultats de la détection de flotte sont intégrés directement dans le DataFrame du cycle courant via deux colonnes additionnelles : - `fleet_score` : score de la communauté à laquelle appartient l'IP (0 si aucune flotte détectée) - `fleet_campaign_flag` : 1 si `fleet_score ≥ FLEET_SCORE_THRESHOLD` (défaut : 2.0) Les IPs appartenant à des communautés avec `fleet_score` élevé reçoivent un **malus de score d'anomalie** dans le pipeline final (ajout d'une constante au score brut EIF avant application de la fusion LR). --- ### 5.3 Fingerprinting par timing inter-requêtes (Request Cadence Fingerprint) `[impl.]` #### Constat et motivation Les features temporelles existantes — `hit_velocity` (vitesse moyenne de requêtes) et `temporal_entropy` (distribution horaire) — capturent la fréquence et la régularité macro-temporelle des requêtes. Elles manquent cependant le **rythme microscopique** : la distribution des intervalles entre requêtes consécutives (inter-request intervals, **Δt**) contient un signal discriminant riche. Les humains produisent des intervalles irréguliers avec des rafales de chargement de page suivies de pauses de lecture. Les bots produisent des intervalles réguliers (basés sur `sleep(N)`) ou des intervalles exponentiels (backoff). Ces différences de structure statistique sont exploitées via quatre signaux complémentaires. #### Signal 1 : Coefficient de variation (CV) Le **coefficient de variation** est le rapport entre l'écart-type et la moyenne de la distribution des intervalles : ``` CV(Δt) = σ(Δt) / μ(Δt) ``` Valeurs caractéristiques : - **Navigation humaine** : CV ≈ 1,5–3,0 (haute variabilité naturelle due aux temps de lecture variables) - **Bot régulier** (sleep fixe N ms) : CV ≈ 0,01–0,3 (faible variabilité, intervalles quasi-constants) - **Bot avec jitter gaussien** : CV ≈ 0,3–0,7 (variabilité modérée mais inférieure à l'humain) #### Signal 2 : Autocorrélation de décalage 1 (Lag-1) L'**autocorrélation de décalage 1** mesure la corrélation entre des intervalles consécutifs : ``` ρ₁ = corr(Δt_n, Δt_{n+1}) = Cov(Δt_n, Δt_{n+1}) / (σ_n × σ_{n+1}) ``` Valeurs caractéristiques : - **Navigation humaine** : ρ₁ ≈ 0 (intervalles indépendants — la durée de lecture d'une page n'est pas corrélée à celle de la suivante) - **Bot avec jitter proportionnel** : ρ₁ ≈ 0,8+ (corrélé — le jitter est généré proportionnellement à l'intervalle de base, créant une dépendance entre intervalles consécutifs) Ce signal distingue spécifiquement les bots utilisant un jitter proportionnel (ex. `sleep(N + random() × N)`) des bots avec jitter additif indépendant. #### Signal 3 : Ratio rafale/pause Proportion des intervalles Δt classifiés comme : - **Rafale** : Δt < 100 ms (chargement parallèle de ressources dans le même rendu de page) - **Pause** : Δt > 5 000 ms (temps de lecture d'une page) - **Intermédiaire** : 100 ms ≤ Δt ≤ 5 000 ms (navigation entre pages) Patterns caractéristiques : - **Navigateur réel** : alternance rafale (chargement de page) → pause (lecture) → rafale, avec ratio rafale/pause ≈ 0,3–0,6 - **Scraper séquentiel** : exclusivement intermédiaire (requêtes espacées uniformément, ni rafale ni pause) - **Playwright/Puppeteer** : rafales très courtes (ordre de grandeur < 50 ms, à valider empiriquement) mais pauses quasi-absentes #### Signal 4 : Déviation par rapport à la loi de Benford La **loi de Benford** (aussi appelée loi du premier chiffre, [Benford, 1938](https://en.wikipedia.org/wiki/Benford%27s_law)) stipule que dans de nombreux jeux de données naturels, la probabilité que le premier chiffre significatif d'un nombre soit d vaut : ``` P(premier chiffre = d) = log₁₀(1 + 1/d) ``` **Table de la loi de Benford** : | Chiffre d | Probabilité attendue | |-----------|---------------------| | 1 | 30,1 % | | 2 | 17,6 % | | 3 | 12,5 % | | 4 | 9,7 % | | 5 | 7,9 % | | 6 | 6,7 % | | 7 | 5,8 % | | 8 | 5,1 % | | 9 | 4,6 % | Les jeux de données naturels (durées de visite, revenus fiscaux, séquences physiques mesurées) suivent cette distribution ; les jeux de données artificiels (timers `sleep(1000ms)`) ne la suivent pas — ils concentrent les premiers chiffres sur des valeurs spécifiques. Par exemple, un bot utilisant `sleep(1000ms)` génère exclusivement des intervalles Δt autour de 1000 ms, avec premier chiffre 1 en quasi-totalité. Un bot utilisant `sleep(2500ms + jitter±200ms)` surreprésente les chiffres 2 et 3. **Métrique de déviation** : ``` D_benford = Σ_{d=1}^{9} |P_observée(d) - P_benford(d)| = mean absolute deviation ``` Valeurs caractéristiques : - Navigation humaine : D_benford ≈ 0,02–0,08 (faible déviation) - Bot sleep(N) : D_benford ≈ 0,30–0,80 (forte concentration sur un chiffre) > **Caveat** : l'applicabilité de la loi de Benford aux intervalles inter-requêtes est une hypothèse heuristique, non démontrée mathématiquement pour ce cas d'usage. Les données d'intervalles (quelques ms à quelques secondes) ne couvrent pas nécessairement les ordres de grandeur requis par la loi. Les seuils ci-dessus sont empiriques et nécessitent une validation sur les données de production. #### Implémentation **Table ClickHouse** : `agg_request_timing_1h` - Colonnes : `(src_ip, ja4, session_id, groupArray(timestamp_ns) AS ts_array)` **Vue** : `view_thesis_features_1h` - Colonnes : `cadence_cv`, `lag1_autocorrelation`, `burst_ratio`, `benford_deviation`, `pause_ratio` **SQL** : ```sql WITH deltas AS ( SELECT session_id, arrayMap( (t1, t2) -> (t2 - t1) / 1e6, -- nanoseconds → milliseconds arraySlice(ts_array, 1, length(ts_array) - 1), arraySlice(ts_array, 2) ) AS delta_ms FROM agg_request_timing_1h WHERE length(ts_array) >= 3 ), stats AS ( SELECT session_id, arrayReduce('avg', delta_ms) AS mu, arrayReduce('stddevPop', delta_ms) AS sigma, countIf(d -> d < 100, delta_ms) AS burst_count, countIf(d -> d > 5000, delta_ms) AS pause_count, length(delta_ms) AS n FROM deltas ARRAY JOIN delta_ms AS d GROUP BY session_id ) SELECT session_id, sigma / nullIf(mu, 0) AS cadence_cv, burst_count / n AS burst_ratio, pause_count / n AS pause_ratio -- lag1_autocorrelation et benford_deviation calculés en Python post-processing FROM stats; ``` --- ### 5.4 Détection de navigation synthétique par arbre de dépendances (Resource Dependency Tree) `[impl.]` #### Constat et motivation La feature `asset_ratio` détecte les bots qui ne chargent pas les ressources statiques (CSS, JS, images). Les frameworks de scraping modernes — Playwright, Puppeteer, Selenium — **chargent toutes les ressources** pour paraître identiques à un navigateur réel. Le signal n'est plus la présence du chargement de ressources, mais son **ordre temporel**. Un navigateur réel parse le HTML, construit le Document Object Model (DOM), puis déclenche le chargement des ressources selon leur type et priorité. Ce processus crée des **cascades waterfall** caractéristiques. Playwright simule ce processus mais avec des différences temporelles mesurables. #### Concept : cascade de dépendances de ressources Le processus de chargement d'une page par un vrai navigateur suit cette séquence déterministe : 1. **Requête HTML** : t = 0 ms — le navigateur demande le document HTML 2. **Parsing HTML** : t = 0–50 ms — construction du DOM, identification des ressources 3. **CSS bloquant** : t = 50–100 ms — les feuilles de style CSS référencées dans `` sont chargées en priorité absolue (bloquant le rendu) 4. **Construction CSSOM** : t = 100–150 ms — le CSS Object Model est construit à partir des CSS téléchargés 5. **JS différé et images** : t = 150–300 ms — après la construction du CSSOM, les scripts différés (`defer`, `async`) et les images sont chargés en parallèle Cette structure produit deux signaux mesurables : #### Signal 1 : root_to_first_asset_delay Le délai entre la requête HTML et la première requête de ressource (CSS ou JS) est caractéristique : - **Navigateur réel** : 50–200 ms (temps de parsing HTML réel) - **Playwright headless** : délai attendu faible (< 50 ms, hypothèse nécessitant validation empirique sur notre infrastructure) - **Scraper avec chargement d'assets** : 0 ms (requêtes séquentielles immédiates) #### Signal 2 : asset_load_stddev L'écart-type des timestamps de chargement au sein d'une cascade (batch de ressources déclenchées en parallèle) : - **Navigateur réel** : faible dans le batch (chargement effectivement parallèle sur HTTP/2), mais les batches successifs sont séparés par des gaps caractéristiques - **Playwright** : quasi-nul (toutes les ressources déclenchées simultanément par le moteur JS simulé) - **Scraper séquentiel** : élevé (les ressources sont chargées une par une) #### Implémentation **Tables ClickHouse** : ```sql CREATE TABLE agg_resource_cascade_1h ( session_id String, src_ip IPv4, resource_type Enum('html', 'css', 'js', 'image', 'font', 'other'), timestamp_ns UInt64, path String ) ENGINE = MergeTree() ORDER BY (session_id, timestamp_ns) TTL toDateTime(timestamp_ns / 1e9) + INTERVAL 2 HOUR; ``` **Vue** : `view_resource_cascade_1h` **Fichier SQL** : `12_thesis_features.sql` **Colonnes** : `root_to_first_asset_delay` (ms), `asset_load_stddev` (ms) **Identification des ressources** : double-source pour robustesse : 1. **En-tête Accept** : `Accept: text/css` → CSS ; `Accept: */*` avec `Sec-Fetch-Dest: script` → JS 2. **Extension de chemin** : regex `\.(css|js|png|jpg|webp|woff2|svg)$` ```sql WITH html_times AS ( SELECT session_id, min(timestamp_ns) AS html_ts FROM agg_resource_cascade_1h WHERE resource_type = 'html' GROUP BY session_id ), first_asset AS ( SELECT session_id, min(timestamp_ns) AS first_asset_ts FROM agg_resource_cascade_1h WHERE resource_type IN ('css', 'js') GROUP BY session_id ), asset_stats AS ( SELECT session_id, stddevPop(timestamp_ns / 1e6) AS asset_load_stddev_ms FROM agg_resource_cascade_1h WHERE resource_type != 'html' GROUP BY session_id ) SELECT h.session_id, (f.first_asset_ts - h.html_ts) / 1e6 AS root_to_first_asset_delay_ms, a.asset_load_stddev_ms FROM html_times h LEFT JOIN first_asset f USING (session_id) LEFT JOIN asset_stats a USING (session_id); ``` --- ### 5.5 Analyse de dérive de fingerprint TLS intra-session (Intra-Session JA4 Drift) `[impl.]` #### Constat et motivation La feature `distinct_ja4_count` mesure la diversité globale des JA4 par IP sur une fenêtre temporelle. Elle détecte les bots qui **changent fréquemment de JA4**. Cependant, un attaquant sophistiqué peut maintenir un JA4 stable pendant la majeure partie de la session et ne le changer qu'à la **transition de phase d'attaque** — une stratégie de discrétion qui contourne `distinct_ja4_count`. Cette technique analyse la **dynamique temporelle** de la dérive JA4 plutôt que sa magnitude globale. #### Technique : analyse de dérive temporelle 1. **Segmentation** : la session est découpée en fenêtres de 10 minutes (W₁, W₂, …, W_k) 2. **JA4 dominant par segment** : pour chaque fenêtre, le JA4 le plus fréquent (mode) est retenu comme JA4 représentatif du segment 3. **Comptage des transitions** : une transition est comptée quand le JA4 dominant change entre deux segments consécutifs 4. **Calcul du ratio** : ``` drift_ratio = nb_transitions / (nb_segments - 1) ∈ [0, 1] ``` Valeurs caractéristiques : - **Navigation humaine** : drift_ratio = 0 (même navigateur = même JA4 tout au long de la session) - **Bot en rotation simple** : drift_ratio ≈ 1 (JA4 changeant à chaque segment) - **Bot APT multi-phase** : drift_ratio faible (0,1–0,3) avec une transition unique corrélée à un changement comportemental #### Détection APT (Advanced Persistent Threat) La combinaison `drift_ratio` + `post_ratio` + changement de `path_prefix` permet de détecter les transitions **reconnaissance → exploitation** : - Phase 1 (reconnaissance) : JA4_A, requêtes GET, chemins `/`, `/robots.txt`, `/sitemap.xml` - Transition : changement de JA4 (JA4_A → JA4_B) - Phase 2 (exploitation) : JA4_B, requêtes POST, chemins `/admin/login`, `/wp-json/wp/v2/users` Ce pattern — faible drift_ratio global avec une transition ponctuelle coïncidant avec un changement de `post_ratio` et de `path_prefix` dominant — est un indicateur fort d'activité APT coordonnée. #### Implémentation **Vue ClickHouse** : `view_thesis_features_1h` - Colonne : `ja4_drift_ratio` (Float32) **SQL** : ```sql WITH segmented AS ( SELECT src_ip, ja4_fingerprint, -- Segment de 10 minutes toStartOfInterval(event_time, INTERVAL 10 MINUTE) AS segment_ts, count() AS hits_in_segment FROM ja4_processing.sessions WHERE event_time >= now() - INTERVAL 1 HOUR GROUP BY src_ip, ja4_fingerprint, segment_ts ), dominant_ja4 AS ( SELECT src_ip, segment_ts, argMax(ja4_fingerprint, hits_in_segment) AS dominant_ja4 FROM segmented GROUP BY src_ip, segment_ts ), ordered AS ( SELECT src_ip, segment_ts, dominant_ja4, lagInFrame(dominant_ja4) OVER ( PARTITION BY src_ip ORDER BY segment_ts ) AS prev_ja4, count() OVER (PARTITION BY src_ip) AS nb_segments FROM dominant_ja4 ) SELECT src_ip, sum(dominant_ja4 != prev_ja4 AND prev_ja4 IS NOT NULL) / nullIf(max(nb_segments) - 1, 0) AS ja4_drift_ratio FROM ordered GROUP BY src_ip HAVING max(nb_segments) >= 3; -- Minimum 3 segments (30+ minutes) ``` La condition `HAVING max(nb_segments) >= 3` exclut les sessions trop courtes pour un calcul fiable de drift_ratio (voir §6.5 pour les limites). Les sessions exclues reçoivent la valeur `NULL` dans le vecteur feature, traitée par imputation avec la médiane de la population. --- ### 5.6 Corrélation DNS passive × flux HTTP (DNS Shadow Analysis) `[todo]` #### Constat et motivation L'architecture actuelle ne capture pas les requêtes DNS. Or, chaque première visite d'un navigateur vers un domaine est précédée d'une résolution DNS — le client interroge un résolveur récursif pour obtenir l'adresse IP du serveur. Les bots qui ciblent directement des IPs, utilisent un fichier `/etc/hosts` personnalisé, ou recourent au **DNS-over-HTTPS (DoH)** ne génèrent aucune requête DNS observable sur le réseau local. Cette asymétrie constitue un signal exploitable : un flux HTTP sans résolution DNS préalable observable est suspect. #### Technique : ratio DNS shadow Capture DNS passive via `ja4ebpf` étendu au port UDP/53, puis corrélation avec les flux HTTP : ``` dns_shadow_ratio = requêtes HTTP vers hôte X / résolutions DNS de l'hôte X observées ``` Valeurs caractéristiques : - **Navigateur réel** : ratio ≈ 1 (une résolution DNS → de multiples requêtes HTTP via connexions Keep-Alive réutilisées) - **Bot utilisant /etc/hosts ou DoH** : ratio → ∞ (requêtes HTTP sans résolution DNS observable) - **CDN/proxy** : ratio variable mais cohérent dans le temps #### DNS-over-HTTPS (DoH) **DNS-over-HTTPS** ([RFC 8484](https://www.rfc-editor.org/rfc/rfc8484)) est un protocole qui envoie les requêtes DNS encapsulées dans des connexions HTTPS plutôt qu'en clair sur UDP/53. Ceci prévient l'observation passive des résolutions DNS. Fournisseurs courants : Cloudflare 1.1.1.1, Google 8.8.8.8. Les bots utilisant DoH contournent l'analyse DNS shadow. Cependant, les connexions HTTPS vers 1.1.1.1 ou 8.8.8.8 depuis le réseau serveur peuvent elles-mêmes être corrélées comme indicateur d'utilisation de DoH par le client (si observable au niveau réseau) — cette corrélation indirecte est une atténuation partielle. #### Extension JA4D Le fingerprinting **JA4D/JA4D6** des requêtes DHCP/DHCPv6 pourrait compléter l'analyse en identifiant les dispositifs derrière NAT, ajoutant une couche d'identification au-delà de l'adresse IP. Cette extension est envisagée comme travail futur conjoint à DNS Shadow Analysis. **`[todo]` Non implémenté** : nécessite l'extension de `ja4ebpf` pour la capture UDP/53. Travail futur priorité 1 (voir §6.6). --- ### 5.7 Détection par invariant de ratio de compression (Compression Ratio Invariant) `[todo]` #### Constat et motivation La feature `missing_accept_enc_ratio` détecte l'absence de l'en-tête `Accept-Encoding` dans les requêtes HTTP. Certains bots incluent cet en-tête (`Accept-Encoding: gzip, deflate, br`) sans pour autant **traiter effectivement** la compression dans les réponses reçues. L'en-tête est présent pour paraître légitime, mais le bot traite les données brutes sans décompresser. #### Technique : invariant de ratio de compression côté serveur Instrumentation Apache (module custom) : 1. Le serveur compare la taille de la réponse compressée envoyée avec la taille `Content-Length` non compressée 2. Le **ratio de compression effectif** par session est calculé : `ratio = taille_compressée / taille_non_compressée` 3. Ce ratio est corrélé avec les requêtes suivantes du client : un client qui décompresse correctement peut traiter des réponses de taille variable sans délai anormal ; un bot qui ne décompresse pas lit des données plus petites (la réponse compressée) mais échoue à les interpréter correctement #### Signal subtil : timing différentiel Brotli vs gzip **Brotli** ([RFC 7932](https://www.rfc-editor.org/rfc/rfc7932)) est un algorithme de compression développé par Google, offrant 20 à 26 % de meilleure compression que gzip sur du contenu web, au prix d'un coût computationnel de décompression plus élevé. Il est activé via `Accept-Encoding: br`. **gzip** ([RFC 1952](https://www.rfc-editor.org/rfc/rfc1952)) est le format DEFLATE-based largement supporté depuis HTTP/1.1, avec une décompression rapide et faible consommation CPU. Un client **réel** est légèrement plus lent à traiter une réponse Brotli (décompression coûteuse) qu'une réponse gzip de même contenu. Ce délai différentiel est mesurable en quelques dizaines de millisecondes sur les connexions à faible latence. Un bot qui n'effectue pas de décompression répond à vitesse constante indépendamment de l'encodage — l'absence de différentiel est le signal. **`[todo]` Non implémenté** : nécessite une instrumentation Apache pour le suivi de compression. Interférence avec la compression CDN pour les sessions `has_xff=1`. Travail futur priorité 2 (voir §6.6). --- ### 5.8 Empreinte comportementale de session multi-domaine (Cross-Domain Session Linking) `[impl.]` #### Constat et motivation Dans un environnement multi-hôtes (plusieurs Apache VirtualHosts sur le même serveur), les features sont calculées indépendamment par triplet `(src_ip, ja4, host)`. Un attaquant scannant plusieurs domaines depuis la même IP présente un pattern **inter-domaine** caractéristique invisible dans l'analyse mono-domaine. Cette technique fusionne les informations de sessions multi-domaines pour détecter les comportements de **scan d'infrastructure** systématique. #### Métrique 1 : host_diversity ```sql host_diversity = uniqExact(host) OVER (PARTITION BY src_ip) ``` Valeurs caractéristiques : - **Navigation humaine** : 1–3 hôtes (un utilisateur visite rarement plus de 3 domaines hébergés sur la même infrastructure) - **Scanner d'infrastructure** : 10+ hôtes (balayage systématique de tous les VirtualHosts) #### Métrique 2 : host_sweep_speed ``` host_sweep_speed = uniqExact(host) / session_duration_seconds ``` Un scanner parcourt rapidement de nombreux hôtes différents. Un utilisateur humain reste longtemps sur un même hôte. La dimension temporelle distingue un utilisateur occasionnel multi-domaine d'un scanner actif. #### Métrique 3 : host_coverage_uniformity ``` host_coverage_uniformity = 1 − stddev(hits_per_host) / avg(hits_per_host) ``` Un scanner distribue ses requêtes **uniformément** entre les hôtes (chaque hôte reçoit approximativement le même nombre de requêtes de reconnaissance). Un utilisateur humain se concentre sur 1–2 hôtes avec une distribution très inégale. Valeurs caractéristiques : - Scanner : host_coverage_uniformity ≈ 0,8–1,0 (distribution uniforme) - Utilisateur humain : host_coverage_uniformity ≈ 0,1–0,4 (très inégal) #### Métrique 4 : cross_domain_path_similarity (coefficient de Jaccard) Le **coefficient de Jaccard** mesure la similarité entre deux ensembles : ``` Jaccard(A, B) = |A ∩ B| / |A ∪ B| ``` où : - |A ∩ B| = nombre de chemins apparaissant sur **les deux** hôtes A et B - |A ∪ B| = nombre total de chemins distincts sur les deux hôtes réunis - La valeur est dans [0, 1] : 0 = aucun chemin commun, 1 = ensembles identiques Un scanner utilisant une wordlist commune (`/admin`, `/wp-login.php`, `/.env`, `/phpinfo.php`, `/.git/config`) sur tous les hôtes produit un Jaccard élevé (0,7–1,0). Une navigation humaine sur des sites distincts produit un Jaccard proche de 0. **Implémentation SQL** : ```sql WITH paths_by_host AS ( SELECT src_ip, host, groupArray(DISTINCT path) AS paths FROM ja4_processing.sessions WHERE event_time >= now() - INTERVAL 1 HOUR GROUP BY src_ip, host ), host_pairs AS ( SELECT a.src_ip, a.host AS host_a, b.host AS host_b, arrayIntersect(a.paths, b.paths) AS common_paths, arrayDistinct(arrayConcat(a.paths, b.paths)) AS union_paths FROM paths_by_host a JOIN paths_by_host b ON a.src_ip = b.src_ip AND a.host < b.host ) SELECT src_ip, avg(length(common_paths) / nullIf(length(union_paths), 0)) AS cross_domain_path_similarity FROM host_pairs GROUP BY src_ip; ``` **Table récapitulative des features Cross-Domain** : | Feature | Type | Description | Statut | |---------|------|-------------|--------| | `host_diversity` | UInt16 | Nombre d'hôtes distincts par IP | `[impl.]` | | `host_sweep_speed` | Float32 | Hôtes distincts / durée session (hôtes/s) | `[impl.]` | | `host_coverage_uniformity` | Float32 | 1 − stddev/avg des hits par hôte | `[impl.]` | | `cross_domain_path_similarity` | Float32 | Jaccard moyen entre paires d'hôtes | `[impl.]` | Ces quatre features sont intégrées dans le vecteur feature du Modèle Complet (famille F8 — Multi-domaine).