docs: add standardized comments to all services (Python, Go, Bash)

- Add docs/commenting-standard.md defining per-language comment standards
  (Go godoc, Python PEP-257, C Doxygen, Bash header blocks, SQL banners)

- services/dashboard: 100% docstring coverage (100/100 functions)
  - All FastAPI route handlers, helpers, classes, and models documented
  - Language: French (project convention)

- services/bot-detector: 100% docstring coverage (53/53 symbols)
  - bot_detector.py: 14 functions + module docstring
  - anubis/fetch_rules.py: 9 functions

- shared/python/ja4_common: full docstrings on ClickHouseClient (7 methods)
  and ClickHouseSettings class

- services/correlator: 24 godoc comments added across 6 Go files
  - correlation_service.go: 10 private helpers
  - unixsocket/source.go: 6 parsing/socket helpers
  - correlated_log.go: 4 field extraction helpers
  - orchestrator.go, logger.go, main.go: 4 comments

- services/correlator/scripts/audit-architecture.sh: standardized header block

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-07 21:32:29 +02:00
parent 12d60975da
commit 3dfeba860b
22 changed files with 388 additions and 10 deletions

210
docs/commenting-standard.md Normal file
View File

@ -0,0 +1,210 @@
# Standard de commentaires — ja4-platform
Ce document définit les conventions de commentaires pour tous les projets du monorepo.
Toutes les nouvelles contributions doivent respecter ce standard.
Les commentaires de **code fonctionnel** sont en **français** ; les identifiants, types, et
noms de variables restent en anglais (convention Go / Python).
---
## Go
### Règles
- Tout package public : `// Package foo fournit...`
- Toute fonction/méthode **exportée** : commentaire godoc commençant par le nom de la fonction
- Toute fonction/méthode **privée non triviale** (>5 lignes) : une ligne `// nomFonction fait X`
- Structures exportées : un commentaire par champ si la sémantique n'est pas évidente
- Pas de commentaires pour les getters/setters triviaux ni les stubs
### Exemple
```go
// Package capture gère la capture de paquets réseau bruts via libpcap.
package capture
// RawPacket représente un paquet TCP/IP brut capturé sur l'interface réseau.
type RawPacket struct {
Data []byte // Contenu brut du paquet (à partir de la couche Ethernet)
Timestamp time.Time // Horodatage de capture (précision nanoseconde)
SrcIP net.IP // Adresse IP source
DstIP net.IP // Adresse IP destination
}
// Capture définit l'interface pour la capture de paquets bruts.
// Les implémentations doivent écouter sur une interface réseau configurée,
// appliquer des filtres BPF et émettre des RawPacket vers un canal.
// Close doit être appelé pour libérer le handle pcap.
type Capture interface {
Start(ctx context.Context) (<-chan RawPacket, error)
Close() error
}
// NewCapture crée une nouvelle instance de capture pcap sur l'interface donnée.
// Retourne une erreur si l'interface n'existe pas ou si les droits NET_RAW sont absents.
func NewCapture(iface string, ports []int) (Capture, error) { ... }
// buildBPFFilter construit le filtre BPF pour les ports et IPs locales spécifiés.
// Format : "(tcp dst port 443) and (dst host 192.168.1.10)"
func buildBPFFilter(ports []int, localIPs []net.IP) string { ... }
```
---
## Python
### Règles
- Tout module : docstring triple-guillemets en première ligne
- Toute classe : docstring décrivant le rôle et les attributs principaux
- Toute fonction/méthode publique : docstring une ou plusieurs lignes
- Fonctions privées simples (`_helper`) : une ligne si non triviales
- Routes FastAPI : docstring courte décrivant ce que renvoie l'endpoint (utilisée par Swagger/OpenAPI)
- Langue : **français**
### Exemple
```python
"""
Module de détection de bots par machine learning.
Utilise un modèle IsolationForest entraîné sur les features comportementales
extraites de ja4_processing.agg_host_ip_ja4_1h.
"""
class BotDetector:
"""
Détecteur de bots basé sur IsolationForest.
Attributes:
model: Modèle entraîné (None avant le premier entraînement).
threshold: Seuil de score d'anomalie en dessous duquel un IP est flaggée.
"""
def train(self, df: pd.DataFrame) -> None:
"""
Entraîne le modèle IsolationForest sur le DataFrame fourni.
Le DataFrame doit contenir les colonnes de features définies dans FEATURE_COLS.
Le modèle précédent est remplacé à chaque appel.
"""
def score_batch(self, ips: list[str]) -> dict[str, float]:
"""Calcule le score d'anomalie pour une liste d'IPs. Retourne {ip: score}."""
# --- Routes FastAPI ---
@router.get("/hourly")
async def get_heatmap_hourly(db=Depends(get_db)):
"""Retourne les hits agrégés par heure sur les 72 dernières heures."""
```
---
## C
### Règles
- Chaque fichier : bloc d'en-tête `/* filename.c — description */`
- Chaque fonction : bloc `/** @brief description */` au-dessus de la déclaration
- Macros complexes : commentaire inline expliquant le comportement
- Section logique : bannière `/* ====== Nom de section ====== */`
- Membres de struct : commentaire `/* description */` en fin de ligne si non évident
### Exemple
```c
/*
* mod_reqin_log.c — Module Apache HTTPD pour la journalisation JSON des requêtes HTTP.
*
* Ce module capture chaque requête entrante, sérialise ses métadonnées (headers,
* IP, méthode, path) en JSON et les envoie vers une socket Unix pour le correlateur.
*/
/* ====== Fonctions de buffer dynamique ====== */
/**
* @brief Initialise un buffer dynamique avec une capacité initiale.
*
* @param buf Pointeur vers le buffer à initialiser.
* @param pool Pool APR utilisé pour les allocations.
* @param init Capacité initiale en octets.
*/
static void dynbuf_init(dynbuf_t *buf, apr_pool_t *pool, size_t init) { ... }
/**
* @brief Ajoute une chaîne dans le buffer, réalloue si nécessaire.
*
* Croissance exponentielle (×2) pour amortir les allocations.
* Retourne 0 en cas de succès, -1 si la taille maximale MAX_JSON_SIZE est dépassée.
*/
static int dynbuf_append(dynbuf_t *buf, const char *str, size_t len) { ... }
```
---
## Bash
### Règles
- Tout script : bloc d'en-tête standardisé avec description, Usage, et variables d'environnement
- Toute fonction : commentaire `# nomFonction — description` sur la ligne précédente
- Variables globales non évidentes : commentaire `# description` en fin de ligne
### Exemple
```bash
#!/usr/bin/env bash
# =============================================================================
# script.sh — Description courte du script
#
# Description longue si nécessaire.
#
# Usage:
# ./script.sh [OPTIONS]
#
# Options:
# --dry-run Simuler sans modifier
# --verbose Afficher les détails
#
# Variables d'environnement:
# CLICKHOUSE_HOST — Hôte ClickHouse (défaut: localhost)
# CLICKHOUSE_PORT — Port natif ClickHouse (défaut: 9000)
# =============================================================================
set -euo pipefail
# log — Affiche un message horodaté sur stderr
log() { echo "[$(date +%H:%M:%S)] $*" >&2; }
```
---
## SQL (ClickHouse)
### Règles
- Chaque fichier : bannière `-- ==== filename.sql — description ====`
- Chaque table/vue/dictionnaire : section `-- --- Nom ---` + description du rôle
- Colonnes groupées : commentaire de groupe `-- Groupe (ex: Réseau, TLS, Métadonnées IP)`
- TODOs de sécurité : `-- TODO: ...` clairement identifiés
### Exemple
```sql
-- =============================================================================
-- 01_raw_tables.sql — Tables brutes (ingestion directe du correlateur)
--
-- Ces tables reçoivent les logs JSON bruts du correlateur via INSERT.
-- Le TTL d'un jour évite l'accumulation de données non traitées.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- http_logs_raw — Table d'ingestion brute
-- Reçoit les entrées du correlateur ; la MV mv_http_logs parse et enrichit.
-- TTL : 1 jour (données transformées conservées dans http_logs)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ja4_logs.http_logs_raw
(
-- Identifiant de flux
conn_id String,
-- Payload JSON brut
raw_json String CODEC(ZSTD(3))
)
ENGINE = MergeTree ...
```

View File

@ -72,6 +72,7 @@ UA_PARENT_OVERRIDE: dict[str, str] = {}
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
def _fetch_url(url: str, timeout: int = 15) -> str | None: def _fetch_url(url: str, timeout: int = 15) -> str | None:
"""Télécharge le contenu d'une URL en texte UTF-8. Retourne None en cas d'erreur réseau."""
try: try:
with urllib.request.urlopen(url, timeout=timeout) as resp: with urllib.request.urlopen(url, timeout=timeout) as resp:
return resp.read().decode("utf-8") return resp.read().decode("utf-8")
@ -81,6 +82,7 @@ def _fetch_url(url: str, timeout: int = 15) -> str | None:
def fetch_yaml_url(url: str) -> list | dict | None: def fetch_yaml_url(url: str) -> list | dict | None:
"""Télécharge et désérialise un fichier YAML depuis une URL. Retourne None si inaccessible."""
content = _fetch_url(url) content = _fetch_url(url)
if content: if content:
return yaml.safe_load(content) return yaml.safe_load(content)
@ -334,6 +336,7 @@ def collect_all_rules() -> tuple[list, list, list, list]:
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
def get_ch_client(): def get_ch_client():
"""Crée et retourne un client ClickHouse configuré depuis les variables d'environnement."""
return clickhouse_connect.get_client( return clickhouse_connect.get_client(
host=os.environ.get("CLICKHOUSE_HOST", "clickhouse"), host=os.environ.get("CLICKHOUSE_HOST", "clickhouse"),
database=os.environ.get("CLICKHOUSE_DB_PROCESSING", os.environ.get("CLICKHOUSE_DB", "ja4_processing")), database=os.environ.get("CLICKHOUSE_DB_PROCESSING", os.environ.get("CLICKHOUSE_DB", "ja4_processing")),
@ -346,6 +349,10 @@ DB_PROC = os.environ.get("CLICKHOUSE_DB_PROCESSING", os.environ.get("CLICKHOUSE_
def insert_ua_rules(client, rules: list[dict]) -> None: def insert_ua_rules(client, rules: list[dict]) -> None:
"""Tronque et remplace la table anubis_ua_rules avec les règles User-Agent fournies.
Le format cible est REGEXP_TREE (colonnes id, parent_id, regexp, keys[], values[]).
"""
if not rules: if not rules:
print("[INFO] Aucune règle UA.") print("[INFO] Aucune règle UA.")
return return
@ -366,6 +373,7 @@ def insert_ua_rules(client, rules: list[dict]) -> None:
def insert_ip_rules(client, rules: list[dict]) -> None: def insert_ip_rules(client, rules: list[dict]) -> None:
"""Tronque et remplace la table anubis_ip_rules avec les règles CIDR/IP fournies."""
if not rules: if not rules:
print("[INFO] Aucune règle IP.") print("[INFO] Aucune règle IP.")
return return
@ -381,6 +389,7 @@ def insert_ip_rules(client, rules: list[dict]) -> None:
def insert_asn_rules(client, rules: list[dict]) -> None: def insert_asn_rules(client, rules: list[dict]) -> None:
"""Tronque et remplace la table anubis_asn_rules avec les règles ASN fournies."""
if not rules: if not rules:
print("[INFO] Aucune règle ASN.") print("[INFO] Aucune règle ASN.")
return return
@ -392,6 +401,7 @@ def insert_asn_rules(client, rules: list[dict]) -> None:
def insert_country_rules(client, rules: list[dict]) -> None: def insert_country_rules(client, rules: list[dict]) -> None:
"""Tronque et remplace la table anubis_country_rules avec les règles pays fournies."""
if not rules: if not rules:
print("[INFO] Aucune règle pays.") print("[INFO] Aucune règle pays.")
return return
@ -403,6 +413,7 @@ def insert_country_rules(client, rules: list[dict]) -> None:
def reload_dicts(client) -> None: def reload_dicts(client) -> None:
"""Recharge les quatre dictionnaires ClickHouse Anubis après mise à jour des tables sources."""
dicts = [ dicts = [
f"{DB_PROC}.dict_anubis_ua", f"{DB_PROC}.dict_anubis_ua",
f"{DB_PROC}.dict_anubis_ip", f"{DB_PROC}.dict_anubis_ip",
@ -422,6 +433,7 @@ def reload_dicts(client) -> None:
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
def print_summary(ua_rules, ip_rules, asn_rules, country_rules): def print_summary(ua_rules, ip_rules, asn_rules, country_rules):
"""Affiche un résumé lisible des règles collectées (UA, IP, ASN, pays) sur la sortie standard."""
print("\n── Règles UA ──") print("\n── Règles UA ──")
by_cat: dict[str, list] = {} by_cat: dict[str, list] = {}
for r in ua_rules: for r in ua_rules:
@ -460,6 +472,7 @@ def print_summary(ua_rules, ip_rules, asn_rules, country_rules):
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
def main() -> None: def main() -> None:
"""Point d'entrée principal : collecte les règles Anubis et les charge dans ClickHouse."""
print("[INFO] Collecte des règles Anubis depuis GitHub…") print("[INFO] Collecte des règles Anubis depuis GitHub…")
ua_rules, ip_rules, asn_rules, country_rules = collect_all_rules() ua_rules, ip_rules, asn_rules, country_rules = collect_all_rules()

View File

@ -1,3 +1,13 @@
"""Détecteur de bots par apprentissage automatique semi-supervisé (IsolationForest).
Ce module implémente le cycle de détection IA du service bot_detector :
- chargement et retraining automatique du modèle IsolationForest,
- scoring, normalisation et classification du trafic (fenêtre 1h / 24h),
- intégration des règles Anubis (ALLOW / DENY / WEIGH),
- clustering comportemental DBSCAN, déduplication inter-cycles,
- explainabilité SHAP, détection de dérive conceptuelle,
- écriture des résultats dans ClickHouse (ml_detected_anomalies, ml_all_scores).
"""
import time import time
import os import os
import json import json
@ -30,6 +40,10 @@ warnings.filterwarnings('ignore')
# CONFIGURATION # CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
def _require_float(name, default, lo=None, hi=None): def _require_float(name, default, lo=None, hi=None):
"""Lit une variable d'environnement comme flottant et valide la plage si spécifiée.
Lève SystemExit si la valeur est non numérique ou hors plage (lo, hi) exclusive.
"""
raw = os.getenv(name, str(default)) raw = os.getenv(name, str(default))
try: try:
v = float(raw) v = float(raw)
@ -119,9 +133,15 @@ logger.addHandler(_file_handler)
# Wrapper court pour homogénéiser les appels de logging (évite d'importer logger partout). # Wrapper court pour homogénéiser les appels de logging (évite d'importer logger partout).
def log_info(message: str): def log_info(message: str):
"""Enregistre un message de niveau INFO dans le logger du service."""
logger.info(message) logger.info(message)
def log_decision(event: str, cycle_id: str, model: str = '', row: dict = None): def log_decision(event: str, cycle_id: str, model: str = '', row: dict = None):
"""Enregistre un événement de décision IA au format JSONL dans le fichier de log rotatif.
Chaque ligne contient l'horodatage, le cycle_id, l'événement, le modèle,
la contamination, le seuil et les données supplémentaires de ``row``.
"""
entry = { entry = {
'ts': datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), 'ts': datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
'cycle_id': cycle_id, 'cycle_id': cycle_id,
@ -136,6 +156,7 @@ def log_decision(event: str, cycle_id: str, model: str = '', row: dict = None):
_file_handler.stream.flush() _file_handler.stream.flush()
def _append_training_history(entry: dict): def _append_training_history(entry: dict):
"""Ajoute une entrée de métadonnées d'entraînement au fichier d'historique JSONL."""
with open(TRAINING_HISTORY_FILE, 'a', encoding='utf-8') as f: with open(TRAINING_HISTORY_FILE, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False, default=str) + '\n') f.write(json.dumps(entry, ensure_ascii=False, default=str) + '\n')
@ -143,6 +164,7 @@ def _append_training_history(entry: dict):
# ARRÊT PROPRE ET HEALTH CHECK # ARRÊT PROPRE ET HEALTH CHECK
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
def _shutdown(sig, frame): def _shutdown(sig, frame):
"""Gestionnaire de signal SIGTERM/SIGINT : journalise l'arrêt et quitte proprement."""
log_info(f"Signal {sig} reçu — arrêt propre.") log_info(f"Signal {sig} reçu — arrêt propre.")
log_decision('SERVICE_STOP', 'shutdown', '', {'signal': sig}) log_decision('SERVICE_STOP', 'shutdown', '', {'signal': sig})
sys.exit(0) sys.exit(0)
@ -152,12 +174,20 @@ signal.signal(signal.SIGINT, _shutdown)
_service_healthy = True _service_healthy = True
class _HealthHandler(BaseHTTPRequestHandler): class _HealthHandler(BaseHTTPRequestHandler):
"""Gestionnaire HTTP minimal pour le point de santé du service.
Répond 200/OK si le service est sain, 503/DEGRADED dans le cas contraire.
"""
def do_GET(self): def do_GET(self):
"""Répond à la requête GET : renvoie 200 OK ou 503 DEGRADED selon l'état du service."""
code = 200 if _service_healthy else 503 code = 200 if _service_healthy else 503
self.send_response(code) self.send_response(code)
self.end_headers() self.end_headers()
self.wfile.write(b'OK' if _service_healthy else b'DEGRADED') self.wfile.write(b'OK' if _service_healthy else b'DEGRADED')
def log_message(self, *args): pass def log_message(self, *args):
"""Supprime les logs HTTP internes pour ne pas polluer la sortie standard."""
pass
threading.Thread( threading.Thread(
target=lambda: HTTPServer(('', HEALTH_PORT), _HealthHandler).serve_forever(), target=lambda: HTTPServer(('', HEALTH_PORT), _HealthHandler).serve_forever(),
@ -174,7 +204,10 @@ def get_client():
return _ja4_get_client().connect() return _ja4_get_client().connect()
def score_to_threat_level(score: float) -> str: def score_to_threat_level(score: float) -> str:
# Seuils : CRITICAL < -0.30 | HIGH < -0.15 | MEDIUM < -0.05 | LOW < 0 | NORMAL ≥ 0 """Convertit un score d'anomalie brut IsolationForest en niveau de menace textuel.
Seuils : CRITICAL < 0.30 | HIGH < 0.15 | MEDIUM < 0.05 | LOW < 0 | NORMAL ≥ 0.
"""
if score < -0.30: return 'CRITICAL' if score < -0.30: return 'CRITICAL'
if score < -0.15: return 'HIGH' if score < -0.15: return 'HIGH'
if score < -0.05: return 'MEDIUM' if score < -0.05: return 'MEDIUM'
@ -185,9 +218,11 @@ def score_to_threat_level(score: float) -> str:
# GESTION DES MODÈLES # GESTION DES MODÈLES
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
def _current_pointer_path(name: str) -> str: def _current_pointer_path(name: str) -> str:
"""Retourne le chemin du fichier pointeur vers la version courante du modèle ``name``."""
return os.path.join(MODEL_DIR, f'model_{name}.current') return os.path.join(MODEL_DIR, f'model_{name}.current')
def _get_current_version(name: str): def _get_current_version(name: str):
"""Lit le fichier pointeur et retourne (chemin_modèle, métadonnées) ou (None, None) si absent."""
pointer = _current_pointer_path(name) pointer = _current_pointer_path(name)
if not os.path.exists(pointer): return None, None if not os.path.exists(pointer): return None, None
with open(pointer) as f: version_id = f.read().strip() with open(pointer) as f: version_id = f.read().strip()
@ -198,6 +233,7 @@ def _get_current_version(name: str):
return model_path, meta return model_path, meta
def _purge_old_versions(name: str): def _purge_old_versions(name: str):
"""Supprime les versions excédentaires du modèle ``name`` en ne conservant que MODEL_HISTORY_COUNT fichiers."""
pattern = os.path.join(MODEL_DIR, f'model_{name}_*.joblib') pattern = os.path.join(MODEL_DIR, f'model_{name}_*.joblib')
versions = sorted(glob.glob(pattern)) versions = sorted(glob.glob(pattern))
to_delete = versions[:-MODEL_HISTORY_COUNT] if len(versions) > MODEL_HISTORY_COUNT else [] to_delete = versions[:-MODEL_HISTORY_COUNT] if len(versions) > MODEL_HISTORY_COUNT else []
@ -209,6 +245,15 @@ def _purge_old_versions(name: str):
log_info(f"[{name}] Version purgée : {version_id} (limite={MODEL_HISTORY_COUNT})") log_info(f"[{name}] Version purgée : {version_id} (limite={MODEL_HISTORY_COUNT})")
def load_or_train_model(name: str, human_baseline: pd.DataFrame, features: list, cycle_id: str): def load_or_train_model(name: str, human_baseline: pd.DataFrame, features: list, cycle_id: str):
"""Charge le modèle IsolationForest existant ou en entraîne un nouveau si nécessaire.
Réutilise le modèle si son âge est inférieur à RETRAIN_INTERVAL_H et si aucune
dérive conceptuelle significative n'est détectée (A1). En cas d'expiration ou de
dérive, entraîne un nouveau modèle sur ``human_baseline``, le sérialise sur disque,
met à jour le fichier pointeur et purge les anciennes versions.
Retourne l'objet IsolationForest entraîné ou rechargé.
"""
model_path, meta = _get_current_version(name) model_path, meta = _get_current_version(name)
if model_path and meta: if model_path and meta:
trained_at = datetime.fromisoformat(meta['trained_at']) trained_at = datetime.fromisoformat(meta['trained_at'])
@ -475,7 +520,15 @@ def _cluster_anomalies(anomalies: pd.DataFrame, features: list) -> pd.DataFrame:
# ANALYSE SEMI-SUPERVISÉE # ANALYSE SEMI-SUPERVISÉE
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
def run_semi_supervised_logic(df, features, name, cycle_id, recurrence_map): def run_semi_supervised_logic(df, features, name, cycle_id, recurrence_map):
# ── Trifurcation du trafic selon bot_name et Anubis ───────────────────── """Applique le pipeline de détection semi-supervisée sur un sous-ensemble du trafic.
Trifurque le trafic en bots connus, bots Anubis ALLOW et trafic inconnu,
entraîne ou charge le modèle IsolationForest sur la baseline humaine,
score le trafic inconnu, applique les améliorations A2/A4/A6/A8,
et retourne (threats, all_scored) sous forme de DataFrames.
Effets de bord : écriture dans les logs de décision via log_decision.
"""
# 1. Bots connus (dict_bot_ip / dict_bot_ja4) → exclus du scoring IF # 1. Bots connus (dict_bot_ip / dict_bot_ja4) → exclus du scoring IF
known_bots = df[df['bot_name'] != ''].copy() known_bots = df[df['bot_name'] != ''].copy()
rest = df[df['bot_name'] == ''].copy() rest = df[df['bot_name'] == ''].copy()
@ -668,6 +721,7 @@ def _filter_recent_detections(client, all_anom: pd.DataFrame) -> pd.DataFrame:
return all_anom return all_anom
recent_map = dict(zip(recent_df['src_ip'], recent_df['best_score'])) recent_map = dict(zip(recent_df['src_ip'], recent_df['best_score']))
def _should_insert(row): def _should_insert(row):
"""Détermine si une anomalie doit être réinsérée selon l'évolution du score."""
prev = recent_map.get(row['src_ip']) prev = recent_map.get(row['src_ip'])
if prev is None: if prev is None:
return True return True
@ -712,7 +766,13 @@ def _preprocess_df(df: pd.DataFrame) -> pd.DataFrame:
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
_consecutive_failures = 0 _consecutive_failures = 0
def fetch_and_analyze(): def fetch_and_analyze():
global _service_healthy, _consecutive_failures """Exécute un cycle complet de détection : requête ClickHouse, scoring et insertion des résultats.
Récupère le trafic depuis la vue view_ai_features_1h (et optionnellement view_ai_features_24h),
applique run_semi_supervised_logic sur les deux modèles (Complet / Applicatif),
insère les scores dans ml_all_scores et les anomalies dans ml_detected_anomalies.
Met à jour _service_healthy et _consecutive_failures en cas d'échec de requête.
"""
cycle_id = datetime.now().strftime('%Y%m%d_%H%M%S') cycle_id = datetime.now().strftime('%Y%m%d_%H%M%S')
log_info('=== Lancement cycle IA ===') log_info('=== Lancement cycle IA ===')

View File

@ -1,3 +1,4 @@
// Package main initialise et démarre le service logcorrelator.
package main package main
import ( import (
@ -23,6 +24,7 @@ import (
var Version = "dev" var Version = "dev"
// main configure les sources, les puits et le service de corrélation, puis démarre l'orchestrateur.
func main() { func main() {
configPath := flag.String("config", "config.yml", "path to configuration file") configPath := flag.String("config", "config.yml", "path to configuration file")
version := flag.Bool("version", false, "print version and exit") version := flag.Bool("version", false, "print version and exit")

View File

@ -117,6 +117,7 @@ func (s *UnixSocketSource) Start(ctx context.Context, eventChan chan<- *domain.N
return nil return nil
} }
// readDatagrams lit en continu les datagrammes sur la socket Unix et envoie les événements normalisés sur le canal.
func (s *UnixSocketSource) readDatagrams(ctx context.Context, eventChan chan<- *domain.NormalizedEvent) { func (s *UnixSocketSource) readDatagrams(ctx context.Context, eventChan chan<- *domain.NormalizedEvent) {
buf := make([]byte, MaxDatagramSize) buf := make([]byte, MaxDatagramSize)
@ -176,6 +177,7 @@ func (s *UnixSocketSource) readDatagrams(ctx context.Context, eventChan chan<- *
} }
} }
// resolveSource détermine la source d'un événement à partir du type déclaré ou de la présence d'en-têtes HTTP.
func resolveSource(sourceType string, headers map[string]string) domain.EventSource { func resolveSource(sourceType string, headers map[string]string) domain.EventSource {
switch strings.ToLower(strings.TrimSpace(sourceType)) { switch strings.ToLower(strings.TrimSpace(sourceType)) {
case "a", "apache", "http": case "a", "apache", "http":
@ -191,6 +193,7 @@ func resolveSource(sourceType string, headers map[string]string) domain.EventSou
} }
} }
// parseJSONEvent désérialise un datagramme JSON et construit un NormalizedEvent validé avec ses champs obligatoires.
func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, error) { func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, error) {
var raw map[string]any var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil { if err := json.Unmarshal(data, &raw); err != nil {
@ -298,6 +301,7 @@ func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, er
return event, nil return event, nil
} }
// getString extrait la valeur d'une clé sous forme de chaîne depuis une map JSON désérialisée.
func getString(m map[string]any, key string) (string, bool) { func getString(m map[string]any, key string) (string, bool) {
if v, ok := m[key]; ok { if v, ok := m[key]; ok {
if s, ok := v.(string); ok { if s, ok := v.(string); ok {
@ -307,6 +311,7 @@ func getString(m map[string]any, key string) (string, bool) {
return "", false return "", false
} }
// getInt extrait la valeur d'une clé sous forme d'entier depuis une map JSON en gérant les conversions de types courants.
func getInt(m map[string]any, key string) (int, bool) { func getInt(m map[string]any, key string) (int, bool) {
if v, ok := m[key]; ok { if v, ok := m[key]; ok {
switch val := v.(type) { switch val := v.(type) {
@ -328,6 +333,7 @@ func getInt(m map[string]any, key string) (int, bool) {
return 0, false return 0, false
} }
// getInt64 extrait la valeur d'une clé sous forme d'entier 64 bits depuis une map JSON en gérant les conversions de types courants.
func getInt64(m map[string]any, key string) (int64, bool) { func getInt64(m map[string]any, key string) (int64, bool) {
if v, ok := m[key]; ok { if v, ok := m[key]; ok {
switch val := v.(type) { switch val := v.(type) {

View File

@ -103,6 +103,7 @@ func (o *Orchestrator) Start() error {
return nil return nil
} }
// processEvents lit les événements du canal, les soumet au service de corrélation et écrit les résultats dans le puits.
func (o *Orchestrator) processEvents(eventChan <-chan *domain.NormalizedEvent) { func (o *Orchestrator) processEvents(eventChan <-chan *domain.NormalizedEvent) {
for { for {
select { select {

View File

@ -101,6 +101,7 @@ func NewCorrelatedLog(apacheEvent, networkEvent *NormalizedEvent) CorrelatedLog
} }
} }
// extractFields copie l'ensemble des champs bruts d'un événement dans une nouvelle map.
func extractFields(e *NormalizedEvent) map[string]any { func extractFields(e *NormalizedEvent) map[string]any {
result := make(map[string]any) result := make(map[string]any)
for k, v := range e.Raw { for k, v := range e.Raw {
@ -109,6 +110,7 @@ func extractFields(e *NormalizedEvent) map[string]any {
return result return result
} }
// mergeFields fusionne les champs bruts de deux événements en préfixant les clés en collision par "a_" et "b_".
func mergeFields(a, b *NormalizedEvent) map[string]any { func mergeFields(a, b *NormalizedEvent) map[string]any {
result := make(map[string]any) result := make(map[string]any)
@ -136,6 +138,7 @@ func mergeFields(a, b *NormalizedEvent) map[string]any {
return result return result
} }
// coalesceString retourne la première chaîne non vide parmi les deux arguments.
func coalesceString(a, b string) string { func coalesceString(a, b string) string {
if a != "" { if a != "" {
return a return a
@ -143,6 +146,7 @@ func coalesceString(a, b string) string {
return b return b
} }
// coalesceInt retourne le premier entier non nul parmi les deux arguments.
func coalesceInt(a, b int) int { func coalesceInt(a, b int) int {
if a != 0 { if a != 0 {
return a return a

View File

@ -74,6 +74,7 @@ type eventBuffer struct {
events *list.List events *list.List
} }
// newEventBuffer crée un nouveau tampon d'événements vide basé sur une liste doublement chaînée.
func newEventBuffer() *eventBuffer { func newEventBuffer() *eventBuffer {
return &eventBuffer{ return &eventBuffer{
events: list.New(), events: list.New(),
@ -288,6 +289,7 @@ func (s *CorrelationService) ProcessEvent(event *NormalizedEvent) []CorrelatedLo
return results return results
} }
// getBufferSize retourne la taille actuelle du tampon correspondant à la source donnée.
func (s *CorrelationService) getBufferSize(source EventSource) int { func (s *CorrelationService) getBufferSize(source EventSource) int {
switch source { switch source {
case SourceA: case SourceA:
@ -298,6 +300,7 @@ func (s *CorrelationService) getBufferSize(source EventSource) int {
return 0 return 0
} }
// isBufferFull vérifie si le tampon de la source donnée a atteint sa capacité maximale.
func (s *CorrelationService) isBufferFull(source EventSource) bool { func (s *CorrelationService) isBufferFull(source EventSource) bool {
switch source { switch source {
case SourceA: case SourceA:
@ -355,6 +358,7 @@ func (s *CorrelationService) rotateOldestB() {
delete(s.networkTTLs, elem) delete(s.networkTTLs, elem)
} }
// processSourceA traite un événement de source A (HTTP/Apache) et retourne les journaux corrélés ou les place en attente d'orphelins.
func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]CorrelatedLog, bool) { func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]CorrelatedLog, bool) {
key := event.CorrelationKey() key := event.CorrelationKey()
// Assign Keep-Alive sequence number (1-based) for this connection // Assign Keep-Alive sequence number (1-based) for this connection
@ -457,6 +461,7 @@ func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]Correlate
return nil, true return nil, true
} }
// processSourceB traite un événement de source B (réseau) et retourne les journaux corrélés si une correspondance est trouvée.
func (s *CorrelationService) processSourceB(event *NormalizedEvent) ([]CorrelatedLog, bool) { func (s *CorrelationService) processSourceB(event *NormalizedEvent) ([]CorrelatedLog, bool) {
key := event.CorrelationKey() key := event.CorrelationKey()
s.logger.Debugf("processing B event: key=%s timestamp=%v", key, event.Timestamp) s.logger.Debugf("processing B event: key=%s timestamp=%v", key, event.Timestamp)
@ -511,6 +516,7 @@ func (s *CorrelationService) processSourceB(event *NormalizedEvent) ([]Correlate
return nil, true return nil, true
} }
// eventsMatch vérifie si deux événements se trouvent dans la fenêtre temporelle de corrélation configurée.
func (s *CorrelationService) eventsMatch(a, b *NormalizedEvent) bool { func (s *CorrelationService) eventsMatch(a, b *NormalizedEvent) bool {
diff := a.Timestamp.Sub(b.Timestamp) diff := a.Timestamp.Sub(b.Timestamp)
if diff < 0 { if diff < 0 {
@ -536,6 +542,7 @@ func (s *CorrelationService) bEventHasValidTTL(bEvent *NormalizedEvent) bool {
return false return false
} }
// addEvent ajoute un événement au tampon correspondant à sa source et initialise son TTL réseau si nécessaire.
func (s *CorrelationService) addEvent(event *NormalizedEvent) { func (s *CorrelationService) addEvent(event *NormalizedEvent) {
key := event.CorrelationKey() key := event.CorrelationKey()
@ -551,6 +558,7 @@ func (s *CorrelationService) addEvent(event *NormalizedEvent) {
} }
} }
// cleanExpired supprime les événements expirés des tampons et retourne les orphelins forcés par l'expiration du TTL réseau.
func (s *CorrelationService) cleanExpired() []CorrelatedLog { func (s *CorrelationService) cleanExpired() []CorrelatedLog {
// Clean expired B events first - use TTL map only (not event timestamp) // Clean expired B events first - use TTL map only (not event timestamp)
// This is critical for Keep-Alive: TTL is reset on each correlation, // This is critical for Keep-Alive: TTL is reset on each correlation,
@ -693,6 +701,7 @@ func (s *CorrelationService) cleanNetworkBufferByTTL() []CorrelatedLog {
return forced return forced
} }
// findAndPopFirstMatch recherche et supprime le premier événement satisfaisant le critère dans le tampon.
func (s *CorrelationService) findAndPopFirstMatch( func (s *CorrelationService) findAndPopFirstMatch(
buffer *eventBuffer, buffer *eventBuffer,
pending map[string][]*list.Element, pending map[string][]*list.Element,
@ -908,6 +917,7 @@ func (s *CorrelationService) EmitPendingOrphans() []CorrelatedLog {
return s.emitPendingOrphans() return s.emitPendingOrphans()
} }
// removeElementFromSlice retire l'élément ciblé d'une tranche de list.Element sans modifier l'ordre.
func removeElementFromSlice(elements []*list.Element, target *list.Element) []*list.Element { func removeElementFromSlice(elements []*list.Element, target *list.Element) []*list.Element {
if len(elements) == 0 { if len(elements) == 0 {
return elements return elements

View File

@ -4,8 +4,10 @@ package observability
import jalogger "github.com/antitbone/ja4/ja4common/logger" import jalogger "github.com/antitbone/ja4/ja4common/logger"
// Type aliases — all existing correlator code compiles unchanged. // Logger est un alias du type Logger de ja4common pour la journalisation structurée.
type Logger = jalogger.Logger type Logger = jalogger.Logger
// LogLevel est un alias du type LogLevel de ja4common pour le niveau de journalisation.
type LogLevel = jalogger.LogLevel type LogLevel = jalogger.LogLevel
const ( const (

View File

@ -1,4 +1,21 @@
#!/bin/bash #!/usr/bin/env bash
# =============================================================================
# audit-architecture.sh — Vérifie la conformité de l'architecture du correlateur
#
# Ce script valide que les composants implémentés (service systemd, packaging RPM,
# configuration YAML, sockets Unix, sinks de sortie, logique de corrélation) sont
# présents et correctement configurés, conformément aux spécifications d'architecture.
#
# Usage:
# ./audit-architecture.sh
# docker run --rm -v $(pwd):/src <image> /src/scripts/audit-architecture.sh
#
# Prérequis:
# - Exécuté depuis le répertoire source /src du correlateur (ou monté en volume)
# - Les sources Go doivent être présentes (les checks sont basés sur grep)
#
# Variables d'environnement: aucune
# =============================================================================
set -e set -e
echo "=== AUDIT ARCHITECTURE COMPLIANCE ===" echo "=== AUDIT ARCHITECTURE COMPLIANCE ==="

View File

@ -1 +1 @@
# Backend package """Package principal du backend FastAPI bot-detector."""

View File

@ -5,6 +5,7 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
"""Paramètres de configuration de l'application chargés depuis l'environnement."""
# ClickHouse # ClickHouse
CLICKHOUSE_HOST: str = "clickhouse" CLICKHOUSE_HOST: str = "clickhouse"
CLICKHOUSE_PORT: int = 8123 CLICKHOUSE_PORT: int = 8123
@ -22,6 +23,7 @@ class Settings(BaseSettings):
CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"] CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"]
class Config: class Config:
"""Configuration Pydantic pour le chargement du fichier .env."""
env_file = ".env" env_file = ".env"
case_sensitive = True case_sensitive = True

View File

@ -8,6 +8,7 @@ from enum import Enum
class ThreatLevel(str, Enum): class ThreatLevel(str, Enum):
"""Niveaux de menace supportés par le modèle de détection."""
CRITICAL = "CRITICAL" CRITICAL = "CRITICAL"
HIGH = "HIGH" HIGH = "HIGH"
MEDIUM = "MEDIUM" MEDIUM = "MEDIUM"
@ -19,6 +20,7 @@ class ThreatLevel(str, Enum):
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
class MetricsSummary(BaseModel): class MetricsSummary(BaseModel):
"""Résumé agrégé des métriques sur les dernières 24 heures."""
total_detections: int total_detections: int
critical_count: int critical_count: int
high_count: int high_count: int
@ -30,6 +32,7 @@ class MetricsSummary(BaseModel):
class TimeSeriesPoint(BaseModel): class TimeSeriesPoint(BaseModel):
"""Point de série temporelle par heure pour les métriques."""
hour: datetime hour: datetime
total: int total: int
critical: int critical: int
@ -39,6 +42,7 @@ class TimeSeriesPoint(BaseModel):
class MetricsResponse(BaseModel): class MetricsResponse(BaseModel):
"""Réponse complète des métriques du dashboard avec série temporelle."""
summary: MetricsSummary summary: MetricsSummary
timeseries: List[TimeSeriesPoint] timeseries: List[TimeSeriesPoint]
threat_distribution: Dict[str, int] threat_distribution: Dict[str, int]
@ -49,6 +53,7 @@ class MetricsResponse(BaseModel):
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
class Detection(BaseModel): class Detection(BaseModel):
"""Représentation d'une détection d'anomalie émise par le modèle ML."""
detected_at: datetime detected_at: datetime
src_ip: str src_ip: str
ja4: str ja4: str
@ -82,6 +87,7 @@ class Detection(BaseModel):
class DetectionsListResponse(BaseModel): class DetectionsListResponse(BaseModel):
"""Liste paginée de détections d'anomalies."""
items: List[Detection] items: List[Detection]
total: int total: int
page: int page: int
@ -94,6 +100,7 @@ class DetectionsListResponse(BaseModel):
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
class AttributeValue(BaseModel): class AttributeValue(BaseModel):
"""Valeur d'attribut avec comptage, pourcentage et métadonnées temporelles."""
value: str value: str
count: int count: int
percentage: float percentage: float
@ -105,6 +112,7 @@ class AttributeValue(BaseModel):
class VariabilityAttributes(BaseModel): class VariabilityAttributes(BaseModel):
"""Ensemble des attributs de variabilité comportementale pour une entité."""
user_agents: List[AttributeValue] = Field(default_factory=list) user_agents: List[AttributeValue] = Field(default_factory=list)
ja4: List[AttributeValue] = Field(default_factory=list) ja4: List[AttributeValue] = Field(default_factory=list)
countries: List[AttributeValue] = Field(default_factory=list) countries: List[AttributeValue] = Field(default_factory=list)
@ -115,11 +123,13 @@ class VariabilityAttributes(BaseModel):
class Insight(BaseModel): class Insight(BaseModel):
"""Message d'analyse contextuelle (alerte, information ou succès)."""
type: str # "warning", "info", "success" type: str # "warning", "info", "success"
message: str message: str
class VariabilityResponse(BaseModel): class VariabilityResponse(BaseModel):
"""Réponse d'analyse de variabilité pour un attribut donné."""
type: str type: str
value: str value: str
total_detections: int total_detections: int
@ -134,11 +144,13 @@ class VariabilityResponse(BaseModel):
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
class AttributeListItem(BaseModel): class AttributeListItem(BaseModel):
"""Élément de la liste des valeurs uniques d'un attribut avec son comptage."""
value: str value: str
count: int count: int
class AttributeListResponse(BaseModel): class AttributeListResponse(BaseModel):
"""Réponse de la liste des valeurs uniques pour un type d'attribut."""
type: str type: str
items: List[AttributeListItem] items: List[AttributeListItem]
total: int total: int
@ -149,6 +161,7 @@ class AttributeListResponse(BaseModel):
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
class UserAgentValue(BaseModel): class UserAgentValue(BaseModel):
"""Valeur de User-Agent avec comptage et plage temporelle d'observation."""
value: str value: str
count: int count: int
percentage: float percentage: float
@ -157,6 +170,7 @@ class UserAgentValue(BaseModel):
class UserAgentsResponse(BaseModel): class UserAgentsResponse(BaseModel):
"""Réponse de la liste des User-Agents associés à une entité."""
type: str type: str
value: str value: str
user_agents: List[UserAgentValue] user_agents: List[UserAgentValue]
@ -169,12 +183,14 @@ class UserAgentsResponse(BaseModel):
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
class ClassificationLabel(str, Enum): class ClassificationLabel(str, Enum):
"""Étiquettes de classification SOC pour les IPs et fingerprints JA4."""
LEGITIMATE = "legitimate" LEGITIMATE = "legitimate"
SUSPICIOUS = "suspicious" SUSPICIOUS = "suspicious"
MALICIOUS = "malicious" MALICIOUS = "malicious"
class ClassificationBase(BaseModel): class ClassificationBase(BaseModel):
"""Modèle de base partagé pour les classifications SOC."""
ip: Optional[str] = None ip: Optional[str] = None
ja4: Optional[str] = None ja4: Optional[str] = None
label: ClassificationLabel label: ClassificationLabel
@ -198,6 +214,7 @@ class Classification(ClassificationBase):
class ClassificationsListResponse(BaseModel): class ClassificationsListResponse(BaseModel):
"""Liste paginée des classifications SOC enregistrées."""
items: List[Classification] items: List[Classification]
total: int total: int

View File

@ -1 +1 @@
# Routes package """Package des routes FastAPI de l'API bot-detector."""

View File

@ -374,6 +374,7 @@ async def analyze_user_agents(ip: str):
# Classification des UAs # Classification des UAs
def classify_ua(ua: str) -> str: def classify_ua(ua: str) -> str:
"""Classe un User-Agent en 'bot', 'script', 'browser' ou 'unknown'."""
ua_lower = ua.lower() ua_lower = ua.lower()
if any(bot in ua_lower for bot in ['bot', 'crawler', 'spider', 'curl', 'wget', 'python', 'requests', 'scrapy']): if any(bot in ua_lower for bot in ['bot', 'crawler', 'spider', 'curl', 'wget', 'python', 'requests', 'scrapy']):
return 'bot' return 'bot'

View File

@ -10,6 +10,7 @@ router = APIRouter(prefix="/api/botnets", tags=["botnets"])
def _botnet_class(unique_countries: int) -> str: def _botnet_class(unique_countries: int) -> str:
"""Classifie un JA4 selon sa dispersion géographique."""
if unique_countries > 100: if unique_countries > 100:
return "global_botnet" return "global_botnet"
if unique_countries > 20: if unique_countries > 20:

View File

@ -222,6 +222,7 @@ def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None:
continue continue
def avg_f(key: str, crows: list[dict] = cluster_rows[j]) -> float: def avg_f(key: str, crows: list[dict] = cluster_rows[j]) -> float:
"""Calcule la moyenne flottante d'un champ numérique sur les lignes du cluster."""
return float(np.mean([float(r.get(key) or 0) for r in crows])) return float(np.mean([float(r.get(key) or 0) for r in crows]))
mean_ttl = avg_f("ttl") mean_ttl = avg_f("ttl")
@ -245,6 +246,7 @@ def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None:
orgs = [str(r.get("asn_org") or "") for r in cluster_rows[j] if r.get("asn_org")] orgs = [str(r.get("asn_org") or "") for r in cluster_rows[j] if r.get("asn_org")]
def topk(lst: list[str], n: int = 5) -> list[str]: def topk(lst: list[str], n: int = 5) -> list[str]:
"""Retourne les n valeurs les plus fréquentes d'une liste (valeurs vides exclues)."""
return [v for v, _ in Counter(lst).most_common(n) if v] return [v for v, _ in Counter(lst).most_common(n) if v]
radar = [ radar = [

View File

@ -489,6 +489,7 @@ async def get_ua_analysis(
def _build_ua_risk_flags(ua: str, ua_type: str, unique_ja4s: int, ip_count: int) -> list: def _build_ua_risk_flags(ua: str, ua_type: str, unique_ja4s: int, ip_count: int) -> list:
"""Construit la liste des indicateurs de risque pour un User-Agent."""
flags = [] flags = []
if ua_type == "bot": if ua_type == "bot":
flags.append("ua_bot_signature") flags.append("ua_bot_signature")

View File

@ -144,6 +144,7 @@ async def get_metrics_baseline():
row = r.result_rows[0] if r.result_rows else None row = r.result_rows[0] if r.result_rows else None
def pct_change(today: int, yesterday: int) -> float: def pct_change(today: int, yesterday: int) -> float:
"""Calcule la variation en pourcentage entre aujourd'hui et hier. Retourne 100 si hier=0 et aujourd'hui>0."""
if yesterday == 0: if yesterday == 0:
return 100.0 if today > 0 else 0.0 return 100.0 if today > 0 else 0.0
return round((today - yesterday) / yesterday * 100, 1) return round((today - yesterday) / yesterday * 100, 1)

View File

@ -11,6 +11,7 @@ router = APIRouter(prefix="/api/ml", tags=["ml_features"])
def _attack_type(fuzzing_index: float, hit_velocity: float, def _attack_type(fuzzing_index: float, hit_velocity: float,
is_fake_nav: int, ua_ch_mismatch: int) -> str: is_fake_nav: int, ua_ch_mismatch: int) -> str:
"""Déduit le type d'attaque depuis les métriques comportementales."""
if fuzzing_index > 50: if fuzzing_index > 50:
return "brute_force" return "brute_force"
if hit_velocity > 1.0: if hit_velocity > 1.0:
@ -113,6 +114,7 @@ async def get_ip_radar(ip: str):
row = result.result_rows[0] row = result.result_rows[0]
def _f(v) -> float: def _f(v) -> float:
"""Convertit une valeur nullable en float (None ou falsy → 0.0)."""
return float(v or 0) return float(v or 0)
return { return {

View File

@ -1,4 +1,4 @@
"""Unified singleton ClickHouse client for the JA4 security suite.""" """Client ClickHouse singleton partagé pour la suite de sécurité JA4."""
import clickhouse_connect import clickhouse_connect
from typing import Optional from typing import Optional
@ -6,10 +6,19 @@ from .settings import settings
class ClickHouseClient: class ClickHouseClient:
"""Client ClickHouse singleton avec reconnexion automatique.
Attributs :
_client : instance du client clickhouse_connect sous-jacent,
ou None si la connexion n'est pas encore établie.
"""
def __init__(self): def __init__(self):
"""Initialise le client sans ouvrir de connexion immédiate."""
self._client: Optional[clickhouse_connect.driver.client.Client] = None self._client: Optional[clickhouse_connect.driver.client.Client] = None
def connect(self) -> clickhouse_connect.driver.client.Client: def connect(self) -> clickhouse_connect.driver.client.Client:
"""Retourne un client connecté, en créant ou rétablissant la connexion si nécessaire."""
if self._client is None or not self._ping(): if self._client is None or not self._ping():
self._client = clickhouse_connect.get_client( self._client = clickhouse_connect.get_client(
host=settings.CLICKHOUSE_HOST, host=settings.CLICKHOUSE_HOST,
@ -22,6 +31,7 @@ class ClickHouseClient:
return self._client return self._client
def _ping(self) -> bool: def _ping(self) -> bool:
"""Vérifie que la connexion existante est active. Retourne False en cas d'erreur."""
try: try:
if self._client: if self._client:
self._client.ping() self._client.ping()
@ -31,15 +41,19 @@ class ClickHouseClient:
return False return False
def query(self, query: str, params: Optional[dict] = None): def query(self, query: str, params: Optional[dict] = None):
"""Exécute une requête SELECT et retourne le résultat."""
return self.connect().query(query, params) return self.connect().query(query, params)
def command(self, query: str, params: Optional[dict] = None): def command(self, query: str, params: Optional[dict] = None):
"""Exécute une commande DDL/DML (INSERT, CREATE, TRUNCATE, etc.)."""
return self.connect().command(query, parameters=params) return self.connect().command(query, parameters=params)
def insert(self, table: str, data, column_names=None): def insert(self, table: str, data, column_names=None):
"""Insère des données dans la table cible."""
return self.connect().insert(table, data, column_names=column_names) return self.connect().insert(table, data, column_names=column_names)
def close(self): def close(self):
"""Ferme la connexion et réinitialise le client interne."""
if self._client: if self._client:
self._client.close() self._client.close()
self._client = None self._client = None
@ -49,6 +63,7 @@ _client: Optional[ClickHouseClient] = None
def get_client() -> ClickHouseClient: def get_client() -> ClickHouseClient:
"""Retourne l'instance singleton du ClickHouseClient, en la créant si nécessaire."""
global _client global _client
if _client is None: if _client is None:
_client = ClickHouseClient() _client = ClickHouseClient()

View File

@ -1,8 +1,19 @@
"""Unified ClickHouse settings using pydantic-settings.""" """Paramètres de connexion ClickHouse centralisés, chargés depuis les variables d'environnement."""
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
class ClickHouseSettings(BaseSettings): class ClickHouseSettings(BaseSettings):
"""Paramètres de connexion ClickHouse lus depuis l'environnement ou un fichier .env.
Attributs :
CLICKHOUSE_HOST : hôte du serveur ClickHouse.
CLICKHOUSE_PORT : port HTTP de l'API ClickHouse (défaut 8123).
CLICKHOUSE_DB : base de données de connexion par défaut.
CLICKHOUSE_DB_LOGS : base de données des logs bruts.
CLICKHOUSE_DB_PROCESSING : base de données de traitement analytique.
CLICKHOUSE_USER : nom d'utilisateur.
CLICKHOUSE_PASSWORD : mot de passe (chaîne vide si aucun).
"""
CLICKHOUSE_HOST: str = "clickhouse" CLICKHOUSE_HOST: str = "clickhouse"
CLICKHOUSE_PORT: int = 8123 CLICKHOUSE_PORT: int = 8123
CLICKHOUSE_DB: str = "ja4_processing" # default connection database CLICKHOUSE_DB: str = "ja4_processing" # default connection database